cac-webhook 0.1.0

PR webhook server for GitHub and Codeberg/Gitea compliance scans
Documentation
use crate::config::WebhookConfig;
use crate::git;
use crate::payload::{PrContext, ProviderKind, PullRequestEvent};
use crate::provider::ProviderClient;
use cac_core::{
    audit::{default_ledger_path, AuditLedger, AuditPhase, LedgerConfig},
    violation::ScanReport,
};
use cac_fixer::Fixer;
use cac_scanner::{ScanConfig, Scanner};
use cac_validator::Validator;
use std::sync::Arc;
use thiserror::Error;
use tracing::{error, info, warn};

#[derive(Debug, Error)]
pub enum HandlerError {
    #[error("git error: {0}")]
    Git(#[from] git::GitError),
    #[error("scan error: {0}")]
    Scan(#[from] cac_scanner::ScanError),
    #[error("fix error: {0}")]
    Fix(#[from] cac_fixer::FixError),
    #[error("validate error: {0}")]
    Validate(#[from] cac_validator::ValidateError),
    #[error("provider error: {0}")]
    Provider(#[from] crate::provider::ProviderError),
    #[error("audit error: {0}")]
    Audit(#[from] cac_core::audit::AuditError),
    #[error("json error: {0}")]
    Json(#[from] serde_json::Error),
}

pub struct WebhookHandler {
    config: Arc<WebhookConfig>,
    provider: ProviderClient,
}

impl WebhookHandler {
    pub fn new(config: WebhookConfig) -> Self {
        let provider = ProviderClient::new(
            config.github_token.clone(),
            config.codeberg_token.clone(),
        );
        Self {
            config: Arc::new(config),
            provider,
        }
    }

    pub async fn handle_pull_request(&self, ctx: PrContext) -> Result<(), HandlerError> {
        info!(
            provider = ?ctx.provider,
            pr = ctx.pr_number,
            repo = %format!("{}/{}", ctx.owner, ctx.repo),
            action = %ctx.action,
            "processing pull request webhook"
        );

        let ledger = AuditLedger::new(LedgerConfig {
            signing_key: self.config.signing_key.clone(),
            ledger_path: default_ledger_path(&self.config.work_dir),
        });

        ledger.record(
            AuditPhase::Webhook,
            "webhook-agent",
            "pr_received",
            None,
            None,
            None,
            serde_json::json!({
                "provider": format!("{:?}", ctx.provider),
                "owner": ctx.owner,
                "repo": ctx.repo,
                "pr_number": ctx.pr_number,
                "head_sha": ctx.head_sha,
                "action": ctx.action,
            }),
        )?;

        let _ = self
            .provider
            .set_pending(
                ctx.provider,
                &ctx.owner,
                &ctx.repo,
                &ctx.head_sha,
                &self.config.status_context,
            )
            .await;

        let token = self.token_for(ctx.provider);
        let clone_target = if ctx.head_clone_url != ctx.clone_url {
            &ctx.head_clone_url
        } else {
            &ctx.clone_url
        };
        let repo_dir = git::checkout_pr(
            &self.config.work_dir,
            clone_target,
            ctx.pr_number,
            token.as_deref(),
        )?;

        let scan = self.scan_repo(&repo_dir)?;
        ledger.record(
            AuditPhase::Detect,
            "detector-agent",
            "pr_scan_complete",
            None,
            None,
            None,
            serde_json::json!({
                "violations": scan.violation_count(),
                "files_scanned": scan.files_scanned,
                "pr_number": ctx.pr_number,
            }),
        )?;

        let mut fix_pr_url = None;
        if self.config.auto_fix_pr && !scan.violations.is_empty() {
            if let Ok(url) = self.create_fix_pr(&ctx, &repo_dir, &scan, token.as_deref()).await {
                if !url.is_empty() {
                    fix_pr_url = Some(url);
                }
            }
        }

        let passed = scan.violations.is_empty();
        let description = if passed {
            "No compliance violations detected".into()
        } else {
            format!("{} compliance violation(s) detected", scan.violations.len())
        };

        let target_url = fix_pr_url
            .clone()
            .or_else(|| self.config.public_url.clone())
            .or(ctx.pr_url.clone());

        self.provider
            .set_result(
                ctx.provider,
                &ctx.owner,
                &ctx.repo,
                &ctx.head_sha,
                &self.config.status_context,
                passed,
                description,
                target_url,
            )
            .await?;

        let comment = format_pr_comment(&scan, fix_pr_url.as_deref());
        if let Err(err) = self
            .provider
            .comment_on_pr(ctx.provider, &ctx.owner, &ctx.repo, ctx.pr_number, &comment)
            .await
        {
            warn!(error = %err, "failed to post PR comment");
        }

        ledger.record(
            AuditPhase::Webhook,
            "webhook-agent",
            if passed { "pr_passed" } else { "pr_failed" },
            None,
            None,
            None,
            serde_json::json!({
                "passed": passed,
                "violations": scan.violation_count(),
            }),
        )?;

        Ok(())
    }

    pub fn parse_event(
        provider: ProviderKind,
        body: &[u8],
    ) -> Result<Option<PrContext>, HandlerError> {
        let event: PullRequestEvent = serde_json::from_slice(body)?;
        Ok(event.into_context(provider))
    }

    fn scan_repo(&self, repo_dir: &std::path::Path) -> Result<ScanReport, HandlerError> {
        let scanner = Scanner::from_config(ScanConfig::new(
            repo_dir,
            &self.config.policies_dir,
        ))?;
        Ok(scanner.scan()?)
    }

    async fn create_fix_pr(
        &self,
        ctx: &PrContext,
        repo_dir: &std::path::Path,
        scan: &ScanReport,
        token: Option<&str>,
    ) -> Result<String, HandlerError> {
        let fixer = Fixer::new(repo_dir, false);
        let proposals = fixer.propose(&scan.violations);
        let applied = fixer.apply(&proposals)?;
        if applied == 0 {
            warn!("auto-fix PR skipped: no fixes applied");
            return Ok(String::new());
        }

        let validator = Validator::new(repo_dir, &self.config.policies_dir);
        let validation = validator.validate_after_fix(scan, applied)?;
        if !validation.passed {
            warn!("auto-fix PR skipped: validation failed after fixes");
            return Ok(String::new());
        }

        let fix_branch = format!(
            "cac-fix/pr-{}-{}",
            ctx.pr_number,
            &ctx.head_sha[..7.min(ctx.head_sha.len())]
        );
        let committed = git::commit_all(
            repo_dir,
            &format!("fix(compliance): auto-fix {} violation(s) [CAC]", applied),
        )?;
        if !committed {
            warn!("auto-fix PR skipped: nothing to commit");
            return Ok(String::new());
        }
        git::push_branch(repo_dir, &fix_branch, token)?;

        let title = format!(
            "fix(compliance): auto-fix PR #{} violations",
            ctx.pr_number
        );
        let body = format!(
            "Automated compliance fixes from Compliance-as-Code Agent.\n\n\
             - Original PR: #{}\n\
             - Violations fixed: {}\n\
             - Validator: passed\n\n\
             Please review and merge if acceptable.",
            ctx.pr_number, applied
        );

        self.provider
            .open_fix_pr(
                ctx.provider,
                &ctx.owner,
                &ctx.repo,
                &fix_branch,
                &ctx.base_ref,
                &title,
                &body,
            )
            .await
            .map_err(Into::into)
    }

    fn token_for(&self, provider: ProviderKind) -> Option<String> {
        match provider {
            ProviderKind::GitHub => self.config.github_token.clone(),
            ProviderKind::Gitea => self.config.codeberg_token.clone(),
        }
    }
}

fn format_pr_comment(scan: &ScanReport, fix_pr_url: Option<&str>) -> String {
    let mut lines = vec![
        "## Compliance-as-Code Scan".into(),
        String::new(),
        format!(
            "**Result:** {} violation(s) across {} file(s)",
            scan.violations.len(),
            scan.files_scanned
        ),
    ];

    if let Some(url) = fix_pr_url {
        lines.push(String::new());
        lines.push(format!("**Auto-fix PR:** {url}"));
    }

    if scan.violations.is_empty() {
        lines.push(String::new());
        lines.push("All policy checks passed.".into());
        return lines.join("\n");
    }

    lines.push(String::new());
    lines.push("### Violations".into());
    for v in scan.violations.iter().take(20) {
        lines.push(format!(
            "- **[{:?}]** `{}:{}` — {} (`{}`)",
            v.severity, v.file_path, v.line, v.message, v.rule_id
        ));
    }
    if scan.violations.len() > 20 {
        lines.push(format!(
            "\n_...and {} more violation(s)._",
            scan.violations.len() - 20
        ));
    }

    lines.join("\n")
}

pub fn spawn_pr_job(handler: Arc<WebhookHandler>, ctx: PrContext) {
    tokio::spawn(async move {
        if let Err(err) = handler.handle_pull_request(ctx).await {
            error!(error = %err, "PR webhook job failed");
        }
    });
}