Skip to main content

ward/cli/
commit.rs

1use anyhow::{Context, Result};
2use clap::Args;
3use console::style;
4use dialoguer::Confirm;
5
6use crate::config::Manifest;
7use crate::config::templates::load_templates_with_custom_dir;
8use crate::detection::project_type::ProjectType;
9use crate::detection::versions;
10use crate::engine::audit_log::AuditLog;
11use crate::github::Client;
12use crate::github::commits::CommitFile;
13
14#[derive(Args)]
15pub struct CommitCommand {
16    #[command(subcommand)]
17    action: CommitAction,
18}
19
20#[derive(clap::Subcommand)]
21enum CommitAction {
22    /// Preview what files would be committed
23    Plan {
24        /// Template name (dependabot, codeql, dependency-submission)
25        #[arg(long)]
26        template: String,
27    },
28
29    /// Commit template files and create PRs
30    Apply {
31        /// Template name (dependabot, codeql, dependency-submission)
32        #[arg(long)]
33        template: String,
34
35        /// Skip confirmation prompt
36        #[arg(long)]
37        yes: bool,
38    },
39}
40
41/// Resolved template output for a single repo.
42struct TemplateResult {
43    repo_name: String,
44    target_path: String,
45    rendered: String,
46    already_exists: bool,
47    existing_matches: bool,
48}
49
50impl CommitCommand {
51    pub async fn run(
52        &self,
53        client: &Client,
54        manifest: &Manifest,
55        system: Option<&str>,
56        repo: Option<&str>,
57    ) -> Result<()> {
58        match &self.action {
59            CommitAction::Plan { template } => plan(client, manifest, system, repo, template).await,
60            CommitAction::Apply { template, yes } => {
61                apply(client, manifest, system, repo, template, *yes).await
62            }
63        }
64    }
65}
66
67fn resolve_template_info(template: &str) -> Result<(&str, &str)> {
68    match template {
69        "dependabot" => Ok((".github/dependabot.yml", "dependabot")),
70        "codeql" => Ok((".github/workflows/codeql.yml", "codeql")),
71        "dependency-submission" => Ok((
72            ".github/workflows/dependency-submission.yml",
73            "dependency-submission",
74        )),
75        _ => anyhow::bail!(
76            "Unknown template: {template}. Available: dependabot, codeql, dependency-submission"
77        ),
78    }
79}
80
81async fn detect_and_render(
82    client: &Client,
83    repo_name: &str,
84    default_branch: &str,
85    template_category: &str,
86    target_path: &str,
87    manifest: &Manifest,
88) -> Result<TemplateResult> {
89    // Detect project type by checking for build files
90    let project_type = detect_project_type(client, repo_name).await?;
91
92    let tera_template_name = match (&project_type, template_category) {
93        (ProjectType::Gradle, "dependabot") => "dependabot/gradle.yml.tera",
94        (ProjectType::Npm, "dependabot") => "dependabot/npm.yml.tera",
95        (ProjectType::Gradle, "codeql") => "codeql/gradle.yml.tera",
96        (ProjectType::Npm, "codeql") => "codeql/npm.yml.tera",
97        (ProjectType::Gradle, "dependency-submission") => "dependency-submission/gradle.yml.tera",
98        (pt, cat) => {
99            anyhow::bail!("No template for {cat} + {pt} in repo {repo_name}");
100        }
101    };
102
103    // Detect version
104    let mut tera_context = tera::Context::new();
105    tera_context.insert("default_branch", default_branch);
106
107    match project_type {
108        ProjectType::Gradle => {
109            let java_ver = detect_java_version(client, repo_name).await?;
110            tera_context.insert("java_version", &java_ver.to_string());
111
112            if let Some(reg) = manifest.templates.registries.get("gradle-artifactory") {
113                tera_context.insert("registry_url", &reg.url);
114                if let Some(ref provider) = reg.jfrog_oidc_provider {
115                    tera_context.insert("jfrog_oidc_provider", provider);
116                }
117            }
118        }
119        ProjectType::Npm => {
120            let node_ver = detect_node_version(client, repo_name).await?;
121            tera_context.insert("node_version", &node_ver);
122        }
123        _ => {}
124    }
125
126    let tera = load_templates_with_custom_dir(
127        manifest
128            .templates
129            .custom_dir
130            .as_ref()
131            .map(std::path::Path::new),
132    )?;
133    let rendered = tera
134        .render(tera_template_name, &tera_context)
135        .with_context(|| format!("Failed to render template {tera_template_name}"))?;
136
137    // Check if file already exists
138    let existing = client.get_file(repo_name, target_path, None).await?;
139    let (already_exists, existing_matches) = if let Some(ref content) = existing {
140        let decoded = Client::decode_content(content).unwrap_or_default();
141        (true, decoded.trim() == rendered.trim())
142    } else {
143        (false, false)
144    };
145
146    Ok(TemplateResult {
147        repo_name: repo_name.to_owned(),
148        target_path: target_path.to_owned(),
149        rendered,
150        already_exists,
151        existing_matches,
152    })
153}
154
155async fn detect_project_type(client: &Client, repo: &str) -> Result<ProjectType> {
156    // Check for Gradle first (more common in our org)
157    if client
158        .get_file(repo, "build.gradle.kts", None)
159        .await?
160        .is_some()
161    {
162        return Ok(ProjectType::Gradle);
163    }
164    if client.get_file(repo, "build.gradle", None).await?.is_some() {
165        return Ok(ProjectType::Gradle);
166    }
167    if client.get_file(repo, "package.json", None).await?.is_some() {
168        return Ok(ProjectType::Npm);
169    }
170    if client.get_file(repo, "Cargo.toml", None).await?.is_some() {
171        return Ok(ProjectType::Cargo);
172    }
173    Ok(ProjectType::Unknown)
174}
175
176async fn detect_java_version(client: &Client, repo: &str) -> Result<u8> {
177    // Try build.gradle.kts first, then build.gradle
178    for file in &["build.gradle.kts", "build.gradle"] {
179        if let Some(content) = client.get_file(repo, file, None).await? {
180            let text = Client::decode_content(&content)?;
181            if let Some(ver) = versions::extract_java_version(&text) {
182                tracing::info!("{repo}: detected Java {ver} from {file}");
183                return Ok(ver);
184            }
185        }
186    }
187
188    tracing::warn!("{repo}: could not detect Java version, defaulting to 21");
189    Ok(21)
190}
191
192async fn detect_node_version(client: &Client, repo: &str) -> Result<String> {
193    if let Some(content) = client.get_file(repo, "package.json", None).await? {
194        let text = Client::decode_content(&content)?;
195        if let Some(ver) = versions::extract_node_version(&text) {
196            // Extract just the major version number
197            let major: String = ver.chars().filter(|c| c.is_ascii_digit()).collect();
198            if !major.is_empty() {
199                tracing::info!("{repo}: detected Node {major} from package.json");
200                return Ok(major);
201            }
202        }
203    }
204
205    tracing::warn!("{repo}: could not detect Node version, defaulting to 20");
206    Ok("20".to_owned())
207}
208
209async fn resolve_repos_with_branches(
210    client: &Client,
211    manifest: &Manifest,
212    system: Option<&str>,
213    repo: Option<&str>,
214) -> Result<Vec<(String, String)>> {
215    if let Some(repo_name) = repo {
216        let r = client.get_repo(repo_name).await?;
217        return Ok(vec![(r.name, r.default_branch)]);
218    }
219
220    let sys = system.ok_or_else(|| anyhow::anyhow!("Either --system or --repo is required"))?;
221    let excludes = manifest.exclude_patterns_for_system(sys);
222    let explicit = manifest.explicit_repos_for_system(sys);
223    let repos = client
224        .list_repos_for_system(sys, &excludes, &explicit)
225        .await?;
226    Ok(repos
227        .into_iter()
228        .map(|r| (r.name, r.default_branch))
229        .collect())
230}
231
232async fn plan(
233    client: &Client,
234    manifest: &Manifest,
235    system: Option<&str>,
236    repo: Option<&str>,
237    template: &str,
238) -> Result<()> {
239    let (target_path, template_category) = resolve_template_info(template)?;
240    let repos = resolve_repos_with_branches(client, manifest, system, repo).await?;
241
242    println!();
243    println!(
244        "  {} Commit plan: {} → {}",
245        style("📋").bold(),
246        style(template).cyan().bold(),
247        style(target_path).dim()
248    );
249    println!(
250        "  {} Scanning {} repositories...",
251        style("🔍").bold(),
252        repos.len()
253    );
254    println!();
255
256    let mut to_create = 0;
257    let mut to_update = 0;
258    let mut up_to_date = 0;
259    let mut skipped = 0;
260
261    for (repo_name, default_branch) in &repos {
262        match detect_and_render(
263            client,
264            repo_name,
265            default_branch,
266            template_category,
267            target_path,
268            manifest,
269        )
270        .await
271        {
272            Ok(result) => {
273                if result.existing_matches {
274                    println!(
275                        "  {} {}",
276                        style("✓").green(),
277                        style(&result.repo_name).dim()
278                    );
279                    up_to_date += 1;
280                } else if result.already_exists {
281                    println!(
282                        "  {} {} (update {})",
283                        style("⚡").yellow(),
284                        style(&result.repo_name).bold(),
285                        target_path
286                    );
287                    to_update += 1;
288                } else {
289                    println!(
290                        "  {} {} (create {})",
291                        style("⚡").yellow(),
292                        style(&result.repo_name).bold(),
293                        target_path
294                    );
295                    to_create += 1;
296                }
297            }
298            Err(e) => {
299                println!(
300                    "  {} {}: {}",
301                    style("⏭").dim(),
302                    style(&repo_name).dim(),
303                    style(e).dim()
304                );
305                skipped += 1;
306            }
307        }
308    }
309
310    println!();
311    println!(
312        "  Summary: {} to create, {} to update, {} up to date, {} skipped",
313        style(to_create).yellow().bold(),
314        style(to_update).yellow().bold(),
315        style(up_to_date).green(),
316        style(skipped).dim()
317    );
318
319    if to_create + to_update > 0 {
320        println!(
321            "\n  Run {} to apply.",
322            style(format!("ward commit apply --template {template}"))
323                .cyan()
324                .bold()
325        );
326    }
327
328    Ok(())
329}
330
331async fn apply(
332    client: &Client,
333    manifest: &Manifest,
334    system: Option<&str>,
335    repo: Option<&str>,
336    template: &str,
337    yes: bool,
338) -> Result<()> {
339    let (target_path, template_category) = resolve_template_info(template)?;
340    let repos = resolve_repos_with_branches(client, manifest, system, repo).await?;
341    let branch_name = &manifest.templates.branch;
342
343    println!();
344    println!(
345        "  {} Preparing commits: {} → {}",
346        style("📋").bold(),
347        style(template).cyan().bold(),
348        style(target_path).dim()
349    );
350
351    // Build all template results
352    let mut pending: Vec<TemplateResult> = Vec::new();
353    for (repo_name, default_branch) in &repos {
354        match detect_and_render(
355            client,
356            repo_name,
357            default_branch,
358            template_category,
359            target_path,
360            manifest,
361        )
362        .await
363        {
364            Ok(result) if !result.existing_matches => {
365                pending.push(result);
366            }
367            Ok(_) => {
368                tracing::debug!("{repo_name}: already up to date, skipping");
369            }
370            Err(e) => {
371                tracing::warn!("{repo_name}: skipped ({e})");
372            }
373        }
374    }
375
376    if pending.is_empty() {
377        println!(
378            "\n  {} All repositories already up to date.",
379            style("✅").green()
380        );
381        return Ok(());
382    }
383
384    println!(
385        "\n  {} repos need changes. Branch: {}",
386        style(pending.len()).yellow().bold(),
387        style(branch_name).cyan()
388    );
389
390    for r in &pending {
391        let action = if r.already_exists { "update" } else { "create" };
392        println!(
393            "  {} {} → {action} {}",
394            style("⚡").yellow(),
395            r.repo_name,
396            r.target_path
397        );
398    }
399
400    if !yes {
401        println!();
402        let proceed = Confirm::new()
403            .with_prompt(format!(
404                "  Commit to {} repos and create PRs?",
405                pending.len()
406            ))
407            .default(false)
408            .interact()?;
409
410        if !proceed {
411            println!("  Aborted.");
412            return Ok(());
413        }
414    }
415
416    let audit_log = AuditLog::new()?;
417    let mut succeeded = 0usize;
418    let mut failed: Vec<(String, String)> = Vec::new();
419
420    for result in &pending {
421        println!("  {} {} ...", style("▶").magenta(), result.repo_name);
422
423        let default_branch = repos
424            .iter()
425            .find(|(n, _)| *n == result.repo_name)
426            .map(|(_, b)| b.as_str())
427            .unwrap_or("main");
428
429        match commit_and_pr(&CommitPrParams {
430            client,
431            repo: &result.repo_name,
432            default_branch,
433            branch_name,
434            target_path: &result.target_path,
435            content: &result.rendered,
436            template,
437            reviewers: &manifest.templates.reviewers,
438            commit_prefix: &manifest.templates.commit_message_prefix,
439        })
440        .await
441        {
442            Ok(pr_url) => {
443                println!("    {} PR: {}", style("✅").green(), style(&pr_url).cyan());
444                audit_log.log(
445                    &result.repo_name,
446                    &format!("commit_template_{template}"),
447                    "success",
448                    result.already_exists,
449                    true,
450                )?;
451                succeeded += 1;
452            }
453            Err(e) => {
454                println!("    {} {}", style("❌").red(), e);
455                failed.push((result.repo_name.clone(), e.to_string()));
456            }
457        }
458    }
459
460    println!();
461    if failed.is_empty() {
462        println!(
463            "  {} All {} repos committed and PRs created.",
464            style("✅").green(),
465            succeeded
466        );
467    } else {
468        println!(
469            "  {} {} succeeded, {} failed:",
470            style("⚠️").yellow(),
471            succeeded,
472            failed.len()
473        );
474        for (repo, err) in &failed {
475            println!("    {} {}: {}", style("❌").red(), repo, err);
476        }
477    }
478
479    println!(
480        "\n  {} Audit log: {}",
481        style("📋").bold(),
482        audit_log.path().display()
483    );
484
485    Ok(())
486}
487
488struct CommitPrParams<'a> {
489    client: &'a Client,
490    repo: &'a str,
491    default_branch: &'a str,
492    branch_name: &'a str,
493    target_path: &'a str,
494    content: &'a str,
495    template: &'a str,
496    reviewers: &'a [String],
497    commit_prefix: &'a str,
498}
499
500async fn commit_and_pr(params: &CommitPrParams<'_>) -> Result<String> {
501    let CommitPrParams {
502        client,
503        repo,
504        default_branch,
505        branch_name,
506        target_path,
507        content,
508        template,
509        reviewers,
510        commit_prefix,
511    } = params;
512
513    // Create branch from default branch
514    client
515        .create_branch(repo, branch_name, default_branch)
516        .await?;
517
518    // Commit the file
519    let message = format!("{commit_prefix}add {template} configuration");
520    let files = vec![CommitFile {
521        path: target_path.to_string(),
522        content: content.to_string(),
523    }];
524
525    client
526        .create_commit(repo, branch_name, &message, &files)
527        .await?;
528
529    // Create PR
530    let pr_title = format!("{commit_prefix}add {template} configuration");
531    let pr_body = format!(
532        "## Ward: automated template commit\n\n\
533         Template: `{template}`\n\
534         File: `{target_path}`\n\n\
535         This PR was created by [ward](https://github.com/OriginalMHV/ward).\n\n\
536         ---\n\
537         *Review the file contents, then merge.*"
538    );
539
540    let pr = client
541        .create_pull_request(
542            repo,
543            &pr_title,
544            &pr_body,
545            branch_name,
546            default_branch,
547            reviewers,
548        )
549        .await?;
550
551    Ok(pr.html_url)
552}