From 6f7ade14cf8113c06f93a421e8e1377ebffe1869 Mon Sep 17 00:00:00 2001 From: Georg Bauer Date: Fri, 31 Jul 2026 15:59:28 +0200 Subject: [PATCH] Complete Gotcha issue CLI workflow --- AGENTS.md | 35 ++- API_COVERAGE.md | 4 +- README.md | 3 + crates/cli/src/work_items.rs | 517 ++++++++++++++++++++++++++++++----- 4 files changed, 488 insertions(+), 71 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a135eff..0bda8a6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,14 +2,21 @@ ## Gitea issue workflow -Use the `tea` command-line tool as the preferred way to list, inspect, create, -and update Gitea issues. Run it from the repository root so it can use the -configured remote, for example `tea issues` or `tea issues 1`. +Use this repository's `gotcha` CLI to list, inspect, create, update, comment on, +and close Gitea issues. Run it from the repository root so the configured Git +remote selects the server and repository: -Run issue-detail commands directly, for example `tea issues 7`. In a sandboxed -runner, grant network access before invoking `tea`. Without DNS/network access, -the current `tea` detail path may panic inside Gitea SDK version handling -instead of returning the underlying connection error. +```sh +cargo run -q -p gotcha-cli -- issue list +cargo run -q -p gotcha-cli -- issue list --milestones "first feature complete release" +cargo run -q -p gotcha-cli -- issue show 7 +cargo run -q -p gotcha-cli -- issue comment 7 < comment.txt +cargo run -q -p gotcha-cli -- issue close 7 +``` + +Use `gotcha issue list --help` for state, kind, keyword, label, milestone, +author, assignee, mention, date, and pagination filters. Grant network access +before invoking commands that contact Gitea in a sandboxed runner. ## Required pre-commit gates @@ -114,6 +121,20 @@ iPhone (currently `xcrun simctl boot "iPhone 17 Pro"`) and open Simulator. CoreSimulator access may require running `xcodebuild` and `simctl` outside the workspace sandbox. +## Post-issue release deployment + +After an issue is verified, committed, pushed, commented on, and closed, deploy +the completed version according to the code it changes: + +- For CLI changes, build `gotcha-cli` in release mode and install the resulting + `gotcha` executable in `~/.local/bin/` so the command is available on `PATH`. +- For iOS app changes, install the completed app on the currently paired iPhone. +- The Gitea crate is shared by the CLI and iOS app, so changes to that crate also + require installing the completed app on the paired iPhone. + +A purely CLI change does not require an iPhone deployment. If an issue changes +both release surfaces, perform both deployments. + ## Generated build data Use Xcode's default DerivedData location; do not pass `-derivedDataPath`. diff --git a/API_COVERAGE.md b/API_COVERAGE.md index 697af57..6e81f2a 100644 --- a/API_COVERAGE.md +++ b/API_COVERAGE.md @@ -31,7 +31,9 @@ Typed text commands currently implemented include: - `user show` → `userGetCurrent` - `repo list` → `userCurrentListRepos` - `repo show` → `repoGet` -- issue list/show/create/edit/delete/comments/comment +- issue list with state, kind, keyword, label, milestone, author, assignee, + mention, date, and pagination filters; show/create/edit/close/reopen/delete; + comments/comment - milestone list/show/create/edit/delete - pull list/show/create/edit/merge/commits/files/reviews diff --git a/README.md b/README.md index 19f4045..586e6cf 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,9 @@ cargo run -p gotcha-cli -- user show cargo run -p gotcha-cli -- repo list cargo run -p gotcha-cli -- repo show cargo run -p gotcha-cli -- issue list +cargo run -p gotcha-cli -- issue list --milestones "Version 1.0" --state open +cargo run -p gotcha-cli -- issue show 7 +cargo run -p gotcha-cli -- issue close 7 cargo run -p gotcha-cli -- milestone list cargo run -p gotcha-cli -- pull list cargo run -p gotcha-cli -- api request GET repos/owner/project/issues diff --git a/crates/cli/src/work_items.rs b/crates/cli/src/work_items.rs index a5e3091..8da8d38 100644 --- a/crates/cli/src/work_items.rs +++ b/crates/cli/src/work_items.rs @@ -8,7 +8,7 @@ use gotcha_gitea::{ EditPullRequestOption, Issue, MergePullRequestOption, Milestone, PullRequest, PullReview, }, }; -use serde::de::DeserializeOwned; +use serde::{Deserialize, de::DeserializeOwned}; use crate::config::{RepositoryScope, Selection}; @@ -16,10 +16,15 @@ const ISSUE_HELP: &str = "\ Usage: gotcha issue SUBCOMMAND Subcommands: - list [OWNER/REPOSITORY] List issues + list [OWNER/REPOSITORY] [OPTIONS] + List and filter issues show INDEX [OWNER/REPOSITORY] Show an issue create [OWNER/REPOSITORY] Create from CreateIssueOption YAML on stdin - edit INDEX [OWNER/REPOSITORY] Edit from EditIssueOption YAML on stdin + edit INDEX... [OWNER/REPOSITORY] Edit issues from EditIssueOption YAML on stdin + close INDEX... [OWNER/REPOSITORY] + Close one or more issues + reopen INDEX... [OWNER/REPOSITORY] + Reopen one or more issues delete INDEX [OWNER/REPOSITORY] Delete an issue comments INDEX [OWNER/REPOSITORY] List comments @@ -59,14 +64,18 @@ pub fn domain_help(domain: &str) -> Option<&'static str> { pub fn subcommand_help(domain: &str, command: &str) -> Option<&'static str> { match (domain, command) { - ("issue", "list") => Some("Usage: gotcha issue list [OWNER/REPOSITORY]"), + ("issue", "list") => Some( + "Usage: gotcha issue list [OWNER/REPOSITORY] [OPTIONS]\n\nOptions:\n --state open|closed|all\n -K, --kind issues|pulls|all\n -k, --keyword TEXT\n -L, --labels NAMES\n -m, --milestones NAMES\n -A, --author USER\n -a, --assignee USER\n -M, --mentions USER\n -F, --from TIMESTAMP\n -u, --until TIMESTAMP\n -p, --page NUMBER\n --limit NUMBER", + ), ("issue", "show") => Some("Usage: gotcha issue show INDEX [OWNER/REPOSITORY]"), ("issue", "create") => Some( - "Usage: gotcha issue create [OWNER/REPOSITORY] < issue.yaml\n\nReads CreateIssueOption YAML from stdin, for example:\n title: Fix the bug\n body: Reproduction steps", + "Usage: gotcha issue create [OWNER/REPOSITORY] < issue.yaml\n\nReads CreateIssueOption YAML from stdin. label_names and milestone_name accept display names, for example:\n title: Fix the bug\n body: Reproduction steps\n label_names: [bug, critical]\n milestone_name: Version 1.0", ), ("issue", "edit") => Some( - "Usage: gotcha issue edit INDEX [OWNER/REPOSITORY] < issue.yaml\n\nReads EditIssueOption YAML from stdin.", + "Usage: gotcha issue edit INDEX... [OWNER/REPOSITORY] < issue.yaml\n\nReads EditIssueOption YAML from stdin and applies it to every index. milestone_name resolves a display name; add_labels and remove_labels accept label names; add_assignees adds users without replacing existing assignees.", ), + ("issue", "close") => Some("Usage: gotcha issue close INDEX... [OWNER/REPOSITORY]"), + ("issue", "reopen") => Some("Usage: gotcha issue reopen INDEX... [OWNER/REPOSITORY]"), ("issue", "delete") => Some("Usage: gotcha issue delete INDEX [OWNER/REPOSITORY]"), ("issue", "comments") => Some("Usage: gotcha issue comments INDEX [OWNER/REPOSITORY]"), ("issue", "comment") => Some( @@ -99,6 +108,138 @@ pub fn subcommand_help(domain: &str, command: &str) -> Option<&'static str> { } } +#[derive(Debug, PartialEq)] +struct IssueListOptions { + repository: Option, + state: String, + kind: String, + keyword: Option, + labels: Option, + milestones: Option, + author: Option, + assignee: Option, + mentions: Option, + from: Option, + until: Option, + page: i32, + limit: i32, +} + +#[derive(Deserialize)] +struct CreateIssueInput { + #[serde(flatten)] + issue: CreateIssueOption, + #[serde(default)] + label_names: Vec, + milestone_name: Option, +} + +#[derive(Clone, Deserialize)] +struct EditIssueInput { + #[serde(flatten)] + issue: EditIssueOption, + #[serde(default)] + add_labels: Vec, + #[serde(default)] + remove_labels: Vec, + #[serde(default)] + add_assignees: Vec, + milestone_name: Option, +} + +impl Default for IssueListOptions { + fn default() -> Self { + Self { + repository: None, + state: "open".into(), + kind: "issues".into(), + keyword: None, + labels: None, + milestones: None, + author: None, + assignee: None, + mentions: None, + from: None, + until: None, + page: 1, + limit: 30, + } + } +} + +fn parse_issue_list(arguments: &[String]) -> Result> { + let mut options = IssueListOptions::default(); + let mut index = 0; + while index < arguments.len() { + let argument = arguments[index].as_str(); + let value = |index: &mut usize| -> Result> { + *index += 1; + arguments + .get(*index) + .cloned() + .ok_or_else(|| format!("{argument} requires a value").into()) + }; + match argument { + "--state" => options.state = value(&mut index)?, + "-K" | "--kind" => options.kind = value(&mut index)?, + "-k" | "--keyword" => options.keyword = Some(value(&mut index)?), + "-L" | "--labels" => options.labels = Some(value(&mut index)?), + "-m" | "--milestones" => options.milestones = Some(value(&mut index)?), + "-A" | "--author" => options.author = Some(value(&mut index)?), + "-a" | "--assignee" => options.assignee = Some(value(&mut index)?), + "-M" | "--mentions" => options.mentions = Some(value(&mut index)?), + "-F" | "--from" => options.from = Some(value(&mut index)?), + "-u" | "--until" => options.until = Some(value(&mut index)?), + "-p" | "--page" => options.page = positive_i32(&value(&mut index)?, "page")?, + "--limit" | "--lm" => options.limit = positive_i32(&value(&mut index)?, "limit")?, + unknown if unknown.starts_with('-') => { + return Err(format!("unknown issue list option {unknown:?}").into()); + } + repository if options.repository.is_none() => { + RepositoryScope::parse(repository)?; + options.repository = Some(repository.into()); + } + value => return Err(format!("unexpected issue list argument {value:?}").into()), + } + index += 1; + } + if !matches!(options.state.as_str(), "open" | "closed" | "all") { + return Err("--state must be open, closed, or all".into()); + } + if !matches!(options.kind.as_str(), "issues" | "pulls" | "all") { + return Err("--kind must be issues, pulls, or all".into()); + } + Ok(options) +} + +fn positive_i32(value: &str, name: &str) -> Result> { + let value: i32 = value.parse()?; + if value < 1 { + return Err(format!("{name} must be a positive integer").into()); + } + Ok(value) +} + +fn issue_targets(arguments: &[String]) -> Result<(Vec, Option), Box> { + let mut indexes = Vec::new(); + let mut repository = None; + for argument in arguments { + if argument.contains('/') { + if repository.is_some() { + return Err("only one OWNER/REPOSITORY may be provided".into()); + } + RepositoryScope::parse(argument)?; + repository = Some(argument.clone()); + } else { + indexes.push(number(argument)?); + } + } + if indexes.is_empty() { + return Err("at least one issue index is required".into()); + } + Ok((indexes, repository)) +} + pub async fn run( command: &[String], selection: &Selection, @@ -106,46 +247,25 @@ pub async fn run( ) -> Result> { let configuration = client.configuration(); match command { - [domain, action] if domain == "issue" && action == "list" => { - let scope = scope(selection, None)?; + [domain, action, arguments @ ..] if domain == "issue" && action == "list" => { + let options = parse_issue_list(arguments)?; + let scope = scope(selection, options.repository.as_deref())?; let issues = apis::issue_api::issue_list_issues( &configuration, &scope.owner, &scope.repository, - None, - None, - None, - None, - None, - None, - None, - None, - None, - None, - None, - None, - ) - .await?; - print_issues(&issues); - } - [domain, action, repository] if domain == "issue" && action == "list" => { - let scope = scope(selection, Some(repository))?; - let issues = apis::issue_api::issue_list_issues( - &configuration, - &scope.owner, - &scope.repository, - None, - None, - None, - None, - None, - None, - None, - None, - None, - None, - None, - None, + Some(&options.state), + options.labels.as_deref(), + options.keyword.as_deref(), + Some(&options.kind), + options.milestones.as_deref(), + options.from, + options.until, + options.author.as_deref(), + options.assignee.as_deref(), + options.mentions.as_deref(), + Some(options.page), + Some(options.limit), ) .await?; print_issues(&issues); @@ -180,14 +300,25 @@ pub async fn run( [domain, action, repository] if domain == "issue" && action == "create" => { create_issue(&configuration, &scope(selection, Some(repository))?).await?; } - [domain, action, index] if domain == "issue" && action == "edit" => { - edit_issue(&configuration, &scope(selection, None)?, number(index)?).await?; - } - [domain, action, index, repository] if domain == "issue" && action == "edit" => { - edit_issue( + [domain, action, arguments @ ..] if domain == "issue" && action == "edit" => { + let (indexes, repository) = issue_targets(arguments)?; + edit_issues( &configuration, - &scope(selection, Some(repository))?, - number(index)?, + &scope(selection, repository.as_deref())?, + &indexes, + ) + .await?; + } + [domain, action, arguments @ ..] + if domain == "issue" && matches!(action.as_str(), "close" | "reopen") => + { + let (indexes, repository) = issue_targets(arguments)?; + let scope = scope(selection, repository.as_deref())?; + set_issue_state( + &configuration, + &scope, + &indexes, + if action == "close" { "closed" } else { "open" }, ) .await?; } @@ -367,36 +498,203 @@ async fn create_issue( configuration: &apis::configuration::Configuration, scope: &RepositoryScope, ) -> Result<(), Box> { - let body: CreateIssueOption = read_yaml()?; - if body.title.trim().is_empty() { + let mut input: CreateIssueInput = read_yaml()?; + if input.issue.title.trim().is_empty() { return Err("issue title must not be empty".into()); } + if input.issue.milestone.is_some() && input.milestone_name.is_some() { + return Err("use either milestone or milestone_name, not both".into()); + } + if let Some(name) = input.milestone_name.as_deref() { + input.issue.milestone = Some(resolve_milestone_id(configuration, scope, name).await?); + } + if !input.label_names.is_empty() { + input + .issue + .labels + .get_or_insert_default() + .extend(resolve_label_ids(configuration, scope, &input.label_names).await?); + } print_issue( &apis::issue_api::issue_create_issue( configuration, &scope.owner, &scope.repository, - Some(body), + Some(input.issue), ) .await?, ); Ok(()) } -async fn edit_issue( +async fn edit_issues( + configuration: &apis::configuration::Configuration, + scope: &RepositoryScope, + indexes: &[i64], +) -> Result<(), Box> { + let mut input: EditIssueInput = read_yaml()?; + if input.issue.milestone.is_some() && input.milestone_name.is_some() { + return Err("use either milestone or milestone_name, not both".into()); + } + if let Some(name) = input.milestone_name.as_deref() { + input.issue.milestone = Some(resolve_milestone_id(configuration, scope, name).await?); + } + if input.issue.assignees.is_some() && !input.add_assignees.is_empty() { + return Err("use either assignees or add_assignees, not both".into()); + } + let remove = resolve_label_ids(configuration, scope, &input.remove_labels).await?; + let add = resolve_label_ids(configuration, scope, &input.add_labels).await?; + for &index in indexes { + apply_issue_edit(configuration, scope, index, &input, &remove, &add).await?; + } + Ok(()) +} + +async fn apply_issue_edit( configuration: &apis::configuration::Configuration, scope: &RepositoryScope, index: i64, + input: &EditIssueInput, + remove: &[i64], + add: &[i64], ) -> Result<(), Box> { - let issue = apis::issue_api::issue_edit_issue( + let mut edit = input.issue.clone(); + if !input.add_assignees.is_empty() { + let issue = + apis::issue_api::issue_get_issue(configuration, &scope.owner, &scope.repository, index) + .await?; + let mut assignees = issue + .assignees + .as_deref() + .unwrap_or_default() + .iter() + .filter_map(|user| user.login.clone()) + .collect::>(); + for assignee in &input.add_assignees { + if !assignees.contains(assignee) { + assignees.push(assignee.clone()); + } + } + edit.assignees = Some(assignees); + } + if edit != EditIssueOption::default() { + apis::issue_api::issue_edit_issue( + configuration, + &scope.owner, + &scope.repository, + index, + Some(edit), + ) + .await?; + } + for &id in remove { + apis::issue_api::issue_remove_label( + configuration, + &scope.owner, + &scope.repository, + index, + id, + ) + .await?; + } + if !add.is_empty() { + apis::issue_api::issue_add_label( + configuration, + &scope.owner, + &scope.repository, + index, + Some(gotcha_gitea::models::IssueLabelsOption { + labels: Some(add.iter().copied().map(serde_json::Value::from).collect()), + }), + ) + .await?; + } + print_issue( + &apis::issue_api::issue_get_issue(configuration, &scope.owner, &scope.repository, index) + .await?, + ); + Ok(()) +} + +async fn resolve_label_ids( + configuration: &apis::configuration::Configuration, + scope: &RepositoryScope, + names: &[String], +) -> Result, Box> { + if names.is_empty() { + return Ok(Vec::new()); + } + let mut labels = Vec::new(); + let mut page = 1; + loop { + let batch = apis::issue_api::issue_list_labels( + configuration, + &scope.owner, + &scope.repository, + Some(page), + Some(50), + ) + .await?; + let has_more = batch.len() == 50; + labels.extend(batch); + if !has_more { + break; + } + page += 1; + } + names + .iter() + .map(|name| { + labels + .iter() + .find(|label| label.name.as_deref() == Some(name)) + .and_then(|label| label.id) + .ok_or_else(|| format!("unknown label {name:?}").into()) + }) + .collect() +} + +async fn resolve_milestone_id( + configuration: &apis::configuration::Configuration, + scope: &RepositoryScope, + name: &str, +) -> Result> { + apis::issue_api::issue_get_milestones_list( configuration, &scope.owner, &scope.repository, - index, - Some(read_yaml::()?), + Some("all"), + Some(name), + Some(1), + Some(2), ) - .await?; - print_issue(&issue); + .await? + .into_iter() + .find(|milestone| milestone.title.as_deref() == Some(name)) + .and_then(|milestone| milestone.id) + .ok_or_else(|| format!("unknown milestone {name:?}").into()) +} + +async fn set_issue_state( + configuration: &apis::configuration::Configuration, + scope: &RepositoryScope, + indexes: &[i64], + state: &str, +) -> Result<(), Box> { + for &index in indexes { + let issue = apis::issue_api::issue_edit_issue( + configuration, + &scope.owner, + &scope.repository, + index, + Some(EditIssueOption { + state: Some(state.into()), + ..Default::default() + }), + ) + .await?; + print_issue(&issue); + } Ok(()) } @@ -671,13 +969,24 @@ async fn pull_details( } fn print_issues(issues: &[Issue]) { - println!("INDEX\tSTATE\tTITLE\tUPDATED"); + println!("INDEX\tSTATE\tTITLE\tAUTHOR\tMILESTONE\tLABELS\tUPDATED"); for issue in issues { println!( - "{}\t{}\t{}\t{}", + "{}\t{}\t{}\t{}\t{}\t{}\t{}", issue.number.unwrap_or_default(), issue.state.as_deref().unwrap_or("unknown"), one_line(issue.title.as_deref().unwrap_or("")), + issue + .user + .as_deref() + .and_then(|user| user.login.as_deref()) + .unwrap_or("unknown"), + issue + .milestone + .as_deref() + .and_then(|milestone| milestone.title.as_deref()) + .unwrap_or("-"), + issue_labels(issue), issue.updated_at.as_deref().unwrap_or("-") ); } @@ -695,10 +1004,43 @@ fn print_issue(issue: &Issue) { "author", issue.user.as_deref().and_then(|user| user.login.as_deref()), ); + let milestone = issue + .milestone + .as_deref() + .and_then(|milestone| milestone.title.as_deref()); + field("milestone", milestone); + let labels = issue_labels(issue); + field("labels", (!labels.is_empty()).then_some(labels.as_str())); + let assignees = issue + .assignees + .as_deref() + .unwrap_or_default() + .iter() + .filter_map(|user| user.login.as_deref()) + .collect::>() + .join(","); + field( + "assignees", + (!assignees.is_empty()).then_some(assignees.as_str()), + ); + field("due", issue.due_date.as_deref()); + field("created", issue.created_at.as_deref()); field("updated", issue.updated_at.as_deref()); + println!("comments: {}", issue.comments.unwrap_or_default()); body(issue.body.as_deref()); } +fn issue_labels(issue: &Issue) -> String { + issue + .labels + .as_deref() + .unwrap_or_default() + .iter() + .filter_map(|label| label.name.as_deref()) + .collect::>() + .join(",") +} + fn print_comments(comments: &[Comment]) { for comment in comments { println!( @@ -843,11 +1185,60 @@ mod tests { #[test] fn parses_typed_yaml_and_rejects_bad_indexes() { - let issue: CreateIssueOption = parse_yaml("title: Fix it\nbody: Details\n").unwrap(); + let issue: CreateIssueInput = parse_yaml( + "title: Fix it\nbody: Details\nlabel_names: [bug]\nmilestone_name: Version 1\n", + ) + .unwrap(); - assert_eq!(issue.title, "Fix it"); - assert_eq!(issue.body.as_deref(), Some("Details")); + assert_eq!(issue.issue.title, "Fix it"); + assert_eq!(issue.issue.body.as_deref(), Some("Details")); + assert_eq!(issue.label_names, ["bug"]); + assert_eq!(issue.milestone_name.as_deref(), Some("Version 1")); + let edit: EditIssueInput = parse_yaml( + "title: Updated\nadd_labels: [critical]\nremove_labels: [bug]\nadd_assignees: [alice]\n", + ) + .unwrap(); + assert_eq!(edit.issue.title.as_deref(), Some("Updated")); + assert_eq!(edit.add_labels, ["critical"]); + assert_eq!(edit.remove_labels, ["bug"]); + assert_eq!(edit.add_assignees, ["alice"]); assert!(number("0").is_err()); assert!(number("7").is_ok()); } + + #[test] + fn parses_issue_workflow_filters_and_bulk_state_targets() { + let options = parse_issue_list( + &[ + "hugo/Gotcha", + "--state", + "all", + "--milestones", + "first feature complete release", + "-L", + "bug,critical", + "--page", + "2", + "--limit", + "50", + ] + .map(str::to_owned), + ) + .unwrap(); + assert_eq!(options.repository.as_deref(), Some("hugo/Gotcha")); + assert_eq!(options.state, "all"); + assert_eq!( + options.milestones.as_deref(), + Some("first feature complete release") + ); + assert_eq!(options.labels.as_deref(), Some("bug,critical")); + assert_eq!((options.page, options.limit), (2, 50)); + + let (indexes, repository) = + issue_targets(&["4", "16", "hugo/Gotcha"].map(str::to_owned)).unwrap(); + assert_eq!(indexes, [4, 16]); + assert_eq!(repository.as_deref(), Some("hugo/Gotcha")); + assert!(parse_issue_list(&["--state".into(), "invalid".into()]).is_err()); + assert!(issue_targets(&[]).is_err()); + } }