Skip to main content

ward/engine/
executor.rs

1use anyhow::Result;
2use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
3
4use crate::github::Client;
5
6use super::audit_log::AuditLog;
7use super::planner::RepoPlan;
8
9/// Execute a security plan, applying changes repo by repo.
10pub async fn execute_security_plan(
11    client: &Client,
12    plans: &[RepoPlan],
13    audit_log: &AuditLog,
14) -> Result<ExecutionReport> {
15    let multi = MultiProgress::new();
16    let style = ProgressStyle::with_template("  {spinner:.green} [{elapsed_precise}] {msg}")?;
17
18    let mut report = ExecutionReport::default();
19
20    for plan in plans.iter().filter(|p| p.has_changes()) {
21        let pb = multi.add(ProgressBar::new_spinner());
22        pb.set_style(style.clone());
23        pb.set_message(format!(
24            "{}: applying {} changes...",
25            plan.repo,
26            plan.changes.len()
27        ));
28
29        match apply_repo_security(client, plan, audit_log).await {
30            Ok(()) => {
31                pb.finish_with_message(format!("{}: ✅ done", plan.repo));
32                report.succeeded += 1;
33            }
34            Err(e) => {
35                pb.finish_with_message(format!("{}: ❌ {e}", plan.repo));
36                report.failed.push((plan.repo.clone(), e.to_string()));
37            }
38        }
39    }
40
41    Ok(report)
42}
43
44async fn apply_repo_security(client: &Client, plan: &RepoPlan, audit_log: &AuditLog) -> Result<()> {
45    for change in &plan.changes {
46        match change.feature.as_str() {
47            "dependabot_alerts" if change.desired => {
48                client.enable_dependabot_alerts(&plan.repo).await?;
49            }
50            "dependabot_security_updates" if change.desired => {
51                client
52                    .enable_dependabot_security_updates(&plan.repo)
53                    .await?;
54            }
55            "secret_scanning" | "secret_scanning_ai_detection" | "push_protection" => {
56                // These are set together via a single PATCH call
57                // We'll collect them and apply once below
58                continue;
59            }
60            _ => {
61                tracing::warn!("Disabling {} is not yet supported", change.feature);
62                continue;
63            }
64        }
65
66        audit_log.log(
67            &plan.repo,
68            &format!("enable_{}", change.feature),
69            "success",
70            change.current,
71            change.desired,
72        )?;
73    }
74
75    // Apply secret scanning features if any changed
76    let ss_changes: Vec<_> = plan
77        .changes
78        .iter()
79        .filter(|c| {
80            matches!(
81                c.feature.as_str(),
82                "secret_scanning" | "secret_scanning_ai_detection" | "push_protection"
83            )
84        })
85        .collect();
86
87    if !ss_changes.is_empty() {
88        // Determine desired state for each (use desired from change, or keep current)
89        let secret_scanning = ss_changes
90            .iter()
91            .find(|c| c.feature == "secret_scanning")
92            .map(|c| c.desired)
93            .unwrap_or(true);
94        let ai_detection = ss_changes
95            .iter()
96            .find(|c| c.feature == "secret_scanning_ai_detection")
97            .map(|c| c.desired)
98            .unwrap_or(true);
99        let push_protection = ss_changes
100            .iter()
101            .find(|c| c.feature == "push_protection")
102            .map(|c| c.desired)
103            .unwrap_or(true);
104
105        client
106            .set_security_features(&plan.repo, secret_scanning, ai_detection, push_protection)
107            .await?;
108
109        for change in &ss_changes {
110            audit_log.log(
111                &plan.repo,
112                &format!("set_{}", change.feature),
113                "success",
114                change.current,
115                change.desired,
116            )?;
117        }
118    }
119
120    Ok(())
121}
122
123#[derive(Debug, Default)]
124pub struct ExecutionReport {
125    pub succeeded: usize,
126    pub failed: Vec<(String, String)>,
127}
128
129impl ExecutionReport {
130    pub fn print_summary(&self) {
131        use console::style;
132
133        println!();
134        if self.failed.is_empty() {
135            println!(
136                "  {} All {} repositories updated successfully.",
137                style("✅").green(),
138                self.succeeded
139            );
140        } else {
141            println!(
142                "  {} {} succeeded, {} {} failed:",
143                style("⚠️").yellow(),
144                self.succeeded,
145                self.failed.len(),
146                if self.failed.len() == 1 {
147                    "repo"
148                } else {
149                    "repos"
150                }
151            );
152            for (repo, err) in &self.failed {
153                println!("    {} {}: {}", style("❌").red(), repo, err);
154            }
155        }
156    }
157}