feat(cli): expose available_statuses in robot mode and hide status_category

(Supersedes empty commit f3788eb — jj auto-snapshot race.)

Three related refinements to how work item status is presented:

1. available_statuses in meta (list.rs, main.rs):
   Robot-mode issue list responses now include meta.available_statuses —
   a sorted array of all distinct status_name values in the database.
   Agents can use this to validate --status filter values or display
   valid options without a separate query.

2. Hide status_category from JSON (list.rs, show.rs):
   status_category is a GitLab internal classification that duplicates
   the state field. Switched to skip_serializing so it never appears
   in JSON output while remaining available internally.

3. Simplify human-readable status display (show.rs):
   Removed the "(category)" parenthetical from the Status line.

4. robot-docs schema updates (main.rs):
   Documented --status filter semantics and meta.available_statuses.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Taylor Eernisse
2026-02-11 10:24:41 -05:00
parent 8d18552298
commit 06229ce98b
3 changed files with 28 additions and 12 deletions

View File

@@ -59,7 +59,7 @@ pub struct IssueListRow {
pub unresolved_count: i64, pub unresolved_count: i64,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub status_name: Option<String>, pub status_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing)]
pub status_category: Option<String>, pub status_category: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub status_color: Option<String>, pub status_color: Option<String>,
@@ -86,7 +86,7 @@ pub struct IssueListRowJson {
pub project_path: String, pub project_path: String,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub status_name: Option<String>, pub status_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing)]
pub status_category: Option<String>, pub status_category: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub status_color: Option<String>, pub status_color: Option<String>,
@@ -124,6 +124,7 @@ impl From<&IssueListRow> for IssueListRowJson {
pub struct ListResult { pub struct ListResult {
pub issues: Vec<IssueListRow>, pub issues: Vec<IssueListRow>,
pub total_count: usize, pub total_count: usize,
pub available_statuses: Vec<String>,
} }
#[derive(Serialize)] #[derive(Serialize)]
@@ -268,10 +269,21 @@ pub fn run_list_issues(config: &Config, filters: ListFilters) -> Result<ListResu
let db_path = get_db_path(config.storage.db_path.as_deref()); let db_path = get_db_path(config.storage.db_path.as_deref());
let conn = create_connection(&db_path)?; let conn = create_connection(&db_path)?;
let result = query_issues(&conn, &filters)?; let mut result = query_issues(&conn, &filters)?;
result.available_statuses = query_available_statuses(&conn)?;
Ok(result) Ok(result)
} }
fn query_available_statuses(conn: &Connection) -> Result<Vec<String>> {
let mut stmt = conn.prepare(
"SELECT DISTINCT status_name FROM issues WHERE status_name IS NOT NULL ORDER BY status_name",
)?;
let statuses = stmt
.query_map([], |row| row.get::<_, String>(0))?
.collect::<std::result::Result<Vec<_>, _>>()?;
Ok(statuses)
}
fn query_issues(conn: &Connection, filters: &ListFilters) -> Result<ListResult> { fn query_issues(conn: &Connection, filters: &ListFilters) -> Result<ListResult> {
let mut where_clauses = Vec::new(); let mut where_clauses = Vec::new();
let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new(); let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
@@ -457,6 +469,7 @@ fn query_issues(conn: &Connection, filters: &ListFilters) -> Result<ListResult>
Ok(ListResult { Ok(ListResult {
issues, issues,
total_count, total_count,
available_statuses: Vec::new(),
}) })
} }
@@ -822,11 +835,13 @@ pub fn print_list_issues(result: &ListResult) {
pub fn print_list_issues_json(result: &ListResult, elapsed_ms: u64, fields: Option<&[String]>) { pub fn print_list_issues_json(result: &ListResult, elapsed_ms: u64, fields: Option<&[String]>) {
let json_result = ListResultJson::from(result); let json_result = ListResultJson::from(result);
let meta = RobotMeta { elapsed_ms };
let output = serde_json::json!({ let output = serde_json::json!({
"ok": true, "ok": true,
"data": json_result, "data": json_result,
"meta": meta, "meta": {
"elapsed_ms": elapsed_ms,
"available_statuses": result.available_statuses,
},
}); });
let mut output = output; let mut output = output;
if let Some(f) = fields { if let Some(f) = fields {

View File

@@ -628,13 +628,9 @@ pub fn print_show_issue(issue: &IssueDetail) {
println!("State: {}", state_styled); println!("State: {}", state_styled);
if let Some(status) = &issue.status_name { if let Some(status) = &issue.status_name {
let display = match &issue.status_category {
Some(cat) => format!("{status} ({})", cat.to_ascii_lowercase()),
None => status.clone(),
};
println!( println!(
"Status: {}", "Status: {}",
style_with_hex(&display, issue.status_color.as_deref()) style_with_hex(status, issue.status_color.as_deref())
); );
} }
@@ -944,6 +940,7 @@ pub struct IssueDetailJson {
pub closing_merge_requests: Vec<ClosingMrRefJson>, pub closing_merge_requests: Vec<ClosingMrRefJson>,
pub discussions: Vec<DiscussionDetailJson>, pub discussions: Vec<DiscussionDetailJson>,
pub status_name: Option<String>, pub status_name: Option<String>,
#[serde(skip_serializing)]
pub status_category: Option<String>, pub status_category: Option<String>,
pub status_color: Option<String>, pub status_color: Option<String>,
pub status_icon_name: Option<String>, pub status_icon_name: Option<String>,

View File

@@ -2109,11 +2109,15 @@ fn handle_robot_docs(robot_mode: bool, brief: bool) -> Result<(), Box<dyn std::e
"description": "List or show issues", "description": "List or show issues",
"flags": ["<IID>", "-n/--limit", "--fields <list>", "-s/--state", "--status <name>", "-p/--project", "-a/--author", "-A/--assignee", "-l/--label", "-m/--milestone", "--since", "--due-before", "--has-due", "--no-has-due", "--sort", "--asc", "--no-asc", "-o/--open", "--no-open"], "flags": ["<IID>", "-n/--limit", "--fields <list>", "-s/--state", "--status <name>", "-p/--project", "-a/--author", "-A/--assignee", "-l/--label", "-m/--milestone", "--since", "--due-before", "--has-due", "--no-has-due", "--sort", "--asc", "--no-asc", "-o/--open", "--no-open"],
"example": "lore --robot issues --state opened --limit 10", "example": "lore --robot issues --state opened --limit 10",
"notes": {
"status_filter": "--status filters by work item status NAME (case-insensitive). Valid values are in meta.available_statuses of any issues list response.",
"status_name": "status_name is the board column label (e.g. 'In review', 'Blocked'). This is the canonical status identifier for filtering."
},
"response_schema": { "response_schema": {
"list": { "list": {
"ok": "bool", "ok": "bool",
"data": {"issues": "[{iid:int, title:string, state:string, author_username:string, labels:[string], assignees:[string], discussion_count:int, unresolved_count:int, created_at_iso:string, updated_at_iso:string, web_url:string?, project_path:string}]", "total_count": "int", "showing": "int"}, "data": {"issues": "[{iid:int, title:string, state:string, author_username:string, labels:[string], assignees:[string], discussion_count:int, unresolved_count:int, created_at_iso:string, updated_at_iso:string, web_url:string?, project_path:string, status_name:string?}]", "total_count": "int", "showing": "int"},
"meta": {"elapsed_ms": "int"} "meta": {"elapsed_ms": "int", "available_statuses": "[string] — all distinct status names in the database, for use with --status filter"}
}, },
"show": { "show": {
"ok": "bool", "ok": "bool",