use std::io::Write as _;
use clap::{Args, Subcommand};
use crate::api::query::Filter;
use crate::cli::write::{Gate, Intent, check, parse_assignment};
use crate::cli::{Session, emit, report};
use crate::exit::ExitCode;
use crate::render::{self, Format, image, machine, text};
#[derive(Debug, Subcommand)]
pub enum IssueCommand {
#[command(long_about = crate::cli::help::md(crate::cli::help::ISSUE_GET))]
Get {
key: String,
#[arg(long, value_delimiter = ',')]
fields: Vec<String>,
},
#[command(visible_alias = "list", long_about = crate::cli::help::md(crate::cli::help::ISSUE_FIND))]
Find(FindArgs),
#[command(long_about = crate::cli::help::md(crate::cli::help::ISSUE_COUNT))]
Count(FindArgs),
#[command(long_about = crate::cli::help::md(crate::cli::help::ISSUE_LINKS))]
Links { key: String },
#[command(long_about = crate::cli::help::md(crate::cli::help::ISSUE_REMOTELINKS))]
Remotelinks { key: String },
#[command(long_about = crate::cli::help::md(crate::cli::help::ISSUE_CHANGELOG))]
Changelog {
key: String,
#[arg(long, default_value_t = 50)]
limit: u32,
},
#[command(long_about = crate::cli::help::md(crate::cli::help::ISSUE_COMMENTS))]
Comments { key: String },
#[command(long_about = crate::cli::help::md(crate::cli::help::ISSUE_CREATE))]
Create {
#[arg(long, short = 'q')]
queue: Option<String>,
#[arg(long, short = 's')]
summary: String,
#[arg(long, short = 'd')]
description: Option<String>,
#[arg(long, value_name = "PATH", conflicts_with = "description")]
description_file: Option<String>,
#[arg(long)]
assignee: Option<String>,
#[arg(long, value_delimiter = ',')]
tags: Vec<String>,
},
#[command(long_about = crate::cli::help::md(crate::cli::help::ISSUE_UPDATE))]
Update {
#[arg(required = true)]
keys: Vec<String>,
#[arg(long, short = 's')]
summary: Option<String>,
#[arg(long, short = 'd')]
description: Option<String>,
#[arg(long, value_name = "PATH", conflicts_with = "description")]
description_file: Option<String>,
#[arg(long)]
assignee: Option<String>,
#[arg(long = "set", value_name = "KEY=VALUE")]
set: Vec<String>,
#[arg(long)]
no_wait: bool,
},
#[command(args_conflicts_with_subcommands = true,
long_about = crate::cli::help::md(crate::cli::help::ISSUE_COMMENT))]
Comment {
#[command(subcommand)]
command: Option<CommentCommand>,
key: Option<String>,
text: Option<String>,
},
#[command(long_about = crate::cli::help::md(crate::cli::help::ISSUE_WORKLOGS))]
Worklogs { key: String },
#[command(long_about = crate::cli::help::md(crate::cli::help::ISSUE_CHECKLIST))]
Checklist { key: String },
#[command(long_about = crate::cli::help::md(crate::cli::help::ISSUE_TIMERS))]
Timers,
#[command(subcommand, long_about = crate::cli::help::md(crate::cli::help::ISSUE_TIMER))]
Timer(TimerCommand),
#[command(subcommand, long_about = crate::cli::help::md(crate::cli::help::ISSUE_WORKLOG))]
Worklog(WorklogCommand),
#[command(subcommand, long_about = crate::cli::help::md(crate::cli::help::ISSUE_CHECK))]
Check(CheckCommand),
#[command(subcommand, long_about = crate::cli::help::md(crate::cli::help::ISSUE_LINK))]
Link(LinkCommand),
#[command(long_about = crate::cli::help::md(crate::cli::help::ISSUE_MOVE))]
Move {
#[arg(required = true)]
keys: Vec<String>,
#[arg(long, short = 't')]
to: String,
#[arg(long)]
keep_fields: bool,
#[arg(long)]
initial_status: bool,
#[arg(long)]
no_wait: bool,
},
#[command(long_about = crate::cli::help::md(crate::cli::help::ISSUE_TRANSITION))]
Transition {
#[arg(required = true)]
keys: Vec<String>,
#[arg(long, short = 't')]
to: Option<String>,
#[arg(long, short = 'r')]
resolution: Option<String>,
#[arg(long = "set", value_name = "KEY=VALUE")]
set: Vec<String>,
#[arg(long)]
no_wait: bool,
},
}
#[derive(Debug, Subcommand)]
pub enum TimerCommand {
#[command(long_about = crate::cli::help::md(crate::cli::help::TIMER_START))]
Start { key: String },
#[command(long_about = crate::cli::help::md(crate::cli::help::TIMER_STOP))]
Stop {
key: String,
#[arg(long, short = 'm')]
comment: Option<String>,
},
#[command(long_about = crate::cli::help::md(crate::cli::help::TIMER_CANCEL))]
Cancel { key: String },
}
#[derive(Debug, Subcommand)]
pub enum WorklogCommand {
#[command(long_about = crate::cli::help::md(crate::cli::help::WORKLOG_ADD))]
Add {
key: String,
duration: String,
#[arg(long, short = 'm')]
comment: Option<String>,
#[arg(long)]
start: Option<String>,
},
#[command(long_about = crate::cli::help::md(crate::cli::help::WORKLOG_EDIT))]
Edit {
key: String,
id: String,
#[arg(long, short = 'd')]
duration: Option<String>,
#[arg(long, short = 'm')]
comment: Option<String>,
},
#[command(long_about = crate::cli::help::md(crate::cli::help::WORKLOG_DELETE))]
Delete { key: String, id: String },
}
#[derive(Debug, Subcommand)]
pub enum CommentCommand {
#[command(long_about = crate::cli::help::md(crate::cli::help::ISSUE_COMMENT))]
Add {
key: String,
text: String,
},
#[command(long_about = crate::cli::help::md(crate::cli::help::COMMENT_EDIT))]
Edit {
key: String,
id: String,
text: String,
},
#[command(long_about = crate::cli::help::md(crate::cli::help::COMMENT_DELETE))]
Delete { key: String, id: String },
}
#[derive(Debug, Subcommand)]
pub enum CheckCommand {
#[command(long_about = crate::cli::help::md(crate::cli::help::CHECK_ADD))]
Add {
key: String,
text: String,
#[arg(long)]
assignee: Option<String>,
#[arg(long)]
deadline: Option<String>,
},
#[command(long_about = crate::cli::help::md(crate::cli::help::CHECK_TICK))]
Tick { key: String, id: String },
#[command(long_about = crate::cli::help::md(crate::cli::help::CHECK_UNTICK))]
Untick { key: String, id: String },
#[command(long_about = crate::cli::help::md(crate::cli::help::CHECK_DELETE))]
Delete { key: String, id: String },
}
#[derive(Debug, Subcommand)]
pub enum LinkCommand {
#[command(long_about = crate::cli::help::md(crate::cli::help::LINK_ADD))]
Add {
key: String,
relation: String,
other: String,
},
#[command(long_about = crate::cli::help::md(crate::cli::help::LINK_DELETE))]
Delete { key: String, id: String },
}
#[derive(Debug, Args, Clone)]
pub struct FindArgs {
#[arg(long, short = 'q')]
pub queue: Option<String>,
#[arg(long, short = 'a')]
pub assignee: Option<String>,
#[arg(long, short = 's')]
pub status: Option<String>,
#[arg(long, value_delimiter = ',')]
pub tags: Vec<String>,
#[arg(long, conflicts_with_all = ["queue", "assignee", "status", "tags"])]
pub yql: Option<String>,
#[arg(long)]
pub limit: Option<usize>,
#[arg(long, default_value_t = 1)]
pub page: u32,
#[arg(long)]
pub all: bool,
#[arg(long)]
pub max: Option<usize>,
}
pub async fn run(command: &IssueCommand, session: &Session) -> ExitCode {
match command {
IssueCommand::Get { key, fields } => get(key, fields, session).await,
IssueCommand::Find(args) => find(args, session).await,
IssueCommand::Count(args) => count(args, session).await,
IssueCommand::Links { key } => links(key, session).await,
IssueCommand::Remotelinks { key } => remote_links(key, session).await,
IssueCommand::Comments { key } => comments(key, session).await,
IssueCommand::Changelog { key, limit } => changelog(key, *limit, session).await,
IssueCommand::Move {
keys,
to,
keep_fields,
initial_status,
no_wait,
} => move_issues(keys, to, *keep_fields, *initial_status, *no_wait, session).await,
IssueCommand::Create {
queue,
summary,
description,
description_file,
assignee,
tags,
} => {
create(
queue.as_deref(),
summary,
description.as_deref(),
description_file.as_deref(),
assignee.as_deref(),
tags,
session,
)
.await
}
IssueCommand::Update {
keys,
summary,
description,
description_file,
assignee,
set,
no_wait,
} => {
update(
keys,
&Changes {
summary: summary.as_deref(),
description: description.as_deref(),
description_file: description_file.as_deref(),
assignee: assignee.as_deref(),
set,
},
*no_wait,
session,
)
.await
}
IssueCommand::Comment { command, key, text } => match (command, key, text) {
(Some(command), _, _) => comment_write(command, session).await,
(None, Some(key), Some(text)) => comment(key, text, session).await,
(None, ..) => report(
&"usage: ytcli issue comment <KEY> <TEXT>, or `issue comment --help`",
ExitCode::ConfirmationRequired,
),
},
IssueCommand::Transition {
keys,
to,
resolution,
set,
no_wait,
} => {
transition_cmd(
keys,
to.as_deref(),
resolution.as_deref(),
set,
*no_wait,
session,
)
.await
}
IssueCommand::Worklogs { key } => worklogs(key, session).await,
IssueCommand::Checklist { key } => checklist(key, session).await,
IssueCommand::Timers => timers(session),
IssueCommand::Timer(command) => timer(command, session).await,
IssueCommand::Worklog(command) => worklog_write(command, session).await,
IssueCommand::Check(command) => check_write(command, session).await,
IssueCommand::Link(command) => link_write(command, session).await,
}
}
async fn worklogs(target: &str, session: &Session) -> ExitCode {
let (client, key) = match session.client_for(target).await {
Ok(pair) => pair,
Err(code) => return code,
};
match client.worklogs(&key).await {
Ok(entries) => {
let rendered = match session.render.format {
Format::Text => Ok(text::worklogs(&key, &entries, &session.render)),
other => machine(&entries, other),
};
finish(rendered)
}
Err(error) => {
let code = error.exit_code();
report(&error, code)
}
}
}
async fn checklist(target: &str, session: &Session) -> ExitCode {
let (client, key) = match session.client_for(target).await {
Ok(pair) => pair,
Err(code) => return code,
};
match client.checklist(&key).await {
Ok(items) => {
let rendered = match session.render.format {
Format::Text => Ok(text::checklist(&key, &items, &session.render)),
other => machine(&items, other),
};
finish(rendered)
}
Err(error) => {
let code = error.exit_code();
report(&error, code)
}
}
}
async fn worklog_write(command: &WorklogCommand, session: &Session) -> ExitCode {
match command {
WorklogCommand::Add {
key,
duration,
comment,
start,
} => {
let (client, key) = match session.client_for(key).await {
Ok(pair) => pair,
Err(code) => return code,
};
let iso = match crate::api::duration::to_iso8601(duration) {
Ok(iso) => iso,
Err(error) => return report(&error, ExitCode::ConfirmationRequired),
};
let mut body = serde_json::Map::new();
body.insert("duration".to_owned(), serde_json::json!(iso));
body.insert(
"start".to_owned(),
serde_json::json!(start.clone().unwrap_or_else(now_for_tracker)),
);
if let Some(comment) = comment {
body.insert("comment".to_owned(), serde_json::json!(comment));
}
let body = serde_json::Value::Object(body);
let targets = [key.clone()];
let intent = Intent {
action: &format!("log {duration} against {key}"),
targets: &targets,
body: &body,
always_confirm: false,
};
if let Gate::Stop(code) = check(&intent, session) {
return code;
}
match client.add_worklog(&key, &body).await {
Ok(entry) => {
emit(&format!(
"{key} worklog {} {}\n",
entry.id,
crate::api::duration::human(&entry.duration)
));
ExitCode::Success
}
Err(error) => {
let code = error.exit_code();
report(&error, code)
}
}
}
WorklogCommand::Edit {
key,
id,
duration,
comment,
} => worklog_edit(key, id, duration.as_deref(), comment.as_deref(), session).await,
WorklogCommand::Delete { key, id } => {
delete_with_gate(key, id, session, "worklog", |client, key, id| {
Box::pin(async move { client.delete_worklog(key, id).await })
})
.await
}
}
}
async fn worklog_edit(
key: &str,
id: &str,
duration: Option<&str>,
comment: Option<&str>,
session: &Session,
) -> ExitCode {
if duration.is_none() && comment.is_none() {
return report(
&"nothing to change: pass --duration, --comment, or both",
ExitCode::ConfirmationRequired,
);
}
let (client, key) = match session.client_for(key).await {
Ok(pair) => pair,
Err(code) => return code,
};
let mut body = serde_json::Map::new();
if let Some(duration) = duration {
let iso = match crate::api::duration::to_iso8601(duration) {
Ok(iso) => iso,
Err(error) => return report(&error, ExitCode::ConfirmationRequired),
};
body.insert("duration".to_owned(), serde_json::json!(iso));
}
if let Some(comment) = comment {
body.insert("comment".to_owned(), serde_json::json!(comment));
}
let body = serde_json::Value::Object(body);
let targets = [key.clone()];
let intent = Intent {
action: &format!("correct worklog {id} of {key}"),
targets: &targets,
body: &body,
always_confirm: false,
};
if let Gate::Stop(code) = check(&intent, session) {
return code;
}
match client.update_worklog(&key, id, &body).await {
Ok(entry) => {
emit(&format!(
"{key} worklog {} {}\n",
entry.id,
crate::api::duration::human(&entry.duration)
));
ExitCode::Success
}
Err(error) => {
let code = error.exit_code();
report(&error, code)
}
}
}
async fn comment_write(command: &CommentCommand, session: &Session) -> ExitCode {
match command {
CommentCommand::Add { key, text } => comment(key, text, session).await,
CommentCommand::Edit { key, id, text } => {
let (client, key) = match session.client_for(key).await {
Ok(pair) => pair,
Err(code) => return code,
};
let text_body = match body_text(text) {
Ok(text) => text,
Err(code) => return code,
};
let body = serde_json::json!({ "text": text_body });
let targets = [key.clone()];
let intent = Intent {
action: &format!("replace the text of comment {id} on {key}"),
targets: &targets,
body: &body,
always_confirm: false,
};
if let Gate::Stop(code) = check(&intent, session) {
return code;
}
match client.update_comment(&key, id, &text_body).await {
Ok(comment) => {
emit(&format!("{key} comment {} edited\n", comment.id));
ExitCode::Success
}
Err(error) => {
let code = error.exit_code();
report(&error, code)
}
}
}
CommentCommand::Delete { key, id } => {
delete_with_gate(key, id, session, "comment", |client, key, id| {
Box::pin(async move { client.delete_comment(key, id).await })
})
.await
}
}
}
async fn check_write(command: &CheckCommand, session: &Session) -> ExitCode {
match command {
CheckCommand::Add {
key,
text: line,
assignee,
deadline,
} => {
let (client, key) = match session.client_for(key).await {
Ok(pair) => pair,
Err(code) => return code,
};
let mut body = serde_json::Map::new();
body.insert("text".to_owned(), serde_json::json!(line));
if let Some(assignee) = assignee {
body.insert("assignee".to_owned(), serde_json::json!(assignee));
}
if let Some(deadline) = deadline {
body.insert(
"deadline".to_owned(),
serde_json::json!({ "date": deadline }),
);
}
let body = serde_json::Value::Object(body);
let targets = [key.clone()];
let intent = Intent {
action: &format!("add a checklist line to {key}"),
targets: &targets,
body: &body,
always_confirm: false,
};
if let Gate::Stop(code) = check(&intent, session) {
return code;
}
match client.add_checklist_item(&key, &body).await {
Ok(items) => {
emit(&text::checklist(&key, &items, &session.render));
ExitCode::Success
}
Err(error) => {
let code = error.exit_code();
report(&error, code)
}
}
}
CheckCommand::Tick { key, id } => set_checked(key, id, true, session).await,
CheckCommand::Untick { key, id } => set_checked(key, id, false, session).await,
CheckCommand::Delete { key, id } => {
delete_with_gate(key, id, session, "checklist item", |client, key, id| {
Box::pin(async move { client.delete_checklist_item(key, id).await })
})
.await
}
}
}
async fn set_checked(target: &str, id: &str, checked: bool, session: &Session) -> ExitCode {
let (client, key) = match session.client_for(target).await {
Ok(pair) => pair,
Err(code) => return code,
};
let body = serde_json::json!({ "checked": checked });
let targets = [key.clone()];
let verb = if checked { "tick" } else { "untick" };
let intent = Intent {
action: &format!("{verb} checklist item {id} of {key}"),
targets: &targets,
body: &body,
always_confirm: false,
};
if let Gate::Stop(code) = check(&intent, session) {
return code;
}
match client.update_checklist_item(&key, id, &body).await {
Ok(items) => {
emit(&text::checklist(&key, &items, &session.render));
ExitCode::Success
}
Err(error) => {
let code = error.exit_code();
report(&error, code)
}
}
}
fn corrected(relation: &str) -> Option<&'static str> {
let normalised = relation
.trim()
.to_ascii_lowercase()
.replace(['-', '_'], " ");
Some(match normalised.as_str() {
"depends" => "depends on",
"parent" => "is parent task for",
"subtask" => "is subtask for",
"epic" => "is epic of",
_ => return None,
})
}
async fn link_write(command: &LinkCommand, session: &Session) -> ExitCode {
match command {
LinkCommand::Add {
key,
relation,
other,
} => {
if let Some(correct) = corrected(relation) {
return report(
&format!(
"`{relation}` is the id of a link type, not a relationship: write `{correct}`. \
`ytcli link types` lists both."
),
ExitCode::ConfirmationRequired,
);
}
let (client, key) = match session.client_for(key).await {
Ok(pair) => pair,
Err(code) => return code,
};
let body = serde_json::json!({ "relationship": relation, "issue": other });
let targets = [key.clone()];
let intent = Intent {
action: &format!("link {key} {relation} {other}"),
targets: &targets,
body: &body,
always_confirm: false,
};
if let Gate::Stop(code) = check(&intent, session) {
return code;
}
match client.add_link(&key, relation, other).await {
Ok(()) => {
emit(&format!("{key} {relation} {other}\n"));
ExitCode::Success
}
Err(error) => {
let code = error.exit_code();
report(&error, code)
}
}
}
LinkCommand::Delete { key, id } => {
delete_with_gate(key, id, session, "link", |client, key, id| {
Box::pin(async move { client.delete_link(key, id).await })
})
.await
}
}
}
async fn delete_with_gate<F>(
target: &str,
id: &str,
session: &Session,
what: &str,
delete: F,
) -> ExitCode
where
F: for<'a> FnOnce(
&'a crate::api::Client,
&'a str,
&'a str,
) -> std::pin::Pin<
Box<dyn std::future::Future<Output = Result<(), crate::api::error::ApiError>> + 'a>,
>,
{
let (client, key) = match session.client_for(target).await {
Ok(pair) => pair,
Err(code) => return code,
};
let body = serde_json::json!({ "delete": id });
let targets = [key.clone()];
let intent = Intent {
action: &format!("delete {what} {id} of {key}"),
targets: &targets,
body: &body,
always_confirm: false,
};
if let Gate::Stop(code) = check(&intent, session) {
return code;
}
match delete(&client, &key, id).await {
Ok(()) => {
emit(&format!("{key} {what} {id} deleted\n"));
ExitCode::Success
}
Err(error) => {
let code = error.exit_code();
report(&error, code)
}
}
}
fn now_for_tracker() -> String {
jiff::Zoned::now()
.strftime("%Y-%m-%dT%H:%M:%S%.3f%z")
.to_string()
}
async fn get(target: &str, fields: &[String], session: &Session) -> ExitCode {
let (client, key) = match session.client_for(target).await {
Ok(pair) => pair,
Err(code) => return code,
};
let key = key.as_str();
let (mut issue, raw) = match client.issue(key).await {
Ok(pair) => pair,
Err(error) => {
let code = error.exit_code();
return report(&error, code);
}
};
match client.issue_links(key).await {
Ok(links) => issue.links = links,
Err(error) => {
tracing::warn!(%error, "could not fetch links");
}
}
let mut ctx = session.render.clone();
let mut drawn = Vec::new();
if fields.is_empty() {
let (inline, used) = inline_images(&client, key, issue.description.as_deref(), &ctx).await;
ctx.inline = inline;
drawn = used;
}
let rendered = match ctx.format {
Format::Text if !fields.is_empty() => Ok(text::issue_selected(&issue, fields)),
Format::Text => Ok(text::issue(&issue, &ctx)),
Format::JsonRaw => machine(&raw, Format::JsonRaw),
other => machine(&issue, other),
};
match rendered {
Ok(text) => {
emit(&text);
draw_remaining_images(&client, key, &drawn, &ctx).await;
ExitCode::Success
}
Err(error) => report(&error, ExitCode::Failure),
}
}
const IMAGES_SHOWN: usize = 4;
fn drawing(ctx: &crate::render::Context) -> Option<image::Protocol> {
if !ctx.images || !ctx.is_human() || ctx.format != Format::Text {
return None;
}
image::protocol()
}
async fn inline_images(
client: &crate::api::Client,
key: &str,
description: Option<&str>,
ctx: &crate::render::Context,
) -> (image::Inline, Vec<String>) {
let mut inline = image::Inline::default();
let mut used = Vec::new();
let Some(protocol) = drawing(ctx) else {
return (inline, used);
};
let Some(description) = description else {
return (inline, used);
};
let references = crate::render::markdown::image_references(description);
if references.is_empty() {
return (inline, used);
}
let attachments = match client.attachments(key).await {
Ok(attachments) => attachments,
Err(error) => {
tracing::warn!(%error, "could not fetch attachments");
return (inline, used);
}
};
let width = ctx.width.saturating_sub(2);
for (alt, url) in references {
let Some(attachment) = attachment_for(&attachments, url) else {
tracing::debug!(url, "no attachment matches this image reference");
continue;
};
let Some(picture) = fetch_picture(client, attachment, protocol, width).await else {
continue;
};
used.push(attachment.id.clone());
let caption = if alt.is_empty() {
attachment.name.clone()
} else {
format!("{alt} — {}", attachment.name)
};
inline.insert(url.to_owned(), image::Picture { caption, ..picture });
}
(inline, used)
}
fn attachment_for<'a>(
attachments: &'a [crate::api::models::Attachment],
url: &str,
) -> Option<&'a crate::api::models::Attachment> {
let path = url.split(['?', '#']).next().unwrap_or(url);
let last = path.rsplit('/').find(|segment| !segment.is_empty())?;
attachments
.iter()
.find(|attachment| attachment.id == last || attachment.name == last)
}
async fn fetch_picture(
client: &crate::api::Client,
attachment: &crate::api::models::Attachment,
protocol: image::Protocol,
width: usize,
) -> Option<image::Picture> {
let url = attachment.content.as_deref()?;
let bytes = match client.download(url).await {
Ok(bytes) => bytes,
Err(error) => {
tracing::warn!(%error, id = attachment.id, "could not download attachment");
return None;
}
};
let kind = image::Kind::of(&bytes)?;
if !protocol.carries(kind) {
return None;
}
Some(image::Picture {
escape: image::draw(protocol, &bytes, &attachment.name, width),
caption: attachment.name.clone(),
})
}
async fn draw_remaining_images(
client: &crate::api::Client,
key: &str,
already_drawn: &[String],
ctx: &crate::render::Context,
) {
let Some(protocol) = drawing(ctx) else {
return;
};
let attachments = match client.attachments(key).await {
Ok(attachments) => attachments,
Err(error) => {
tracing::warn!(%error, "could not fetch attachments");
return;
}
};
let images: Vec<_> = attachments
.iter()
.filter(|attachment| !already_drawn.contains(&attachment.id))
.filter(|attachment| {
attachment
.mimetype
.as_deref()
.is_some_and(|kind| kind.starts_with("image/"))
})
.collect();
for attachment in images.iter().take(IMAGES_SHOWN) {
if let Some(picture) = fetch_picture(client, attachment, protocol, ctx.width).await {
emit(&picture.escape);
emit(&format!("{}\n", picture.caption));
}
}
if images.len() > IMAGES_SHOWN {
emit(&format!(
"{} more image(s): ytcli attachment show {key} <id>\n",
images.len() - IMAGES_SHOWN
));
}
}
fn query_for(args: &FindArgs, session: &Session) -> Result<String, ExitCode> {
if let Some(yql) = &args.yql {
return Ok(yql.clone());
}
let mut filter = Filter {
queue: args.queue.clone(),
assignee: args.assignee.clone(),
status: args.status.clone(),
tags: args.tags.clone(),
};
if filter.queue.is_none() {
filter.queue = session.default_queue().map(ToOwned::to_owned);
}
if filter.is_empty() {
return Err(report(
&"no filter given: pass --queue, --assignee, --status, --tags or --yql, \
or pin a queue in .tracker.toml",
ExitCode::ConfirmationRequired,
));
}
Ok(filter.to_query())
}
async fn find(args: &FindArgs, session: &Session) -> ExitCode {
let client = match session.client() {
Ok(client) => client,
Err(code) => return code,
};
let query = match query_for(args, session) {
Ok(query) => query,
Err(code) => return code,
};
let display = session.display();
let per_page = args.limit.unwrap_or(display.limit);
let Ok(per_page) = u32::try_from(per_page.max(1)) else {
return report(&"--limit is too large", ExitCode::ConfirmationRequired);
};
if args.all {
return find_all(&client, &query, per_page, args, session).await;
}
match client.search(&query, args.page.max(1), per_page).await {
Ok(page) => emit_page(&page, session),
Err(error) => {
let code = error.exit_code();
report(&error, code)
}
}
}
async fn find_all(
client: &crate::api::Client,
query: &str,
per_page: u32,
args: &FindArgs,
session: &Session,
) -> ExitCode {
let max = args.max.unwrap_or(session.display().max);
let mut collected: Vec<crate::api::models::Issue> = Vec::new();
let mut page_number = 1;
let mut total = None;
let walk = crate::render::progress::Walk::start("searching");
loop {
let page = match client.search(query, page_number, per_page).await {
Ok(page) => page,
Err(error) => {
walk.finish();
let code = error.exit_code();
return report(&error, code);
}
};
total = page.total.or(total);
if collected.len() + page.items.len() > max {
walk.finish();
return report(
&format!(
"more than --max {max} issues match ({}); narrow the filter or raise --max",
total.map_or_else(|| "unknown total".to_owned(), |t| t.to_string()),
),
ExitCode::ConfirmationRequired,
);
}
let more = page.has_more();
collected.extend(page.items);
walk.page(page_number, collected.len(), total);
if !more {
break;
}
page_number += 1;
}
walk.finish();
let Ok(count) = u32::try_from(collected.len()) else {
return report(&"too many results to render", ExitCode::Failure);
};
let page = crate::api::models::Page {
items: collected,
page: 1,
per_page: count.max(1),
total: total.or(Some(u64::from(count))),
};
emit_page(&page, session)
}
fn emit_page(
page: &crate::api::models::Page<crate::api::models::Issue>,
session: &Session,
) -> ExitCode {
let rendered = match session.render.format {
Format::Text => Ok(text::issue_page(page, &session.render)),
Format::JsonRaw => machine(&page.items, Format::Json),
other => machine(&page.items, other),
};
match rendered {
Ok(text) => {
emit(&text);
ExitCode::Success
}
Err(error) => report(&error, ExitCode::Failure),
}
}
async fn count(args: &FindArgs, session: &Session) -> ExitCode {
let client = match session.client() {
Ok(client) => client,
Err(code) => return code,
};
let query = match query_for(args, session) {
Ok(query) => query,
Err(code) => return code,
};
match client.count(&query).await {
Ok(count) => {
emit(&format!("{count}\n"));
ExitCode::Success
}
Err(error) => {
let code = error.exit_code();
report(&error, code)
}
}
}
async fn links(target: &str, session: &Session) -> ExitCode {
let (client, key) = match session.client_for(target).await {
Ok(pair) => pair,
Err(code) => return code,
};
let key = key.as_str();
match client.issue_links(key).await {
Ok(links) => {
let rendered = match session.render.format {
Format::Text => Ok(text::links(key, &links)),
Format::JsonRaw => machine(&links, Format::Json),
other => machine(&links, other),
};
finish(rendered)
}
Err(error) => {
let code = error.exit_code();
report(&error, code)
}
}
}
async fn remote_links(target: &str, session: &Session) -> ExitCode {
let (client, key) = match session.client_for(target).await {
Ok(pair) => pair,
Err(code) => return code,
};
let key = key.as_str();
match client.issue_remote_links(key).await {
Ok(links) => {
let rendered = match session.render.format {
Format::Text => Ok(text::remote_links(key, &links, &session.render)),
Format::JsonRaw => machine(&links, Format::Json),
other => machine(&links, other),
};
finish(rendered)
}
Err(error) => {
let code = error.exit_code();
report(&error, code)
}
}
}
async fn comments(target: &str, session: &Session) -> ExitCode {
let (client, key) = match session.client_for(target).await {
Ok(pair) => pair,
Err(code) => return code,
};
let key = key.as_str();
match client.issue_comments(key).await {
Ok(comments) => {
let rendered = match session.render.format {
Format::Text => Ok(text::comments(key, &comments, &session.render)),
Format::JsonRaw => machine(&comments, Format::Json),
other => machine(&comments, other),
};
finish(rendered)
}
Err(error) => {
let code = error.exit_code();
report(&error, code)
}
}
}
async fn changelog(target: &str, limit: u32, session: &Session) -> ExitCode {
let (client, key) = match session.client_for(target).await {
Ok(pair) => pair,
Err(code) => return code,
};
let key = key.as_str();
match client.changelog(key, limit.max(1)).await {
Ok(changes) => {
let rendered = match session.render.format {
Format::Text => Ok(text::changelog(key, &changes, &session.render)),
Format::JsonRaw => machine(&changes, Format::Json),
other => machine(&changes, other),
};
finish(rendered)
}
Err(error) => {
let code = error.exit_code();
report(&error, code)
}
}
}
fn finish(rendered: Result<String, crate::render::RenderError>) -> ExitCode {
match rendered {
Ok(text) => {
emit(&text);
ExitCode::Success
}
Err(error) => report(&error, ExitCode::Failure),
}
}
fn timers(session: &Session) -> ExitCode {
let store =
crate::config::timers::Timers::load(&crate::config::timers::path_for(&session.config_file));
let running = store.all();
let now = jiff::Timestamp::now();
let rendered = match session.render.format {
Format::Text => Ok(text::timers(&running, now, &session.render)),
Format::JsonRaw => machine(&running, Format::Json),
other => machine(&running, other),
};
finish(rendered)
}
async fn timer(command: &TimerCommand, session: &Session) -> ExitCode {
match command {
TimerCommand::Start { key } => timer_start(key, session).await,
TimerCommand::Stop { key, comment } => timer_stop(key, comment.as_deref(), session).await,
TimerCommand::Cancel { key } => timer_cancel(key, session).await,
}
}
async fn timer_store(
target: &str,
session: &Session,
) -> Result<
(
crate::api::Client,
String,
String,
crate::config::timers::Timers,
std::path::PathBuf,
),
ExitCode,
> {
let (client, key, profile) = session.routed(target).await?;
let path = crate::config::timers::path_for(&session.config_file);
let store = crate::config::timers::Timers::load(&path);
Ok((client, key, profile, store, path))
}
async fn timer_start(target: &str, session: &Session) -> ExitCode {
let (client, key, profile, mut store, path) = match timer_store(target, session).await {
Ok(parts) => parts,
Err(code) => return code,
};
let now = jiff::Timestamp::now();
if let Err(running) = store.start(client.org(), &profile, &key, now) {
return report(
&format!(
"{key} has been timed since {} — stop it, or cancel it",
running.started
),
ExitCode::ConfirmationRequired,
);
}
if let Err(error) = store.save(&path) {
return report(
&format!("could not record the timer: {error}"),
ExitCode::Failure,
);
}
emit(&format!("{key} timer started\n"));
ExitCode::Success
}
async fn timer_stop(target: &str, comment: Option<&str>, session: &Session) -> ExitCode {
let (client, key, _, mut store, path) = match timer_store(target, session).await {
Ok(parts) => parts,
Err(code) => return code,
};
let Some(entry) = store.get(client.org(), &key).cloned() else {
return report(&no_timer(&store, client.org(), &key), ExitCode::NotFound);
};
let elapsed = jiff::Timestamp::now()
.since(entry.started)
.unwrap_or_default();
let iso = crate::api::duration::from_minutes(elapsed.get_minutes());
let mut body = serde_json::json!({ "duration": iso, "start": entry.started.to_string() });
if let Some(comment) = comment {
body["comment"] = serde_json::json!(comment);
}
let targets = [key.clone()];
let intent = Intent {
action: &format!("record {} on {key}", crate::api::duration::human(&iso)),
targets: &targets,
body: &body,
always_confirm: false,
};
if let Gate::Stop(code) = check(&intent, session) {
return code;
}
match client.add_worklog(&key, &body).await {
Ok(entry) => {
store.take(client.org(), &key);
if let Err(error) = store.save(&path) {
let mut err = anstream::stderr();
let _ = writeln!(
err,
"the worklog was recorded; the timer file was not: {error}"
);
}
emit(&format!(
"{key} worklog {} {}\n",
entry.id,
crate::api::duration::human(&entry.duration)
));
ExitCode::Success
}
Err(error) => {
let code = error.exit_code();
let outcome = report(&error, code);
let mut err = anstream::stderr();
let _ = writeln!(err, "the timer is still running; nothing was lost");
outcome
}
}
}
async fn timer_cancel(target: &str, session: &Session) -> ExitCode {
let (client, key, _, mut store, path) = match timer_store(target, session).await {
Ok(parts) => parts,
Err(code) => return code,
};
let Some(entry) = store.take(client.org(), &key) else {
return report(&no_timer(&store, client.org(), &key), ExitCode::NotFound);
};
if let Err(error) = store.save(&path) {
return report(
&format!("could not update the timers: {error}"),
ExitCode::Failure,
);
}
let elapsed = jiff::Timestamp::now()
.since(entry.started)
.unwrap_or_default();
let iso = crate::api::duration::from_minutes(elapsed.get_minutes());
emit(&format!(
"{key} timer cancelled — {} not recorded\n",
crate::api::duration::human(&iso)
));
ExitCode::Success
}
fn no_timer(store: &crate::config::timers::Timers, org: &str, key: &str) -> String {
match store.elsewhere(org, key) {
Some(entry) => format!(
"no timer for {key} here; one is running through profile {} — stop it there",
entry.profile
),
None => format!("no timer running for {key}"),
}
}
fn body_text(raw: &str) -> Result<String, ExitCode> {
if raw != "-" {
return Ok(raw.to_owned());
}
let mut text = String::new();
match std::io::Read::read_to_string(&mut std::io::stdin(), &mut text) {
Ok(_) => Ok(text),
Err(error) => Err(report(&error, ExitCode::Failure)),
}
}
fn description_of(inline: Option<&str>, file: Option<&str>) -> Result<Option<String>, ExitCode> {
match (inline, file) {
(Some(_), Some(_)) => Err(report(
&"pass either --description or --description-file, not both",
ExitCode::ConfirmationRequired,
)),
(Some(text), None) => body_text(text).map(Some),
(None, Some(path)) => match std::fs::read_to_string(path) {
Ok(text) => Ok(Some(text)),
Err(error) => Err(report(
&format!("could not read {path}: {error}"),
ExitCode::Failure,
)),
},
(None, None) => Ok(None),
}
}
async fn create(
queue: Option<&str>,
summary: &str,
description: Option<&str>,
description_file: Option<&str>,
assignee: Option<&str>,
tags: &[String],
session: &Session,
) -> ExitCode {
let Some(queue) = queue.or_else(|| session.default_queue()) else {
return report(
&"no queue given: pass --queue or pin one in .tracker.toml",
ExitCode::ConfirmationRequired,
);
};
let description = match description_of(description, description_file) {
Ok(description) => description,
Err(code) => return code,
};
let mut body = serde_json::json!({
"queue": { "key": queue },
"summary": summary,
});
if let Some(description) = &description {
body["description"] = serde_json::json!(description);
}
if let Some(assignee) = assignee {
body["assignee"] = serde_json::json!(assignee);
}
if !tags.is_empty() {
body["tags"] = serde_json::json!(tags);
}
let intent = Intent {
action: &format!("create an issue in {queue}"),
targets: &[],
body: &body,
always_confirm: false,
};
if let Gate::Stop(code) = check(&intent, session) {
return code;
}
let client = match session.client() {
Ok(client) => client,
Err(code) => return code,
};
match client.create_issue(&body).await {
Ok(issue) => {
emit(&format!("{} {}\n", issue.key, issue.summary));
ExitCode::Success
}
Err(error) => {
let code = error.exit_code();
report(&error, code)
}
}
}
#[derive(Debug)]
struct Changes<'a> {
summary: Option<&'a str>,
description: Option<&'a str>,
description_file: Option<&'a str>,
assignee: Option<&'a str>,
set: &'a [String],
}
async fn update(
targets: &[String],
changes: &Changes<'_>,
no_wait: bool,
session: &Session,
) -> ExitCode {
let description = match description_of(changes.description, changes.description_file) {
Ok(description) => description,
Err(code) => return code,
};
let mut resolved = Vec::with_capacity(targets.len());
for target in targets {
match session.client_for(target).await {
Ok(pair) => resolved.push(pair),
Err(code) => return code,
}
}
let mut body = serde_json::Map::new();
if let Some(summary) = changes.summary {
body.insert("summary".to_owned(), serde_json::json!(summary));
}
if let Some(description) = &description {
body.insert("description".to_owned(), serde_json::json!(description));
}
if let Some(assignee) = changes.assignee {
body.insert("assignee".to_owned(), serde_json::json!(assignee));
}
for assignment in changes.set {
match parse_assignment(assignment) {
Ok((field, value)) => {
body.insert(field, value);
}
Err(error) => return report(&error, ExitCode::ConfirmationRequired),
}
}
if body.is_empty() {
return report(
&"nothing to change: pass --summary, --description, --assignee or --set key=value",
ExitCode::ConfirmationRequired,
);
}
let body = serde_json::Value::Object(body);
let keys: Vec<String> = resolved.iter().map(|(_, key)| key.clone()).collect();
let one_org = resolved.first().is_some_and(|(first, _)| {
resolved
.iter()
.all(|(client, _)| client.org() == first.org())
});
let bulk = keys.len() > 1 && one_org;
let request = if bulk {
serde_json::json!({ "issues": keys, "values": body })
} else {
body.clone()
};
let intent = Intent {
action: &format!("update {}", keys.join(", ")),
targets: &keys,
body: &request,
always_confirm: false,
};
if let Gate::Stop(code) = check(&intent, session) {
return code;
}
if bulk {
let Some((client, _)) = resolved.first() else {
return ExitCode::Success;
};
return bulk_update(client, &keys, &body, no_wait, session).await;
}
let mut done = 0_u64;
for (client, key) in &resolved {
match client.update_issue(key, &body).await {
Ok(issue) => {
done += 1;
emit(&text::issue_selected(
&issue,
&["status".to_owned(), "assignee".to_owned()],
));
}
Err(error) => {
let code = error.exit_code();
if keys.len() > 1 {
emit(&render::bulk::changed(
done,
keys.len() as u64,
&session.render,
));
}
return report(&error, code);
}
}
}
if keys.len() > 1 {
emit(&render::bulk::changed(
done,
keys.len() as u64,
&session.render,
));
}
ExitCode::Success
}
const BULK_WAIT: std::time::Duration = std::time::Duration::from_secs(60);
async fn bulk_update(
client: &crate::api::Client,
keys: &[String],
values: &serde_json::Value,
no_wait: bool,
session: &Session,
) -> ExitCode {
let started = match client.bulk_update(keys, values).await {
Ok(change) => change,
Err(error) => {
let code = error.exit_code();
return report(&error, code);
}
};
awaited(client, started, no_wait, session).await
}
async fn awaited(
client: &crate::api::Client,
started: crate::api::BulkChange,
no_wait: bool,
session: &Session,
) -> ExitCode {
if no_wait {
emit(&render::bulk::change(&started, &session.render));
return ExitCode::Success;
}
let mut change = started;
let deadline = std::time::Instant::now() + BULK_WAIT;
while !change.finished() {
if std::time::Instant::now() >= deadline {
emit(&render::bulk::change(&change, &session.render));
return report(
&format!(
"Tracker is still working on it; ask again with `ytcli bulk status {}`",
change.id
),
ExitCode::Failure,
);
}
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
match client.bulk_change(&change.id).await {
Ok(next) => change = next,
Err(error) => {
let code = error.exit_code();
return report(&error, code);
}
}
}
finished(client, &change, session).await
}
async fn finished(
client: &crate::api::Client,
change: &crate::api::BulkChange,
session: &Session,
) -> ExitCode {
emit(&render::bulk::change(change, &session.render));
if change.succeeded() {
return ExitCode::Success;
}
match client.bulk_change_issues(&change.id).await {
Ok(outcomes) => emit(&render::bulk::failures(&outcomes, &session.render)),
Err(error) => {
let mut err = anstream::stderr();
let _ = writeln!(err, "could not read which issues failed: {error}");
}
}
ExitCode::ApiRejected
}
async fn comment(target: &str, raw: &str, session: &Session) -> ExitCode {
let (client, key) = match session.client_for(target).await {
Ok(pair) => pair,
Err(code) => return code,
};
let key = key.as_str();
let text_body = match body_text(raw) {
Ok(text) => text,
Err(code) => return code,
};
let body = serde_json::json!({ "text": text_body });
let targets = [key.to_owned()];
let intent = Intent {
action: &format!("comment on {key}"),
targets: &targets,
body: &body,
always_confirm: false,
};
if let Gate::Stop(code) = check(&intent, session) {
return code;
}
match client.add_comment(key, &text_body).await {
Ok(comment) => {
emit(&format!("{key} comment {}\n", comment.id));
ExitCode::Success
}
Err(error) => {
let code = error.exit_code();
report(&error, code)
}
}
}
async fn resolve_all(
targets: &[String],
session: &Session,
) -> Result<(Vec<(crate::api::Client, String)>, bool), ExitCode> {
let mut resolved = Vec::with_capacity(targets.len());
for target in targets {
match session.client_for(target).await {
Ok(pair) => resolved.push(pair),
Err(code) => return Err(code),
}
}
let one_org = resolved.first().is_some_and(|(first, _)| {
resolved
.iter()
.all(|(client, _)| client.org() == first.org())
});
Ok((resolved, one_org))
}
async fn transitions_of(target: &str, session: &Session) -> ExitCode {
let (client, key) = match session.client_for(target).await {
Ok(pair) => pair,
Err(code) => return code,
};
match client.transitions(&key).await {
Ok(transitions) => {
let rendered = match session.render.format {
Format::Text => Ok(text::transitions(&key, &transitions)),
Format::JsonRaw => machine(&transitions, Format::Json),
other => machine(&transitions, other),
};
finish(rendered)
}
Err(error) => {
let code = error.exit_code();
report(&error, code)
}
}
}
async fn transition_cmd(
targets: &[String],
to: Option<&str>,
resolution: Option<&str>,
set: &[String],
no_wait: bool,
session: &Session,
) -> ExitCode {
let (targets, transition) = match (to, targets) {
(Some(to), keys) => (keys, Some(to.to_owned())),
(None, [key]) => (std::slice::from_ref(key), None),
(None, [key, id]) => (std::slice::from_ref(key), Some(id.clone())),
(None, _) => {
return report(
&"naming several issues needs --to TRANSITION: ytcli issue transition A-1 A-2 --to close --yes",
ExitCode::ConfirmationRequired,
);
}
};
let Some(transition) = transition else {
return match targets.first() {
Some(target) => transitions_of(target, session).await,
None => ExitCode::Success,
};
};
let mut fields = serde_json::Map::new();
if let Some(resolution) = resolution {
fields.insert("resolution".to_owned(), serde_json::json!(resolution));
}
for assignment in set {
match parse_assignment(assignment) {
Ok((field, value)) => {
fields.insert(field, value);
}
Err(error) => return report(&error, ExitCode::ConfirmationRequired),
}
}
let body = serde_json::Value::Object(fields);
let (resolved, one_org) = match resolve_all(targets, session).await {
Ok(pair) => pair,
Err(code) => return code,
};
let keys: Vec<String> = resolved.iter().map(|(_, key)| key.clone()).collect();
let bulk = keys.len() > 1 && one_org;
let request = if bulk {
serde_json::json!({ "issues": keys, "transition": transition, "values": body })
} else {
body.clone()
};
let intent = Intent {
action: &format!("move {} through `{transition}`", keys.join(", ")),
targets: &keys,
body: &request,
always_confirm: false,
};
if let Gate::Stop(code) = check(&intent, session) {
return code;
}
if bulk {
let Some((client, _)) = resolved.first() else {
return ExitCode::Success;
};
return bulk_transition(client, &keys, &transition, &body, no_wait, session).await;
}
let mut done = 0_u64;
for (client, key) in &resolved {
match transition_once(client, key, &transition, &body).await {
Ok(used) => {
done += 1;
emit(&format!("{key} {used}\n"));
}
Err(error) => {
let code = error.exit_code();
if keys.len() > 1 {
emit(&render::bulk::changed(
done,
keys.len() as u64,
&session.render,
));
}
return if matches!(error, crate::api::error::ApiError::NotFound(_)) {
no_such_transition(&error, code)
} else {
rejected_for_fields(&error, &body, code)
};
}
}
}
if keys.len() > 1 {
emit(&render::bulk::changed(
done,
keys.len() as u64,
&session.render,
));
}
ExitCode::Success
}
async fn bulk_transition(
client: &crate::api::Client,
keys: &[String],
transition: &str,
body: &serde_json::Value,
no_wait: bool,
session: &Session,
) -> ExitCode {
let mut outcome = client.bulk_transition(keys, transition, body).await;
if outcome.is_err()
&& let Some(first) = keys.first()
&& let Some(found) = named_transition(client, first, transition).await
{
outcome = client.bulk_transition(keys, &found, body).await;
}
match outcome {
Ok(started) => awaited(client, started, no_wait, session).await,
Err(error) => {
let code = error.exit_code();
no_such_transition(&error, code)
}
}
}
async fn transition_once(
client: &crate::api::Client,
key: &str,
wanted: &str,
body: &serde_json::Value,
) -> Result<String, crate::api::error::ApiError> {
let first = match client.execute_transition(key, wanted, body).await {
Ok(()) => return Ok(wanted.to_owned()),
Err(error) => error,
};
let Some(found) = named_transition(client, key, wanted).await else {
return Err(first);
};
client.execute_transition(key, &found, body).await?;
Ok(found)
}
async fn named_transition(client: &crate::api::Client, key: &str, wanted: &str) -> Option<String> {
let transitions = client.transitions(key).await.ok()?;
let same = |value: Option<&str>| value.is_some_and(|value| value.eq_ignore_ascii_case(wanted));
let found = transitions.iter().find(|transition| {
same(Some(&transition.id))
|| same(transition.to_key.as_deref())
|| same(transition.to.as_deref())
|| same(Some(&transition.name))
})?;
(found.id != wanted).then(|| found.id.clone())
}
fn no_such_transition(error: &crate::api::error::ApiError, code: ExitCode) -> ExitCode {
let outcome = report(error, code);
let mut err = anstream::stderr();
let _ = writeln!(
err,
"`ytcli issue transition KEY` lists the transitions available from the status an issue \
is in; ids are defined per workflow, so a status key is only accepted when this \
workflow has a transition that reaches it"
);
outcome
}
fn rejected_for_fields(
error: &crate::api::error::ApiError,
body: &serde_json::Value,
code: ExitCode,
) -> ExitCode {
let wants_fields = body.as_object().is_some_and(serde_json::Map::is_empty)
&& matches!(error, crate::api::error::ApiError::Rejected { .. });
let outcome = report(error, code);
if wants_fields {
let mut err = anstream::stderr();
let _ = writeln!(
err,
"this transition wants fields: pass them with --resolution or --set key=value \
(`ytcli dict list --kind resolutions` names the resolutions)"
);
}
outcome
}
async fn move_issues(
targets: &[String],
queue: &str,
keep_fields: bool,
initial_status: bool,
no_wait: bool,
session: &Session,
) -> ExitCode {
let (resolved, one_org) = match resolve_all(targets, session).await {
Ok(pair) => pair,
Err(code) => return code,
};
let keys: Vec<String> = resolved.iter().map(|(_, key)| key.clone()).collect();
let bulk = keys.len() > 1 && one_org;
let mut request = serde_json::json!({
"queue": queue,
"moveAllFields": keep_fields,
"initialStatus": initial_status,
});
if bulk && let Some(object) = request.as_object_mut() {
object.insert("issues".to_owned(), serde_json::json!(keys));
}
let intent = Intent {
action: &format!("move {} to {queue}, changing the key", keys.join(", ")),
targets: &keys,
body: &request,
always_confirm: true,
};
if let Gate::Stop(code) = check(&intent, session) {
return code;
}
if bulk {
let Some((client, _)) = resolved.first() else {
return ExitCode::Success;
};
return match client
.bulk_move(&keys, queue, keep_fields, initial_status)
.await
{
Ok(started) => awaited(client, started, no_wait, session).await,
Err(error) => {
let code = error.exit_code();
report(&error, code)
}
};
}
let mut done = 0_u64;
for (client, key) in &resolved {
match client
.move_issue(key, queue, keep_fields, initial_status)
.await
{
Ok(issue) => {
done += 1;
emit(&format!("{key} → {}\n", issue.key));
}
Err(error) => {
let code = error.exit_code();
if keys.len() > 1 {
emit(&render::bulk::changed(
done,
keys.len() as u64,
&session.render,
));
}
return report(&error, code);
}
}
}
if keys.len() > 1 {
emit(&render::bulk::changed(
done,
keys.len() as u64,
&session.render,
));
}
ExitCode::Success
}
#[cfg(test)]
mod tests {
use super::*;
use crate::api::models::Attachment;
fn attachment(id: &str, name: &str) -> Attachment {
Attachment {
id: id.to_owned(),
name: name.to_owned(),
size: None,
mimetype: Some("image/png".to_owned()),
author: None,
created_at: None,
content: Some("https://api.tracker.yandex.net/x".to_owned()),
}
}
#[test]
fn an_attachment_url_resolves_by_its_last_path_segment() {
let attachments = [attachment("29", "screenshot.png")];
assert_eq!(
attachment_for(&attachments, "/ajax/v2/attachments/29?inline=true").map(|a| &a.id),
Some(&"29".to_owned())
);
assert_eq!(
attachment_for(&attachments, "/ajax/v2/attachments/29/").map(|a| &a.id),
Some(&"29".to_owned())
);
assert_eq!(
attachment_for(&attachments, "screenshot.png").map(|a| &a.id),
Some(&"29".to_owned())
);
}
#[test]
fn a_url_that_is_not_an_attachment_of_this_issue_is_not_followed() {
let attachments = [attachment("29", "screenshot.png")];
assert!(attachment_for(&attachments, "https://example.com/evil.png").is_none());
assert!(attachment_for(&attachments, "/ajax/v2/attachments/30").is_none());
assert!(attachment_for(&attachments, "http://169.254.169.254/latest/meta-data").is_none());
}
}