Skip to main content

ytcli/cli/
issue.rs

1//! Issue commands.
2//!
3//! `find` takes both shapes on purpose: flags for the queries people actually
4//! run, and `--yql` for everything else. YQL is a read-only search language —
5//! the escape hatch widens what can be *read*, never what can be changed
6//! (`docs/adr/0001-security-model.md`).
7
8use 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    /// Show one issue: summary, fields, links, first lines of the description.
21    #[command(long_about = crate::cli::help::md(crate::cli::help::ISSUE_GET))]
22    Get {
23        /// Issue key, e.g. PROJ-42. Prefix it with a profile — `work/PROJ-42` —
24        /// when two profiles can both see a queue with that key.
25        key: String,
26        /// Comma-separated field list; accepts custom field keys.
27        #[arg(long, value_delimiter = ',')]
28        fields: Vec<String>,
29    },
30    /// Search for issues.
31    ///
32    /// Every other group here lists with `list` — queues, boards, fields,
33    /// templates, projects. An agent that has learnt that spelling reaches for
34    /// `issue list` too, and a "no such subcommand" for a verb the tool already
35    /// uses everywhere else costs a round trip to discover nothing.
36    #[command(visible_alias = "list", long_about = crate::cli::help::md(crate::cli::help::ISSUE_FIND))]
37    Find(FindArgs),
38    /// Count matching issues without fetching them. The cheapest question here.
39    #[command(long_about = crate::cli::help::md(crate::cli::help::ISSUE_COUNT))]
40    Count(FindArgs),
41    /// Show the links of an issue.
42    #[command(long_about = crate::cli::help::md(crate::cli::help::ISSUE_LINKS))]
43    Links { key: String },
44    /// Show the links from an issue to things outside Tracker.
45    #[command(long_about = crate::cli::help::md(crate::cli::help::ISSUE_REMOTELINKS))]
46    Remotelinks { key: String },
47    /// Show what changed on an issue, and who changed it.
48    #[command(long_about = crate::cli::help::md(crate::cli::help::ISSUE_CHANGELOG))]
49    Changelog {
50        key: String,
51        /// How many recorded events to fetch.
52        #[arg(long, default_value_t = 50)]
53        limit: u32,
54    },
55    /// Show the comments of an issue.
56    #[command(long_about = crate::cli::help::md(crate::cli::help::ISSUE_COMMENTS))]
57    Comments { key: String },
58    /// Create an issue.
59    #[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        /// Description; `-` reads it from stdin.
66        #[arg(long, short = 'd')]
67        description: Option<String>,
68        /// Read the description from a file instead.
69        #[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    /// Change fields of one or more issues.
77    #[command(long_about = crate::cli::help::md(crate::cli::help::ISSUE_UPDATE))]
78    Update {
79        /// Issues to change. More than one needs --yes.
80        #[arg(required = true)]
81        keys: Vec<String>,
82        #[arg(long, short = 's')]
83        summary: Option<String>,
84        /// Replace the description; `-` reads it from stdin.
85        #[arg(long, short = 'd')]
86        description: Option<String>,
87        /// Read the new description from a file instead.
88        #[arg(long, value_name = "PATH", conflicts_with = "description")]
89        description_file: Option<String>,
90        #[arg(long)]
91        assignee: Option<String>,
92        /// Set any field, including custom ones: --set storyPoints=3
93        #[arg(long = "set", value_name = "KEY=VALUE")]
94        set: Vec<String>,
95        /// Hand the change to Tracker and print its id without waiting for it
96        /// to finish. Several issues only.
97        #[arg(long)]
98        no_wait: bool,
99    },
100    /// Add a comment, or edit and remove one. Every verb here writes.
101    ///
102    /// A group with a bare form: `issue comment PROJ-1 "text"` is how this was
103    /// spelled before there was anything to edit, it is in every allowlist
104    /// people wrote, and breaking it to gain two subcommands would be a poor
105    /// trade. `add` is the same thing said explicitly.
106    #[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        /// Issue to comment on.
112        key: Option<String>,
113        /// Comment body; `-` reads from stdin.
114        text: Option<String>,
115    },
116    /// Show the worklog of an issue.
117    #[command(long_about = crate::cli::help::md(crate::cli::help::ISSUE_WORKLOGS))]
118    Worklogs { key: String },
119    /// Show the checklist of an issue.
120    #[command(long_about = crate::cli::help::md(crate::cli::help::ISSUE_CHECKLIST))]
121    Checklist { key: String },
122    /// Show the timers running on this machine. Reads nothing from Tracker.
123    #[command(long_about = crate::cli::help::md(crate::cli::help::ISSUE_TIMERS))]
124    Timers,
125    /// Start, stop or drop a timer. `stop` records a worklog.
126    #[command(subcommand, long_about = crate::cli::help::md(crate::cli::help::ISSUE_TIMER))]
127    Timer(TimerCommand),
128    /// Record or remove time spent. Every verb here writes.
129    #[command(subcommand, long_about = crate::cli::help::md(crate::cli::help::ISSUE_WORKLOG))]
130    Worklog(WorklogCommand),
131    /// Change an issue's checklist. Every verb here writes.
132    #[command(subcommand, long_about = crate::cli::help::md(crate::cli::help::ISSUE_CHECK))]
133    Check(CheckCommand),
134    /// Link or unlink issues. Every verb here writes.
135    #[command(subcommand, long_about = crate::cli::help::md(crate::cli::help::ISSUE_LINK))]
136    Link(LinkCommand),
137    /// Move an issue to another queue. Its key changes, and nothing undoes it.
138    #[command(long_about = crate::cli::help::md(crate::cli::help::ISSUE_MOVE))]
139    Move {
140        /// Issue keys. Several go in one request, to one queue.
141        #[arg(required = true)]
142        keys: Vec<String>,
143        /// Queue to move them into.
144        #[arg(long, short = 't')]
145        to: String,
146        /// Carry over fields the target queue does not define. Without this,
147        /// Tracker drops them.
148        #[arg(long)]
149        keep_fields: bool,
150        /// Start the issue at the target workflow's first status instead of
151        /// keeping the one it has.
152        #[arg(long)]
153        initial_status: bool,
154        /// Return the bulk change id instead of waiting for Tracker to finish.
155        #[arg(long)]
156        no_wait: bool,
157    },
158    /// Move an issue through a workflow transition.
159    #[command(long_about = crate::cli::help::md(crate::cli::help::ISSUE_TRANSITION))]
160    Transition {
161        /// One issue key, or a key and a transition id; several keys need --to.
162        #[arg(required = true)]
163        keys: Vec<String>,
164        /// Transition id. Required when naming more than one issue.
165        #[arg(long, short = 't')]
166        to: Option<String>,
167        /// Resolution to close with. `dict list --kind resolutions` lists them.
168        #[arg(long, short = 'r')]
169        resolution: Option<String>,
170        /// Any other field the transition needs: --set comment=text
171        #[arg(long = "set", value_name = "KEY=VALUE")]
172        set: Vec<String>,
173        /// Return the bulk change id instead of waiting for Tracker to finish.
174        #[arg(long)]
175        no_wait: bool,
176    },
177}
178
179/// A timer, which is local until it stops.
180///
181/// Tracker has no "started working" — only "worked this long" — so the start is
182/// kept on this machine and turned into a worklog on `stop`. The whole group
183/// writes, even the two verbs that only touch a local file: a host allowlists by
184/// prefix, and a group holding both a read and a write cannot be allowed
185/// without allowing the write. Reading is `issue timers`.
186#[derive(Debug, Subcommand)]
187pub enum TimerCommand {
188    /// Start timing an issue.
189    #[command(long_about = crate::cli::help::md(crate::cli::help::TIMER_START))]
190    Start { key: String },
191    /// Stop timing it, and record the elapsed time as a worklog.
192    #[command(long_about = crate::cli::help::md(crate::cli::help::TIMER_STOP))]
193    Stop {
194        key: String,
195        /// What the time went on.
196        #[arg(long, short = 'm')]
197        comment: Option<String>,
198    },
199    /// Forget a running timer without recording anything.
200    #[command(long_about = crate::cli::help::md(crate::cli::help::TIMER_CANCEL))]
201    Cancel { key: String },
202}
203
204/// Writing to a worklog.
205///
206/// Reading it is `issue worklogs`, deliberately a different word rather than a
207/// `list` under here: a host allowlists by command prefix, and a group holding
208/// both a read and a write cannot be allowed without allowing the writes too.
209#[derive(Debug, Subcommand)]
210pub enum WorklogCommand {
211    /// Record time spent on an issue.
212    #[command(long_about = crate::cli::help::md(crate::cli::help::WORKLOG_ADD))]
213    Add {
214        key: String,
215        /// How long: 1h30m, 45m, 2d, or an ISO 8601 duration.
216        duration: String,
217        /// What the time went on.
218        #[arg(long, short = 'm')]
219        comment: Option<String>,
220        /// When the work started, as a date or a timestamp. Defaults to now.
221        #[arg(long)]
222        start: Option<String>,
223    },
224    /// Correct an entry that is already recorded.
225    #[command(long_about = crate::cli::help::md(crate::cli::help::WORKLOG_EDIT))]
226    Edit {
227        key: String,
228        /// Worklog id, from `issue worklogs`.
229        id: String,
230        /// The corrected duration.
231        #[arg(long, short = 'd')]
232        duration: Option<String>,
233        /// The corrected comment.
234        #[arg(long, short = 'm')]
235        comment: Option<String>,
236    },
237    /// Remove one worklog entry.
238    #[command(long_about = crate::cli::help::md(crate::cli::help::WORKLOG_DELETE))]
239    Delete { key: String, id: String },
240}
241
242/// Editing comments. Reading them is `issue comments`.
243#[derive(Debug, Subcommand)]
244pub enum CommentCommand {
245    /// Add a comment.
246    #[command(long_about = crate::cli::help::md(crate::cli::help::ISSUE_COMMENT))]
247    Add {
248        key: String,
249        /// Comment body; `-` reads from stdin.
250        text: String,
251    },
252    /// Replace the text of a comment.
253    #[command(long_about = crate::cli::help::md(crate::cli::help::COMMENT_EDIT))]
254    Edit {
255        key: String,
256        /// Comment id, from `issue comments`.
257        id: String,
258        /// The new body in full; `-` reads from stdin.
259        text: String,
260    },
261    /// Remove a comment.
262    #[command(long_about = crate::cli::help::md(crate::cli::help::COMMENT_DELETE))]
263    Delete { key: String, id: String },
264}
265
266/// Writing to a checklist. Reading it is `issue checklist`.
267#[derive(Debug, Subcommand)]
268pub enum CheckCommand {
269    /// Add a line to the checklist.
270    #[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        /// Deadline, as `2026-09-01`.
277        #[arg(long)]
278        deadline: Option<String>,
279    },
280    /// Tick a line off.
281    #[command(long_about = crate::cli::help::md(crate::cli::help::CHECK_TICK))]
282    Tick { key: String, id: String },
283    /// Put a ticked line back.
284    #[command(long_about = crate::cli::help::md(crate::cli::help::CHECK_UNTICK))]
285    Untick { key: String, id: String },
286    /// Remove a line.
287    #[command(long_about = crate::cli::help::md(crate::cli::help::CHECK_DELETE))]
288    Delete { key: String, id: String },
289}
290
291/// Writing links. Reading them is `issue links`.
292#[derive(Debug, Subcommand)]
293pub enum LinkCommand {
294    /// Link two issues.
295    #[command(long_about = crate::cli::help::md(crate::cli::help::LINK_ADD))]
296    Add {
297        key: String,
298        /// Relationship from this issue to the other: relates, depends,
299        /// is-dependent-by, subtask, parent, duplicates, is-duplicated-by,
300        /// epic, has-epic.
301        relation: String,
302        /// The other issue.
303        other: String,
304    },
305    /// Remove a link, by the link id `issue links` prints.
306    #[command(long_about = crate::cli::help::md(crate::cli::help::LINK_DELETE))]
307    Delete { key: String, id: String },
308}
309
310/// Search arguments shared by `find` and `count`.
311#[derive(Debug, Args, Clone)]
312pub struct FindArgs {
313    #[arg(long, short = 'q')]
314    pub queue: Option<String>,
315    /// Login, or `me`.
316    #[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    /// Raw Yandex Query Language filter. Read-only, like every other search.
323    ///
324    /// Conflicts with the flag filters on purpose: combining them would have to
325    /// either silently drop half of what was asked for or invent an AND the
326    /// caller did not write.
327    #[arg(long, conflicts_with_all = ["queue", "assignee", "status", "tags"])]
328    pub yql: Option<String>,
329    /// Rows per page.
330    #[arg(long)]
331    pub limit: Option<usize>,
332    /// 1-based page number.
333    #[arg(long, default_value_t = 1)]
334    pub page: u32,
335    /// Walk every page, up to --max.
336    #[arg(long)]
337    pub all: bool,
338    /// Hard ceiling for --all; refuses rather than silently truncating.
339    #[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            // clap cannot require two positionals that a subcommand replaces,
405            // so the bare form is checked here rather than in the parser.
406            (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
438/// Read an issue's worklog.
439async 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
460/// Read an issue's checklist.
461async 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            // Tracker requires a start; "now" is what somebody logging time at
503            // the end of the work means, and it is what they would type.
504            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
554/// Correct an entry that is already recorded.
555async fn worklog_edit(
556    key: &str,
557    id: &str,
558    duration: Option<&str>,
559    comment: Option<&str>,
560    session: &Session,
561) -> ExitCode {
562    // Nothing to change is a mistake worth catching before a request,
563    // like an update that sets no field.
564    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
616/// Adding, rewriting and removing comments.
617async 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
753/// The four link *type* ids that are not link *relationships*.
754///
755/// `GET /v3/linktypes` answers with `depends`, `subtask`, `epic` and the rest;
756/// a write takes a directional phrase — `depends on`, `is subtask for` — and
757/// Tracker refuses anything else with `Unrecognized value`. The two lists were
758/// confused in this tool's own help until a live check caught it, which is
759/// evidence enough that the confusion is easy.
760///
761/// Only these four are refused here. Tracker tolerates hyphens for the rest,
762/// and `cloners` is a real type in an organisation checked against, so a closed
763/// list of accepted values here would block a write that would have worked.
764fn 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            // Four names look right and are not, and they are wrong in a way
786            // Tracker's own refusal does not help with: it names the value it
787            // did not recognise and never what it wanted instead.
788            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
835/// The shape every deletion here shares: announce, gate, delete, say so.
836///
837/// Tracker has no undelete for any of these, and none of them is reported by
838/// anything else afterwards, so the line printed at the end is the only record
839/// the caller gets.
840async 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
885/// Now, in the form Tracker takes for a worklog start.
886fn now_for_tracker() -> String {
887    jiff::Zoned::now()
888        .strftime("%Y-%m-%dT%H:%M:%S%.3f%z")
889        .to_string()
890}
891
892/// Fetch one issue and render it at whichever rung of the ladder was asked for.
893async 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    // Links live on their own endpoint. A failure to fetch them must not hide
909    // the issue itself: the caller asked for the issue, and a missing links
910    // section is a smaller loss than no output at all.
911    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    // Pictures are fetched before the issue is rendered, because the ones the
919    // description points at are drawn where it points at them.
920    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            // Whatever the description did not point at is still worth seeing,
939            // and it goes after the issue because nothing said where it belongs.
940            draw_remaining_images(&client, key, &drawn, &ctx).await;
941            ExitCode::Success
942        }
943        Err(error) => report(&error, ExitCode::Failure),
944    }
945}
946
947/// How many unreferenced images are drawn after an issue before it stops and
948/// names the rest.
949///
950/// A screenshot is worth the space; six of them are a wall between the reader
951/// and the next command. The rest are one `attachment show` away.
952const IMAGES_SHOWN: usize = 4;
953
954/// Whether this command may draw at all, and how wide.
955///
956/// Nothing is drawn for a pipe, an agent, a `--format` other than text, a
957/// terminal without a graphics protocol, or `--no-images`. In every one of
958/// those cases the attachments are not requested either, so the cheap path
959/// costs exactly what it did before.
960fn 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
967/// Fetch and draw the pictures the description points at.
968///
969/// Returns them keyed by the URL as written, plus the ids that were used, so
970/// the caller knows which attachments have already been shown.
971///
972/// Only attachments are drawn. A description can name any URL it likes, and
973/// following one would turn reading an issue into fetching whatever an issue's
974/// author decided this tool should fetch — with the client's own credentials,
975/// at that. The reference has to resolve to a file already attached to this
976/// issue, or it stays the markdown it was.
977async 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    // Two columns for the margin bar the quoted block puts on every line.
1006    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        // The caption names the file, because the alt text is what the author
1019        // said and the filename is what `attachment show` and `download` take.
1020        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
1031/// The attachment a markdown image URL refers to.
1032///
1033/// Tracker writes these as `/ajax/v2/attachments/29?inline=true`, so the id is
1034/// the last path segment; a description written by hand may name the file
1035/// instead. Both are matched against what is actually attached to this issue,
1036/// and nothing else is followed.
1037fn 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
1049/// Download one attachment and turn it into a picture, if it is one.
1050async 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    // The declared type is a claim; the bytes are the fact. Drawing on the
1066    // claim alone would hand a terminal whatever a file said it was.
1067    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
1078/// Draw the image attachments the description never mentioned.
1079///
1080/// Failures are logged and swallowed. The caller asked for an issue and already
1081/// has it; losing a picture is a smaller loss than replacing the issue with an
1082/// error about one.
1083async 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    // The declared type decides what is worth downloading; the bytes decide
1102    // what is actually drawn. Trusting the declaration alone would fetch a
1103    // 40 MB video that claimed to be a PNG.
1104    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
1130/// Turn the search flags into a query, falling back to the profile's queue.
1131///
1132/// A search with nothing to narrow it would ask Tracker for every issue in the
1133/// organisation, so the pinned queue steps in — that is what pinning is for.
1134fn 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
1190/// Walk every page up to the ceiling.
1191///
1192/// Refusing past `--max` rather than truncating is the whole point: a silently
1193/// short answer reads exactly like a complete one.
1194async 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        // The upstream payload for a search is an array of issues; raw and
1258        // normalised differ only in field names, so raw maps onto our schema.
1259        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
1272/// The cheapest question in the tool: one number, no issue bodies.
1273async 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
1364/// What changed, and who changed it.
1365async 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
1398/// Show what is being timed on this machine.
1399///
1400/// A read, and a read of a local file: no request is made, which is why it is
1401/// spelled `timers` and not `timer status`.
1402fn 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
1424/// Where the timers are kept, and what is in them.
1425///
1426/// The key is routed first even though nothing is sent: `42` has to become
1427/// `PROJ-42` and `work/PROJ-1` has to name its organisation, or the timer is
1428/// filed under something the caller will never say again.
1429async 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    // The timer is forgotten only once Tracker has the worklog. The other order
1506    // loses the time it was keeping, which is the one thing this must not do.
1507    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    // How long it had been running, because dropping a number silently is how
1551    // somebody discovers afterwards that they lost an afternoon.
1552    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
1563/// Why there is no timer to stop, in the words that help.
1564///
1565/// "No timer running" is true and useless when it is running in the other
1566/// organisation, which is exactly the case somebody hits after switching
1567/// profiles.
1568fn 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
1578/// Read a body that may have been piped in: `-` means stdin.
1579///
1580/// Agents produce long text; making them quote it into an argument invites
1581/// mangling, and a shell argument is visible in `ps` besides.
1582fn 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
1594/// The description a write should carry, from whichever way it was given.
1595///
1596/// A description is the field most likely to hold quotes, newlines and
1597/// markdown, and the one people most often already have in a file. Both ways at
1598/// once is an error rather than a precedence rule: guessing which one was meant
1599/// is how the wrong text gets written.
1600fn 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/// Apply the same change to every named issue.
1681///
1682/// The body is built once, because the point of naming several issues is that
1683/// they get the same change; a per-issue variation would be several commands.
1684/// Targets are resolved before anything is sent, so a typo in the third key does
1685/// not leave the first two changed and the caller guessing.
1686/// What an `issue update` was asked to change, in the words it was asked in.
1687///
1688/// One struct rather than six arguments: they are one thought — the change
1689/// itself, which is built once and applied to every key.
1690#[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    // A bulk change is one request to one organisation. Keys resolve per
1748    // profile, so a list can straddle two of them, and that list has to go the
1749    // slow way round.
1750    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    // What --dry-run prints has to be what would actually be sent, and the two
1758    // paths do not send the same shape.
1759    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    // One issue, or several that no single request can cover. Stopping at the
1782    // first failure: a run that carried on would leave the caller to work out
1783    // which issues it got to before it stopped.
1784    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                // The tally first: how far it got is the part that decides what
1797                // the caller has to do next.
1798                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
1820/// How long to wait for Tracker to finish a bulk change before saying so.
1821///
1822/// Long enough that an ordinary change is simply done when the command returns,
1823/// and short enough that a caller is not held indefinitely by work that is
1824/// Tracker's to finish either way. Past it the id is the answer.
1825const BULK_WAIT: std::time::Duration = std::time::Duration::from_secs(60);
1826
1827/// Every issue in one request, and the answer polled until it is one.
1828///
1829/// Tracker validates the whole list before it writes anything: an unknown key
1830/// is a refusal naming it, rather than half the change applied and an error.
1831async 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
1849/// Poll a bulk change to its end, and report it.
1850///
1851/// Shared by every command that can start one: what a caller is owed after
1852/// `_update`, `_transition` and `_move` is the same tally and the same id.
1853async 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        // Accepted, which is what was asked for and all that is being claimed.
1861        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
1891/// Report a bulk change that Tracker has finished with.
1892///
1893/// The per-issue listing costs a request, so it is only asked for when the
1894/// counts leave something unexplained. A change where everything worked is one
1895/// line, which is the whole point.
1896async 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            // The change itself was reported; failing to explain it further is
1911            // not a reason to lose the part that was already answered.
1912            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}
1954/// Resolve every target, and say whether one request could cover them all.
1955///
1956/// A bulk change is one request to one organisation, so a list that straddles
1957/// two profiles has to go the slow way round whatever the endpoint offers.
1958async 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
1978/// With no transition named, list what is available.
1979///
1980/// That is the common case for a caller who does not know the workflow, and it
1981/// is a read: listing must not require the write gate.
1982async 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
2004/// Move issues through one workflow transition.
2005///
2006/// Unlike a move, this is reversible in kind — a workflow that got somewhere
2007/// can usually get back — so a list of keys is gated the ordinary way: `--yes`
2008/// for more than one, nothing for a single issue.
2009async 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    // One command, two shapes. `transition PROJ-1 close` reads naturally and is
2018    // what the documentation has always shown; a list of keys leaves no
2019    // unambiguous place for the transition, so it is named with --to.
2020    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    // A transition can require fields, and a workflow that closes an issue
2040    // almost always requires a resolution. Without these the command could not
2041    // reach half the statuses in an ordinary queue.
2042    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    // What --dry-run prints has to be what would actually be sent, and the two
2064    // paths do not send the same shape.
2065    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                // Two different failures, two different hints: Tracker either
2104                // wanted fields it did not get, or does not have this
2105                // transition at all from where the issue stands.
2106                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
2125/// One workflow step for every issue in the list, in one request.
2126async 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    // The same second chance the single-issue path gets, resolved once against
2136    // the first key: every issue in a bulk change takes the same step, so there
2137    // is one id to find.
2138    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
2154/// Perform a transition, taking either a transition id or a target status.
2155///
2156/// Transition ids are defined per workflow — `close`, `closed` and
2157/// `close_issue` are all real — while a status key is the same everywhere and
2158/// is what a caller reading `status_key` already has. The direct attempt is
2159/// made first, so the ordinary path is still one request; only after it fails
2160/// is Tracker asked what this issue's workflow offers.
2161///
2162/// Returns the id that actually worked, which is what gets printed: a caller
2163/// who asked by status should be told the id, so the next call can skip the
2164/// second request.
2165async 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
2183/// The id of the transition a caller meant, when what they gave was not one.
2184///
2185/// `None` when nothing matches, and also when the match is the string that was
2186/// already tried: repeating a request that just failed would turn one refusal
2187/// into two and change nothing.
2188async 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
2202/// Report a transition Tracker would not take, and say where the ids come from.
2203///
2204/// The list is a request away and the command that prints it is one word, so
2205/// the hint names it rather than spending a request to inline it into an error.
2206fn 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
2218/// Report a refused transition, and say how to supply what it wanted.
2219///
2220/// Tracker names the fields it wanted, in the organisation's own language and by
2221/// their display names — which are not what `--set` takes. Its sentence is
2222/// passed through as written, and what is added after it is the part it cannot
2223/// know: how to supply them here.
2224fn 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
2244/// Send issues to another queue.
2245///
2246/// Gated with `always_confirm` rather than the ordinary write gate: the key
2247/// changes, every reference to the old one is left pointing at a redirect, and
2248/// no request puts it back. That is irreversible in kind, like claiming a queue
2249/// key, not merely at scale — so a single issue asks for `--yes` too, and a list
2250/// of them asks once for all of them after printing every key it will change.
2251async 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                // The new key is the whole result: nothing else the caller holds
2309                // still addresses this issue.
2310                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    /// The form Tracker actually writes into a description.
2354    #[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        // A description written by hand names the file instead.
2367        assert_eq!(
2368            attachment_for(&attachments, "screenshot.png").map(|a| &a.id),
2369            Some(&"29".to_owned())
2370        );
2371    }
2372
2373    /// A description can name any URL its author likes. Following one would
2374    /// turn reading an issue into fetching whatever that author chose — with
2375    /// this client's own credentials attached. Only files already on the issue
2376    /// are drawn; everything else stays the markdown it was.
2377    #[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}