Files
Gotcha/crates/cli/src/work_items.rs
2026-07-31 16:44:16 +02:00

951 lines
34 KiB
Rust

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<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,
client: &Client,
) -> Result<bool, Box<dyn Error>> {
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<RepositoryScope, Box<dyn Error>> {
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<i64, Box<dyn Error>> {
let number: i64 = value.parse()?;
if number < 1 {
return Err("index must be a positive integer".into());
}
Ok(number)
}
fn read_stdin() -> Result<String, Box<dyn Error>> {
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<T: DeserializeOwned>() -> Result<T, Box<dyn Error>> {
parse_yaml(&read_stdin()?)
}
fn parse_yaml<T: DeserializeOwned>(input: &str) -> Result<T, Box<dyn Error>> {
Ok(serde_yaml::from_str(input)?)
}
async fn create_issue(client: &Client, scope: &RepositoryScope) -> Result<(), Box<dyn Error>> {
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<dyn Error>> {
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<dyn Error>> {
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<dyn Error>> {
client.delete_issue(scope, index).await?;
println!("Deleted issue #{index}.");
Ok(())
}
async fn list_comments(
client: &Client,
scope: &RepositoryScope,
index: i64,
) -> Result<(), Box<dyn Error>> {
let comments = client.issue_comments(scope, index).await?;
print_comments(&comments);
Ok(())
}
async fn create_comment(
client: &Client,
scope: &RepositoryScope,
index: i64,
) -> Result<(), Box<dyn Error>> {
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<dyn Error>> {
let milestones = client.milestones(scope).await?;
print_milestones(&milestones);
Ok(())
}
async fn show_milestone(
client: &Client,
scope: &RepositoryScope,
id: &str,
) -> Result<(), Box<dyn Error>> {
let milestone = client.milestone(scope, number(id)?).await?;
print_milestone(&milestone);
Ok(())
}
async fn create_milestone(client: &Client, scope: &RepositoryScope) -> Result<(), Box<dyn Error>> {
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<dyn Error>> {
let milestone = client
.edit_milestone(scope, id, read_yaml::<EditMilestoneOption>()?)
.await?;
print_milestone(&milestone);
Ok(())
}
async fn delete_milestone(
client: &Client,
scope: &RepositoryScope,
id: &str,
) -> Result<(), Box<dyn Error>> {
client.delete_milestone(scope, id).await?;
println!("Deleted milestone {id}.");
Ok(())
}
async fn list_pulls(client: &Client, scope: &RepositoryScope) -> Result<(), Box<dyn Error>> {
print_pulls(&client.repository_pulls(scope, "open", 1, 30).await?.items);
Ok(())
}
async fn show_pull(
client: &Client,
scope: &RepositoryScope,
index: i64,
) -> Result<(), Box<dyn Error>> {
let pull = client.pull(scope, index).await?;
print_pull(&pull);
Ok(())
}
async fn create_pull(client: &Client, scope: &RepositoryScope) -> Result<(), Box<dyn Error>> {
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<dyn Error>> {
let pull = client
.edit_pull(scope, index, read_yaml::<EditPullRequestOption>()?)
.await?;
print_pull(&pull);
Ok(())
}
async fn merge_pull(
client: &Client,
scope: &RepositoryScope,
index: i64,
) -> Result<(), Box<dyn Error>> {
client
.merge_pull(scope, index, read_yaml::<MergePullRequestOption>()?)
.await?;
println!("Merged pull request #{index}.");
Ok(())
}
async fn pull_details(
client: &Client,
scope: &RepositoryScope,
index: i64,
action: &str,
) -> Result<(), Box<dyn Error>> {
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::<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!(
"{} · {}",
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());
}
}