From 157951d2bcc6d5cff2a3aec1813e3e9f749931d4 Mon Sep 17 00:00:00 2001 From: erishforG Date: Mon, 14 Sep 2026 09:33:55 +0900 Subject: [PATCH] feat(triage): add explicit label apply mode Co-Authored-By: Claude Opus 4.7 --- src/cli/commands/triage.rs | 41 ++++++++++++++++++------------- src/cli/mod.rs | 17 +++++++++---- tests/cli_tests.rs | 49 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 87 insertions(+), 20 deletions(-) diff --git a/src/cli/commands/triage.rs b/src/cli/commands/triage.rs index 0ed9f66..dc46d3a 100644 --- a/src/cli/commands/triage.rs +++ b/src/cli/commands/triage.rs @@ -1,15 +1,10 @@ -//! `parsec triage` — rule-based issue/PR auto-labelling (#302). +//! `parsec triage` — rule-based issue auto-labelling (#302). //! -//! # Phase 1 //! - Load `[[triage.rules]]` from parsec config. //! - Fetch open GitHub issues for the repo. //! - Apply each rule (case-insensitive substring match on title). //! - Print a table: issue #, title, matched rule, proposed labels, trust score. -//! - Dry-run only: no labels are written to GitHub. -//! -//! # Phase 2 (planned) -//! - `--apply` flag: write proposed labels via `gh` API (`add_labels`). -//! - AI-assisted labelling from issue body (requires LLM integration). +//! - Write proposed labels only when `--apply` is explicitly passed. //! //! Trust-score logic: //! - First (highest-priority) rule to match → **1.0** @@ -18,7 +13,7 @@ use std::path::Path; -use anyhow::Result; +use anyhow::{Context, Result}; use colored::Colorize; use tabled::{settings::Style, Table, Tabled}; @@ -33,9 +28,9 @@ use crate::output::Mode; /// Entry point for `parsec triage`. /// -/// Fetches open issues, applies the configured rules, and prints a dry-run -/// table of proposed labels. No changes are written to GitHub in Phase 1. -pub async fn triage(repo: &Path, limit: u8, mode: Mode) -> Result<()> { +/// Fetches open issues, applies the configured rules, and prints the proposed +/// labels. Labels are written only when `apply` is true. +pub async fn triage(repo: &Path, limit: u8, apply: bool, mode: Mode) -> Result<()> { let config = ParsecConfig::load()?; if config.triage.rules.is_empty() { @@ -102,6 +97,14 @@ pub async fn triage(repo: &Path, limit: u8, mode: Mode) -> Result<()> { return Ok(()); } + if apply { + for entry in &entries { + gh.add_labels(entry.number, &entry.labels) + .await + .with_context(|| format!("failed to apply labels to issue #{}", entry.number))?; + } + } + match mode { Mode::Json => { println!("{}", serde_json::to_string_pretty(&entries)?); @@ -116,7 +119,8 @@ pub async fn triage(repo: &Path, limit: u8, mode: Mode) -> Result<()> { println!( "{}", format!( - " parsec triage — dry-run ({} rule(s), {} issue(s) matched of {})", + " parsec triage — {} ({} rule(s), {} issue(s) matched of {})", + if apply { "applied" } else { "dry-run" }, config.triage.rules.len(), entries.len(), issues.len() @@ -140,10 +144,12 @@ pub async fn triage(repo: &Path, limit: u8, mode: Mode) -> Result<()> { let table = Table::new(rows).with(Style::rounded()).to_string(); println!("{table}"); println!(); - println!( - "{}", - "Tip: No labels were written (dry-run). Phase 2 will add --apply.".dimmed() - ); + if !apply { + println!( + "{}", + "Tip: No labels were written. Pass --apply to apply.".dimmed() + ); + } } } @@ -193,6 +199,7 @@ fn build_entry( title: title.to_string(), matched_rule: first.pattern.clone(), proposed_labels, + labels, trust_score, already_labelled: !existing_labels.is_empty(), }) @@ -218,6 +225,8 @@ pub struct TriageEntry { pub title: String, pub matched_rule: String, pub proposed_labels: String, + #[serde(skip)] + labels: Vec, pub trust_score: f32, pub already_labelled: bool, } diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 2b383dd..30f8fc5 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -683,13 +683,12 @@ pub enum Command { action: CheckpointAction, }, - /// Rule-based issue/PR auto-labelling (dry-run in Phase 1). + /// Rule-based issue auto-labelling (dry-run by default). /// /// Reads `[[triage.rules]]` from your parsec config, fetches open GitHub /// issues, and prints a table of proposed labels with a trust score. /// - /// Phase 1: dry-run only — no labels are written to GitHub. - /// Phase 2 will add `--apply` to write proposed labels. + /// Labels are only written when `--apply` is explicitly passed. /// /// Example config rule: /// [[triage.rules]] @@ -699,6 +698,10 @@ pub enum Command { /// Maximum number of open issues to inspect (default: 30). #[arg(long, default_value = "30")] limit: u8, + + /// Apply proposed labels to GitHub issues (default: dry-run). + #[arg(long)] + apply: bool, }, } @@ -1227,7 +1230,13 @@ pub async fn run(cli: Cli) -> Result<()> { commands::checkpoint_drop(&repo_path, &name, output_mode) } }, - Command::Triage { limit } => commands::triage(&repo_path, limit, output_mode).await, + Command::Triage { limit, apply } => { + let apply = apply && !cli.dry_run; + if apply && offline { + anyhow::bail!("cannot use --apply in offline mode"); + } + commands::triage(&repo_path, limit, apply, output_mode).await + } }; // Startup version hint — one-line stderr notice when a newer release is cached. diff --git a/tests/cli_tests.rs b/tests/cli_tests.rs index 406ba83..4fafbb7 100644 --- a/tests/cli_tests.rs +++ b/tests/cli_tests.rs @@ -2794,3 +2794,52 @@ fn test_crash_report_list_json_no_reports() { "expected empty JSON array, got: {stdout}" ); } + +// --------------------------------------------------------------------------- +// triage — Phase 2 CLI safety tests (#302) +// --------------------------------------------------------------------------- + +#[test] +fn test_triage_help_shows_apply() { + parsec() + .args(["triage", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("--apply")) + .stdout(predicate::str::contains("dry-run")); +} + +#[test] +fn test_triage_apply_rejected_offline() { + let repo = setup_repo(); + parsec() + .args([ + "--offline", + "--repo", + repo.path().to_str().unwrap(), + "triage", + "--apply", + ]) + .assert() + .failure() + .stderr(predicate::str::contains( + "cannot use --apply in offline mode", + )); +} + +#[test] +fn test_triage_global_dry_run_overrides_apply() { + let repo = setup_repo(); + parsec() + .args([ + "--offline", + "--dry-run", + "--repo", + repo.path().to_str().unwrap(), + "triage", + "--apply", + ]) + .assert() + .success() + .stdout(predicate::str::contains("No triage rules configured")); +}