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 Plan,
20
21 Apply {
23 #[arg(long, short)]
25 yes: bool,
26 },
27
28 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
70pub 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}