From b468a2670c659060738eafe76690495badbd22b8 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/main.rs | 144 +++++++- crates/cli/src/work_items.rs | 697 ++++++++++++++++++++++++++++------- 5 files changed, 730 insertions(+), 153 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/main.rs b/crates/cli/src/main.rs index 17054d1..1090dc5 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -169,20 +169,30 @@ fn print_user(user: &User) { } fn print_repositories(repositories: &[Repository]) { - println!("REPOSITORY\tVISIBILITY\tUPDATED\tDESCRIPTION"); - for repository in repositories { - println!( - "{}\t{}\t{}\t{}", - repository.full_name.as_deref().unwrap_or("unknown"), - if repository.private.unwrap_or(false) { - "private" - } else { - "public" - }, - repository.updated_at.as_deref().unwrap_or("-"), - repository.description.as_deref().unwrap_or("") - ); - } + print_table( + &[ + ("REPOSITORY", 40), + ("VISIBILITY", 10), + ("UPDATED", 25), + ("DESCRIPTION", 60), + ], + repositories + .iter() + .map(|repository| { + vec![ + repository.full_name.as_deref().unwrap_or("unknown").into(), + if repository.private.unwrap_or(false) { + "private" + } else { + "public" + } + .into(), + repository.updated_at.as_deref().unwrap_or("-").into(), + repository.description.as_deref().unwrap_or("").into(), + ] + }) + .collect(), + ); } fn print_repository(repository: &Repository) { @@ -216,6 +226,93 @@ fn print_json(value: &Value) -> Result<(), serde_json::Error> { Ok(()) } +fn print_table(columns: &[(&str, usize)], rows: Vec>) { + println!("{}", format_table(columns, &rows, terminal_width())); +} + +fn format_table(columns: &[(&str, usize)], rows: &[Vec], width: usize) -> String { + let minimums = columns + .iter() + .map(|(header, _)| header.chars().count()) + .collect::>(); + let mut widths = minimums.clone(); + for (column, (_, maximum)) in columns.iter().enumerate() { + widths[column] = rows + .iter() + .filter_map(|row| row.get(column)) + .map(|value| value.chars().count()) + .max() + .unwrap_or_default() + .max(widths[column]) + .min(*maximum); + } + let separator_width = columns.len().saturating_sub(1) * 2; + while widths.iter().sum::() + separator_width > width { + let Some(column) = widths + .iter() + .zip(&minimums) + .enumerate() + .filter(|(_, (current, minimum))| current > minimum) + .max_by_key(|(_, (current, minimum))| *current - *minimum) + .map(|(column, _)| column) + else { + break; + }; + widths[column] -= 1; + } + + let mut lines = Vec::with_capacity(rows.len() + 2); + lines.push(table_row( + &columns + .iter() + .map(|(header, _)| (*header).to_owned()) + .collect::>(), + &widths, + )); + lines.push( + widths + .iter() + .map(|width| "-".repeat(*width)) + .collect::>() + .join(" "), + ); + lines.extend(rows.iter().map(|row| table_row(row, &widths))); + lines.join("\n") +} + +fn table_row(values: &[String], widths: &[usize]) -> String { + values + .iter() + .zip(widths) + .map(|(value, width)| table_cell(value, *width)) + .collect::>() + .join(" ") + .trim_end() + .to_owned() +} + +fn table_cell(value: &str, width: usize) -> String { + let value = value.lines().next().unwrap_or_default(); + let length = value.chars().count(); + if length <= width { + return format!("{value}{}", " ".repeat(width - length)); + } + let mut shortened = value + .chars() + .take(width.saturating_sub(1)) + .collect::(); + shortened.push('…'); + shortened +} + +fn terminal_width() -> usize { + env::var("COLUMNS") + .ok() + .and_then(|value| value.parse().ok()) + .filter(|width| *width >= 40) + .unwrap_or(100) +} + fn requested_help(command: &[String]) -> Result, String> { match command { [] => Ok(Some(ROOT_HELP)), @@ -393,4 +490,23 @@ mod tests { .starts_with("Usage: gotcha repo show") ); } + + #[test] + fn formats_bounded_aligned_tables_without_tabs() { + let table = format_table( + &[("INDEX", 8), ("STATE", 10), ("TITLE", 60)], + &[vec![ + "22".into(), + "open".into(), + "A deliberately long issue title that must be shortened".into(), + ]], + 40, + ); + + assert!(!table.contains('\t')); + assert!(table.contains('…')); + assert_eq!(table.lines().count(), 3); + assert!(table.lines().all(|line| line.chars().count() <= 40)); + assert!(table.lines().next().unwrap().starts_with("INDEX STATE")); + } } diff --git a/crates/cli/src/work_items.rs b/crates/cli/src/work_items.rs index a5e3091..e4f8a8c 100644 --- a/crates/cli/src/work_items.rs +++ b/crates/cli/src/work_items.rs @@ -8,18 +8,26 @@ use gotcha_gitea::{ EditPullRequestOption, Issue, MergePullRequestOption, Milestone, PullRequest, PullReview, }, }; -use serde::de::DeserializeOwned; +use serde::{Deserialize, de::DeserializeOwned}; -use crate::config::{RepositoryScope, Selection}; +use crate::{ + config::{RepositoryScope, Selection}, + print_table, +}; 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 +67,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 +111,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 +250,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 +303,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 +501,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,16 +972,32 @@ async fn pull_details( } fn print_issues(issues: &[Issue]) { - println!("INDEX\tSTATE\tTITLE\tUPDATED"); - for issue in issues { - println!( - "{}\t{}\t{}\t{}", - issue.number.unwrap_or_default(), - issue.state.as_deref().unwrap_or("unknown"), - one_line(issue.title.as_deref().unwrap_or("")), - issue.updated_at.as_deref().unwrap_or("-") - ); - } + print_table( + &[ + ("INDEX", 8), + ("STATE", 10), + ("TITLE", 60), + ("MILESTONE", 30), + ("LABELS", 30), + ], + issues + .iter() + .map(|issue| { + vec![ + issue.number.unwrap_or_default().to_string(), + issue.state.as_deref().unwrap_or("unknown").into(), + one_line(issue.title.as_deref().unwrap_or("")).into(), + issue + .milestone + .as_deref() + .and_then(|milestone| milestone.title.as_deref()) + .unwrap_or("-") + .into(), + issue_labels(issue), + ] + }) + .collect(), + ); } fn print_issue(issue: &Issue) { @@ -695,10 +1012,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!( @@ -715,18 +1065,31 @@ fn print_comments(comments: &[Comment]) { } fn print_milestones(milestones: &[Milestone]) { - println!("ID\tSTATE\tTITLE\tDUE\tOPEN/CLOSED"); - for milestone in milestones { - println!( - "{}\t{}\t{}\t{}\t{}/{}", - milestone.id.unwrap_or_default(), - milestone.state.as_deref().unwrap_or("unknown"), - one_line(milestone.title.as_deref().unwrap_or("")), - milestone.due_on.as_deref().unwrap_or("-"), - milestone.open_issues.unwrap_or_default(), - milestone.closed_issues.unwrap_or_default() - ); - } + print_table( + &[ + ("ID", 8), + ("STATE", 10), + ("TITLE", 60), + ("DUE", 25), + ("OPEN/CLOSED", 12), + ], + milestones + .iter() + .map(|milestone| { + vec![ + milestone.id.unwrap_or_default().to_string(), + milestone.state.as_deref().unwrap_or("unknown").into(), + one_line(milestone.title.as_deref().unwrap_or("")).into(), + milestone.due_on.as_deref().unwrap_or("-").into(), + format!( + "{}/{}", + milestone.open_issues.unwrap_or_default(), + milestone.closed_issues.unwrap_or_default() + ), + ] + }) + .collect(), + ); } fn print_milestone(milestone: &Milestone) { @@ -746,16 +1109,20 @@ fn print_milestone(milestone: &Milestone) { } fn print_pulls(pulls: &[PullRequest]) { - println!("INDEX\tSTATE\tTITLE\tUPDATED"); - for pull in pulls { - println!( - "{}\t{}\t{}\t{}", - pull.number.unwrap_or_default(), - pull.state.as_deref().unwrap_or("unknown"), - one_line(pull.title.as_deref().unwrap_or("")), - pull.updated_at.as_deref().unwrap_or("-") - ); - } + print_table( + &[("INDEX", 8), ("STATE", 10), ("TITLE", 70), ("UPDATED", 25)], + pulls + .iter() + .map(|pull| { + vec![ + pull.number.unwrap_or_default().to_string(), + pull.state.as_deref().unwrap_or("unknown").into(), + one_line(pull.title.as_deref().unwrap_or("")).into(), + pull.updated_at.as_deref().unwrap_or("-").into(), + ] + }) + .collect(), + ); } fn print_pull(pull: &PullRequest) { @@ -776,49 +1143,68 @@ fn print_pull(pull: &PullRequest) { } fn print_commits(commits: &[Commit]) { - println!("SHA\tMESSAGE"); - for commit in commits { - println!( - "{}\t{}", - commit.sha.as_deref().unwrap_or("unknown"), - one_line( - commit - .commit - .as_deref() - .and_then(|commit| commit.message.as_deref()) - .unwrap_or("") - ) - ); - } + print_table( + &[("SHA", 40), ("MESSAGE", 80)], + commits + .iter() + .map(|commit| { + vec![ + commit.sha.as_deref().unwrap_or("unknown").into(), + one_line( + commit + .commit + .as_deref() + .and_then(|commit| commit.message.as_deref()) + .unwrap_or(""), + ) + .into(), + ] + }) + .collect(), + ); } fn print_files(files: &[ChangedFile]) { - println!("STATUS\tCHANGES\tFILE"); - for file in files { - println!( - "{}\t{}\t{}", - file.status.as_deref().unwrap_or("unknown"), - file.changes.unwrap_or_default(), - file.filename.as_deref().unwrap_or("") - ); - } + print_table( + &[("STATUS", 12), ("CHANGES", 10), ("FILE", 90)], + files + .iter() + .map(|file| { + vec![ + file.status.as_deref().unwrap_or("unknown").into(), + file.changes.unwrap_or_default().to_string(), + file.filename.as_deref().unwrap_or("").into(), + ] + }) + .collect(), + ); } fn print_reviews(reviews: &[PullReview]) { - println!("ID\tSTATE\tREVIEWER\tSUBMITTED"); - for review in reviews { - println!( - "{}\t{}\t{}\t{}", - review.id.unwrap_or_default(), - review.state.as_deref().unwrap_or("unknown"), - review - .user - .as_deref() - .and_then(|user| user.login.as_deref()) - .unwrap_or("unknown"), - review.submitted_at.as_deref().unwrap_or("-") - ); - } + print_table( + &[ + ("ID", 12), + ("STATE", 20), + ("REVIEWER", 25), + ("SUBMITTED", 25), + ], + reviews + .iter() + .map(|review| { + vec![ + review.id.unwrap_or_default().to_string(), + review.state.as_deref().unwrap_or("unknown").into(), + review + .user + .as_deref() + .and_then(|user| user.login.as_deref()) + .unwrap_or("unknown") + .into(), + review.submitted_at.as_deref().unwrap_or("-").into(), + ] + }) + .collect(), + ); } fn field(name: &str, value: Option<&str>) { @@ -843,11 +1229,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()); + } }