From 1a3089f4d5f22c78e1a550ca09b4aff09819b5fc Mon Sep 17 00:00:00 2001 From: Tyler Dammann Date: Wed, 19 Aug 2026 15:01:08 -0400 Subject: [PATCH 1/2] feat(bugs): support priority in list, show, and a new prioritize command --- .claude/skills/detail-bugs/SKILL.md | 18 +- docs/HELP.md | 38 +++ openapi.json | 154 +++++++++++ src/api/client.rs | 49 +++- src/api/types.rs | 59 +++- src/commands/bugs.rs | 409 +++++++++++++++++++++++++--- src/lib.rs | 3 +- 7 files changed, 682 insertions(+), 48 deletions(-) diff --git a/.claude/skills/detail-bugs/SKILL.md b/.claude/skills/detail-bugs/SKILL.md index 0c91405..db97f12 100644 --- a/.claude/skills/detail-bugs/SKILL.md +++ b/.claude/skills/detail-bugs/SKILL.md @@ -1,11 +1,11 @@ --- name: detail-bugs -description: Interact with Detail bugs for a repository via the CLI — list and filter bugs, inspect reports, close as resolved or dismissed, and reopen previously closed bugs. +description: Interact with Detail bugs for a repository via the CLI — list and filter bugs, inspect reports, close as resolved or dismissed, reopen previously closed bugs, and override a bug's priority. --- # Detail Bugs -The Detail CLI exposes per-repository bugs through four subcommands: `list`, `show`, `close`, and `reopen`. This skill describes that surface so you can pick the right command for whatever the user is trying to do. +The Detail CLI exposes per-repository bugs through five subcommands: `list`, `show`, `close`, `reopen`, and `prioritize`. This skill describes that surface so you can pick the right command for whatever the user is trying to do. ## Prerequisites @@ -28,6 +28,8 @@ Lists bugs for the inferred or specified repository. - `--status pending|resolved|dismissed` — default `pending`; comma-separate or repeat the flag to combine (e.g. `--status resolved,dismissed`). - `--vulns` — only security vulnerabilities. +- `--priority p1|p2|p3|none` — only bugs at these priorities; comma-separate or repeat the flag (e.g. `--priority p1,p2`). `none` selects bugs Detail never scored — most bugs found before priority scoring shipped, so prefer `--priority p1,p2,p3` over `--priority p1` when the user asks for "prioritized" bugs. Default: every priority. +- `--sort newest|oldest|priority` — default `newest`. `priority` puts the most severe first and unscored last. - `--introduced-by ` — filter by authors (comma-separated or repeated). - `--scan-id ` — limit to a specific scan. Workflow IDs come from `detail scans list`. - `--since` / `--until` — accept a duration (`1d`, `24h`), an ISO date (`YYYY-MM-DD`), or an RFC3339 timestamp. @@ -39,6 +41,8 @@ Lists bugs for the inferred or specified repository. Shows the full report for a single bug. Reports often include a suggested fix. +Also shows `Priority` and, when Detail scored the bug, a `Rationale` explaining why. If someone has since overridden that score, an `Override` line reports what Detail originally assigned and why it was changed. + - `--format table|json` — use `json` when parsing rather than displaying. ### `detail bugs close ` @@ -53,3 +57,13 @@ Marks a bug as resolved or dismissed. The CLI prompts for `--state` interactivel ### `detail bugs reopen ` Flips a previously resolved or dismissed bug back to `pending`. Takes only the bug ID — useful when a fix is reverted or a dismissal is overturned. + +### `detail bugs prioritize ` + +Overrides Detail's priority for a bug and records the change on its timeline. The CLI prompts for `--priority` interactively in a TTY; pass it explicitly when invoking non-interactively. + +- `--priority p1|p2|p3`. +- `--comment "..."` — why the priority is changing. Worth passing: it is what a later `detail bugs show` reports as the override reason. +- `--format table|json`. + +Setting the priority a bug already has is a no-op — the CLI reports "no change" rather than recording a second identical entry. diff --git a/docs/HELP.md b/docs/HELP.md index 1120ef9..546d770 100644 --- a/docs/HELP.md +++ b/docs/HELP.md @@ -14,6 +14,7 @@ This document contains the help content for the `detail` command-line program. * [`detail bugs show`↴](#detail-bugs-show) * [`detail bugs close`↴](#detail-bugs-close) * [`detail bugs reopen`↴](#detail-bugs-reopen) +* [`detail bugs prioritize`↴](#detail-bugs-prioritize) * [`detail completions`↴](#detail-completions) * [`detail rules`↴](#detail-rules) * [`detail rules create`↴](#detail-rules-create) @@ -115,6 +116,7 @@ List, show, and close bugs * `show` — Show the report for a bug * `close` — Close a bug as resolved or dismissed * `reopen` — Reopen a previously resolved or dismissed bug — flips it back to pending. Useful when a "fix" PR is reverted or a "won't fix" decision is overturned +* `prioritize` — Set a bug's priority, overriding Detail's own assessment @@ -137,6 +139,16 @@ List bugs for a given repository Possible values: `pending`, `resolved`, `dismissed` * `--vulns` — Only show security vulnerabilities +* `--priority ` — Only show bugs at these priorities — repeat the flag or comma-separate values (e.g. `--priority p1,p2`). Use `none` for bugs Detail never assigned a priority. Default: all priorities + + Possible values: `p1`, `p2`, `p3`, `none` + +* `--sort ` — Result ordering. `priority` puts the most severe bugs first and unprioritized bugs last + + Default value: `newest` + + Possible values: `newest`, `oldest`, `priority` + * `--introduced-by ` — Only show bugs introduced by these authors (comma-separated or repeat flag) * `--scan-id ` — Filter bugs to a specific scan by workflow request ID * `--since ` — Only show bugs created at or after this point. Accepts a duration (e.g. 1d, 24h, 30m) interpreted as "now minus this", an ISO date (YYYY-MM-DD), or an RFC3339 timestamp @@ -220,6 +232,32 @@ Reopen a previously resolved or dismissed bug — flips it back to pending. Usef +## `detail bugs prioritize` + +Set a bug's priority, overriding Detail's own assessment + +**Usage:** `detail bugs prioritize [OPTIONS] ` + +###### **Arguments:** + +* `` — Bug ID + +###### **Options:** + +* `--priority ` — Priority to set (prompted interactively if omitted in a TTY) + + Possible values: `p1`, `p2`, `p3` + +* `--comment ` — Why the priority is changing — recorded on the bug's timeline +* `--format ` — Output format + + Default value: `table` + + Possible values: `table`, `json` + + + + ## `detail completions` Print shell completion script to stdout. diff --git a/openapi.json b/openapi.json index e5ae35f..64e3149 100644 --- a/openapi.json +++ b/openapi.json @@ -72,6 +72,12 @@ }, "type": "array" }, + "priority": { + "$ref": "#/components/schemas/Priority" + }, + "priorityReason": { + "$ref": "#/components/schemas/PriorityReason" + }, "repoId": { "$ref": "#/components/schemas/RepoId" }, @@ -180,6 +186,14 @@ ], "type": "string" }, + "BugSortOrder": { + "enum": [ + "newest", + "oldest", + "priority" + ], + "type": "string" + }, "BugSource": { "enum": [ "review", @@ -304,6 +318,37 @@ "pattern": "^org_.*", "type": "string" }, + "Priority": { + "description": "Detail's severity assessment, most severe first: P1 (High), P2 (Medium), P3 (Low).", + "enum": [ + "P1", + "P2", + "P3" + ], + "type": "string" + }, + "PriorityReason": { + "properties": { + "generatedPriority": { + "$ref": "#/components/schemas/Priority" + }, + "isCurrent": { + "type": "boolean" + }, + "overrideComment": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": [ + "text", + "generatedPriority", + "isCurrent" + ], + "type": "object" + }, "Repo": { "properties": { "fullName": { @@ -570,6 +615,26 @@ "$ref": "#/components/schemas/BugReviewState" } }, + { + "description": "Comma-separated priorities to include, e.g. \"P1,P2\". Use \"none\" to include bugs that were never assigned a priority. Omit to include every priority.", + "example": "P1,none", + "in": "query", + "name": "priority", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Result ordering. `priority` puts the most severe bugs first and unprioritized bugs last.", + "in": "query", + "name": "sort", + "required": false, + "schema": { + "$ref": "#/components/schemas/BugSortOrder", + "default": "newest" + } + }, { "in": "query", "name": "limit", @@ -710,6 +775,95 @@ "summary": "Get a bug" } }, + "/public/v1/bugs/{bug_id}/priority": { + "post": { + "description": "Overrides Detail's priority for a bug and records the change on the bug's timeline. Setting the priority the bug already has is a no-op and returns no priorityChangeId.", + "operationId": "updatePublicBugPriority", + "parameters": [ + { + "in": "path", + "name": "bug_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/BugId" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "comment": { + "description": "Why the priority is being changed.", + "maxLength": 2000, + "type": "string" + }, + "priority": { + "$ref": "#/components/schemas/Priority" + } + }, + "required": [ + "priority" + ], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "priority": { + "$ref": "#/components/schemas/Priority" + }, + "priorityChangeId": { + "pattern": "^bfrpc_.*", + "type": "string" + } + }, + "required": [ + "priority" + ], + "type": "object" + } + } + }, + "description": "OK" + }, + "4XX": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Client error" + }, + "5XX": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Server error" + } + }, + "security": [ + { + "BearerAuth": [] + } + ], + "summary": "Set a bug's priority" + } + }, "/public/v1/bugs/{bug_id}/review": { "post": { "description": "Creates or updates a review on a bug (resolve, dismiss, or reopen).", diff --git a/src/api/client.rs b/src/api/client.rs index af9e745..248d7f3 100644 --- a/src/api/client.rs +++ b/src/api/client.rs @@ -10,12 +10,24 @@ use progenitor::progenitor_client::{Error as ProgenitorError, ResponseValue}; use super::generated::types::CreateRuleBody; use super::types::{ - Bug, BugDismissalReason, BugId, BugReview, BugReviewState, BugsResponse, + Bug, BugDismissalReason, BugId, BugReview, BugReviewState, BugSortOrder, BugsResponse, CreatePublicBugReviewBody, CreateRuleInput, CreateRuleResponse, - ListPublicBugsWorkflowRequestId, RepoId, ReposResponse, Rule, RuleCreationRequestId, RuleId, - RuleRequestStatus, RuleRequestsResponse, RulesResponse, ScansResponse, UserInfo, + ListPublicBugsWorkflowRequestId, Priority, PriorityUpdate, RepoId, ReposResponse, Rule, + RuleCreationRequestId, RuleId, RuleRequestStatus, RuleRequestsResponse, RulesResponse, + ScansResponse, UpdatePublicBugPriorityBody, UserInfo, }; +/// Server-side knobs for `GET /public/v1/bugs` beyond repo, status, and paging. +/// Bundled so the fetch helpers can thread them through without growing a long +/// positional tail. +#[derive(Clone, Copy, Default)] +pub struct BugListQuery<'a> { + /// Comma-separated priority filter, already in the API's wire form. + pub priority: Option<&'a str>, + pub sort: Option, + pub scan_id: Option<&'a ListPublicBugsWorkflowRequestId>, +} + /// Convert a progenitor client error into a concise anyhow error. /// /// progenitor's own `Display` for `ErrorResponse` dumps headers and the typed @@ -104,15 +116,17 @@ impl ApiClient { status: BugReviewState, limit: u32, offset: u32, - scan_id: Option<&ListPublicBugsWorkflowRequestId>, + query: BugListQuery<'_>, ) -> Result { self.inner .list_public_bugs( NonZeroU64::new(limit.into()), Some(offset.into()), + query.priority, repo_id, + query.sort, status, - scan_id, + query.scan_id, ) .await .map(ResponseValue::into_inner) @@ -147,6 +161,31 @@ impl ApiClient { .map_err(api_error) } + /// Override Detail's priority for a bug. + /// + /// A response with no `priority_change_id` means the bug already carried + /// this priority and nothing was written. + pub async fn set_bug_priority( + &self, + bug_id: &BugId, + priority: Priority, + comment: Option<&str>, + ) -> Result { + // The spec caps the comment at 2000 chars, so progenitor wraps it in a + // validating newtype — reject an over-long comment here rather than + // letting the API do it in a round trip. + let comment = comment + .map(TryInto::try_into) + .transpose() + .map_err(|e| anyhow::anyhow!("Invalid comment: {e}"))?; + let body = UpdatePublicBugPriorityBody { comment, priority }; + self.inner + .update_public_bug_priority(bug_id, &body) + .await + .map(ResponseValue::into_inner) + .map_err(api_error) + } + pub async fn list_scans( &self, repo_id: &RepoId, diff --git a/src/api/types.rs b/src/api/types.rs index 0edc096..26560b2 100644 --- a/src/api/types.rs +++ b/src/api/types.rs @@ -6,10 +6,11 @@ use crate::utils::datetime::{format_date, format_datetime}; // Re-export generated types as the public API for this crate. pub use super::generated::types::{ Bug, BugCounts, BugDismissalReason, BugId, BugReview, BugReviewId, BugReviewState, - CreatePublicBugReviewBody, CreateRuleInput, CreateRuleResponse, FixPr, IntroducedIn, - LinkedIssue, LinkedIssueTracker, ListPublicBugsWorkflowRequestId, Org, OrgId, Repo, RepoId, - Rule, RuleCreationRequestId, RuleId, RuleListItem, RuleRequestResult, RuleRequestStatus, - RuleStatus, Scan, ScanInitiator, ScanType, WorkflowStatus, + BugSortOrder, CreatePublicBugReviewBody, CreateRuleInput, CreateRuleResponse, FixPr, + IntroducedIn, LinkedIssue, LinkedIssueTracker, ListPublicBugsWorkflowRequestId, Org, OrgId, + Priority, PriorityReason, Repo, RepoId, Rule, RuleCreationRequestId, RuleId, RuleListItem, + RuleRequestResult, RuleRequestStatus, RuleStatus, Scan, ScanInitiator, ScanType, + UpdatePublicBugPriorityBody, WorkflowStatus, }; // Friendlier aliases for the generated response-wrapper names. @@ -19,6 +20,7 @@ pub type ReposResponse = super::generated::types::ListPublicReposResponse; pub type ScansResponse = super::generated::types::ListPublicScansResponse; pub type RulesResponse = super::generated::types::ListRulesResponse; pub type RuleRequestsResponse = super::generated::types::ListRuleRequestsResponse; +pub type PriorityUpdate = super::generated::types::UpdatePublicBugPriorityResponse; // ── Display helpers ────────────────────────────────────────────────── // progenitor already implements Display for the generated enums, so we @@ -41,6 +43,16 @@ pub const fn dismissal_reason_label(r: &BugDismissalReason) -> &'static str { } } +/// Priority with its severity word, for detail views. List views use the bare +/// `P1`/`P2`/`P3` from `Display` to keep columns narrow. +pub const fn priority_label(p: &Priority) -> &'static str { + match p { + Priority::P1 => "P1 (High)", + Priority::P2 => "P2 (Medium)", + Priority::P3 => "P3 (Low)", + } +} + pub const fn rule_status_label(s: &RuleStatus) -> &'static str { match s { RuleStatus::Pending => "Pending", @@ -128,6 +140,34 @@ impl clap::ValueEnum for BugDismissalReason { } } +impl clap::ValueEnum for Priority { + fn value_variants<'a>() -> &'a [Self] { + &[Self::P1, Self::P2, Self::P3] + } + + fn to_possible_value(&self) -> Option { + match self { + Self::P1 => Some(PossibleValue::new("p1")), + Self::P2 => Some(PossibleValue::new("p2")), + Self::P3 => Some(PossibleValue::new("p3")), + } + } +} + +impl clap::ValueEnum for BugSortOrder { + fn value_variants<'a>() -> &'a [Self] { + &[Self::Newest, Self::Oldest, Self::Priority] + } + + fn to_possible_value(&self) -> Option { + match self { + Self::Newest => Some(PossibleValue::new("newest")), + Self::Oldest => Some(PossibleValue::new("oldest")), + Self::Priority => Some(PossibleValue::new("priority")), + } + } +} + impl clap::ValueEnum for WorkflowStatus { fn value_variants<'a>() -> &'a [Self] { &[Self::InProgress, Self::Complete, Self::Failed, Self::Dlq] @@ -160,10 +200,13 @@ impl clap::ValueEnum for ScanType { impl Formattable for Bug { fn to_card(&self) -> (String, Vec<(&'static str, String)>) { - let mut pairs = vec![ - ("Bug ID", self.id.to_string()), - ("Created", format_date(self.created_at)), - ]; + let mut pairs = vec![("Bug ID", self.id.to_string())]; + // Priority leads: it is the field triage sorts and filters on, and a + // bare `P1` keeps the column narrow. Absent for bugs never scored. + if let Some(priority) = &self.priority { + pairs.push(("Priority", priority.to_string())); + } + pairs.push(("Created", format_date(self.created_at))); // Each remaining field is conditional so non-applicable bugs don't // get a forest of "-" rows. Triage threads regularly need file path // and introducing PR — pulling them straight from `--format json` diff --git a/src/commands/bugs.rs b/src/commands/bugs.rs index 2fd1d55..5bae6d1 100644 --- a/src/commands/bugs.rs +++ b/src/commands/bugs.rs @@ -1,15 +1,17 @@ use std::convert::TryInto; +use std::fmt::Write as _; use anyhow::{bail, Context, Result}; +use clap::builder::PossibleValue; use clap::Subcommand; use console::{style, Term}; use dialoguer::{Input, Select}; -use crate::api::client::ApiClient; +use crate::api::client::{ApiClient, BugListQuery}; use crate::api::types::{ dismissal_reason_label, format_fix_pr, format_introduced_in, format_linked_issue, - review_state_label, Bug, BugDismissalReason, BugId, BugReviewState, - ListPublicBugsWorkflowRequestId, RepoId, + priority_label, review_state_label, Bug, BugDismissalReason, BugId, BugReviewState, + BugSortOrder, ListPublicBugsWorkflowRequestId, Priority, RepoId, }; use crate::output::{clamp_page, output_list, SectionRenderer}; use crate::utils::datetime::{format_datetime, parse_time_spec}; @@ -17,6 +19,74 @@ use crate::utils::pagination::page_to_offset; use crate::utils::repos::resolve_repo_id; use crate::utils::vcs::resolve_repo_arg; +/// A `--priority` selection: one of the three levels, or `none` for bugs +/// Detail never scored. +/// +/// `none` has to be expressible. Priorities are assigned at scan time, so a +/// bug found before scoring existed carries none and matches no level — this +/// is the only way to ask for those bugs rather than have a filter drop them. +// `pub` because it is a field type on `BugCommands`, which xtask reaches +// through `detail_cli::Cli` to generate docs/HELP.md; narrowing it trips +// `private_interfaces`. The helpers below have no such constraint. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PriorityFilter { + P1, + P2, + P3, + None, +} + +impl PriorityFilter { + /// The wire value the API expects for this entry. + const fn as_query_value(self) -> &'static str { + match self { + Self::P1 => "P1", + Self::P2 => "P2", + Self::P3 => "P3", + Self::None => "none", + } + } +} + +/// Join `--priority` values into the comma-separated form the API takes. +/// +/// Preserves first-seen order and drops repeats. Returns `None` for an empty +/// selection so the query param is omitted rather than sent empty — the API +/// rejects an empty value rather than reading it as "no filter". +fn priority_query(filters: &[PriorityFilter]) -> Option { + if filters.is_empty() { + return None; + } + let mut deduped: Vec = Vec::with_capacity(filters.len()); + for filter in filters { + if !deduped.contains(filter) { + deduped.push(*filter); + } + } + Some( + deduped + .iter() + .map(|f| f.as_query_value()) + .collect::>() + .join(","), + ) +} + +impl clap::ValueEnum for PriorityFilter { + fn value_variants<'a>() -> &'a [Self] { + &[Self::P1, Self::P2, Self::P3, Self::None] + } + + fn to_possible_value(&self) -> Option { + match self { + Self::P1 => Some(PossibleValue::new("p1")), + Self::P2 => Some(PossibleValue::new("p2")), + Self::P3 => Some(PossibleValue::new("p3")), + Self::None => Some(PossibleValue::new("none")), + } + } +} + /// Return only bugs where `isSecurityVulnerability` is `true`. fn filter_vulns_only(bugs: &[Bug]) -> Vec { bugs.iter() @@ -101,6 +171,46 @@ fn empty_filter_hint(pre_filter: &[Bug], vulns: bool) -> String { } } +/// Severity rank for `--sort priority`: most severe first, unscored last. +/// Mirrors the ordering the API applies, so a merged multi-status result reads +/// the same as a single-status one; keep the two in step. +const fn priority_rank(bug: &Bug) -> u8 { + match bug.priority { + Some(Priority::P1) => 0, + Some(Priority::P2) => 1, + Some(Priority::P3) => 2, + None => 3, + } +} + +/// Re-apply `sort` across bugs merged from several single-status requests. +/// +/// The API takes one status per call, so `--status pending,resolved` is two +/// calls whose results get concatenated: each block is ordered, the whole is +/// not. Without this, `--sort priority` over two statuses would show every +/// pending bug (P1 first) and only then start over at P1 for the resolved +/// ones. Single-status queries are already ordered by the server and skip it. +fn sort_bugs(bugs: &mut [Bug], sort: BugSortOrder) { + match sort { + BugSortOrder::Newest => bugs.sort_by(|a, b| { + b.created_at + .cmp(&a.created_at) + .then_with(|| b.id.as_str().cmp(a.id.as_str())) + }), + BugSortOrder::Oldest => bugs.sort_by(|a, b| { + a.created_at + .cmp(&b.created_at) + .then_with(|| a.id.as_str().cmp(b.id.as_str())) + }), + BugSortOrder::Priority => bugs.sort_by(|a, b| { + priority_rank(a) + .cmp(&priority_rank(b)) + .then_with(|| b.created_at.cmp(&a.created_at)) + .then_with(|| b.id.as_str().cmp(a.id.as_str())) + }), + } +} + fn paginate_items(items: &[T], page: u32, limit: u32) -> Vec { let offset = usize::try_from(page_to_offset(page, limit)).unwrap_or(0); items @@ -128,6 +238,17 @@ pub enum BugCommands { #[arg(long)] vulns: bool, + /// Only show bugs at these priorities — repeat the flag or + /// comma-separate values (e.g. `--priority p1,p2`). Use `none` for + /// bugs Detail never assigned a priority. Default: all priorities. + #[arg(long, value_enum, value_delimiter = ',')] + priority: Vec, + + /// Result ordering. `priority` puts the most severe bugs first and + /// unprioritized bugs last. + #[arg(long, value_enum, default_value = "newest")] + sort: BugSortOrder, + /// Only show bugs introduced by these authors (comma-separated or repeat flag) #[arg(long, value_delimiter = ',')] introduced_by: Vec, @@ -202,6 +323,24 @@ pub enum BugCommands { /// Bug ID bug_id: String, }, + + /// Set a bug's priority, overriding Detail's own assessment + Prioritize { + /// Bug ID + bug_id: String, + + /// Priority to set (prompted interactively if omitted in a TTY) + #[arg(long, value_enum)] + priority: Option, + + /// Why the priority is changing — recorded on the bug's timeline + #[arg(long)] + comment: Option, + + /// Output format + #[arg(long, value_enum, default_value = "table")] + format: crate::OutputFormat, + }, } // ── Interactive prompt helpers ────────────────────────────────────── @@ -238,6 +377,22 @@ fn prompt_dismissal_reason() -> Result { } } +/// Prompt for priority via arrow-key selection. +fn prompt_priority() -> Result { + let items = ["P1 (High)", "P2 (Medium)", "P3 (Low)"]; + let selection = Select::new() + .with_prompt("Priority") + .items(items) + .default(0) + .interact() + .context("Failed to read priority selection")?; + match selection { + 0 => Ok(Priority::P1), + 1 => Ok(Priority::P2), + _ => Ok(Priority::P3), + } +} + /// Prompt for optional notes via text input. fn prompt_notes() -> Result> { let input: String = Input::new() @@ -306,9 +461,24 @@ fn validate_close_flags( /// 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()), + let mut pairs: Vec<(&str, String)> = + vec![("ID", bug.id.to_string()), ("Title", bug.title.clone())]; + if let Some(priority) = &bug.priority { + pairs.push(("Priority", priority_label(priority).to_string())); + } + // Only the single-bug route returns a rationale, so this is the one view + // that can answer "why is this a P1?". + if let Some(reason) = &bug.priority_reason { + pairs.push(("Rationale", reason.text.clone())); + if !reason.is_current { + let mut overridden = format!("Detail assigned {}", reason.generated_priority); + if let Some(note) = &reason.override_comment { + let _ = write!(overridden, "; changed because: {note}"); + } + pairs.push(("Override", overridden)); + } + } + pairs.extend([ ("File", bug.file_path.as_deref().unwrap_or("-").to_string()), ("Created", format_datetime(bug.created_at)), ( @@ -317,7 +487,7 @@ fn render_bug_show(bug: &Bug) -> Result<()> { .map_or("-", |v| if v { "Yes" } else { "No" }) .to_string(), ), - ]; + ]); if let Some(intro) = &bug.introduced_in { pairs.push(("Introduced", format_introduced_in(intro))); } @@ -351,14 +521,14 @@ async fn fetch_all_bugs( client: &ApiClient, repo_id: &RepoId, status: BugReviewState, - scan_id: Option<&ListPublicBugsWorkflowRequestId>, + query: BugListQuery<'_>, ) -> Result> { let mut all_bugs = Vec::new(); let mut offset = 0; loop { let response = client - .list_bugs(repo_id, status, BUG_PAGE_SIZE, offset, scan_id) + .list_bugs(repo_id, status, BUG_PAGE_SIZE, offset, query) .await .context("Failed to fetch bugs from repository")?; @@ -397,13 +567,19 @@ async fn fetch_all_bugs_multi_status( client: &ApiClient, repo_id: &RepoId, statuses: &[BugReviewState], - scan_id: Option<&ListPublicBugsWorkflowRequestId>, + query: BugListQuery<'_>, ) -> Result> { + let deduped = dedupe_statuses(statuses); let mut combined = Vec::new(); - for status in dedupe_statuses(statuses) { - let bugs = fetch_all_bugs(client, repo_id, status, scan_id).await?; + for status in &deduped { + let bugs = fetch_all_bugs(client, repo_id, *status, query).await?; combined.extend(bugs); } + // Concatenating per-status blocks loses the global ordering the server + // applied within each one. + if deduped.len() > 1 { + sort_bugs(&mut combined, query.sort.unwrap_or(BugSortOrder::Newest)); + } Ok(combined) } @@ -416,7 +592,7 @@ async fn fetch_bugs_up_to( repo_id: &RepoId, status: BugReviewState, max_items: u32, - scan_id: Option<&ListPublicBugsWorkflowRequestId>, + query: BugListQuery<'_>, ) -> Result<(Vec, usize)> { let max_usize = usize::try_from(max_items).unwrap_or(usize::MAX); let mut bugs = Vec::new(); @@ -430,7 +606,7 @@ async fn fetch_bugs_up_to( } let page_size = BUG_PAGE_SIZE.min(remaining); let response = client - .list_bugs(repo_id, status, page_size, offset, scan_id) + .list_bugs(repo_id, status, page_size, offset, query) .await .context("Failed to fetch bugs from repository")?; total = usize::try_from(response.total.max(0)).unwrap_or(0); @@ -464,18 +640,24 @@ async fn fetch_page_multi_status( statuses: &[BugReviewState], limit: u32, page: u32, - scan_id: Option<&ListPublicBugsWorkflowRequestId>, + query: BugListQuery<'_>, ) -> Result<(Vec, usize)> { let offset = page_to_offset(page, limit); let fetch_limit = offset.saturating_add(limit); + let deduped = dedupe_statuses(statuses); let mut combined = Vec::new(); let mut total: usize = 0; - for status in dedupe_statuses(statuses) { + for status in &deduped { let (bugs, status_total) = - fetch_bugs_up_to(client, repo_id, status, fetch_limit, scan_id).await?; + fetch_bugs_up_to(client, repo_id, *status, fetch_limit, query).await?; total += status_total; combined.extend(bugs); } + // Order the merged window before slicing it, so the page the user asked + // for holds the bugs that ordering actually puts there. + if deduped.len() > 1 { + sort_bugs(&mut combined, query.sort.unwrap_or(BugSortOrder::Newest)); + } let offset_usize = usize::try_from(offset).unwrap_or(usize::MAX); let limit_usize = usize::try_from(limit).unwrap_or(usize::MAX); combined.drain(..offset_usize.min(combined.len())); @@ -491,6 +673,8 @@ pub async fn handle(command: &BugCommands, cli: &crate::Cli) -> Result<()> { repo, status, vulns, + priority, + sort, introduced_by, scan_id, since, @@ -519,6 +703,16 @@ pub async fn handle(command: &BugCommands, cli: &crate::Cli) -> Result<()> { let since_ms = resolve_time_flag("--since", since.as_deref(), now)?; let until_ms = resolve_time_flag("--until", until.as_deref(), now)?; + // `--priority` and `--sort` are applied by the API, so they do + // not join the list below: they shrink the fetch rather than + // forcing one. + let priority_filter = priority_query(priority); + let query = BugListQuery { + priority: priority_filter.as_deref(), + sort: Some(*sort), + scan_id: scan_id.as_ref(), + }; + // The bugs API takes a single status per request. When the // user asks for client-side filters (`--all`, `--vulns`, // `--introduced-by`, `--since`, `--until`) we must fetch every @@ -532,13 +726,8 @@ pub async fn handle(command: &BugCommands, cli: &crate::Cli) -> Result<()> { let multi_status = status.len() > 1; if needs_full_fetch { - let all_bugs = fetch_all_bugs_multi_status( - &client, - &resolved_repo_id, - status, - scan_id.as_ref(), - ) - .await?; + let all_bugs = + fetch_all_bugs_multi_status(&client, &resolved_repo_id, status, query).await?; let mut filtered = all_bugs; if since_ms.is_some() || until_ms.is_some() { filtered = filter_by_time_range(&filtered, since_ms, until_ms); @@ -586,7 +775,7 @@ pub async fn handle(command: &BugCommands, cli: &crate::Cli) -> Result<()> { status, *limit, *page, - scan_id.as_ref(), + query, ) .await?; output_list(&bugs, total, *page, *limit, format) @@ -597,13 +786,7 @@ pub async fn handle(command: &BugCommands, cli: &crate::Cli) -> Result<()> { let single_status = status.first().copied().unwrap_or(BugReviewState::Pending); let offset = page_to_offset(*page, *limit); let bugs = client - .list_bugs( - &resolved_repo_id, - single_status, - *limit, - offset, - scan_id.as_ref(), - ) + .list_bugs(&resolved_repo_id, single_status, *limit, offset, query) .await .context("Failed to fetch bugs from repository")?; @@ -713,6 +896,50 @@ pub async fn handle(command: &BugCommands, cli: &crate::Cli) -> Result<()> { .ok(); Ok(()) } + + BugCommands::Prioritize { + bug_id, + priority, + comment, + format, + } => { + let bug_id: BugId = bug_id + .as_str() + .try_into() + .context("Invalid bug ID format (expected bug_...)")?; + + let priority = match priority { + Some(p) => *p, + None if Term::stdout().is_term() => prompt_priority()?, + None => bail!( + "--priority is required in non-interactive mode. Use --priority p1, p2, or p3." + ), + }; + + let result = client + .set_bug_priority(&bug_id, priority, comment.as_deref()) + .await + .context("Failed to set bug priority")?; + + if matches!(format, crate::OutputFormat::Json) { + Term::stdout().write_line(&serde_json::to_string_pretty(&result)?)?; + return Ok(()); + } + + // No change id means the bug already carried this priority and + // nothing was written — say so rather than implying an edit. + let label = priority_label(&result.priority); + let line = if result.priority_change_id.is_some() { + format!("{}", style(format!("✓ Priority set to {label}")).green()) + } else { + format!( + "{}", + style(format!("Priority already {label} — no change")).dim() + ) + }; + Term::stdout().write_line(&line).ok(); + Ok(()) + } } } @@ -1335,4 +1562,122 @@ mod tests { // `format_introduced_in` moved to `crate::api::types`; tests now live // alongside the function in `src/api/types.rs`. + + // ── priority filter / sort ─────────────────────────────────────── + + fn make_bug(id: &str, created_at: i64, priority: Option<&str>) -> Bug { + let mut value = serde_json::json!({ + "id": format!("bug_{id}"), + "title": id, + "summary": "...", + "createdAt": created_at, + "repoId": "repo_1", + "linkedIssues": [] + }); + if let Some(p) = priority { + value["priority"] = serde_json::json!(p); + } + serde_json::from_value(value).unwrap() + } + + fn ids(bugs: &[Bug]) -> Vec { + bugs.iter().map(|b| b.id.to_string()).collect() + } + + #[test] + fn priority_query_omits_empty_selection() { + assert!(priority_query(&[]).is_none()); + } + + #[test] + fn priority_query_joins_levels() { + assert_eq!( + priority_query(&[PriorityFilter::P1, PriorityFilter::P3]).as_deref(), + Some("P1,P3") + ); + } + + #[test] + fn priority_query_uses_api_casing() { + assert_eq!(priority_query(&[PriorityFilter::P2]).as_deref(), Some("P2")); + } + + #[test] + fn priority_query_renders_none_sentinel() { + assert_eq!( + priority_query(&[PriorityFilter::P1, PriorityFilter::None]).as_deref(), + Some("P1,none") + ); + } + + #[test] + fn priority_query_dedupes_preserving_order() { + assert_eq!( + priority_query(&[PriorityFilter::P3, PriorityFilter::P1, PriorityFilter::P3]) + .as_deref(), + Some("P3,P1") + ); + } + + #[test] + fn sort_priority_orders_severe_first_unscored_last() { + let mut bugs = vec![ + make_bug("none", 5, None), + make_bug("p3", 4, Some("P3")), + make_bug("p1", 3, Some("P1")), + make_bug("p2", 2, Some("P2")), + ]; + sort_bugs(&mut bugs, BugSortOrder::Priority); + assert_eq!(ids(&bugs), ["bug_p1", "bug_p2", "bug_p3", "bug_none"]); + } + + #[test] + fn sort_priority_breaks_ties_by_newest() { + let mut bugs = vec![ + make_bug("old", 1, Some("P1")), + make_bug("new", 9, Some("P1")), + ]; + sort_bugs(&mut bugs, BugSortOrder::Priority); + assert_eq!(ids(&bugs), ["bug_new", "bug_old"]); + } + + #[test] + fn sort_newest_and_oldest_are_opposites() { + let build = || { + vec![ + make_bug("b", 2, None), + make_bug("c", 3, None), + make_bug("a", 1, None), + ] + }; + let mut newest = build(); + sort_bugs(&mut newest, BugSortOrder::Newest); + let mut oldest = build(); + sort_bugs(&mut oldest, BugSortOrder::Oldest); + + assert_eq!(ids(&newest), ["bug_c", "bug_b", "bug_a"]); + let mut reversed = ids(&oldest); + reversed.reverse(); + assert_eq!(ids(&newest), reversed); + } + + #[test] + fn sort_is_stable_across_equal_timestamps() { + // Bugs found in the same scan can share a createdAt; the id + // tiebreaker keeps a merged multi-status page from reshuffling + // between runs. + let build = || { + vec![ + make_bug("b", 1, Some("P1")), + make_bug("a", 1, Some("P1")), + make_bug("c", 1, Some("P1")), + ] + }; + let mut first = build(); + sort_bugs(&mut first, BugSortOrder::Priority); + let mut second = build(); + second.reverse(); + sort_bugs(&mut second, BugSortOrder::Priority); + assert_eq!(ids(&first), ids(&second)); + } } diff --git a/src/lib.rs b/src/lib.rs index 6e4c0d0..12b181c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -90,7 +90,8 @@ impl Cli { Commands::Bugs { command } => match command { commands::bugs::BugCommands::List { format, .. } | commands::bugs::BugCommands::Show { format, .. } - | commands::bugs::BugCommands::Close { format, .. } => Self::is_json(format), + | commands::bugs::BugCommands::Close { format, .. } + | commands::bugs::BugCommands::Prioritize { format, .. } => Self::is_json(format), commands::bugs::BugCommands::Reopen { .. } => false, }, Commands::Repos { command } => match command { From edd66193e5a9fbbd002f50c8a484c4b1130f0359 Mon Sep 17 00:00:00 2001 From: Tyler Dammann Date: Wed, 19 Aug 2026 21:09:55 -0400 Subject: [PATCH 2/2] chore(deps): bump h2 to 0.4.17 for RUSTSEC-2026-0258 --- Cargo.lock | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7cbdda1..910e7aa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -779,7 +779,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -936,9 +936,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.15" +version = "0.4.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +checksum = "9f877e75f39e9827ec50a572dd592684ac28c029578726c85f1b2aa6ab807449" dependencies = [ "atomic-waker", "bytes", @@ -1867,7 +1867,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2118,7 +2118,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2175,7 +2175,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2618,7 +2618,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3185,7 +3185,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]]