use std::{error::Error, io::Read}; use gotcha_gitea::{ Client, CreateIssue, EditIssue, IssueQuery, models::{ ChangedFile, Comment, Commit, CreateIssueOption, CreateMilestoneOption, CreatePullRequestOption, EditIssueOption, EditMilestoneOption, EditPullRequestOption, Issue, MergePullRequestOption, Milestone, PullRequest, PullReview, }, pull_state, }; use serde::{Deserialize, de::DeserializeOwned}; use crate::{ config::{RepositoryScope, Selection}, print_table, }; const ISSUE_HELP: &str = "\ Usage: gotcha issue SUBCOMMAND Subcommands: 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 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 comment INDEX [OWNER/REPOSITORY] Add a comment read as text from stdin"; const MILESTONE_HELP: &str = "\ Usage: gotcha milestone SUBCOMMAND Subcommands: list [OWNER/REPOSITORY] List milestones show ID [OWNER/REPOSITORY] Show a milestone create [OWNER/REPOSITORY] Create from CreateMilestoneOption YAML on stdin edit ID [OWNER/REPOSITORY] Edit from EditMilestoneOption YAML on stdin delete ID [OWNER/REPOSITORY] Delete a milestone"; const PULL_HELP: &str = "\ Usage: gotcha pull SUBCOMMAND Subcommands: list [OWNER/REPOSITORY] List pull requests show INDEX [OWNER/REPOSITORY] Show a pull request create [OWNER/REPOSITORY] Create from CreatePullRequestOption YAML on stdin edit INDEX [OWNER/REPOSITORY] Edit from EditPullRequestOption YAML on stdin merge INDEX [OWNER/REPOSITORY] Merge from MergePullRequestOption YAML on stdin commits INDEX [OWNER/REPOSITORY] List commits files INDEX [OWNER/REPOSITORY] List changed files reviews INDEX [OWNER/REPOSITORY] List reviews"; pub fn domain_help(domain: &str) -> Option<&'static str> { match domain { "issue" => Some(ISSUE_HELP), "milestone" => Some(MILESTONE_HELP), "pull" => Some(PULL_HELP), _ => None, } } pub fn subcommand_help(domain: &str, command: &str) -> Option<&'static str> { match (domain, command) { ("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. 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 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( "Usage: gotcha issue comment INDEX [OWNER/REPOSITORY] < comment.txt\n\nReads the comment body as text from stdin.", ), ("milestone", "list") => Some("Usage: gotcha milestone list [OWNER/REPOSITORY]"), ("milestone", "show") => Some("Usage: gotcha milestone show ID [OWNER/REPOSITORY]"), ("milestone", "create") => Some( "Usage: gotcha milestone create [OWNER/REPOSITORY] < milestone.yaml\n\nReads CreateMilestoneOption YAML from stdin, for example:\n title: Version 1.0\n due_on: 2026-09-01T00:00:00Z", ), ("milestone", "edit") => Some( "Usage: gotcha milestone edit ID [OWNER/REPOSITORY] < milestone.yaml\n\nReads EditMilestoneOption YAML from stdin.", ), ("milestone", "delete") => Some("Usage: gotcha milestone delete ID [OWNER/REPOSITORY]"), ("pull", "list") => Some("Usage: gotcha pull list [OWNER/REPOSITORY]"), ("pull", "show") => Some("Usage: gotcha pull show INDEX [OWNER/REPOSITORY]"), ("pull", "create") => Some( "Usage: gotcha pull create [OWNER/REPOSITORY] < pull.yaml\n\nReads CreatePullRequestOption YAML from stdin, for example:\n title: Add feature\n head: feature\n base: main", ), ("pull", "edit") => Some( "Usage: gotcha pull edit INDEX [OWNER/REPOSITORY] < pull.yaml\n\nReads EditPullRequestOption YAML from stdin.", ), ("pull", "merge") => Some( "Usage: gotcha pull merge INDEX [OWNER/REPOSITORY] < merge.yaml\n\nReads MergePullRequestOption YAML from stdin, for example:\n Do: squash\n delete_branch_after_merge: true", ), ("pull", "commits") => Some("Usage: gotcha pull commits INDEX [OWNER/REPOSITORY]"), ("pull", "files") => Some("Usage: gotcha pull files INDEX [OWNER/REPOSITORY]"), ("pull", "reviews") => Some("Usage: gotcha pull reviews INDEX [OWNER/REPOSITORY]"), _ => None, } } #[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, client: &Client, ) -> Result> { match command { [domain, action, arguments @ ..] if domain == "issue" && action == "list" => { let options = parse_issue_list(arguments)?; let scope = scope(selection, options.repository.as_deref())?; let issues = client .issues( &scope, &IssueQuery { state: options.state, labels: options.labels, keyword: options.keyword, kind: options.kind, milestones: options.milestones, from: options.from, until: options.until, author: options.author, assignee: options.assignee, mentions: options.mentions, page: options.page, limit: options.limit, }, ) .await?; print_issues(&issues.items); } [domain, action, index] if domain == "issue" && action == "show" => { let scope = scope(selection, None)?; print_issue(&client.issue(&scope, number(index)?).await?); } [domain, action, index, repository] if domain == "issue" && action == "show" => { let scope = scope(selection, Some(repository))?; print_issue(&client.issue(&scope, number(index)?).await?); } [domain, action] if domain == "issue" && action == "create" => { create_issue(client, &scope(selection, None)?).await?; } [domain, action, repository] if domain == "issue" && action == "create" => { create_issue(client, &scope(selection, Some(repository))?).await?; } [domain, action, arguments @ ..] if domain == "issue" && action == "edit" => { let (indexes, repository) = issue_targets(arguments)?; edit_issues(client, &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( client, &scope, &indexes, if action == "close" { "closed" } else { "open" }, ) .await?; } [domain, action, index] if domain == "issue" && action == "delete" => { delete_issue(client, &scope(selection, None)?, number(index)?).await?; } [domain, action, index, repository] if domain == "issue" && action == "delete" => { delete_issue(client, &scope(selection, Some(repository))?, number(index)?).await?; } [domain, action, index] if domain == "issue" && action == "comments" => { list_comments(client, &scope(selection, None)?, number(index)?).await?; } [domain, action, index, repository] if domain == "issue" && action == "comments" => { list_comments(client, &scope(selection, Some(repository))?, number(index)?).await?; } [domain, action, index] if domain == "issue" && action == "comment" => { create_comment(client, &scope(selection, None)?, number(index)?).await?; } [domain, action, index, repository] if domain == "issue" && action == "comment" => { create_comment(client, &scope(selection, Some(repository))?, number(index)?).await?; } [domain, action] if domain == "milestone" && action == "list" => { list_milestones(client, &scope(selection, None)?).await?; } [domain, action, repository] if domain == "milestone" && action == "list" => { list_milestones(client, &scope(selection, Some(repository))?).await?; } [domain, action, id] if domain == "milestone" && action == "show" => { show_milestone(client, &scope(selection, None)?, id).await?; } [domain, action, id, repository] if domain == "milestone" && action == "show" => { show_milestone(client, &scope(selection, Some(repository))?, id).await?; } [domain, action] if domain == "milestone" && action == "create" => { create_milestone(client, &scope(selection, None)?).await?; } [domain, action, repository] if domain == "milestone" && action == "create" => { create_milestone(client, &scope(selection, Some(repository))?).await?; } [domain, action, id] if domain == "milestone" && action == "edit" => { edit_milestone(client, &scope(selection, None)?, id).await?; } [domain, action, id, repository] if domain == "milestone" && action == "edit" => { edit_milestone(client, &scope(selection, Some(repository))?, id).await?; } [domain, action, id] if domain == "milestone" && action == "delete" => { delete_milestone(client, &scope(selection, None)?, id).await?; } [domain, action, id, repository] if domain == "milestone" && action == "delete" => { delete_milestone(client, &scope(selection, Some(repository))?, id).await?; } [domain, action] if domain == "pull" && action == "list" => { list_pulls(client, &scope(selection, None)?).await?; } [domain, action, repository] if domain == "pull" && action == "list" => { list_pulls(client, &scope(selection, Some(repository))?).await?; } [domain, action, index] if domain == "pull" && action == "show" => { show_pull(client, &scope(selection, None)?, number(index)?).await?; } [domain, action, index, repository] if domain == "pull" && action == "show" => { show_pull(client, &scope(selection, Some(repository))?, number(index)?).await?; } [domain, action] if domain == "pull" && action == "create" => { create_pull(client, &scope(selection, None)?).await?; } [domain, action, repository] if domain == "pull" && action == "create" => { create_pull(client, &scope(selection, Some(repository))?).await?; } [domain, action, index] if domain == "pull" && action == "edit" => { edit_pull(client, &scope(selection, None)?, number(index)?).await?; } [domain, action, index, repository] if domain == "pull" && action == "edit" => { edit_pull(client, &scope(selection, Some(repository))?, number(index)?).await?; } [domain, action, index] if domain == "pull" && action == "merge" => { merge_pull(client, &scope(selection, None)?, number(index)?).await?; } [domain, action, index, repository] if domain == "pull" && action == "merge" => { merge_pull(client, &scope(selection, Some(repository))?, number(index)?).await?; } [domain, action, index] if domain == "pull" && matches!(action.as_str(), "commits" | "files" | "reviews") => { pull_details(client, &scope(selection, None)?, number(index)?, action).await?; } [domain, action, index, repository] if domain == "pull" && matches!(action.as_str(), "commits" | "files" | "reviews") => { pull_details( client, &scope(selection, Some(repository))?, number(index)?, action, ) .await?; } [domain, ..] if matches!(domain.as_str(), "issue" | "milestone" | "pull") => { return Ok(false); } _ => return Ok(false), } Ok(true) } fn scope(selection: &Selection, value: Option<&str>) -> Result> { match value { Some(value) => Ok(RepositoryScope::parse(value)?), None => selection.repository.clone().ok_or_else(|| { "cannot infer a repository; run inside one or pass OWNER/REPOSITORY".into() }), } } fn number(value: &str) -> Result> { let number: i64 = value.parse()?; if number < 1 { return Err("index must be a positive integer".into()); } Ok(number) } fn read_stdin() -> Result> { let mut input = String::new(); std::io::stdin().read_to_string(&mut input)?; if input.trim().is_empty() { return Err("stdin must not be empty".into()); } Ok(input) } fn read_yaml() -> Result> { parse_yaml(&read_stdin()?) } fn parse_yaml(input: &str) -> Result> { Ok(serde_yaml::from_str(input)?) } async fn create_issue(client: &Client, scope: &RepositoryScope) -> Result<(), Box> { let input: CreateIssueInput = read_yaml()?; print_issue( &client .create_issue( scope, CreateIssue { option: input.issue, label_names: input.label_names, milestone_name: input.milestone_name, }, ) .await?, ); Ok(()) } async fn edit_issues( client: &Client, scope: &RepositoryScope, indexes: &[i64], ) -> Result<(), Box> { let input: EditIssueInput = read_yaml()?; for issue in client .edit_issues( scope, indexes, EditIssue { option: input.issue, replace_labels: None, add_labels: input.add_labels, remove_labels: input.remove_labels, add_assignees: input.add_assignees, milestone_name: input.milestone_name, }, ) .await? { print_issue(&issue); } Ok(()) } async fn set_issue_state( client: &Client, scope: &RepositoryScope, indexes: &[i64], state: &str, ) -> Result<(), Box> { for issue in client.set_issue_state(scope, indexes, state).await? { print_issue(&issue); } Ok(()) } async fn delete_issue( client: &Client, scope: &RepositoryScope, index: i64, ) -> Result<(), Box> { client.delete_issue(scope, index).await?; println!("Deleted issue #{index}."); Ok(()) } async fn list_comments( client: &Client, scope: &RepositoryScope, index: i64, ) -> Result<(), Box> { let comments = client.issue_comments(scope, index).await?; print_comments(&comments); Ok(()) } async fn create_comment( client: &Client, scope: &RepositoryScope, index: i64, ) -> Result<(), Box> { let body = read_stdin()?.trim_end().to_owned(); let comment = client.create_issue_comment(scope, index, body).await?; print_comments(&[comment]); Ok(()) } async fn list_milestones(client: &Client, scope: &RepositoryScope) -> Result<(), Box> { let milestones = client.milestones(scope).await?; print_milestones(&milestones); Ok(()) } async fn show_milestone( client: &Client, scope: &RepositoryScope, id: &str, ) -> Result<(), Box> { let milestone = client.milestone(scope, number(id)?).await?; print_milestone(&milestone); Ok(()) } async fn create_milestone(client: &Client, scope: &RepositoryScope) -> Result<(), Box> { let body: CreateMilestoneOption = read_yaml()?; let milestone = client.create_milestone(scope, body).await?; print_milestone(&milestone); Ok(()) } async fn edit_milestone( client: &Client, scope: &RepositoryScope, id: &str, ) -> Result<(), Box> { let milestone = client .edit_milestone(scope, id, read_yaml::()?) .await?; print_milestone(&milestone); Ok(()) } async fn delete_milestone( client: &Client, scope: &RepositoryScope, id: &str, ) -> Result<(), Box> { client.delete_milestone(scope, id).await?; println!("Deleted milestone {id}."); Ok(()) } async fn list_pulls(client: &Client, scope: &RepositoryScope) -> Result<(), Box> { print_pulls(&client.repository_pulls(scope, "open", 1, 30).await?.items); Ok(()) } async fn show_pull( client: &Client, scope: &RepositoryScope, index: i64, ) -> Result<(), Box> { let pull = client.pull(scope, index).await?; print_pull(&pull); Ok(()) } async fn create_pull(client: &Client, scope: &RepositoryScope) -> Result<(), Box> { let body: CreatePullRequestOption = read_yaml()?; let pull = client.create_pull(scope, body).await?; print_pull(&pull); Ok(()) } async fn edit_pull( client: &Client, scope: &RepositoryScope, index: i64, ) -> Result<(), Box> { let pull = client .edit_pull(scope, index, read_yaml::()?) .await?; print_pull(&pull); Ok(()) } async fn merge_pull( client: &Client, scope: &RepositoryScope, index: i64, ) -> Result<(), Box> { client .merge_pull(scope, index, read_yaml::()?) .await?; println!("Merged pull request #{index}."); Ok(()) } async fn pull_details( client: &Client, scope: &RepositoryScope, index: i64, action: &str, ) -> Result<(), Box> { match action { "commits" => print_commits(&client.pull_commits(scope, index).await?), "files" => print_files(&client.pull_files(scope, index).await?), "reviews" => print_reviews(&client.pull_reviews(scope, index).await?), _ => unreachable!(), } Ok(()) } fn print_issues(issues: &[Issue]) { 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) { println!( "#{} [{}] {}", issue.number.unwrap_or_default(), issue.state.as_deref().unwrap_or("unknown"), issue.title.as_deref().unwrap_or("") ); field("url", issue.html_url.as_deref()); field( "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!( "{} ยท {}", comment .user .as_deref() .and_then(|user| user.login.as_deref()) .unwrap_or("unknown"), comment.created_at.as_deref().unwrap_or("-") ); body(comment.body.as_deref()); } } fn print_milestones(milestones: &[Milestone]) { 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) { println!( "{} [{}] {}", milestone.id.unwrap_or_default(), milestone.state.as_deref().unwrap_or("unknown"), milestone.title.as_deref().unwrap_or("") ); field("due", milestone.due_on.as_deref()); println!( "issues: {} open, {} closed", milestone.open_issues.unwrap_or_default(), milestone.closed_issues.unwrap_or_default() ); body(milestone.description.as_deref()); } fn print_pulls(pulls: &[PullRequest]) { 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) { println!( "#{} [{}] {}", pull.number.unwrap_or_default(), pull_state(pull), pull.title.as_deref().unwrap_or("") ); field("url", pull.html_url.as_deref()); field( "author", pull.user.as_deref().and_then(|user| user.login.as_deref()), ); field("updated", pull.updated_at.as_deref()); println!("mergeable: {}", pull.mergeable.unwrap_or(false)); body(pull.body.as_deref()); } fn print_commits(commits: &[Commit]) { 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]) { 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]) { 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>) { if let Some(value) = value.filter(|value| !value.is_empty()) { println!("{name}: {value}"); } } fn body(value: Option<&str>) { if let Some(value) = value.filter(|value| !value.is_empty()) { println!("\n{value}"); } } fn one_line(value: &str) -> &str { value.lines().next().unwrap_or_default() } #[cfg(test)] mod tests { use super::*; #[test] fn parses_typed_yaml_and_rejects_bad_indexes() { let issue: CreateIssueInput = parse_yaml( "title: Fix it\nbody: Details\nlabel_names: [bug]\nmilestone_name: Version 1\n", ) .unwrap(); 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()); } }