Skip to main content

ward/cli/
rulesets.rs

1use anyhow::Result;
2use clap::Args;
3use console::style;
4use dialoguer::Confirm;
5
6use crate::config::Manifest;
7use crate::config::manifest::RulesetBranchProtection;
8use crate::github::Client;
9
10#[derive(Args)]
11pub struct RulesetsCommand {
12    #[command(subcommand)]
13    action: RulesetsAction,
14}
15
16#[derive(clap::Subcommand)]
17enum RulesetsAction {
18    /// Preview ruleset changes (dry-run)
19    Plan,
20
21    /// Apply rulesets to repositories
22    Apply {
23        /// Skip confirmation prompt
24        #[arg(long, short)]
25        yes: bool,
26    },
27
28    /// Show current rulesets across repos
29    Audit,
30}
31
32impl RulesetsCommand {
33    pub async fn run(
34        &self,
35        client: &Client,
36        manifest: &Manifest,
37        system: Option<&str>,
38        repo: Option<&str>,
39    ) -> Result<()> {
40        match &self.action {
41            RulesetsAction::Plan => plan(client, manifest, system, repo).await,
42            RulesetsAction::Apply { yes } => apply(client, manifest, system, repo, *yes).await,
43            RulesetsAction::Audit => audit(client, manifest, system, repo).await,
44        }
45    }
46}
47
48async fn resolve_repos(
49    client: &Client,
50    manifest: &Manifest,
51    system: Option<&str>,
52    repo: Option<&str>,
53) -> Result<Vec<String>> {
54    if let Some(repo_name) = repo {
55        return Ok(vec![repo_name.to_owned()]);
56    }
57
58    let sys = system.ok_or_else(|| {
59        anyhow::anyhow!("Either --system or --repo is required for rulesets commands")
60    })?;
61
62    let excludes = manifest.exclude_patterns_for_system(sys);
63    let explicit = manifest.explicit_repos_for_system(sys);
64    let repos = client
65        .list_repos_for_system(sys, &excludes, &explicit)
66        .await?;
67    Ok(repos.into_iter().map(|r| r.name).collect())
68}
69
70/// Build the GitHub API JSON body for a branch protection ruleset.
71pub fn build_ruleset_json(config: &RulesetBranchProtection) -> serde_json::Value {
72    let name = config.name.as_deref().unwrap_or("Branch Protection");
73
74    let mut rules = vec![serde_json::json!({
75        "type": "pull_request",
76        "parameters": {
77            "required_approving_review_count": config.required_approvals,
78            "dismiss_stale_reviews_on_push": config.dismiss_stale_reviews,
79            "require_code_owner_review": config.require_code_owner_reviews,
80            "require_last_push_approval": false,
81            "required_review_thread_resolution": false
82        }
83    })];
84
85    if config.block_force_pushes {
86        rules.push(serde_json::json!({"type": "non_fast_forward"}));
87    }
88
89    if config.block_deletions {
90        rules.push(serde_json::json!({"type": "deletion"}));
91    }
92
93    if config.require_linear_history {
94        rules.push(serde_json::json!({"type": "required_linear_history"}));
95    }
96
97    if !config.required_status_checks.is_empty() {
98        let checks: Vec<serde_json::Value> = config
99            .required_status_checks
100            .iter()
101            .map(|c| serde_json::json!({"context": c}))
102            .collect();
103        rules.push(serde_json::json!({
104            "type": "required_status_checks",
105            "parameters": {
106                "required_status_checks": checks,
107                "strict_required_status_checks_policy": false
108            }
109        }));
110    }
111
112    serde_json::json!({
113        "name": name,
114        "target": "branch",
115        "enforcement": config.enforcement,
116        "conditions": {
117            "ref_name": {
118                "include": ["~DEFAULT_BRANCH"],
119                "exclude": []
120            }
121        },
122        "rules": rules,
123        "bypass_actors": []
124    })
125}
126
127struct RulesetPlan {
128    repo: String,
129    action: RulesetPlanAction,
130}
131
132enum RulesetPlanAction {
133    Create { name: String },
134    Update { id: u64, name: String },
135    InSync { name: String },
136}
137
138async fn build_plans(
139    client: &Client,
140    manifest: &Manifest,
141    system: Option<&str>,
142    repo: Option<&str>,
143) -> Result<(Vec<RulesetPlan>, RulesetBranchProtection)> {
144    let config = match &manifest.rulesets.branch_protection {
145        Some(c) if c.enabled => c.clone(),
146        _ => {
147            anyhow::bail!("No rulesets.branch_protection configured or not enabled in ward.toml");
148        }
149    };
150
151    let repos = resolve_repos(client, manifest, system, repo).await?;
152    let expected_name = config.name.as_deref().unwrap_or("Branch Protection");
153
154    println!();
155    println!(
156        "  {} Scanning {} repositories for rulesets...",
157        style("[..]").dim(),
158        repos.len()
159    );
160
161    let mut plans = Vec::new();
162
163    for repo_name in &repos {
164        let rulesets = client.list_rulesets(repo_name).await?;
165        let existing = rulesets.iter().find(|r| r.name == expected_name);
166
167        let action = match existing {
168            None => RulesetPlanAction::Create {
169                name: expected_name.to_string(),
170            },
171            Some(r) => {
172                let detail = client.get_ruleset(repo_name, r.id).await;
173                match detail {
174                    Ok(d) if d.enforcement == config.enforcement => RulesetPlanAction::InSync {
175                        name: expected_name.to_string(),
176                    },
177                    _ => RulesetPlanAction::Update {
178                        id: r.id,
179                        name: expected_name.to_string(),
180                    },
181                }
182            }
183        };
184
185        plans.push(RulesetPlan {
186            repo: repo_name.clone(),
187            action,
188        });
189    }
190
191    Ok((plans, config))
192}
193
194async fn plan(
195    client: &Client,
196    manifest: &Manifest,
197    system: Option<&str>,
198    repo: Option<&str>,
199) -> Result<()> {
200    let (plans, _config) = build_plans(client, manifest, system, repo).await?;
201
202    print_plan_table(&plans);
203
204    let needs_changes = plans
205        .iter()
206        .filter(|p| !matches!(p.action, RulesetPlanAction::InSync { .. }))
207        .count();
208    if needs_changes > 0 {
209        println!(
210            "\n  Run {} to apply these changes.",
211            style("ward rulesets apply").cyan().bold()
212        );
213    }
214
215    Ok(())
216}
217
218async fn apply(
219    client: &Client,
220    manifest: &Manifest,
221    system: Option<&str>,
222    repo: Option<&str>,
223    yes: bool,
224) -> Result<()> {
225    let (plans, config) = build_plans(client, manifest, system, repo).await?;
226
227    let needs_changes: Vec<&RulesetPlan> = plans
228        .iter()
229        .filter(|p| !matches!(p.action, RulesetPlanAction::InSync { .. }))
230        .collect();
231
232    if needs_changes.is_empty() {
233        println!(
234            "\n  {} All repositories have rulesets up to date.",
235            style("[ok]").green()
236        );
237        return Ok(());
238    }
239
240    print_plan_table(&plans);
241
242    if !yes {
243        let proceed = Confirm::new()
244            .with_prompt(format!(
245                "  Apply rulesets to {} repositories?",
246                needs_changes.len()
247            ))
248            .default(false)
249            .interact()?;
250
251        if !proceed {
252            println!("  Aborted.");
253            return Ok(());
254        }
255    }
256
257    println!();
258    println!("  {} Applying rulesets...", style("[..]").dim());
259
260    let body = build_ruleset_json(&config);
261    let mut succeeded = 0usize;
262    let mut failed: Vec<(String, String)> = Vec::new();
263
264    for plan in &plans {
265        match &plan.action {
266            RulesetPlanAction::InSync { .. } => {}
267            RulesetPlanAction::Create { name } => {
268                match client.create_ruleset(&plan.repo, &body).await {
269                    Ok(_) => {
270                        println!(
271                            "  {} {}: created {}",
272                            style("[ok]").green(),
273                            plan.repo,
274                            name
275                        );
276                        succeeded += 1;
277                    }
278                    Err(e) => {
279                        println!("  {} {}: {}", style("[!!]").red(), plan.repo, e);
280                        failed.push((plan.repo.clone(), e.to_string()));
281                    }
282                }
283            }
284            RulesetPlanAction::Update { id, name } => {
285                match client.update_ruleset(&plan.repo, *id, &body).await {
286                    Ok(()) => {
287                        println!(
288                            "  {} {}: updated {}",
289                            style("[ok]").green(),
290                            plan.repo,
291                            name
292                        );
293                        succeeded += 1;
294                    }
295                    Err(e) => {
296                        println!("  {} {}: {}", style("[!!]").red(), plan.repo, e);
297                        failed.push((plan.repo.clone(), e.to_string()));
298                    }
299                }
300            }
301        }
302    }
303
304    println!();
305    if failed.is_empty() {
306        println!(
307            "  {} All {} repositories updated successfully.",
308            style("[ok]").green(),
309            succeeded
310        );
311    } else {
312        println!(
313            "  {} {} succeeded, {} failed:",
314            style("[!!]").yellow(),
315            succeeded,
316            failed.len()
317        );
318        for (repo, err) in &failed {
319            println!("    {} {}: {}", style("[!!]").red(), repo, err);
320        }
321    }
322
323    Ok(())
324}
325
326async fn audit(
327    client: &Client,
328    manifest: &Manifest,
329    system: Option<&str>,
330    repo: Option<&str>,
331) -> Result<()> {
332    let repos = resolve_repos(client, manifest, system, repo).await?;
333
334    println!();
335    println!(
336        "  {} Auditing rulesets for {} repositories...",
337        style("[..]").dim(),
338        repos.len()
339    );
340
341    println!();
342    println!(
343        "  {} {}",
344        style(format!("{:<40}", "Repository")).bold().underlined(),
345        style("Rulesets").bold().underlined(),
346    );
347    println!("  {}", style("\u{2500}".repeat(70)).dim());
348
349    for repo_name in &repos {
350        let rulesets = client.list_rulesets(repo_name).await?;
351
352        let summary = if rulesets.is_empty() {
353            style("(none)").dim().to_string()
354        } else {
355            rulesets
356                .iter()
357                .map(|r| r.name.clone())
358                .collect::<Vec<_>>()
359                .join(", ")
360        };
361
362        println!("  {:<40} {}", repo_name, summary);
363    }
364
365    println!();
366    println!(
367        "  Summary: {} repositories scanned",
368        style(repos.len()).green().bold()
369    );
370
371    Ok(())
372}
373
374fn print_plan_table(plans: &[RulesetPlan]) {
375    println!();
376    println!("  {}", style("Rulesets Plan").bold().cyan());
377    println!("  {}", style("\u{2500}".repeat(60)).dim());
378
379    for plan in plans {
380        match &plan.action {
381            RulesetPlanAction::Create { name } => {
382                println!(
383                    "  {} {} -- create: {}",
384                    style("[!!]").yellow(),
385                    style(&plan.repo).bold(),
386                    name
387                );
388            }
389            RulesetPlanAction::Update { name, .. } => {
390                println!(
391                    "  {} {} -- update: {}",
392                    style("[!!]").yellow(),
393                    style(&plan.repo).bold(),
394                    name
395                );
396            }
397            RulesetPlanAction::InSync { name } => {
398                println!(
399                    "  {} {} -- {} (in sync)",
400                    style("[ok]").green(),
401                    style(&plan.repo).dim(),
402                    name
403                );
404            }
405        }
406    }
407
408    let needs_changes = plans
409        .iter()
410        .filter(|p| !matches!(p.action, RulesetPlanAction::InSync { .. }))
411        .count();
412    let up_to_date = plans.len() - needs_changes;
413
414    println!();
415    println!(
416        "  Summary: {} need changes, {} up to date",
417        if needs_changes > 0 {
418            style(needs_changes).yellow().bold()
419        } else {
420            style(needs_changes).green().bold()
421        },
422        style(up_to_date).green()
423    );
424}
425
426#[cfg(test)]
427mod tests {
428    use super::*;
429
430    #[test]
431    fn test_build_ruleset_json() {
432        let config = RulesetBranchProtection {
433            enabled: true,
434            name: None,
435            enforcement: "active".to_string(),
436            required_approvals: 1,
437            dismiss_stale_reviews: true,
438            require_code_owner_reviews: false,
439            required_status_checks: vec!["ci".to_string()],
440            require_linear_history: false,
441            block_force_pushes: true,
442            block_deletions: true,
443        };
444
445        let json = build_ruleset_json(&config);
446        assert_eq!(json["name"], "Branch Protection");
447        assert_eq!(json["target"], "branch");
448        assert_eq!(json["enforcement"], "active");
449        assert_eq!(
450            json["conditions"]["ref_name"]["include"][0],
451            "~DEFAULT_BRANCH"
452        );
453
454        let rules = json["rules"].as_array().unwrap();
455        assert_eq!(rules[0]["type"], "pull_request");
456        assert_eq!(rules[0]["parameters"]["required_approving_review_count"], 1);
457        assert_eq!(
458            rules[0]["parameters"]["dismiss_stale_reviews_on_push"],
459            true
460        );
461
462        let rule_types: Vec<&str> = rules.iter().map(|r| r["type"].as_str().unwrap()).collect();
463        assert!(rule_types.contains(&"non_fast_forward"));
464        assert!(rule_types.contains(&"deletion"));
465        assert!(rule_types.contains(&"required_status_checks"));
466    }
467
468    #[test]
469    fn test_build_ruleset_json_minimal() {
470        let config = RulesetBranchProtection {
471            enabled: true,
472            name: Some("Custom".to_string()),
473            enforcement: "evaluate".to_string(),
474            required_approvals: 2,
475            dismiss_stale_reviews: false,
476            require_code_owner_reviews: false,
477            required_status_checks: vec![],
478            require_linear_history: false,
479            block_force_pushes: false,
480            block_deletions: false,
481        };
482
483        let json = build_ruleset_json(&config);
484        assert_eq!(json["name"], "Custom");
485        assert_eq!(json["enforcement"], "evaluate");
486
487        let rules = json["rules"].as_array().unwrap();
488        assert_eq!(rules.len(), 1);
489        assert_eq!(rules[0]["type"], "pull_request");
490        assert_eq!(rules[0]["parameters"]["required_approving_review_count"], 2);
491    }
492
493    #[test]
494    fn test_build_ruleset_json_with_linear_history() {
495        let config = RulesetBranchProtection {
496            enabled: true,
497            name: None,
498            enforcement: "active".to_string(),
499            required_approvals: 1,
500            dismiss_stale_reviews: false,
501            require_code_owner_reviews: true,
502            required_status_checks: vec![],
503            require_linear_history: true,
504            block_force_pushes: false,
505            block_deletions: false,
506        };
507
508        let json = build_ruleset_json(&config);
509        let rules = json["rules"].as_array().unwrap();
510        let rule_types: Vec<&str> = rules.iter().map(|r| r["type"].as_str().unwrap()).collect();
511        assert!(rule_types.contains(&"required_linear_history"));
512        assert_eq!(rules[0]["parameters"]["require_code_owner_review"], true);
513    }
514}