Skip to content

Commit b8e6422

Browse files
committed
feat(server): add signed automation review webhooks
1 parent 4c9f38e commit b8e6422

8 files changed

Lines changed: 300 additions & 27 deletions

File tree

TODO.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ This roadmap is derived from deep research into Greptile's public docs, blog, MC
121121
76. [x] Add APIs for comment resolution and lifecycle updates, not just thumbs.
122122
77. [x] Add an MCP server for DiffScope with review, analytics, and rule-management tools.
123123
78. [x] Add reusable agent skills/workflows for checking PR readiness and running fix loops.
124-
79. [ ] Add signed webhook or event-stream integration for downstream automation consumers.
124+
79. [x] Add signed webhook or event-stream integration for downstream automation consumers.
125125
80. [ ] Add rate-limited API auth and audit trails for automation-heavy deployments.
126126

127127
## 9. Infra, Self-Hosting, and Enterprise Operations

src/config.rs

Lines changed: 90 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,17 @@ pub struct GitHubConfig {
116116
pub webhook_secret: Option<String>,
117117
}
118118

119+
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
120+
pub struct AutomationConfig {
121+
/// Outbound webhook URL for downstream automation consumers.
122+
#[serde(default, rename = "automation_webhook_url")]
123+
pub webhook_url: Option<String>,
124+
125+
/// Optional shared secret for signing outbound automation webhooks.
126+
#[serde(default, rename = "automation_webhook_secret")]
127+
pub webhook_secret: Option<String>,
128+
}
129+
119130
#[derive(Debug, Clone, Serialize, Deserialize)]
120131
pub struct AgentConfig {
121132
/// Enable agent loop for iterative tool-calling review (default false).
@@ -453,6 +464,9 @@ pub struct Config {
453464
#[serde(default, flatten)]
454465
pub github: GitHubConfig,
455466

467+
#[serde(default, flatten)]
468+
pub automation: AutomationConfig,
469+
456470
/// When true, run separate specialized LLM passes for security, correctness,
457471
/// and style instead of a single monolithic review prompt.
458472
#[serde(default = "default_false")]
@@ -655,6 +669,7 @@ impl Default for Config {
655669
rule_priority: Vec::new(),
656670
providers: HashMap::new(),
657671
github: GitHubConfig::default(),
672+
automation: AutomationConfig::default(),
658673
multi_pass_specialized: false,
659674
agent: AgentConfig::default(),
660675
verification: VerificationConfig::default(),
@@ -831,33 +846,20 @@ impl Config {
831846
.ok()
832847
.filter(|s| !s.trim().is_empty());
833848
}
834-
835-
// Validate base_url: must be a valid http/https URL with a host
836-
if let Some(ref raw_url) = self.base_url {
837-
match url::Url::parse(raw_url) {
838-
Ok(parsed) => {
839-
if !matches!(parsed.scheme(), "http" | "https") {
840-
warn!(
841-
"base_url '{}' uses unsupported scheme '{}' (expected http or https), ignoring",
842-
raw_url,
843-
parsed.scheme()
844-
);
845-
self.base_url = None;
846-
} else if parsed.host().is_none() {
847-
warn!("base_url '{}' has no valid host, ignoring", raw_url);
848-
self.base_url = None;
849-
}
850-
}
851-
Err(err) => {
852-
warn!(
853-
"base_url '{}' is not a valid URL ({}), ignoring",
854-
raw_url, err
855-
);
856-
self.base_url = None;
857-
}
858-
}
849+
if self.automation.webhook_url.is_none() {
850+
self.automation.webhook_url = std::env::var("DIFFSCOPE_AUTOMATION_WEBHOOK_URL")
851+
.ok()
852+
.filter(|s| !s.trim().is_empty());
853+
}
854+
if self.automation.webhook_secret.is_none() {
855+
self.automation.webhook_secret = std::env::var("DIFFSCOPE_AUTOMATION_WEBHOOK_SECRET")
856+
.ok()
857+
.filter(|s| !s.trim().is_empty());
859858
}
860859

860+
validate_optional_http_url(&mut self.base_url, "base_url");
861+
validate_optional_http_url(&mut self.automation.webhook_url, "automation_webhook_url");
862+
861863
// Normalize adapter field
862864
if let Some(ref adapter) = self.adapter {
863865
let normalized = adapter.trim().to_lowercase();
@@ -1365,6 +1367,36 @@ impl Config {
13651367
}
13661368
}
13671369

1370+
fn validate_optional_http_url(url: &mut Option<String>, field_name: &str) {
1371+
let Some(raw_url) = url.clone() else {
1372+
return;
1373+
};
1374+
1375+
match url::Url::parse(&raw_url) {
1376+
Ok(parsed) => {
1377+
if !matches!(parsed.scheme(), "http" | "https") {
1378+
warn!(
1379+
"{} '{}' uses unsupported scheme '{}' (expected http or https), ignoring",
1380+
field_name,
1381+
raw_url,
1382+
parsed.scheme()
1383+
);
1384+
*url = None;
1385+
} else if parsed.host().is_none() {
1386+
warn!("{} '{}' has no valid host, ignoring", field_name, raw_url);
1387+
*url = None;
1388+
}
1389+
}
1390+
Err(err) => {
1391+
warn!(
1392+
"{} '{}' is not a valid URL ({}), ignoring",
1393+
field_name, raw_url, err
1394+
);
1395+
*url = None;
1396+
}
1397+
}
1398+
}
1399+
13681400
fn default_model() -> String {
13691401
"anthropic/claude-opus-4.5".to_string()
13701402
}
@@ -1746,6 +1778,39 @@ mod tests {
17461778
assert!(config.base_url.is_none());
17471779
}
17481780

1781+
#[test]
1782+
fn normalize_accepts_automation_webhook_url_https() {
1783+
let mut config = Config {
1784+
automation: AutomationConfig {
1785+
webhook_url: Some("https://automation.example.com/hooks/reviews".to_string()),
1786+
..AutomationConfig::default()
1787+
},
1788+
..Config::default()
1789+
};
1790+
1791+
config.normalize();
1792+
1793+
assert_eq!(
1794+
config.automation.webhook_url.as_deref(),
1795+
Some("https://automation.example.com/hooks/reviews")
1796+
);
1797+
}
1798+
1799+
#[test]
1800+
fn normalize_rejects_automation_webhook_url_bad_scheme() {
1801+
let mut config = Config {
1802+
automation: AutomationConfig {
1803+
webhook_url: Some("ftp://automation.example.com/hooks/reviews".to_string()),
1804+
..AutomationConfig::default()
1805+
},
1806+
..Config::default()
1807+
};
1808+
1809+
config.normalize();
1810+
1811+
assert!(config.automation.webhook_url.is_none());
1812+
}
1813+
17491814
#[test]
17501815
fn normalize_clamps_max_tokens_above_limit() {
17511816
let mut config = Config {

src/server/api.rs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,11 @@ mod tests {
226226
"github_webhook_secret".to_string(),
227227
serde_json::json!("secret5"),
228228
);
229-
obj.insert("vault_token".to_string(), serde_json::json!("secret6"));
229+
obj.insert(
230+
"automation_webhook_secret".to_string(),
231+
serde_json::json!("secret6"),
232+
);
233+
obj.insert("vault_token".to_string(), serde_json::json!("secret7"));
230234
mask_config_secrets(&mut obj);
231235
assert_eq!(obj.get("api_key").unwrap(), &serde_json::json!("***"));
232236
assert_eq!(obj.get("github_token").unwrap(), &serde_json::json!("***"));
@@ -242,6 +246,10 @@ mod tests {
242246
obj.get("github_webhook_secret").unwrap(),
243247
&serde_json::json!("***")
244248
);
249+
assert_eq!(
250+
obj.get("automation_webhook_secret").unwrap(),
251+
&serde_json::json!("***")
252+
);
245253
assert_eq!(obj.get("vault_token").unwrap(), &serde_json::json!("***"));
246254
}
247255

src/server/api/admin.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ pub(crate) fn mask_config_secrets(obj: &mut serde_json::Map<String, serde_json::
132132
"github_client_secret",
133133
"github_private_key",
134134
"github_webhook_secret",
135+
"automation_webhook_secret",
135136
"vault_token",
136137
] {
137138
if obj.get(*key).and_then(|v| v.as_str()).is_some() {

src/server/state.rs

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ pub(crate) use types::*;
3232
mod tests {
3333
use super::*;
3434
use crate::core::comment::{Category, FixEffort, Severity};
35+
use mockito::Matcher;
3536
use std::path::PathBuf;
3637

3738
#[test]
@@ -88,6 +89,75 @@ mod tests {
8889
assert!(json.contains("r-otel"), "payload must contain review_id");
8990
}
9091

92+
#[test]
93+
fn test_serialize_review_event_webhook_payload_includes_delivery_metadata() {
94+
let event = ReviewEventBuilder::new("r-webhook", "review.completed", "head", "gpt-4o")
95+
.duration_ms(100)
96+
.build();
97+
98+
let (delivery_id, body) = serialize_review_event_webhook_payload(&event).unwrap();
99+
let payload: serde_json::Value = serde_json::from_str(&body).unwrap();
100+
101+
assert_eq!(payload["delivery_id"].as_str(), Some(delivery_id.as_str()));
102+
assert!(payload["sent_at"].as_str().is_some());
103+
assert_eq!(payload["review"]["review_id"].as_str(), Some("r-webhook"));
104+
assert_eq!(
105+
payload["review"]["event_type"].as_str(),
106+
Some("review.completed")
107+
);
108+
}
109+
110+
#[tokio::test]
111+
async fn test_send_review_event_webhook_noops_without_url() {
112+
let event = ReviewEventBuilder::new("r-noop", "review.completed", "head", "gpt-4o").build();
113+
114+
let delivered = send_review_event_webhook(&reqwest::Client::new(), None, None, &event)
115+
.await
116+
.unwrap();
117+
118+
assert!(!delivered);
119+
}
120+
121+
#[tokio::test]
122+
async fn test_send_review_event_webhook_posts_signed_request() {
123+
let mut server = mockito::Server::new_async().await;
124+
let mock = server
125+
.mock("POST", "/hooks/reviews")
126+
.match_header(
127+
"content-type",
128+
Matcher::Regex("application/json.*".to_string()),
129+
)
130+
.match_header("x-diffscope-event", "review.completed")
131+
.match_header(
132+
"x-diffscope-delivery",
133+
Matcher::Regex("[0-9a-f-]{36}".to_string()),
134+
)
135+
.match_header(
136+
"x-diffscope-signature-256",
137+
Matcher::Regex("sha256=[0-9a-f]{64}".to_string()),
138+
)
139+
.match_body(Matcher::Regex(
140+
r#""review_id":"r-hook".*"event_type":"review.completed""#.to_string(),
141+
))
142+
.with_status(202)
143+
.create_async()
144+
.await;
145+
146+
let event = ReviewEventBuilder::new("r-hook", "review.completed", "head", "gpt-4o").build();
147+
148+
let delivered = send_review_event_webhook(
149+
&reqwest::Client::new(),
150+
Some(&format!("{}/hooks/reviews", server.url())),
151+
Some("shared-secret"),
152+
&event,
153+
)
154+
.await
155+
.unwrap();
156+
157+
assert!(delivered);
158+
mock.assert_async().await;
159+
}
160+
91161
#[test]
92162
fn test_review_event_builder_minimal() {
93163
let event = ReviewEventBuilder::new("r1", "review.completed", "head", "gpt-4o").build();

src/server/state/events.rs

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
use super::*;
2+
use hmac::{Hmac, Mac};
3+
use sha2::Sha256;
4+
use uuid::Uuid;
25

36
pub struct ReviewEventBuilder {
47
event: ReviewEvent,
@@ -225,3 +228,104 @@ pub fn emit_wide_event(event: &ReviewEvent) {
225228
info!(target: "review.event.json", "{}", json);
226229
}
227230
}
231+
232+
#[derive(Debug, Serialize)]
233+
struct AutomationWebhookPayload<'a> {
234+
delivery_id: String,
235+
sent_at: chrono::DateTime<chrono::Utc>,
236+
review: &'a ReviewEvent,
237+
}
238+
239+
pub(crate) fn dispatch_review_event_webhook(state: Arc<AppState>, event: ReviewEvent) {
240+
tokio::spawn(async move {
241+
let (webhook_url, webhook_secret) = {
242+
let config = state.config.read().await;
243+
(
244+
config.automation.webhook_url.clone(),
245+
config.automation.webhook_secret.clone(),
246+
)
247+
};
248+
249+
if let Err(err) = send_review_event_webhook(
250+
&state.http_client,
251+
webhook_url.as_deref(),
252+
webhook_secret.as_deref(),
253+
&event,
254+
)
255+
.await
256+
{
257+
warn!(
258+
review_id = %event.review_id,
259+
event_type = %event.event_type,
260+
"Failed to deliver automation webhook: {}",
261+
err
262+
);
263+
}
264+
});
265+
}
266+
267+
pub(crate) async fn send_review_event_webhook(
268+
client: &reqwest::Client,
269+
webhook_url: Option<&str>,
270+
webhook_secret: Option<&str>,
271+
event: &ReviewEvent,
272+
) -> anyhow::Result<bool> {
273+
let Some(webhook_url) = webhook_url.map(str::trim).filter(|url| !url.is_empty()) else {
274+
return Ok(false);
275+
};
276+
277+
let (delivery_id, body) = serialize_review_event_webhook_payload(event)?;
278+
let mut request = client
279+
.post(webhook_url)
280+
.header("Content-Type", "application/json")
281+
.header("X-DiffScope-Event", &event.event_type)
282+
.header("X-DiffScope-Delivery", &delivery_id)
283+
.body(body.clone());
284+
285+
if let Some(secret) = webhook_secret
286+
.map(str::trim)
287+
.filter(|secret| !secret.is_empty())
288+
{
289+
request = request.header(
290+
"X-DiffScope-Signature-256",
291+
sign_review_event_webhook_body(secret, body.as_bytes())?,
292+
);
293+
}
294+
295+
let response = request.send().await?;
296+
if !response.status().is_success() {
297+
anyhow::bail!("automation webhook returned {}", response.status());
298+
}
299+
300+
Ok(true)
301+
}
302+
303+
pub(crate) fn serialize_review_event_webhook_payload(
304+
event: &ReviewEvent,
305+
) -> anyhow::Result<(String, String)> {
306+
let payload = AutomationWebhookPayload {
307+
delivery_id: Uuid::new_v4().to_string(),
308+
sent_at: chrono::Utc::now(),
309+
review: event,
310+
};
311+
let delivery_id = payload.delivery_id.clone();
312+
let body = serde_json::to_string(&payload)?;
313+
Ok((delivery_id, body))
314+
}
315+
316+
pub(crate) fn sign_review_event_webhook_body(secret: &str, body: &[u8]) -> anyhow::Result<String> {
317+
let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes())?;
318+
mac.update(body);
319+
Ok(format!(
320+
"sha256={}",
321+
encode_hex(mac.finalize().into_bytes())
322+
))
323+
}
324+
325+
fn encode_hex(bytes: impl AsRef<[u8]>) -> String {
326+
bytes
327+
.as_ref()
328+
.iter()
329+
.map(|byte| format!("{byte:02x}"))
330+
.collect()
331+
}

0 commit comments

Comments
 (0)