Complete Gotcha issue CLI workflow

This commit is contained in:
Georg Bauer
2026-07-31 15:59:28 +02:00
parent 0583d05170
commit 6f7ade14cf
4 changed files with 488 additions and 71 deletions

View File

@@ -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`.

View File

@@ -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

View File

@@ -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

View File

@@ -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<String>,
state: String,
kind: String,
keyword: Option<String>,
labels: Option<String>,
milestones: Option<String>,
author: Option<String>,
assignee: Option<String>,
mentions: Option<String>,
from: Option<String>,
until: Option<String>,
page: i32,
limit: i32,
}
#[derive(Deserialize)]
struct CreateIssueInput {
#[serde(flatten)]
issue: CreateIssueOption,
#[serde(default)]
label_names: Vec<String>,
milestone_name: Option<String>,
}
#[derive(Clone, Deserialize)]
struct EditIssueInput {
#[serde(flatten)]
issue: EditIssueOption,
#[serde(default)]
add_labels: Vec<String>,
#[serde(default)]
remove_labels: Vec<String>,
#[serde(default)]
add_assignees: Vec<String>,
milestone_name: Option<String>,
}
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<IssueListOptions, Box<dyn Error>> {
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<String, Box<dyn Error>> {
*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<i32, Box<dyn Error>> {
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<i64>, Option<String>), Box<dyn Error>> {
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<bool, Box<dyn Error>> {
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<dyn Error>> {
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<dyn Error>> {
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<dyn Error>> {
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::<Vec<_>>();
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<Vec<i64>, Box<dyn Error>> {
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<i64, Box<dyn Error>> {
apis::issue_api::issue_get_milestones_list(
configuration,
&scope.owner,
&scope.repository,
index,
Some(read_yaml::<EditIssueOption>()?),
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<dyn Error>> {
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::<Vec<_>>()
.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::<Vec<_>>()
.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());
}
}