Complete Gotcha issue CLI workflow

This commit is contained in:
Georg Bauer
2026-07-31 15:59:28 +02:00
parent 0583d05170
commit b468a2670c
5 changed files with 730 additions and 153 deletions

View File

@@ -2,14 +2,21 @@
## Gitea issue workflow ## Gitea issue workflow
Use the `tea` command-line tool as the preferred way to list, inspect, create, Use this repository's `gotcha` CLI to list, inspect, create, update, comment on,
and update Gitea issues. Run it from the repository root so it can use the and close Gitea issues. Run it from the repository root so the configured Git
configured remote, for example `tea issues` or `tea issues 1`. remote selects the server and repository:
Run issue-detail commands directly, for example `tea issues 7`. In a sandboxed ```sh
runner, grant network access before invoking `tea`. Without DNS/network access, cargo run -q -p gotcha-cli -- issue list
the current `tea` detail path may panic inside Gitea SDK version handling cargo run -q -p gotcha-cli -- issue list --milestones "first feature complete release"
instead of returning the underlying connection error. 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 ## 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 CoreSimulator access may require running `xcodebuild` and `simctl` outside the
workspace sandbox. 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 ## Generated build data
Use Xcode's default DerivedData location; do not pass `-derivedDataPath`. 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` - `user show``userGetCurrent`
- `repo list``userCurrentListRepos` - `repo list``userCurrentListRepos`
- `repo show``repoGet` - `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 - milestone list/show/create/edit/delete
- pull list/show/create/edit/merge/commits/files/reviews - 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 list
cargo run -p gotcha-cli -- repo show cargo run -p gotcha-cli -- repo show
cargo run -p gotcha-cli -- issue list 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 -- milestone list
cargo run -p gotcha-cli -- pull list cargo run -p gotcha-cli -- pull list
cargo run -p gotcha-cli -- api request GET repos/owner/project/issues cargo run -p gotcha-cli -- api request GET repos/owner/project/issues

View File

@@ -169,20 +169,30 @@ fn print_user(user: &User) {
} }
fn print_repositories(repositories: &[Repository]) { fn print_repositories(repositories: &[Repository]) {
println!("REPOSITORY\tVISIBILITY\tUPDATED\tDESCRIPTION"); print_table(
for repository in repositories { &[
println!( ("REPOSITORY", 40),
"{}\t{}\t{}\t{}", ("VISIBILITY", 10),
repository.full_name.as_deref().unwrap_or("unknown"), ("UPDATED", 25),
if repository.private.unwrap_or(false) { ("DESCRIPTION", 60),
"private" ],
} else { repositories
"public" .iter()
}, .map(|repository| {
repository.updated_at.as_deref().unwrap_or("-"), vec![
repository.description.as_deref().unwrap_or("") 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) { fn print_repository(repository: &Repository) {
@@ -216,6 +226,93 @@ fn print_json(value: &Value) -> Result<(), serde_json::Error> {
Ok(()) Ok(())
} }
fn print_table(columns: &[(&str, usize)], rows: Vec<Vec<String>>) {
println!("{}", format_table(columns, &rows, terminal_width()));
}
fn format_table(columns: &[(&str, usize)], rows: &[Vec<String>], width: usize) -> String {
let minimums = columns
.iter()
.map(|(header, _)| header.chars().count())
.collect::<Vec<_>>();
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::<usize>() + 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::<Vec<_>>(),
&widths,
));
lines.push(
widths
.iter()
.map(|width| "-".repeat(*width))
.collect::<Vec<_>>()
.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::<Vec<_>>()
.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::<String>();
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<Option<&'static str>, String> { fn requested_help(command: &[String]) -> Result<Option<&'static str>, String> {
match command { match command {
[] => Ok(Some(ROOT_HELP)), [] => Ok(Some(ROOT_HELP)),
@@ -393,4 +490,23 @@ mod tests {
.starts_with("Usage: gotcha repo show") .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"));
}
} }

View File

@@ -8,18 +8,26 @@ use gotcha_gitea::{
EditPullRequestOption, Issue, MergePullRequestOption, Milestone, PullRequest, PullReview, 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 = "\ const ISSUE_HELP: &str = "\
Usage: gotcha issue SUBCOMMAND Usage: gotcha issue SUBCOMMAND
Subcommands: Subcommands:
list [OWNER/REPOSITORY] List issues list [OWNER/REPOSITORY] [OPTIONS]
List and filter issues
show INDEX [OWNER/REPOSITORY] Show an issue show INDEX [OWNER/REPOSITORY] Show an issue
create [OWNER/REPOSITORY] Create from CreateIssueOption YAML on stdin 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 delete INDEX [OWNER/REPOSITORY] Delete an issue
comments INDEX [OWNER/REPOSITORY] comments INDEX [OWNER/REPOSITORY]
List comments 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> { pub fn subcommand_help(domain: &str, command: &str) -> Option<&'static str> {
match (domain, command) { 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", "show") => Some("Usage: gotcha issue show INDEX [OWNER/REPOSITORY]"),
("issue", "create") => Some( ("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( ("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", "delete") => Some("Usage: gotcha issue delete INDEX [OWNER/REPOSITORY]"),
("issue", "comments") => Some("Usage: gotcha issue comments INDEX [OWNER/REPOSITORY]"), ("issue", "comments") => Some("Usage: gotcha issue comments INDEX [OWNER/REPOSITORY]"),
("issue", "comment") => Some( ("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<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( pub async fn run(
command: &[String], command: &[String],
selection: &Selection, selection: &Selection,
@@ -106,46 +250,25 @@ pub async fn run(
) -> Result<bool, Box<dyn Error>> { ) -> Result<bool, Box<dyn Error>> {
let configuration = client.configuration(); let configuration = client.configuration();
match command { match command {
[domain, action] if domain == "issue" && action == "list" => { [domain, action, arguments @ ..] if domain == "issue" && action == "list" => {
let scope = scope(selection, None)?; let options = parse_issue_list(arguments)?;
let scope = scope(selection, options.repository.as_deref())?;
let issues = apis::issue_api::issue_list_issues( let issues = apis::issue_api::issue_list_issues(
&configuration, &configuration,
&scope.owner, &scope.owner,
&scope.repository, &scope.repository,
None, Some(&options.state),
None, options.labels.as_deref(),
None, options.keyword.as_deref(),
None, Some(&options.kind),
None, options.milestones.as_deref(),
None, options.from,
None, options.until,
None, options.author.as_deref(),
None, options.assignee.as_deref(),
None, options.mentions.as_deref(),
None, Some(options.page),
None, Some(options.limit),
)
.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,
) )
.await?; .await?;
print_issues(&issues); print_issues(&issues);
@@ -180,14 +303,25 @@ pub async fn run(
[domain, action, repository] if domain == "issue" && action == "create" => { [domain, action, repository] if domain == "issue" && action == "create" => {
create_issue(&configuration, &scope(selection, Some(repository))?).await?; create_issue(&configuration, &scope(selection, Some(repository))?).await?;
} }
[domain, action, index] if domain == "issue" && action == "edit" => { [domain, action, arguments @ ..] if domain == "issue" && action == "edit" => {
edit_issue(&configuration, &scope(selection, None)?, number(index)?).await?; let (indexes, repository) = issue_targets(arguments)?;
} edit_issues(
[domain, action, index, repository] if domain == "issue" && action == "edit" => {
edit_issue(
&configuration, &configuration,
&scope(selection, Some(repository))?, &scope(selection, repository.as_deref())?,
number(index)?, &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?; .await?;
} }
@@ -367,36 +501,203 @@ async fn create_issue(
configuration: &apis::configuration::Configuration, configuration: &apis::configuration::Configuration,
scope: &RepositoryScope, scope: &RepositoryScope,
) -> Result<(), Box<dyn Error>> { ) -> Result<(), Box<dyn Error>> {
let body: CreateIssueOption = read_yaml()?; let mut input: CreateIssueInput = read_yaml()?;
if body.title.trim().is_empty() { if input.issue.title.trim().is_empty() {
return Err("issue title must not be empty".into()); 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( print_issue(
&apis::issue_api::issue_create_issue( &apis::issue_api::issue_create_issue(
configuration, configuration,
&scope.owner, &scope.owner,
&scope.repository, &scope.repository,
Some(body), Some(input.issue),
) )
.await?, .await?,
); );
Ok(()) 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, configuration: &apis::configuration::Configuration,
scope: &RepositoryScope, scope: &RepositoryScope,
index: i64, index: i64,
input: &EditIssueInput,
remove: &[i64],
add: &[i64],
) -> Result<(), Box<dyn Error>> { ) -> 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, configuration,
&scope.owner, &scope.owner,
&scope.repository, &scope.repository,
index, Some("all"),
Some(read_yaml::<EditIssueOption>()?), Some(name),
Some(1),
Some(2),
) )
.await?; .await?
print_issue(&issue); .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(()) Ok(())
} }
@@ -671,16 +972,32 @@ async fn pull_details(
} }
fn print_issues(issues: &[Issue]) { fn print_issues(issues: &[Issue]) {
println!("INDEX\tSTATE\tTITLE\tUPDATED"); print_table(
for issue in issues { &[
println!( ("INDEX", 8),
"{}\t{}\t{}\t{}", ("STATE", 10),
issue.number.unwrap_or_default(), ("TITLE", 60),
issue.state.as_deref().unwrap_or("unknown"), ("MILESTONE", 30),
one_line(issue.title.as_deref().unwrap_or("")), ("LABELS", 30),
issue.updated_at.as_deref().unwrap_or("-") ],
); 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) { fn print_issue(issue: &Issue) {
@@ -695,10 +1012,43 @@ fn print_issue(issue: &Issue) {
"author", "author",
issue.user.as_deref().and_then(|user| user.login.as_deref()), 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()); field("updated", issue.updated_at.as_deref());
println!("comments: {}", issue.comments.unwrap_or_default());
body(issue.body.as_deref()); 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]) { fn print_comments(comments: &[Comment]) {
for comment in comments { for comment in comments {
println!( println!(
@@ -715,18 +1065,31 @@ fn print_comments(comments: &[Comment]) {
} }
fn print_milestones(milestones: &[Milestone]) { fn print_milestones(milestones: &[Milestone]) {
println!("ID\tSTATE\tTITLE\tDUE\tOPEN/CLOSED"); print_table(
for milestone in milestones { &[
println!( ("ID", 8),
"{}\t{}\t{}\t{}\t{}/{}", ("STATE", 10),
milestone.id.unwrap_or_default(), ("TITLE", 60),
milestone.state.as_deref().unwrap_or("unknown"), ("DUE", 25),
one_line(milestone.title.as_deref().unwrap_or("")), ("OPEN/CLOSED", 12),
milestone.due_on.as_deref().unwrap_or("-"), ],
milestone.open_issues.unwrap_or_default(), milestones
milestone.closed_issues.unwrap_or_default() .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) { fn print_milestone(milestone: &Milestone) {
@@ -746,16 +1109,20 @@ fn print_milestone(milestone: &Milestone) {
} }
fn print_pulls(pulls: &[PullRequest]) { fn print_pulls(pulls: &[PullRequest]) {
println!("INDEX\tSTATE\tTITLE\tUPDATED"); print_table(
for pull in pulls { &[("INDEX", 8), ("STATE", 10), ("TITLE", 70), ("UPDATED", 25)],
println!( pulls
"{}\t{}\t{}\t{}", .iter()
pull.number.unwrap_or_default(), .map(|pull| {
pull.state.as_deref().unwrap_or("unknown"), vec![
one_line(pull.title.as_deref().unwrap_or("")), pull.number.unwrap_or_default().to_string(),
pull.updated_at.as_deref().unwrap_or("-") 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) { fn print_pull(pull: &PullRequest) {
@@ -776,49 +1143,68 @@ fn print_pull(pull: &PullRequest) {
} }
fn print_commits(commits: &[Commit]) { fn print_commits(commits: &[Commit]) {
println!("SHA\tMESSAGE"); print_table(
for commit in commits { &[("SHA", 40), ("MESSAGE", 80)],
println!( commits
"{}\t{}", .iter()
commit.sha.as_deref().unwrap_or("unknown"), .map(|commit| {
one_line( vec![
commit commit.sha.as_deref().unwrap_or("unknown").into(),
.commit one_line(
.as_deref() commit
.and_then(|commit| commit.message.as_deref()) .commit
.unwrap_or("") .as_deref()
) .and_then(|commit| commit.message.as_deref())
); .unwrap_or(""),
} )
.into(),
]
})
.collect(),
);
} }
fn print_files(files: &[ChangedFile]) { fn print_files(files: &[ChangedFile]) {
println!("STATUS\tCHANGES\tFILE"); print_table(
for file in files { &[("STATUS", 12), ("CHANGES", 10), ("FILE", 90)],
println!( files
"{}\t{}\t{}", .iter()
file.status.as_deref().unwrap_or("unknown"), .map(|file| {
file.changes.unwrap_or_default(), vec![
file.filename.as_deref().unwrap_or("") 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]) { fn print_reviews(reviews: &[PullReview]) {
println!("ID\tSTATE\tREVIEWER\tSUBMITTED"); print_table(
for review in reviews { &[
println!( ("ID", 12),
"{}\t{}\t{}\t{}", ("STATE", 20),
review.id.unwrap_or_default(), ("REVIEWER", 25),
review.state.as_deref().unwrap_or("unknown"), ("SUBMITTED", 25),
review ],
.user reviews
.as_deref() .iter()
.and_then(|user| user.login.as_deref()) .map(|review| {
.unwrap_or("unknown"), vec![
review.submitted_at.as_deref().unwrap_or("-") 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>) { fn field(name: &str, value: Option<&str>) {
@@ -843,11 +1229,60 @@ mod tests {
#[test] #[test]
fn parses_typed_yaml_and_rejects_bad_indexes() { 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.issue.title, "Fix it");
assert_eq!(issue.body.as_deref(), Some("Details")); 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("0").is_err());
assert!(number("7").is_ok()); 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());
}
} }