Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 25 additions & 16 deletions src/cli/commands/triage.rs
Original file line number Diff line number Diff line change
@@ -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**
Expand All @@ -18,7 +13,7 @@

use std::path::Path;

use anyhow::Result;
use anyhow::{Context, Result};
use colored::Colorize;
use tabled::{settings::Style, Table, Tabled};

Expand All @@ -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() {
Expand Down Expand Up @@ -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)?);
Expand All @@ -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()
Expand All @@ -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()
);
}
}
}

Expand Down Expand Up @@ -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(),
})
Expand All @@ -218,6 +225,8 @@ pub struct TriageEntry {
pub title: String,
pub matched_rule: String,
pub proposed_labels: String,
#[serde(skip)]
labels: Vec<String>,
pub trust_score: f32,
pub already_labelled: bool,
}
Expand Down
17 changes: 13 additions & 4 deletions src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]]
Expand All @@ -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,
},
}

Expand Down Expand Up @@ -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.
Expand Down
49 changes: 49 additions & 0 deletions tests/cli_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
}
Loading