1use crate::config::WebhookConfig;
2use crate::git;
3use crate::payload::{PrContext, ProviderKind, PullRequestEvent};
4use crate::provider::ProviderClient;
5use cac_core::{
6 audit::{default_ledger_path, AuditLedger, AuditPhase, LedgerConfig},
7 violation::ScanReport,
8};
9use cac_fixer::Fixer;
10use cac_scanner::{ScanConfig, Scanner};
11use cac_validator::Validator;
12use std::sync::Arc;
13use thiserror::Error;
14use tracing::{error, info, warn};
15
16#[derive(Debug, Error)]
17pub enum HandlerError {
18 #[error("git error: {0}")]
19 Git(#[from] git::GitError),
20 #[error("scan error: {0}")]
21 Scan(#[from] cac_scanner::ScanError),
22 #[error("fix error: {0}")]
23 Fix(#[from] cac_fixer::FixError),
24 #[error("validate error: {0}")]
25 Validate(#[from] cac_validator::ValidateError),
26 #[error("provider error: {0}")]
27 Provider(#[from] crate::provider::ProviderError),
28 #[error("audit error: {0}")]
29 Audit(#[from] cac_core::audit::AuditError),
30 #[error("json error: {0}")]
31 Json(#[from] serde_json::Error),
32}
33
34pub struct WebhookHandler {
35 config: Arc<WebhookConfig>,
36 provider: ProviderClient,
37}
38
39impl WebhookHandler {
40 pub fn new(config: WebhookConfig) -> Self {
41 let provider = ProviderClient::new(
42 config.github_token.clone(),
43 config.codeberg_token.clone(),
44 );
45 Self {
46 config: Arc::new(config),
47 provider,
48 }
49 }
50
51 pub async fn handle_pull_request(&self, ctx: PrContext) -> Result<(), HandlerError> {
52 info!(
53 provider = ?ctx.provider,
54 pr = ctx.pr_number,
55 repo = %format!("{}/{}", ctx.owner, ctx.repo),
56 action = %ctx.action,
57 "processing pull request webhook"
58 );
59
60 let ledger = AuditLedger::new(LedgerConfig {
61 signing_key: self.config.signing_key.clone(),
62 ledger_path: default_ledger_path(&self.config.work_dir),
63 });
64
65 ledger.record(
66 AuditPhase::Webhook,
67 "webhook-agent",
68 "pr_received",
69 None,
70 None,
71 None,
72 serde_json::json!({
73 "provider": format!("{:?}", ctx.provider),
74 "owner": ctx.owner,
75 "repo": ctx.repo,
76 "pr_number": ctx.pr_number,
77 "head_sha": ctx.head_sha,
78 "action": ctx.action,
79 }),
80 )?;
81
82 let _ = self
83 .provider
84 .set_pending(
85 ctx.provider,
86 &ctx.owner,
87 &ctx.repo,
88 &ctx.head_sha,
89 &self.config.status_context,
90 )
91 .await;
92
93 let token = self.token_for(ctx.provider);
94 let clone_target = if ctx.head_clone_url != ctx.clone_url {
95 &ctx.head_clone_url
96 } else {
97 &ctx.clone_url
98 };
99 let repo_dir = git::checkout_pr(
100 &self.config.work_dir,
101 clone_target,
102 ctx.pr_number,
103 token.as_deref(),
104 )?;
105
106 let scan = self.scan_repo(&repo_dir)?;
107 ledger.record(
108 AuditPhase::Detect,
109 "detector-agent",
110 "pr_scan_complete",
111 None,
112 None,
113 None,
114 serde_json::json!({
115 "violations": scan.violation_count(),
116 "files_scanned": scan.files_scanned,
117 "pr_number": ctx.pr_number,
118 }),
119 )?;
120
121 let mut fix_pr_url = None;
122 if self.config.auto_fix_pr && !scan.violations.is_empty() {
123 if let Ok(url) = self.create_fix_pr(&ctx, &repo_dir, &scan, token.as_deref()).await {
124 if !url.is_empty() {
125 fix_pr_url = Some(url);
126 }
127 }
128 }
129
130 let passed = scan.violations.is_empty();
131 let description = if passed {
132 "No compliance violations detected".into()
133 } else {
134 format!("{} compliance violation(s) detected", scan.violations.len())
135 };
136
137 let target_url = fix_pr_url
138 .clone()
139 .or_else(|| self.config.public_url.clone())
140 .or(ctx.pr_url.clone());
141
142 self.provider
143 .set_result(
144 ctx.provider,
145 &ctx.owner,
146 &ctx.repo,
147 &ctx.head_sha,
148 &self.config.status_context,
149 passed,
150 description,
151 target_url,
152 )
153 .await?;
154
155 let comment = format_pr_comment(&scan, fix_pr_url.as_deref());
156 if let Err(err) = self
157 .provider
158 .comment_on_pr(ctx.provider, &ctx.owner, &ctx.repo, ctx.pr_number, &comment)
159 .await
160 {
161 warn!(error = %err, "failed to post PR comment");
162 }
163
164 ledger.record(
165 AuditPhase::Webhook,
166 "webhook-agent",
167 if passed { "pr_passed" } else { "pr_failed" },
168 None,
169 None,
170 None,
171 serde_json::json!({
172 "passed": passed,
173 "violations": scan.violation_count(),
174 }),
175 )?;
176
177 Ok(())
178 }
179
180 pub fn parse_event(
181 provider: ProviderKind,
182 body: &[u8],
183 ) -> Result<Option<PrContext>, HandlerError> {
184 let event: PullRequestEvent = serde_json::from_slice(body)?;
185 Ok(event.into_context(provider))
186 }
187
188 fn scan_repo(&self, repo_dir: &std::path::Path) -> Result<ScanReport, HandlerError> {
189 let scanner = Scanner::from_config(ScanConfig::new(
190 repo_dir,
191 &self.config.policies_dir,
192 ))?;
193 Ok(scanner.scan()?)
194 }
195
196 async fn create_fix_pr(
197 &self,
198 ctx: &PrContext,
199 repo_dir: &std::path::Path,
200 scan: &ScanReport,
201 token: Option<&str>,
202 ) -> Result<String, HandlerError> {
203 let fixer = Fixer::new(repo_dir, false);
204 let proposals = fixer.propose(&scan.violations);
205 let applied = fixer.apply(&proposals)?;
206 if applied == 0 {
207 warn!("auto-fix PR skipped: no fixes applied");
208 return Ok(String::new());
209 }
210
211 let validator = Validator::new(repo_dir, &self.config.policies_dir);
212 let validation = validator.validate_after_fix(scan, applied)?;
213 if !validation.passed {
214 warn!("auto-fix PR skipped: validation failed after fixes");
215 return Ok(String::new());
216 }
217
218 let fix_branch = format!(
219 "cac-fix/pr-{}-{}",
220 ctx.pr_number,
221 &ctx.head_sha[..7.min(ctx.head_sha.len())]
222 );
223 let committed = git::commit_all(
224 repo_dir,
225 &format!("fix(compliance): auto-fix {} violation(s) [CAC]", applied),
226 )?;
227 if !committed {
228 warn!("auto-fix PR skipped: nothing to commit");
229 return Ok(String::new());
230 }
231 git::push_branch(repo_dir, &fix_branch, token)?;
232
233 let title = format!(
234 "fix(compliance): auto-fix PR #{} violations",
235 ctx.pr_number
236 );
237 let body = format!(
238 "Automated compliance fixes from Compliance-as-Code Agent.\n\n\
239 - Original PR: #{}\n\
240 - Violations fixed: {}\n\
241 - Validator: passed\n\n\
242 Please review and merge if acceptable.",
243 ctx.pr_number, applied
244 );
245
246 self.provider
247 .open_fix_pr(
248 ctx.provider,
249 &ctx.owner,
250 &ctx.repo,
251 &fix_branch,
252 &ctx.base_ref,
253 &title,
254 &body,
255 )
256 .await
257 .map_err(Into::into)
258 }
259
260 fn token_for(&self, provider: ProviderKind) -> Option<String> {
261 match provider {
262 ProviderKind::GitHub => self.config.github_token.clone(),
263 ProviderKind::Gitea => self.config.codeberg_token.clone(),
264 }
265 }
266}
267
268fn format_pr_comment(scan: &ScanReport, fix_pr_url: Option<&str>) -> String {
269 let mut lines = vec![
270 "## Compliance-as-Code Scan".into(),
271 String::new(),
272 format!(
273 "**Result:** {} violation(s) across {} file(s)",
274 scan.violations.len(),
275 scan.files_scanned
276 ),
277 ];
278
279 if let Some(url) = fix_pr_url {
280 lines.push(String::new());
281 lines.push(format!("**Auto-fix PR:** {url}"));
282 }
283
284 if scan.violations.is_empty() {
285 lines.push(String::new());
286 lines.push("All policy checks passed.".into());
287 return lines.join("\n");
288 }
289
290 lines.push(String::new());
291 lines.push("### Violations".into());
292 for v in scan.violations.iter().take(20) {
293 lines.push(format!(
294 "- **[{:?}]** `{}:{}` — {} (`{}`)",
295 v.severity, v.file_path, v.line, v.message, v.rule_id
296 ));
297 }
298 if scan.violations.len() > 20 {
299 lines.push(format!(
300 "\n_...and {} more violation(s)._",
301 scan.violations.len() - 20
302 ));
303 }
304
305 lines.join("\n")
306}
307
308pub fn spawn_pr_job(handler: Arc<WebhookHandler>, ctx: PrContext) {
309 tokio::spawn(async move {
310 if let Err(err) = handler.handle_pull_request(ctx).await {
311 error!(error = %err, "PR webhook job failed");
312 }
313 });
314}