Prepare Gotcha 1.0 for release
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "gotcha-cli"
|
||||
version = "0.1.0"
|
||||
description = "CLI test bed for the Gotcha Gitea client"
|
||||
version = "1.0.0"
|
||||
description = "Command-line Gitea client"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
@@ -463,50 +463,4 @@ impl Args {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_server_selection_and_repository_scope() {
|
||||
let args = Args::parse(
|
||||
["--server", "work", "repo", "show"].map(str::to_owned),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let repository = RepositoryScope::parse("alice/project").unwrap();
|
||||
|
||||
assert_eq!(args.server.as_deref(), Some("work"));
|
||||
assert_eq!(args.command, ["repo", "show"]);
|
||||
assert_eq!(
|
||||
(repository.owner.as_str(), repository.repository.as_str()),
|
||||
("alice", "project")
|
||||
);
|
||||
assert_eq!(requested_help(&[]).unwrap(), Some(ROOT_HELP));
|
||||
assert_eq!(requested_help(&["repo".into()]).unwrap(), Some(REPO_HELP));
|
||||
assert!(
|
||||
requested_help(&["repo".into(), "show".into(), "--help".into()])
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.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"));
|
||||
}
|
||||
}
|
||||
mod tests;
|
||||
|
||||
45
crates/cli/src/tests.rs
Normal file
45
crates/cli/src/tests.rs
Normal file
@@ -0,0 +1,45 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_server_selection_and_repository_scope() {
|
||||
let args = Args::parse(
|
||||
["--server", "work", "repo", "show"].map(str::to_owned),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let repository = RepositoryScope::parse("alice/project").unwrap();
|
||||
|
||||
assert_eq!(args.server.as_deref(), Some("work"));
|
||||
assert_eq!(args.command, ["repo", "show"]);
|
||||
assert_eq!(
|
||||
(repository.owner.as_str(), repository.repository.as_str()),
|
||||
("alice", "project")
|
||||
);
|
||||
assert_eq!(requested_help(&[]).unwrap(), Some(ROOT_HELP));
|
||||
assert_eq!(requested_help(&["repo".into()]).unwrap(), Some(REPO_HELP));
|
||||
assert!(
|
||||
requested_help(&["repo".into(), "show".into(), "--help".into()])
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.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"));
|
||||
}
|
||||
@@ -3,246 +3,13 @@ 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,
|
||||
CreateMilestoneOption, CreatePullRequestOption, EditMilestoneOption, EditPullRequestOption,
|
||||
MergePullRequestOption,
|
||||
},
|
||||
pull_state,
|
||||
};
|
||||
use serde::{Deserialize, de::DeserializeOwned};
|
||||
use serde::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))
|
||||
}
|
||||
use crate::config::{RepositoryScope, Selection};
|
||||
|
||||
pub async fn run(
|
||||
command: &[String],
|
||||
@@ -633,318 +400,13 @@ async fn pull_details(
|
||||
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(),
|
||||
);
|
||||
}
|
||||
mod arguments;
|
||||
mod help;
|
||||
mod output;
|
||||
|
||||
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()
|
||||
}
|
||||
use arguments::*;
|
||||
pub(crate) use help::{domain_help, subcommand_help};
|
||||
use output::*;
|
||||
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
mod tests;
|
||||
|
||||
142
crates/cli/src/work_items/arguments.rs
Normal file
142
crates/cli/src/work_items/arguments.rs
Normal file
@@ -0,0 +1,142 @@
|
||||
use std::error::Error;
|
||||
|
||||
use gotcha_gitea::models::{CreateIssueOption, EditIssueOption};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::config::RepositoryScope;
|
||||
|
||||
use super::number;
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub(super) struct IssueListOptions {
|
||||
pub(super) repository: Option<String>,
|
||||
pub(super) state: String,
|
||||
pub(super) kind: String,
|
||||
pub(super) keyword: Option<String>,
|
||||
pub(super) labels: Option<String>,
|
||||
pub(super) milestones: Option<String>,
|
||||
pub(super) author: Option<String>,
|
||||
pub(super) assignee: Option<String>,
|
||||
pub(super) mentions: Option<String>,
|
||||
pub(super) from: Option<String>,
|
||||
pub(super) until: Option<String>,
|
||||
pub(super) page: i32,
|
||||
pub(super) limit: i32,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(super) struct CreateIssueInput {
|
||||
#[serde(flatten)]
|
||||
pub(super) issue: CreateIssueOption,
|
||||
#[serde(default)]
|
||||
pub(super) label_names: Vec<String>,
|
||||
pub(super) milestone_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
pub(super) struct EditIssueInput {
|
||||
#[serde(flatten)]
|
||||
pub(super) issue: EditIssueOption,
|
||||
#[serde(default)]
|
||||
pub(super) add_labels: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub(super) remove_labels: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub(super) add_assignees: Vec<String>,
|
||||
pub(super) 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) 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)
|
||||
}
|
||||
|
||||
pub(super) 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))
|
||||
}
|
||||
95
crates/cli/src/work_items/help.rs
Normal file
95
crates/cli/src/work_items/help.rs
Normal file
@@ -0,0 +1,95 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
258
crates/cli/src/work_items/output.rs
Normal file
258
crates/cli/src/work_items/output.rs
Normal file
@@ -0,0 +1,258 @@
|
||||
use gotcha_gitea::models::{
|
||||
ChangedFile, Comment, Commit, Issue, Milestone, PullRequest, PullReview,
|
||||
};
|
||||
use gotcha_gitea::pull_state;
|
||||
|
||||
use crate::print_table;
|
||||
|
||||
pub(super) 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(),
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) 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(",")
|
||||
}
|
||||
|
||||
pub(super) 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());
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) 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(),
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) 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());
|
||||
}
|
||||
|
||||
pub(super) 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(),
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) 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());
|
||||
}
|
||||
|
||||
pub(super) 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(),
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) 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(),
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) 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()
|
||||
}
|
||||
59
crates/cli/src/work_items/tests.rs
Normal file
59
crates/cli/src/work_items/tests.rs
Normal file
@@ -0,0 +1,59 @@
|
||||
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());
|
||||
}
|
||||
Reference in New Issue
Block a user