1use std::io::Write as _;
9
10use clap::{Args, Subcommand};
11
12use crate::api::query::Filter;
13use crate::cli::write::{Gate, Intent, check, parse_assignment};
14use crate::cli::{Session, emit, report};
15use crate::exit::ExitCode;
16use crate::render::{self, Format, image, machine, text};
17
18#[derive(Debug, Subcommand)]
19pub enum IssueCommand {
20 #[command(long_about = crate::cli::help::md(crate::cli::help::ISSUE_GET))]
22 Get {
23 key: String,
26 #[arg(long, value_delimiter = ',')]
28 fields: Vec<String>,
29 },
30 #[command(visible_alias = "list", long_about = crate::cli::help::md(crate::cli::help::ISSUE_FIND))]
37 Find(FindArgs),
38 #[command(long_about = crate::cli::help::md(crate::cli::help::ISSUE_COUNT))]
40 Count(FindArgs),
41 #[command(long_about = crate::cli::help::md(crate::cli::help::ISSUE_LINKS))]
43 Links { key: String },
44 #[command(long_about = crate::cli::help::md(crate::cli::help::ISSUE_REMOTELINKS))]
46 Remotelinks { key: String },
47 #[command(long_about = crate::cli::help::md(crate::cli::help::ISSUE_CHANGELOG))]
49 Changelog {
50 key: String,
51 #[arg(long, default_value_t = 50)]
53 limit: u32,
54 },
55 #[command(long_about = crate::cli::help::md(crate::cli::help::ISSUE_COMMENTS))]
57 Comments { key: String },
58 #[command(long_about = crate::cli::help::md(crate::cli::help::ISSUE_CREATE))]
60 Create {
61 #[arg(long, short = 'q')]
62 queue: Option<String>,
63 #[arg(long, short = 's')]
64 summary: String,
65 #[arg(long, short = 'd')]
67 description: Option<String>,
68 #[arg(long, value_name = "PATH", conflicts_with = "description")]
70 description_file: Option<String>,
71 #[arg(long)]
72 assignee: Option<String>,
73 #[arg(long, value_delimiter = ',')]
74 tags: Vec<String>,
75 },
76 #[command(long_about = crate::cli::help::md(crate::cli::help::ISSUE_UPDATE))]
78 Update {
79 #[arg(required = true)]
81 keys: Vec<String>,
82 #[arg(long, short = 's')]
83 summary: Option<String>,
84 #[arg(long, short = 'd')]
86 description: Option<String>,
87 #[arg(long, value_name = "PATH", conflicts_with = "description")]
89 description_file: Option<String>,
90 #[arg(long)]
91 assignee: Option<String>,
92 #[arg(long = "set", value_name = "KEY=VALUE")]
94 set: Vec<String>,
95 #[arg(long)]
98 no_wait: bool,
99 },
100 #[command(args_conflicts_with_subcommands = true,
107 long_about = crate::cli::help::md(crate::cli::help::ISSUE_COMMENT))]
108 Comment {
109 #[command(subcommand)]
110 command: Option<CommentCommand>,
111 key: Option<String>,
113 text: Option<String>,
115 },
116 #[command(long_about = crate::cli::help::md(crate::cli::help::ISSUE_WORKLOGS))]
118 Worklogs { key: String },
119 #[command(long_about = crate::cli::help::md(crate::cli::help::ISSUE_CHECKLIST))]
121 Checklist { key: String },
122 #[command(long_about = crate::cli::help::md(crate::cli::help::ISSUE_TIMERS))]
124 Timers,
125 #[command(subcommand, long_about = crate::cli::help::md(crate::cli::help::ISSUE_TIMER))]
127 Timer(TimerCommand),
128 #[command(subcommand, long_about = crate::cli::help::md(crate::cli::help::ISSUE_WORKLOG))]
130 Worklog(WorklogCommand),
131 #[command(subcommand, long_about = crate::cli::help::md(crate::cli::help::ISSUE_CHECK))]
133 Check(CheckCommand),
134 #[command(subcommand, long_about = crate::cli::help::md(crate::cli::help::ISSUE_LINK))]
136 Link(LinkCommand),
137 #[command(long_about = crate::cli::help::md(crate::cli::help::ISSUE_MOVE))]
139 Move {
140 #[arg(required = true)]
142 keys: Vec<String>,
143 #[arg(long, short = 't')]
145 to: String,
146 #[arg(long)]
149 keep_fields: bool,
150 #[arg(long)]
153 initial_status: bool,
154 #[arg(long)]
156 no_wait: bool,
157 },
158 #[command(long_about = crate::cli::help::md(crate::cli::help::ISSUE_TRANSITION))]
160 Transition {
161 #[arg(required = true)]
163 keys: Vec<String>,
164 #[arg(long, short = 't')]
166 to: Option<String>,
167 #[arg(long, short = 'r')]
169 resolution: Option<String>,
170 #[arg(long = "set", value_name = "KEY=VALUE")]
172 set: Vec<String>,
173 #[arg(long)]
175 no_wait: bool,
176 },
177}
178
179#[derive(Debug, Subcommand)]
187pub enum TimerCommand {
188 #[command(long_about = crate::cli::help::md(crate::cli::help::TIMER_START))]
190 Start { key: String },
191 #[command(long_about = crate::cli::help::md(crate::cli::help::TIMER_STOP))]
193 Stop {
194 key: String,
195 #[arg(long, short = 'm')]
197 comment: Option<String>,
198 },
199 #[command(long_about = crate::cli::help::md(crate::cli::help::TIMER_CANCEL))]
201 Cancel { key: String },
202}
203
204#[derive(Debug, Subcommand)]
210pub enum WorklogCommand {
211 #[command(long_about = crate::cli::help::md(crate::cli::help::WORKLOG_ADD))]
213 Add {
214 key: String,
215 duration: String,
217 #[arg(long, short = 'm')]
219 comment: Option<String>,
220 #[arg(long)]
222 start: Option<String>,
223 },
224 #[command(long_about = crate::cli::help::md(crate::cli::help::WORKLOG_EDIT))]
226 Edit {
227 key: String,
228 id: String,
230 #[arg(long, short = 'd')]
232 duration: Option<String>,
233 #[arg(long, short = 'm')]
235 comment: Option<String>,
236 },
237 #[command(long_about = crate::cli::help::md(crate::cli::help::WORKLOG_DELETE))]
239 Delete { key: String, id: String },
240}
241
242#[derive(Debug, Subcommand)]
244pub enum CommentCommand {
245 #[command(long_about = crate::cli::help::md(crate::cli::help::ISSUE_COMMENT))]
247 Add {
248 key: String,
249 text: String,
251 },
252 #[command(long_about = crate::cli::help::md(crate::cli::help::COMMENT_EDIT))]
254 Edit {
255 key: String,
256 id: String,
258 text: String,
260 },
261 #[command(long_about = crate::cli::help::md(crate::cli::help::COMMENT_DELETE))]
263 Delete { key: String, id: String },
264}
265
266#[derive(Debug, Subcommand)]
268pub enum CheckCommand {
269 #[command(long_about = crate::cli::help::md(crate::cli::help::CHECK_ADD))]
271 Add {
272 key: String,
273 text: String,
274 #[arg(long)]
275 assignee: Option<String>,
276 #[arg(long)]
278 deadline: Option<String>,
279 },
280 #[command(long_about = crate::cli::help::md(crate::cli::help::CHECK_TICK))]
282 Tick { key: String, id: String },
283 #[command(long_about = crate::cli::help::md(crate::cli::help::CHECK_UNTICK))]
285 Untick { key: String, id: String },
286 #[command(long_about = crate::cli::help::md(crate::cli::help::CHECK_DELETE))]
288 Delete { key: String, id: String },
289}
290
291#[derive(Debug, Subcommand)]
293pub enum LinkCommand {
294 #[command(long_about = crate::cli::help::md(crate::cli::help::LINK_ADD))]
296 Add {
297 key: String,
298 relation: String,
302 other: String,
304 },
305 #[command(long_about = crate::cli::help::md(crate::cli::help::LINK_DELETE))]
307 Delete { key: String, id: String },
308}
309
310#[derive(Debug, Args, Clone)]
312pub struct FindArgs {
313 #[arg(long, short = 'q')]
314 pub queue: Option<String>,
315 #[arg(long, short = 'a')]
317 pub assignee: Option<String>,
318 #[arg(long, short = 's')]
319 pub status: Option<String>,
320 #[arg(long, value_delimiter = ',')]
321 pub tags: Vec<String>,
322 #[arg(long, conflicts_with_all = ["queue", "assignee", "status", "tags"])]
328 pub yql: Option<String>,
329 #[arg(long)]
331 pub limit: Option<usize>,
332 #[arg(long, default_value_t = 1)]
334 pub page: u32,
335 #[arg(long)]
337 pub all: bool,
338 #[arg(long)]
340 pub max: Option<usize>,
341}
342
343pub async fn run(command: &IssueCommand, session: &Session) -> ExitCode {
344 match command {
345 IssueCommand::Get { key, fields } => get(key, fields, session).await,
346 IssueCommand::Find(args) => find(args, session).await,
347 IssueCommand::Count(args) => count(args, session).await,
348 IssueCommand::Links { key } => links(key, session).await,
349 IssueCommand::Remotelinks { key } => remote_links(key, session).await,
350 IssueCommand::Comments { key } => comments(key, session).await,
351 IssueCommand::Changelog { key, limit } => changelog(key, *limit, session).await,
352 IssueCommand::Move {
353 keys,
354 to,
355 keep_fields,
356 initial_status,
357 no_wait,
358 } => move_issues(keys, to, *keep_fields, *initial_status, *no_wait, session).await,
359 IssueCommand::Create {
360 queue,
361 summary,
362 description,
363 description_file,
364 assignee,
365 tags,
366 } => {
367 create(
368 queue.as_deref(),
369 summary,
370 description.as_deref(),
371 description_file.as_deref(),
372 assignee.as_deref(),
373 tags,
374 session,
375 )
376 .await
377 }
378 IssueCommand::Update {
379 keys,
380 summary,
381 description,
382 description_file,
383 assignee,
384 set,
385 no_wait,
386 } => {
387 update(
388 keys,
389 &Changes {
390 summary: summary.as_deref(),
391 description: description.as_deref(),
392 description_file: description_file.as_deref(),
393 assignee: assignee.as_deref(),
394 set,
395 },
396 *no_wait,
397 session,
398 )
399 .await
400 }
401 IssueCommand::Comment { command, key, text } => match (command, key, text) {
402 (Some(command), _, _) => comment_write(command, session).await,
403 (None, Some(key), Some(text)) => comment(key, text, session).await,
404 (None, ..) => report(
407 &"usage: ytcli issue comment <KEY> <TEXT>, or `issue comment --help`",
408 ExitCode::ConfirmationRequired,
409 ),
410 },
411 IssueCommand::Transition {
412 keys,
413 to,
414 resolution,
415 set,
416 no_wait,
417 } => {
418 transition_cmd(
419 keys,
420 to.as_deref(),
421 resolution.as_deref(),
422 set,
423 *no_wait,
424 session,
425 )
426 .await
427 }
428 IssueCommand::Worklogs { key } => worklogs(key, session).await,
429 IssueCommand::Checklist { key } => checklist(key, session).await,
430 IssueCommand::Timers => timers(session),
431 IssueCommand::Timer(command) => timer(command, session).await,
432 IssueCommand::Worklog(command) => worklog_write(command, session).await,
433 IssueCommand::Check(command) => check_write(command, session).await,
434 IssueCommand::Link(command) => link_write(command, session).await,
435 }
436}
437
438async fn worklogs(target: &str, session: &Session) -> ExitCode {
440 let (client, key) = match session.client_for(target).await {
441 Ok(pair) => pair,
442 Err(code) => return code,
443 };
444
445 match client.worklogs(&key).await {
446 Ok(entries) => {
447 let rendered = match session.render.format {
448 Format::Text => Ok(text::worklogs(&key, &entries, &session.render)),
449 other => machine(&entries, other),
450 };
451 finish(rendered)
452 }
453 Err(error) => {
454 let code = error.exit_code();
455 report(&error, code)
456 }
457 }
458}
459
460async fn checklist(target: &str, session: &Session) -> ExitCode {
462 let (client, key) = match session.client_for(target).await {
463 Ok(pair) => pair,
464 Err(code) => return code,
465 };
466
467 match client.checklist(&key).await {
468 Ok(items) => {
469 let rendered = match session.render.format {
470 Format::Text => Ok(text::checklist(&key, &items, &session.render)),
471 other => machine(&items, other),
472 };
473 finish(rendered)
474 }
475 Err(error) => {
476 let code = error.exit_code();
477 report(&error, code)
478 }
479 }
480}
481
482async fn worklog_write(command: &WorklogCommand, session: &Session) -> ExitCode {
483 match command {
484 WorklogCommand::Add {
485 key,
486 duration,
487 comment,
488 start,
489 } => {
490 let (client, key) = match session.client_for(key).await {
491 Ok(pair) => pair,
492 Err(code) => return code,
493 };
494
495 let iso = match crate::api::duration::to_iso8601(duration) {
496 Ok(iso) => iso,
497 Err(error) => return report(&error, ExitCode::ConfirmationRequired),
498 };
499
500 let mut body = serde_json::Map::new();
501 body.insert("duration".to_owned(), serde_json::json!(iso));
502 body.insert(
505 "start".to_owned(),
506 serde_json::json!(start.clone().unwrap_or_else(now_for_tracker)),
507 );
508 if let Some(comment) = comment {
509 body.insert("comment".to_owned(), serde_json::json!(comment));
510 }
511 let body = serde_json::Value::Object(body);
512
513 let targets = [key.clone()];
514 let intent = Intent {
515 action: &format!("log {duration} against {key}"),
516 targets: &targets,
517 body: &body,
518 always_confirm: false,
519 };
520 if let Gate::Stop(code) = check(&intent, session) {
521 return code;
522 }
523
524 match client.add_worklog(&key, &body).await {
525 Ok(entry) => {
526 emit(&format!(
527 "{key} worklog {} {}\n",
528 entry.id,
529 crate::api::duration::human(&entry.duration)
530 ));
531 ExitCode::Success
532 }
533 Err(error) => {
534 let code = error.exit_code();
535 report(&error, code)
536 }
537 }
538 }
539 WorklogCommand::Edit {
540 key,
541 id,
542 duration,
543 comment,
544 } => worklog_edit(key, id, duration.as_deref(), comment.as_deref(), session).await,
545 WorklogCommand::Delete { key, id } => {
546 delete_with_gate(key, id, session, "worklog", |client, key, id| {
547 Box::pin(async move { client.delete_worklog(key, id).await })
548 })
549 .await
550 }
551 }
552}
553
554async fn worklog_edit(
556 key: &str,
557 id: &str,
558 duration: Option<&str>,
559 comment: Option<&str>,
560 session: &Session,
561) -> ExitCode {
562 if duration.is_none() && comment.is_none() {
565 return report(
566 &"nothing to change: pass --duration, --comment, or both",
567 ExitCode::ConfirmationRequired,
568 );
569 }
570
571 let (client, key) = match session.client_for(key).await {
572 Ok(pair) => pair,
573 Err(code) => return code,
574 };
575
576 let mut body = serde_json::Map::new();
577 if let Some(duration) = duration {
578 let iso = match crate::api::duration::to_iso8601(duration) {
579 Ok(iso) => iso,
580 Err(error) => return report(&error, ExitCode::ConfirmationRequired),
581 };
582 body.insert("duration".to_owned(), serde_json::json!(iso));
583 }
584 if let Some(comment) = comment {
585 body.insert("comment".to_owned(), serde_json::json!(comment));
586 }
587 let body = serde_json::Value::Object(body);
588
589 let targets = [key.clone()];
590 let intent = Intent {
591 action: &format!("correct worklog {id} of {key}"),
592 targets: &targets,
593 body: &body,
594 always_confirm: false,
595 };
596 if let Gate::Stop(code) = check(&intent, session) {
597 return code;
598 }
599
600 match client.update_worklog(&key, id, &body).await {
601 Ok(entry) => {
602 emit(&format!(
603 "{key} worklog {} {}\n",
604 entry.id,
605 crate::api::duration::human(&entry.duration)
606 ));
607 ExitCode::Success
608 }
609 Err(error) => {
610 let code = error.exit_code();
611 report(&error, code)
612 }
613 }
614}
615
616async fn comment_write(command: &CommentCommand, session: &Session) -> ExitCode {
618 match command {
619 CommentCommand::Add { key, text } => comment(key, text, session).await,
620 CommentCommand::Edit { key, id, text } => {
621 let (client, key) = match session.client_for(key).await {
622 Ok(pair) => pair,
623 Err(code) => return code,
624 };
625
626 let text_body = match body_text(text) {
627 Ok(text) => text,
628 Err(code) => return code,
629 };
630
631 let body = serde_json::json!({ "text": text_body });
632 let targets = [key.clone()];
633 let intent = Intent {
634 action: &format!("replace the text of comment {id} on {key}"),
635 targets: &targets,
636 body: &body,
637 always_confirm: false,
638 };
639 if let Gate::Stop(code) = check(&intent, session) {
640 return code;
641 }
642
643 match client.update_comment(&key, id, &text_body).await {
644 Ok(comment) => {
645 emit(&format!("{key} comment {} edited\n", comment.id));
646 ExitCode::Success
647 }
648 Err(error) => {
649 let code = error.exit_code();
650 report(&error, code)
651 }
652 }
653 }
654 CommentCommand::Delete { key, id } => {
655 delete_with_gate(key, id, session, "comment", |client, key, id| {
656 Box::pin(async move { client.delete_comment(key, id).await })
657 })
658 .await
659 }
660 }
661}
662
663async fn check_write(command: &CheckCommand, session: &Session) -> ExitCode {
664 match command {
665 CheckCommand::Add {
666 key,
667 text: line,
668 assignee,
669 deadline,
670 } => {
671 let (client, key) = match session.client_for(key).await {
672 Ok(pair) => pair,
673 Err(code) => return code,
674 };
675
676 let mut body = serde_json::Map::new();
677 body.insert("text".to_owned(), serde_json::json!(line));
678 if let Some(assignee) = assignee {
679 body.insert("assignee".to_owned(), serde_json::json!(assignee));
680 }
681 if let Some(deadline) = deadline {
682 body.insert(
683 "deadline".to_owned(),
684 serde_json::json!({ "date": deadline }),
685 );
686 }
687 let body = serde_json::Value::Object(body);
688
689 let targets = [key.clone()];
690 let intent = Intent {
691 action: &format!("add a checklist line to {key}"),
692 targets: &targets,
693 body: &body,
694 always_confirm: false,
695 };
696 if let Gate::Stop(code) = check(&intent, session) {
697 return code;
698 }
699
700 match client.add_checklist_item(&key, &body).await {
701 Ok(items) => {
702 emit(&text::checklist(&key, &items, &session.render));
703 ExitCode::Success
704 }
705 Err(error) => {
706 let code = error.exit_code();
707 report(&error, code)
708 }
709 }
710 }
711 CheckCommand::Tick { key, id } => set_checked(key, id, true, session).await,
712 CheckCommand::Untick { key, id } => set_checked(key, id, false, session).await,
713 CheckCommand::Delete { key, id } => {
714 delete_with_gate(key, id, session, "checklist item", |client, key, id| {
715 Box::pin(async move { client.delete_checklist_item(key, id).await })
716 })
717 .await
718 }
719 }
720}
721
722async fn set_checked(target: &str, id: &str, checked: bool, session: &Session) -> ExitCode {
723 let (client, key) = match session.client_for(target).await {
724 Ok(pair) => pair,
725 Err(code) => return code,
726 };
727
728 let body = serde_json::json!({ "checked": checked });
729 let targets = [key.clone()];
730 let verb = if checked { "tick" } else { "untick" };
731 let intent = Intent {
732 action: &format!("{verb} checklist item {id} of {key}"),
733 targets: &targets,
734 body: &body,
735 always_confirm: false,
736 };
737 if let Gate::Stop(code) = check(&intent, session) {
738 return code;
739 }
740
741 match client.update_checklist_item(&key, id, &body).await {
742 Ok(items) => {
743 emit(&text::checklist(&key, &items, &session.render));
744 ExitCode::Success
745 }
746 Err(error) => {
747 let code = error.exit_code();
748 report(&error, code)
749 }
750 }
751}
752
753fn corrected(relation: &str) -> Option<&'static str> {
765 let normalised = relation
766 .trim()
767 .to_ascii_lowercase()
768 .replace(['-', '_'], " ");
769 Some(match normalised.as_str() {
770 "depends" => "depends on",
771 "parent" => "is parent task for",
772 "subtask" => "is subtask for",
773 "epic" => "is epic of",
774 _ => return None,
775 })
776}
777
778async fn link_write(command: &LinkCommand, session: &Session) -> ExitCode {
779 match command {
780 LinkCommand::Add {
781 key,
782 relation,
783 other,
784 } => {
785 if let Some(correct) = corrected(relation) {
789 return report(
790 &format!(
791 "`{relation}` is the id of a link type, not a relationship: write `{correct}`. \
792 `ytcli link types` lists both."
793 ),
794 ExitCode::ConfirmationRequired,
795 );
796 }
797
798 let (client, key) = match session.client_for(key).await {
799 Ok(pair) => pair,
800 Err(code) => return code,
801 };
802
803 let body = serde_json::json!({ "relationship": relation, "issue": other });
804 let targets = [key.clone()];
805 let intent = Intent {
806 action: &format!("link {key} {relation} {other}"),
807 targets: &targets,
808 body: &body,
809 always_confirm: false,
810 };
811 if let Gate::Stop(code) = check(&intent, session) {
812 return code;
813 }
814
815 match client.add_link(&key, relation, other).await {
816 Ok(()) => {
817 emit(&format!("{key} {relation} {other}\n"));
818 ExitCode::Success
819 }
820 Err(error) => {
821 let code = error.exit_code();
822 report(&error, code)
823 }
824 }
825 }
826 LinkCommand::Delete { key, id } => {
827 delete_with_gate(key, id, session, "link", |client, key, id| {
828 Box::pin(async move { client.delete_link(key, id).await })
829 })
830 .await
831 }
832 }
833}
834
835async fn delete_with_gate<F>(
841 target: &str,
842 id: &str,
843 session: &Session,
844 what: &str,
845 delete: F,
846) -> ExitCode
847where
848 F: for<'a> FnOnce(
849 &'a crate::api::Client,
850 &'a str,
851 &'a str,
852 ) -> std::pin::Pin<
853 Box<dyn std::future::Future<Output = Result<(), crate::api::error::ApiError>> + 'a>,
854 >,
855{
856 let (client, key) = match session.client_for(target).await {
857 Ok(pair) => pair,
858 Err(code) => return code,
859 };
860
861 let body = serde_json::json!({ "delete": id });
862 let targets = [key.clone()];
863 let intent = Intent {
864 action: &format!("delete {what} {id} of {key}"),
865 targets: &targets,
866 body: &body,
867 always_confirm: false,
868 };
869 if let Gate::Stop(code) = check(&intent, session) {
870 return code;
871 }
872
873 match delete(&client, &key, id).await {
874 Ok(()) => {
875 emit(&format!("{key} {what} {id} deleted\n"));
876 ExitCode::Success
877 }
878 Err(error) => {
879 let code = error.exit_code();
880 report(&error, code)
881 }
882 }
883}
884
885fn now_for_tracker() -> String {
887 jiff::Zoned::now()
888 .strftime("%Y-%m-%dT%H:%M:%S%.3f%z")
889 .to_string()
890}
891
892async fn get(target: &str, fields: &[String], session: &Session) -> ExitCode {
894 let (client, key) = match session.client_for(target).await {
895 Ok(pair) => pair,
896 Err(code) => return code,
897 };
898 let key = key.as_str();
899
900 let (mut issue, raw) = match client.issue(key).await {
901 Ok(pair) => pair,
902 Err(error) => {
903 let code = error.exit_code();
904 return report(&error, code);
905 }
906 };
907
908 match client.issue_links(key).await {
912 Ok(links) => issue.links = links,
913 Err(error) => {
914 tracing::warn!(%error, "could not fetch links");
915 }
916 }
917
918 let mut ctx = session.render.clone();
921 let mut drawn = Vec::new();
922 if fields.is_empty() {
923 let (inline, used) = inline_images(&client, key, issue.description.as_deref(), &ctx).await;
924 ctx.inline = inline;
925 drawn = used;
926 }
927
928 let rendered = match ctx.format {
929 Format::Text if !fields.is_empty() => Ok(text::issue_selected(&issue, fields)),
930 Format::Text => Ok(text::issue(&issue, &ctx)),
931 Format::JsonRaw => machine(&raw, Format::JsonRaw),
932 other => machine(&issue, other),
933 };
934
935 match rendered {
936 Ok(text) => {
937 emit(&text);
938 draw_remaining_images(&client, key, &drawn, &ctx).await;
941 ExitCode::Success
942 }
943 Err(error) => report(&error, ExitCode::Failure),
944 }
945}
946
947const IMAGES_SHOWN: usize = 4;
953
954fn drawing(ctx: &crate::render::Context) -> Option<image::Protocol> {
961 if !ctx.images || !ctx.is_human() || ctx.format != Format::Text {
962 return None;
963 }
964 image::protocol()
965}
966
967async fn inline_images(
978 client: &crate::api::Client,
979 key: &str,
980 description: Option<&str>,
981 ctx: &crate::render::Context,
982) -> (image::Inline, Vec<String>) {
983 let mut inline = image::Inline::default();
984 let mut used = Vec::new();
985
986 let Some(protocol) = drawing(ctx) else {
987 return (inline, used);
988 };
989 let Some(description) = description else {
990 return (inline, used);
991 };
992 let references = crate::render::markdown::image_references(description);
993 if references.is_empty() {
994 return (inline, used);
995 }
996
997 let attachments = match client.attachments(key).await {
998 Ok(attachments) => attachments,
999 Err(error) => {
1000 tracing::warn!(%error, "could not fetch attachments");
1001 return (inline, used);
1002 }
1003 };
1004
1005 let width = ctx.width.saturating_sub(2);
1007
1008 for (alt, url) in references {
1009 let Some(attachment) = attachment_for(&attachments, url) else {
1010 tracing::debug!(url, "no attachment matches this image reference");
1011 continue;
1012 };
1013 let Some(picture) = fetch_picture(client, attachment, protocol, width).await else {
1014 continue;
1015 };
1016
1017 used.push(attachment.id.clone());
1018 let caption = if alt.is_empty() {
1021 attachment.name.clone()
1022 } else {
1023 format!("{alt} — {}", attachment.name)
1024 };
1025 inline.insert(url.to_owned(), image::Picture { caption, ..picture });
1026 }
1027
1028 (inline, used)
1029}
1030
1031fn attachment_for<'a>(
1038 attachments: &'a [crate::api::models::Attachment],
1039 url: &str,
1040) -> Option<&'a crate::api::models::Attachment> {
1041 let path = url.split(['?', '#']).next().unwrap_or(url);
1042 let last = path.rsplit('/').find(|segment| !segment.is_empty())?;
1043
1044 attachments
1045 .iter()
1046 .find(|attachment| attachment.id == last || attachment.name == last)
1047}
1048
1049async fn fetch_picture(
1051 client: &crate::api::Client,
1052 attachment: &crate::api::models::Attachment,
1053 protocol: image::Protocol,
1054 width: usize,
1055) -> Option<image::Picture> {
1056 let url = attachment.content.as_deref()?;
1057 let bytes = match client.download(url).await {
1058 Ok(bytes) => bytes,
1059 Err(error) => {
1060 tracing::warn!(%error, id = attachment.id, "could not download attachment");
1061 return None;
1062 }
1063 };
1064
1065 let kind = image::Kind::of(&bytes)?;
1068 if !protocol.carries(kind) {
1069 return None;
1070 }
1071
1072 Some(image::Picture {
1073 escape: image::draw(protocol, &bytes, &attachment.name, width),
1074 caption: attachment.name.clone(),
1075 })
1076}
1077
1078async fn draw_remaining_images(
1084 client: &crate::api::Client,
1085 key: &str,
1086 already_drawn: &[String],
1087 ctx: &crate::render::Context,
1088) {
1089 let Some(protocol) = drawing(ctx) else {
1090 return;
1091 };
1092
1093 let attachments = match client.attachments(key).await {
1094 Ok(attachments) => attachments,
1095 Err(error) => {
1096 tracing::warn!(%error, "could not fetch attachments");
1097 return;
1098 }
1099 };
1100
1101 let images: Vec<_> = attachments
1105 .iter()
1106 .filter(|attachment| !already_drawn.contains(&attachment.id))
1107 .filter(|attachment| {
1108 attachment
1109 .mimetype
1110 .as_deref()
1111 .is_some_and(|kind| kind.starts_with("image/"))
1112 })
1113 .collect();
1114
1115 for attachment in images.iter().take(IMAGES_SHOWN) {
1116 if let Some(picture) = fetch_picture(client, attachment, protocol, ctx.width).await {
1117 emit(&picture.escape);
1118 emit(&format!("{}\n", picture.caption));
1119 }
1120 }
1121
1122 if images.len() > IMAGES_SHOWN {
1123 emit(&format!(
1124 "{} more image(s): ytcli attachment show {key} <id>\n",
1125 images.len() - IMAGES_SHOWN
1126 ));
1127 }
1128}
1129
1130fn query_for(args: &FindArgs, session: &Session) -> Result<String, ExitCode> {
1135 if let Some(yql) = &args.yql {
1136 return Ok(yql.clone());
1137 }
1138
1139 let mut filter = Filter {
1140 queue: args.queue.clone(),
1141 assignee: args.assignee.clone(),
1142 status: args.status.clone(),
1143 tags: args.tags.clone(),
1144 };
1145
1146 if filter.queue.is_none() {
1147 filter.queue = session.default_queue().map(ToOwned::to_owned);
1148 }
1149
1150 if filter.is_empty() {
1151 return Err(report(
1152 &"no filter given: pass --queue, --assignee, --status, --tags or --yql, \
1153 or pin a queue in .tracker.toml",
1154 ExitCode::ConfirmationRequired,
1155 ));
1156 }
1157
1158 Ok(filter.to_query())
1159}
1160
1161async fn find(args: &FindArgs, session: &Session) -> ExitCode {
1162 let client = match session.client() {
1163 Ok(client) => client,
1164 Err(code) => return code,
1165 };
1166 let query = match query_for(args, session) {
1167 Ok(query) => query,
1168 Err(code) => return code,
1169 };
1170
1171 let display = session.display();
1172 let per_page = args.limit.unwrap_or(display.limit);
1173 let Ok(per_page) = u32::try_from(per_page.max(1)) else {
1174 return report(&"--limit is too large", ExitCode::ConfirmationRequired);
1175 };
1176
1177 if args.all {
1178 return find_all(&client, &query, per_page, args, session).await;
1179 }
1180
1181 match client.search(&query, args.page.max(1), per_page).await {
1182 Ok(page) => emit_page(&page, session),
1183 Err(error) => {
1184 let code = error.exit_code();
1185 report(&error, code)
1186 }
1187 }
1188}
1189
1190async fn find_all(
1195 client: &crate::api::Client,
1196 query: &str,
1197 per_page: u32,
1198 args: &FindArgs,
1199 session: &Session,
1200) -> ExitCode {
1201 let max = args.max.unwrap_or(session.display().max);
1202 let mut collected: Vec<crate::api::models::Issue> = Vec::new();
1203 let mut page_number = 1;
1204 let mut total = None;
1205 let walk = crate::render::progress::Walk::start("searching");
1206
1207 loop {
1208 let page = match client.search(query, page_number, per_page).await {
1209 Ok(page) => page,
1210 Err(error) => {
1211 walk.finish();
1212 let code = error.exit_code();
1213 return report(&error, code);
1214 }
1215 };
1216 total = page.total.or(total);
1217
1218 if collected.len() + page.items.len() > max {
1219 walk.finish();
1220 return report(
1221 &format!(
1222 "more than --max {max} issues match ({}); narrow the filter or raise --max",
1223 total.map_or_else(|| "unknown total".to_owned(), |t| t.to_string()),
1224 ),
1225 ExitCode::ConfirmationRequired,
1226 );
1227 }
1228
1229 let more = page.has_more();
1230 collected.extend(page.items);
1231 walk.page(page_number, collected.len(), total);
1232 if !more {
1233 break;
1234 }
1235 page_number += 1;
1236 }
1237 walk.finish();
1238
1239 let Ok(count) = u32::try_from(collected.len()) else {
1240 return report(&"too many results to render", ExitCode::Failure);
1241 };
1242 let page = crate::api::models::Page {
1243 items: collected,
1244 page: 1,
1245 per_page: count.max(1),
1246 total: total.or(Some(u64::from(count))),
1247 };
1248 emit_page(&page, session)
1249}
1250
1251fn emit_page(
1252 page: &crate::api::models::Page<crate::api::models::Issue>,
1253 session: &Session,
1254) -> ExitCode {
1255 let rendered = match session.render.format {
1256 Format::Text => Ok(text::issue_page(page, &session.render)),
1257 Format::JsonRaw => machine(&page.items, Format::Json),
1260 other => machine(&page.items, other),
1261 };
1262
1263 match rendered {
1264 Ok(text) => {
1265 emit(&text);
1266 ExitCode::Success
1267 }
1268 Err(error) => report(&error, ExitCode::Failure),
1269 }
1270}
1271
1272async fn count(args: &FindArgs, session: &Session) -> ExitCode {
1274 let client = match session.client() {
1275 Ok(client) => client,
1276 Err(code) => return code,
1277 };
1278 let query = match query_for(args, session) {
1279 Ok(query) => query,
1280 Err(code) => return code,
1281 };
1282
1283 match client.count(&query).await {
1284 Ok(count) => {
1285 emit(&format!("{count}\n"));
1286 ExitCode::Success
1287 }
1288 Err(error) => {
1289 let code = error.exit_code();
1290 report(&error, code)
1291 }
1292 }
1293}
1294
1295async fn links(target: &str, session: &Session) -> ExitCode {
1296 let (client, key) = match session.client_for(target).await {
1297 Ok(pair) => pair,
1298 Err(code) => return code,
1299 };
1300 let key = key.as_str();
1301
1302 match client.issue_links(key).await {
1303 Ok(links) => {
1304 let rendered = match session.render.format {
1305 Format::Text => Ok(text::links(key, &links)),
1306 Format::JsonRaw => machine(&links, Format::Json),
1307 other => machine(&links, other),
1308 };
1309 finish(rendered)
1310 }
1311 Err(error) => {
1312 let code = error.exit_code();
1313 report(&error, code)
1314 }
1315 }
1316}
1317
1318async fn remote_links(target: &str, session: &Session) -> ExitCode {
1319 let (client, key) = match session.client_for(target).await {
1320 Ok(pair) => pair,
1321 Err(code) => return code,
1322 };
1323 let key = key.as_str();
1324
1325 match client.issue_remote_links(key).await {
1326 Ok(links) => {
1327 let rendered = match session.render.format {
1328 Format::Text => Ok(text::remote_links(key, &links, &session.render)),
1329 Format::JsonRaw => machine(&links, Format::Json),
1330 other => machine(&links, other),
1331 };
1332 finish(rendered)
1333 }
1334 Err(error) => {
1335 let code = error.exit_code();
1336 report(&error, code)
1337 }
1338 }
1339}
1340
1341async fn comments(target: &str, session: &Session) -> ExitCode {
1342 let (client, key) = match session.client_for(target).await {
1343 Ok(pair) => pair,
1344 Err(code) => return code,
1345 };
1346 let key = key.as_str();
1347
1348 match client.issue_comments(key).await {
1349 Ok(comments) => {
1350 let rendered = match session.render.format {
1351 Format::Text => Ok(text::comments(key, &comments, &session.render)),
1352 Format::JsonRaw => machine(&comments, Format::Json),
1353 other => machine(&comments, other),
1354 };
1355 finish(rendered)
1356 }
1357 Err(error) => {
1358 let code = error.exit_code();
1359 report(&error, code)
1360 }
1361 }
1362}
1363
1364async fn changelog(target: &str, limit: u32, session: &Session) -> ExitCode {
1366 let (client, key) = match session.client_for(target).await {
1367 Ok(pair) => pair,
1368 Err(code) => return code,
1369 };
1370 let key = key.as_str();
1371
1372 match client.changelog(key, limit.max(1)).await {
1373 Ok(changes) => {
1374 let rendered = match session.render.format {
1375 Format::Text => Ok(text::changelog(key, &changes, &session.render)),
1376 Format::JsonRaw => machine(&changes, Format::Json),
1377 other => machine(&changes, other),
1378 };
1379 finish(rendered)
1380 }
1381 Err(error) => {
1382 let code = error.exit_code();
1383 report(&error, code)
1384 }
1385 }
1386}
1387
1388fn finish(rendered: Result<String, crate::render::RenderError>) -> ExitCode {
1389 match rendered {
1390 Ok(text) => {
1391 emit(&text);
1392 ExitCode::Success
1393 }
1394 Err(error) => report(&error, ExitCode::Failure),
1395 }
1396}
1397
1398fn timers(session: &Session) -> ExitCode {
1403 let store =
1404 crate::config::timers::Timers::load(&crate::config::timers::path_for(&session.config_file));
1405 let running = store.all();
1406 let now = jiff::Timestamp::now();
1407
1408 let rendered = match session.render.format {
1409 Format::Text => Ok(text::timers(&running, now, &session.render)),
1410 Format::JsonRaw => machine(&running, Format::Json),
1411 other => machine(&running, other),
1412 };
1413 finish(rendered)
1414}
1415
1416async fn timer(command: &TimerCommand, session: &Session) -> ExitCode {
1417 match command {
1418 TimerCommand::Start { key } => timer_start(key, session).await,
1419 TimerCommand::Stop { key, comment } => timer_stop(key, comment.as_deref(), session).await,
1420 TimerCommand::Cancel { key } => timer_cancel(key, session).await,
1421 }
1422}
1423
1424async fn timer_store(
1430 target: &str,
1431 session: &Session,
1432) -> Result<
1433 (
1434 crate::api::Client,
1435 String,
1436 String,
1437 crate::config::timers::Timers,
1438 std::path::PathBuf,
1439 ),
1440 ExitCode,
1441> {
1442 let (client, key, profile) = session.routed(target).await?;
1443 let path = crate::config::timers::path_for(&session.config_file);
1444 let store = crate::config::timers::Timers::load(&path);
1445 Ok((client, key, profile, store, path))
1446}
1447
1448async fn timer_start(target: &str, session: &Session) -> ExitCode {
1449 let (client, key, profile, mut store, path) = match timer_store(target, session).await {
1450 Ok(parts) => parts,
1451 Err(code) => return code,
1452 };
1453
1454 let now = jiff::Timestamp::now();
1455 if let Err(running) = store.start(client.org(), &profile, &key, now) {
1456 return report(
1457 &format!(
1458 "{key} has been timed since {} — stop it, or cancel it",
1459 running.started
1460 ),
1461 ExitCode::ConfirmationRequired,
1462 );
1463 }
1464 if let Err(error) = store.save(&path) {
1465 return report(
1466 &format!("could not record the timer: {error}"),
1467 ExitCode::Failure,
1468 );
1469 }
1470
1471 emit(&format!("{key} timer started\n"));
1472 ExitCode::Success
1473}
1474
1475async fn timer_stop(target: &str, comment: Option<&str>, session: &Session) -> ExitCode {
1476 let (client, key, _, mut store, path) = match timer_store(target, session).await {
1477 Ok(parts) => parts,
1478 Err(code) => return code,
1479 };
1480
1481 let Some(entry) = store.get(client.org(), &key).cloned() else {
1482 return report(&no_timer(&store, client.org(), &key), ExitCode::NotFound);
1483 };
1484
1485 let elapsed = jiff::Timestamp::now()
1486 .since(entry.started)
1487 .unwrap_or_default();
1488 let iso = crate::api::duration::from_minutes(elapsed.get_minutes());
1489
1490 let mut body = serde_json::json!({ "duration": iso, "start": entry.started.to_string() });
1491 if let Some(comment) = comment {
1492 body["comment"] = serde_json::json!(comment);
1493 }
1494 let targets = [key.clone()];
1495 let intent = Intent {
1496 action: &format!("record {} on {key}", crate::api::duration::human(&iso)),
1497 targets: &targets,
1498 body: &body,
1499 always_confirm: false,
1500 };
1501 if let Gate::Stop(code) = check(&intent, session) {
1502 return code;
1503 }
1504
1505 match client.add_worklog(&key, &body).await {
1508 Ok(entry) => {
1509 store.take(client.org(), &key);
1510 if let Err(error) = store.save(&path) {
1511 let mut err = anstream::stderr();
1512 let _ = writeln!(
1513 err,
1514 "the worklog was recorded; the timer file was not: {error}"
1515 );
1516 }
1517 emit(&format!(
1518 "{key} worklog {} {}\n",
1519 entry.id,
1520 crate::api::duration::human(&entry.duration)
1521 ));
1522 ExitCode::Success
1523 }
1524 Err(error) => {
1525 let code = error.exit_code();
1526 let outcome = report(&error, code);
1527 let mut err = anstream::stderr();
1528 let _ = writeln!(err, "the timer is still running; nothing was lost");
1529 outcome
1530 }
1531 }
1532}
1533
1534async fn timer_cancel(target: &str, session: &Session) -> ExitCode {
1535 let (client, key, _, mut store, path) = match timer_store(target, session).await {
1536 Ok(parts) => parts,
1537 Err(code) => return code,
1538 };
1539
1540 let Some(entry) = store.take(client.org(), &key) else {
1541 return report(&no_timer(&store, client.org(), &key), ExitCode::NotFound);
1542 };
1543 if let Err(error) = store.save(&path) {
1544 return report(
1545 &format!("could not update the timers: {error}"),
1546 ExitCode::Failure,
1547 );
1548 }
1549
1550 let elapsed = jiff::Timestamp::now()
1553 .since(entry.started)
1554 .unwrap_or_default();
1555 let iso = crate::api::duration::from_minutes(elapsed.get_minutes());
1556 emit(&format!(
1557 "{key} timer cancelled — {} not recorded\n",
1558 crate::api::duration::human(&iso)
1559 ));
1560 ExitCode::Success
1561}
1562
1563fn no_timer(store: &crate::config::timers::Timers, org: &str, key: &str) -> String {
1569 match store.elsewhere(org, key) {
1570 Some(entry) => format!(
1571 "no timer for {key} here; one is running through profile {} — stop it there",
1572 entry.profile
1573 ),
1574 None => format!("no timer running for {key}"),
1575 }
1576}
1577
1578fn body_text(raw: &str) -> Result<String, ExitCode> {
1583 if raw != "-" {
1584 return Ok(raw.to_owned());
1585 }
1586
1587 let mut text = String::new();
1588 match std::io::Read::read_to_string(&mut std::io::stdin(), &mut text) {
1589 Ok(_) => Ok(text),
1590 Err(error) => Err(report(&error, ExitCode::Failure)),
1591 }
1592}
1593
1594fn description_of(inline: Option<&str>, file: Option<&str>) -> Result<Option<String>, ExitCode> {
1601 match (inline, file) {
1602 (Some(_), Some(_)) => Err(report(
1603 &"pass either --description or --description-file, not both",
1604 ExitCode::ConfirmationRequired,
1605 )),
1606 (Some(text), None) => body_text(text).map(Some),
1607 (None, Some(path)) => match std::fs::read_to_string(path) {
1608 Ok(text) => Ok(Some(text)),
1609 Err(error) => Err(report(
1610 &format!("could not read {path}: {error}"),
1611 ExitCode::Failure,
1612 )),
1613 },
1614 (None, None) => Ok(None),
1615 }
1616}
1617
1618async fn create(
1619 queue: Option<&str>,
1620 summary: &str,
1621 description: Option<&str>,
1622 description_file: Option<&str>,
1623 assignee: Option<&str>,
1624 tags: &[String],
1625 session: &Session,
1626) -> ExitCode {
1627 let Some(queue) = queue.or_else(|| session.default_queue()) else {
1628 return report(
1629 &"no queue given: pass --queue or pin one in .tracker.toml",
1630 ExitCode::ConfirmationRequired,
1631 );
1632 };
1633
1634 let description = match description_of(description, description_file) {
1635 Ok(description) => description,
1636 Err(code) => return code,
1637 };
1638
1639 let mut body = serde_json::json!({
1640 "queue": { "key": queue },
1641 "summary": summary,
1642 });
1643 if let Some(description) = &description {
1644 body["description"] = serde_json::json!(description);
1645 }
1646 if let Some(assignee) = assignee {
1647 body["assignee"] = serde_json::json!(assignee);
1648 }
1649 if !tags.is_empty() {
1650 body["tags"] = serde_json::json!(tags);
1651 }
1652
1653 let intent = Intent {
1654 action: &format!("create an issue in {queue}"),
1655 targets: &[],
1656 body: &body,
1657 always_confirm: false,
1658 };
1659 if let Gate::Stop(code) = check(&intent, session) {
1660 return code;
1661 }
1662
1663 let client = match session.client() {
1664 Ok(client) => client,
1665 Err(code) => return code,
1666 };
1667
1668 match client.create_issue(&body).await {
1669 Ok(issue) => {
1670 emit(&format!("{} {}\n", issue.key, issue.summary));
1671 ExitCode::Success
1672 }
1673 Err(error) => {
1674 let code = error.exit_code();
1675 report(&error, code)
1676 }
1677 }
1678}
1679
1680#[derive(Debug)]
1691struct Changes<'a> {
1692 summary: Option<&'a str>,
1693 description: Option<&'a str>,
1694 description_file: Option<&'a str>,
1695 assignee: Option<&'a str>,
1696 set: &'a [String],
1697}
1698
1699async fn update(
1700 targets: &[String],
1701 changes: &Changes<'_>,
1702 no_wait: bool,
1703 session: &Session,
1704) -> ExitCode {
1705 let description = match description_of(changes.description, changes.description_file) {
1706 Ok(description) => description,
1707 Err(code) => return code,
1708 };
1709
1710 let mut resolved = Vec::with_capacity(targets.len());
1711 for target in targets {
1712 match session.client_for(target).await {
1713 Ok(pair) => resolved.push(pair),
1714 Err(code) => return code,
1715 }
1716 }
1717
1718 let mut body = serde_json::Map::new();
1719 if let Some(summary) = changes.summary {
1720 body.insert("summary".to_owned(), serde_json::json!(summary));
1721 }
1722 if let Some(description) = &description {
1723 body.insert("description".to_owned(), serde_json::json!(description));
1724 }
1725 if let Some(assignee) = changes.assignee {
1726 body.insert("assignee".to_owned(), serde_json::json!(assignee));
1727 }
1728 for assignment in changes.set {
1729 match parse_assignment(assignment) {
1730 Ok((field, value)) => {
1731 body.insert(field, value);
1732 }
1733 Err(error) => return report(&error, ExitCode::ConfirmationRequired),
1734 }
1735 }
1736
1737 if body.is_empty() {
1738 return report(
1739 &"nothing to change: pass --summary, --description, --assignee or --set key=value",
1740 ExitCode::ConfirmationRequired,
1741 );
1742 }
1743
1744 let body = serde_json::Value::Object(body);
1745 let keys: Vec<String> = resolved.iter().map(|(_, key)| key.clone()).collect();
1746
1747 let one_org = resolved.first().is_some_and(|(first, _)| {
1751 resolved
1752 .iter()
1753 .all(|(client, _)| client.org() == first.org())
1754 });
1755 let bulk = keys.len() > 1 && one_org;
1756
1757 let request = if bulk {
1760 serde_json::json!({ "issues": keys, "values": body })
1761 } else {
1762 body.clone()
1763 };
1764 let intent = Intent {
1765 action: &format!("update {}", keys.join(", ")),
1766 targets: &keys,
1767 body: &request,
1768 always_confirm: false,
1769 };
1770 if let Gate::Stop(code) = check(&intent, session) {
1771 return code;
1772 }
1773
1774 if bulk {
1775 let Some((client, _)) = resolved.first() else {
1776 return ExitCode::Success;
1777 };
1778 return bulk_update(client, &keys, &body, no_wait, session).await;
1779 }
1780
1781 let mut done = 0_u64;
1785 for (client, key) in &resolved {
1786 match client.update_issue(key, &body).await {
1787 Ok(issue) => {
1788 done += 1;
1789 emit(&text::issue_selected(
1790 &issue,
1791 &["status".to_owned(), "assignee".to_owned()],
1792 ));
1793 }
1794 Err(error) => {
1795 let code = error.exit_code();
1796 if keys.len() > 1 {
1799 emit(&render::bulk::changed(
1800 done,
1801 keys.len() as u64,
1802 &session.render,
1803 ));
1804 }
1805 return report(&error, code);
1806 }
1807 }
1808 }
1809
1810 if keys.len() > 1 {
1811 emit(&render::bulk::changed(
1812 done,
1813 keys.len() as u64,
1814 &session.render,
1815 ));
1816 }
1817 ExitCode::Success
1818}
1819
1820const BULK_WAIT: std::time::Duration = std::time::Duration::from_secs(60);
1826
1827async fn bulk_update(
1832 client: &crate::api::Client,
1833 keys: &[String],
1834 values: &serde_json::Value,
1835 no_wait: bool,
1836 session: &Session,
1837) -> ExitCode {
1838 let started = match client.bulk_update(keys, values).await {
1839 Ok(change) => change,
1840 Err(error) => {
1841 let code = error.exit_code();
1842 return report(&error, code);
1843 }
1844 };
1845
1846 awaited(client, started, no_wait, session).await
1847}
1848
1849async fn awaited(
1854 client: &crate::api::Client,
1855 started: crate::api::BulkChange,
1856 no_wait: bool,
1857 session: &Session,
1858) -> ExitCode {
1859 if no_wait {
1860 emit(&render::bulk::change(&started, &session.render));
1862 return ExitCode::Success;
1863 }
1864
1865 let mut change = started;
1866 let deadline = std::time::Instant::now() + BULK_WAIT;
1867 while !change.finished() {
1868 if std::time::Instant::now() >= deadline {
1869 emit(&render::bulk::change(&change, &session.render));
1870 return report(
1871 &format!(
1872 "Tracker is still working on it; ask again with `ytcli bulk status {}`",
1873 change.id
1874 ),
1875 ExitCode::Failure,
1876 );
1877 }
1878 tokio::time::sleep(std::time::Duration::from_millis(500)).await;
1879 match client.bulk_change(&change.id).await {
1880 Ok(next) => change = next,
1881 Err(error) => {
1882 let code = error.exit_code();
1883 return report(&error, code);
1884 }
1885 }
1886 }
1887
1888 finished(client, &change, session).await
1889}
1890
1891async fn finished(
1897 client: &crate::api::Client,
1898 change: &crate::api::BulkChange,
1899 session: &Session,
1900) -> ExitCode {
1901 emit(&render::bulk::change(change, &session.render));
1902
1903 if change.succeeded() {
1904 return ExitCode::Success;
1905 }
1906
1907 match client.bulk_change_issues(&change.id).await {
1908 Ok(outcomes) => emit(&render::bulk::failures(&outcomes, &session.render)),
1909 Err(error) => {
1910 let mut err = anstream::stderr();
1913 let _ = writeln!(err, "could not read which issues failed: {error}");
1914 }
1915 }
1916 ExitCode::ApiRejected
1917}
1918
1919async fn comment(target: &str, raw: &str, session: &Session) -> ExitCode {
1920 let (client, key) = match session.client_for(target).await {
1921 Ok(pair) => pair,
1922 Err(code) => return code,
1923 };
1924 let key = key.as_str();
1925
1926 let text_body = match body_text(raw) {
1927 Ok(text) => text,
1928 Err(code) => return code,
1929 };
1930
1931 let body = serde_json::json!({ "text": text_body });
1932 let targets = [key.to_owned()];
1933 let intent = Intent {
1934 action: &format!("comment on {key}"),
1935 targets: &targets,
1936 body: &body,
1937 always_confirm: false,
1938 };
1939 if let Gate::Stop(code) = check(&intent, session) {
1940 return code;
1941 }
1942
1943 match client.add_comment(key, &text_body).await {
1944 Ok(comment) => {
1945 emit(&format!("{key} comment {}\n", comment.id));
1946 ExitCode::Success
1947 }
1948 Err(error) => {
1949 let code = error.exit_code();
1950 report(&error, code)
1951 }
1952 }
1953}
1954async fn resolve_all(
1959 targets: &[String],
1960 session: &Session,
1961) -> Result<(Vec<(crate::api::Client, String)>, bool), ExitCode> {
1962 let mut resolved = Vec::with_capacity(targets.len());
1963 for target in targets {
1964 match session.client_for(target).await {
1965 Ok(pair) => resolved.push(pair),
1966 Err(code) => return Err(code),
1967 }
1968 }
1969
1970 let one_org = resolved.first().is_some_and(|(first, _)| {
1971 resolved
1972 .iter()
1973 .all(|(client, _)| client.org() == first.org())
1974 });
1975 Ok((resolved, one_org))
1976}
1977
1978async fn transitions_of(target: &str, session: &Session) -> ExitCode {
1983 let (client, key) = match session.client_for(target).await {
1984 Ok(pair) => pair,
1985 Err(code) => return code,
1986 };
1987
1988 match client.transitions(&key).await {
1989 Ok(transitions) => {
1990 let rendered = match session.render.format {
1991 Format::Text => Ok(text::transitions(&key, &transitions)),
1992 Format::JsonRaw => machine(&transitions, Format::Json),
1993 other => machine(&transitions, other),
1994 };
1995 finish(rendered)
1996 }
1997 Err(error) => {
1998 let code = error.exit_code();
1999 report(&error, code)
2000 }
2001 }
2002}
2003
2004async fn transition_cmd(
2010 targets: &[String],
2011 to: Option<&str>,
2012 resolution: Option<&str>,
2013 set: &[String],
2014 no_wait: bool,
2015 session: &Session,
2016) -> ExitCode {
2017 let (targets, transition) = match (to, targets) {
2021 (Some(to), keys) => (keys, Some(to.to_owned())),
2022 (None, [key]) => (std::slice::from_ref(key), None),
2023 (None, [key, id]) => (std::slice::from_ref(key), Some(id.clone())),
2024 (None, _) => {
2025 return report(
2026 &"naming several issues needs --to TRANSITION: ytcli issue transition A-1 A-2 --to close --yes",
2027 ExitCode::ConfirmationRequired,
2028 );
2029 }
2030 };
2031
2032 let Some(transition) = transition else {
2033 return match targets.first() {
2034 Some(target) => transitions_of(target, session).await,
2035 None => ExitCode::Success,
2036 };
2037 };
2038
2039 let mut fields = serde_json::Map::new();
2043 if let Some(resolution) = resolution {
2044 fields.insert("resolution".to_owned(), serde_json::json!(resolution));
2045 }
2046 for assignment in set {
2047 match parse_assignment(assignment) {
2048 Ok((field, value)) => {
2049 fields.insert(field, value);
2050 }
2051 Err(error) => return report(&error, ExitCode::ConfirmationRequired),
2052 }
2053 }
2054 let body = serde_json::Value::Object(fields);
2055
2056 let (resolved, one_org) = match resolve_all(targets, session).await {
2057 Ok(pair) => pair,
2058 Err(code) => return code,
2059 };
2060 let keys: Vec<String> = resolved.iter().map(|(_, key)| key.clone()).collect();
2061 let bulk = keys.len() > 1 && one_org;
2062
2063 let request = if bulk {
2066 serde_json::json!({ "issues": keys, "transition": transition, "values": body })
2067 } else {
2068 body.clone()
2069 };
2070 let intent = Intent {
2071 action: &format!("move {} through `{transition}`", keys.join(", ")),
2072 targets: &keys,
2073 body: &request,
2074 always_confirm: false,
2075 };
2076 if let Gate::Stop(code) = check(&intent, session) {
2077 return code;
2078 }
2079
2080 if bulk {
2081 let Some((client, _)) = resolved.first() else {
2082 return ExitCode::Success;
2083 };
2084 return bulk_transition(client, &keys, &transition, &body, no_wait, session).await;
2085 }
2086
2087 let mut done = 0_u64;
2088 for (client, key) in &resolved {
2089 match transition_once(client, key, &transition, &body).await {
2090 Ok(used) => {
2091 done += 1;
2092 emit(&format!("{key} {used}\n"));
2093 }
2094 Err(error) => {
2095 let code = error.exit_code();
2096 if keys.len() > 1 {
2097 emit(&render::bulk::changed(
2098 done,
2099 keys.len() as u64,
2100 &session.render,
2101 ));
2102 }
2103 return if matches!(error, crate::api::error::ApiError::NotFound(_)) {
2107 no_such_transition(&error, code)
2108 } else {
2109 rejected_for_fields(&error, &body, code)
2110 };
2111 }
2112 }
2113 }
2114
2115 if keys.len() > 1 {
2116 emit(&render::bulk::changed(
2117 done,
2118 keys.len() as u64,
2119 &session.render,
2120 ));
2121 }
2122 ExitCode::Success
2123}
2124
2125async fn bulk_transition(
2127 client: &crate::api::Client,
2128 keys: &[String],
2129 transition: &str,
2130 body: &serde_json::Value,
2131 no_wait: bool,
2132 session: &Session,
2133) -> ExitCode {
2134 let mut outcome = client.bulk_transition(keys, transition, body).await;
2135 if outcome.is_err()
2139 && let Some(first) = keys.first()
2140 && let Some(found) = named_transition(client, first, transition).await
2141 {
2142 outcome = client.bulk_transition(keys, &found, body).await;
2143 }
2144
2145 match outcome {
2146 Ok(started) => awaited(client, started, no_wait, session).await,
2147 Err(error) => {
2148 let code = error.exit_code();
2149 no_such_transition(&error, code)
2150 }
2151 }
2152}
2153
2154async fn transition_once(
2166 client: &crate::api::Client,
2167 key: &str,
2168 wanted: &str,
2169 body: &serde_json::Value,
2170) -> Result<String, crate::api::error::ApiError> {
2171 let first = match client.execute_transition(key, wanted, body).await {
2172 Ok(()) => return Ok(wanted.to_owned()),
2173 Err(error) => error,
2174 };
2175
2176 let Some(found) = named_transition(client, key, wanted).await else {
2177 return Err(first);
2178 };
2179 client.execute_transition(key, &found, body).await?;
2180 Ok(found)
2181}
2182
2183async fn named_transition(client: &crate::api::Client, key: &str, wanted: &str) -> Option<String> {
2189 let transitions = client.transitions(key).await.ok()?;
2190 let same = |value: Option<&str>| value.is_some_and(|value| value.eq_ignore_ascii_case(wanted));
2191
2192 let found = transitions.iter().find(|transition| {
2193 same(Some(&transition.id))
2194 || same(transition.to_key.as_deref())
2195 || same(transition.to.as_deref())
2196 || same(Some(&transition.name))
2197 })?;
2198
2199 (found.id != wanted).then(|| found.id.clone())
2200}
2201
2202fn no_such_transition(error: &crate::api::error::ApiError, code: ExitCode) -> ExitCode {
2207 let outcome = report(error, code);
2208 let mut err = anstream::stderr();
2209 let _ = writeln!(
2210 err,
2211 "`ytcli issue transition KEY` lists the transitions available from the status an issue \
2212 is in; ids are defined per workflow, so a status key is only accepted when this \
2213 workflow has a transition that reaches it"
2214 );
2215 outcome
2216}
2217
2218fn rejected_for_fields(
2225 error: &crate::api::error::ApiError,
2226 body: &serde_json::Value,
2227 code: ExitCode,
2228) -> ExitCode {
2229 let wants_fields = body.as_object().is_some_and(serde_json::Map::is_empty)
2230 && matches!(error, crate::api::error::ApiError::Rejected { .. });
2231 let outcome = report(error, code);
2232
2233 if wants_fields {
2234 let mut err = anstream::stderr();
2235 let _ = writeln!(
2236 err,
2237 "this transition wants fields: pass them with --resolution or --set key=value \
2238 (`ytcli dict list --kind resolutions` names the resolutions)"
2239 );
2240 }
2241 outcome
2242}
2243
2244async fn move_issues(
2252 targets: &[String],
2253 queue: &str,
2254 keep_fields: bool,
2255 initial_status: bool,
2256 no_wait: bool,
2257 session: &Session,
2258) -> ExitCode {
2259 let (resolved, one_org) = match resolve_all(targets, session).await {
2260 Ok(pair) => pair,
2261 Err(code) => return code,
2262 };
2263 let keys: Vec<String> = resolved.iter().map(|(_, key)| key.clone()).collect();
2264 let bulk = keys.len() > 1 && one_org;
2265
2266 let mut request = serde_json::json!({
2267 "queue": queue,
2268 "moveAllFields": keep_fields,
2269 "initialStatus": initial_status,
2270 });
2271 if bulk && let Some(object) = request.as_object_mut() {
2272 object.insert("issues".to_owned(), serde_json::json!(keys));
2273 }
2274 let intent = Intent {
2275 action: &format!("move {} to {queue}, changing the key", keys.join(", ")),
2276 targets: &keys,
2277 body: &request,
2278 always_confirm: true,
2279 };
2280 if let Gate::Stop(code) = check(&intent, session) {
2281 return code;
2282 }
2283
2284 if bulk {
2285 let Some((client, _)) = resolved.first() else {
2286 return ExitCode::Success;
2287 };
2288 return match client
2289 .bulk_move(&keys, queue, keep_fields, initial_status)
2290 .await
2291 {
2292 Ok(started) => awaited(client, started, no_wait, session).await,
2293 Err(error) => {
2294 let code = error.exit_code();
2295 report(&error, code)
2296 }
2297 };
2298 }
2299
2300 let mut done = 0_u64;
2301 for (client, key) in &resolved {
2302 match client
2303 .move_issue(key, queue, keep_fields, initial_status)
2304 .await
2305 {
2306 Ok(issue) => {
2307 done += 1;
2308 emit(&format!("{key} → {}\n", issue.key));
2311 }
2312 Err(error) => {
2313 let code = error.exit_code();
2314 if keys.len() > 1 {
2315 emit(&render::bulk::changed(
2316 done,
2317 keys.len() as u64,
2318 &session.render,
2319 ));
2320 }
2321 return report(&error, code);
2322 }
2323 }
2324 }
2325
2326 if keys.len() > 1 {
2327 emit(&render::bulk::changed(
2328 done,
2329 keys.len() as u64,
2330 &session.render,
2331 ));
2332 }
2333 ExitCode::Success
2334}
2335
2336#[cfg(test)]
2337mod tests {
2338 use super::*;
2339 use crate::api::models::Attachment;
2340
2341 fn attachment(id: &str, name: &str) -> Attachment {
2342 Attachment {
2343 id: id.to_owned(),
2344 name: name.to_owned(),
2345 size: None,
2346 mimetype: Some("image/png".to_owned()),
2347 author: None,
2348 created_at: None,
2349 content: Some("https://api.tracker.yandex.net/x".to_owned()),
2350 }
2351 }
2352
2353 #[test]
2355 fn an_attachment_url_resolves_by_its_last_path_segment() {
2356 let attachments = [attachment("29", "screenshot.png")];
2357
2358 assert_eq!(
2359 attachment_for(&attachments, "/ajax/v2/attachments/29?inline=true").map(|a| &a.id),
2360 Some(&"29".to_owned())
2361 );
2362 assert_eq!(
2363 attachment_for(&attachments, "/ajax/v2/attachments/29/").map(|a| &a.id),
2364 Some(&"29".to_owned())
2365 );
2366 assert_eq!(
2368 attachment_for(&attachments, "screenshot.png").map(|a| &a.id),
2369 Some(&"29".to_owned())
2370 );
2371 }
2372
2373 #[test]
2378 fn a_url_that_is_not_an_attachment_of_this_issue_is_not_followed() {
2379 let attachments = [attachment("29", "screenshot.png")];
2380
2381 assert!(attachment_for(&attachments, "https://example.com/evil.png").is_none());
2382 assert!(attachment_for(&attachments, "/ajax/v2/attachments/30").is_none());
2383 assert!(attachment_for(&attachments, "http://169.254.169.254/latest/meta-data").is_none());
2384 }
2385}