From 93eaf2d0a79cdbedf1b28db7564b933f43e5fdfb Mon Sep 17 00:00:00 2001 From: Sachin Iyer Date: Wed, 6 May 2026 13:59:04 -0700 Subject: [PATCH] feat(bugs): add --format json to bugs show and bugs close MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Triage agents repeatedly grep stdout for fields like the closed-via PR number and bug metadata (file path, security flag, blame). Only `bugs list` accepted `--format json`; `show` and `close` forced text-parsing. Add `--format ` (default `table`) to both subcommands: - `bugs show --format json` — emits the full Bug payload - `bugs close --state --format json` — emits the resulting BugReview, suppressing the human "✓ Bug closed as: …" banner so stdout is pure JSON `is_silent` extended to suppress auto-update notices on the new JSON paths, matching how `bugs list --format json` already works. The table-view body of `Show` is unchanged; extracted into a `render_bug_show` helper to keep the handler match arm focused on the format dispatch. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/HELP.md | 17 ++++++++- src/commands/bugs.rs | 91 ++++++++++++++++++++++++++++---------------- src/lib.rs | 25 ++++++++++-- 3 files changed, 95 insertions(+), 38 deletions(-) diff --git a/docs/HELP.md b/docs/HELP.md index 691433a..bb24c45 100644 --- a/docs/HELP.md +++ b/docs/HELP.md @@ -159,12 +159,21 @@ List bugs for a given repository Show the report for a bug -**Usage:** `detail bugs show ` +**Usage:** `detail bugs show [OPTIONS] ` ###### **Arguments:** * `` — Bug ID +###### **Options:** + +* `--format ` — Output format + + Default value: `table` + + Possible values: `table`, `json` + + ## `detail bugs close` @@ -188,6 +197,12 @@ Close a bug as resolved or dismissed Possible values: `not-a-bug`, `wont-fix`, `duplicate`, `other` * `--notes ` — Additional notes +* `--format ` — Output format + + Default value: `table` + + Possible values: `table`, `json` + diff --git a/src/commands/bugs.rs b/src/commands/bugs.rs index 294cb38..ee8a63a 100644 --- a/src/commands/bugs.rs +++ b/src/commands/bugs.rs @@ -130,6 +130,10 @@ pub enum BugCommands { Show { /// Bug ID bug_id: String, + + /// Output format + #[arg(long, value_enum, default_value = "table")] + format: crate::OutputFormat, }, /// Close a bug as resolved or dismissed @@ -148,6 +152,10 @@ pub enum BugCommands { /// Additional notes #[arg(long)] notes: Option, + + /// Output format + #[arg(long, value_enum, default_value = "table")] + format: crate::OutputFormat, }, /// Reopen a previously resolved or dismissed bug — flips it back to @@ -259,6 +267,42 @@ fn validate_close_flags( Ok((state, dismissal_reason, notes)) } +/// Render a single bug as the human-readable `bugs show` view. +fn render_bug_show(bug: &Bug) -> Result<()> { + let mut pairs: Vec<(&str, String)> = vec![ + ("ID", bug.id.to_string()), + ("Title", bug.title.clone()), + ("File", bug.file_path.as_deref().unwrap_or("-").to_string()), + ("Created", format_datetime(bug.created_at)), + ( + "Security", + bug.is_security_vulnerability + .map_or("-", |v| if v { "Yes" } else { "No" }) + .to_string(), + ), + ]; + if let Some(intro) = &bug.introduced_in { + pairs.push(("Introduced", format_introduced_in(intro))); + } + if let Some(review) = &bug.review { + pairs.push(("Close", review_state_label(&review.state).to_string())); + pairs.push(("Close Date", format_datetime(review.created_at))); + if let Some(reason) = &review.dismissal_reason { + pairs.push(("Dismissal", dismissal_reason_label(reason).to_string())); + } + if let Some(notes) = &review.notes { + pairs.push(("Notes", notes.clone())); + } + } + for issue in &bug.linked_issues { + pairs.push(("Issue", format_linked_issue(issue))); + } + SectionRenderer::new() + .key_value("", &pairs) + .markdown("", &bug.summary) + .print() +} + /// Page size used when scanning all bugs for client-side vulnerability filtering. const BUG_PAGE_SIZE: u32 = 100; @@ -372,7 +416,7 @@ pub async fn handle(command: &BugCommands, cli: &crate::Cli) -> Result<()> { } } - BugCommands::Show { bug_id } => { + BugCommands::Show { bug_id, format } => { let bug_id: BugId = bug_id .as_str() .try_into() @@ -382,38 +426,11 @@ pub async fn handle(command: &BugCommands, cli: &crate::Cli) -> Result<()> { .await .context("Failed to fetch bug details")?; - let mut pairs: Vec<(&str, String)> = vec![ - ("ID", bug.id.to_string()), - ("Title", bug.title.clone()), - ("File", bug.file_path.as_deref().unwrap_or("-").to_string()), - ("Created", format_datetime(bug.created_at)), - ( - "Security", - bug.is_security_vulnerability - .map_or("-", |v| if v { "Yes" } else { "No" }) - .to_string(), - ), - ]; - if let Some(intro) = &bug.introduced_in { - pairs.push(("Introduced", format_introduced_in(intro))); - } - if let Some(review) = &bug.review { - pairs.push(("Close", review_state_label(&review.state).to_string())); - pairs.push(("Close Date", format_datetime(review.created_at))); - if let Some(reason) = &review.dismissal_reason { - pairs.push(("Dismissal", dismissal_reason_label(reason).to_string())); - } - if let Some(notes) = &review.notes { - pairs.push(("Notes", notes.clone())); - } + if matches!(format, crate::OutputFormat::Json) { + Term::stdout().write_line(&serde_json::to_string_pretty(&bug)?)?; + return Ok(()); } - for issue in &bug.linked_issues { - pairs.push(("Issue", format_linked_issue(issue))); - } - SectionRenderer::new() - .key_value("", &pairs) - .markdown("", &bug.summary) - .print() + render_bug_show(&bug) } BugCommands::Close { @@ -421,6 +438,7 @@ pub async fn handle(command: &BugCommands, cli: &crate::Cli) -> Result<()> { state, dismissal_reason, notes, + format, } => { let bug_id: BugId = bug_id .as_str() @@ -452,11 +470,18 @@ pub async fn handle(command: &BugCommands, cli: &crate::Cli) -> Result<()> { None => None, }; - client + let review = client .update_bug_close(&bug_id, state, dismissal_reason, notes.as_deref()) .await .context("Failed to close bug")?; + if matches!(format, crate::OutputFormat::Json) { + // Emit only the BugReview JSON — the human-friendly success + // banner would corrupt the structured output. + Term::stdout().write_line(&serde_json::to_string_pretty(&review)?)?; + return Ok(()); + } + Term::stdout() .write_line(&format!( "{}", diff --git a/src/lib.rs b/src/lib.rs index b51d71d..1a671ca 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -51,10 +51,10 @@ impl Cli { const fn is_silent(&self) -> bool { match &self.command { Commands::Bugs { command } => match command { - commands::bugs::BugCommands::List { format, .. } => Self::is_json(format), - commands::bugs::BugCommands::Show { .. } - | commands::bugs::BugCommands::Close { .. } - | commands::bugs::BugCommands::Reopen { .. } => false, + commands::bugs::BugCommands::List { format, .. } + | commands::bugs::BugCommands::Show { format, .. } + | commands::bugs::BugCommands::Close { format, .. } => Self::is_json(format), + commands::bugs::BugCommands::Reopen { .. } => false, }, Commands::Repos { command } => match command { commands::repos::RepoCommands::List { format, .. } => Self::is_json(format), @@ -264,6 +264,13 @@ mod tests { assert!(!cli.is_silent()); } + #[test] + fn silent_when_bugs_show_json() { + let cli = + Cli::try_parse_from(["detail", "bugs", "show", "bug_123", "--format", "json"]).unwrap(); + assert!(cli.is_silent()); + } + #[test] fn not_silent_for_bugs_close() { let cli = @@ -298,10 +305,20 @@ mod tests { #[test] fn not_silent_for_bugs_reopen() { + // Reopen has no JSON output to corrupt, so update notices stay on. let cli = Cli::try_parse_from(["detail", "bugs", "reopen", "bug_123"]).unwrap(); assert!(!cli.is_silent()); } + #[test] + fn silent_when_bugs_close_json() { + let cli = Cli::try_parse_from([ + "detail", "bugs", "close", "bug_123", "--state", "resolved", "--format", "json", + ]) + .unwrap(); + assert!(cli.is_silent()); + } + #[test] fn not_silent_for_auth_status() { let cli = Cli::try_parse_from(["detail", "auth", "status"]).unwrap();