Skip to main content

cac_webhook/
handler.rs

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}