Skip to main content

ytcli/api/
mod.rs

1//! HTTP layer against the Tracker REST API.
2//!
3//! We talk to the API directly instead of using the official Python-era client:
4//! see `docs/adr/0004-own-http-client.md`.
5
6pub mod duration;
7pub mod error;
8pub mod models;
9pub mod parse;
10pub mod query;
11
12use std::time::Duration;
13
14use backon::{ExponentialBuilder, Retryable};
15use reqwest::header::{ACCEPT, AUTHORIZATION, HeaderMap, HeaderName, HeaderValue, USER_AGENT};
16
17use serde_json::Value;
18
19use crate::api::error::ApiError;
20use crate::api::models::{
21    Attachment, Change, ChecklistItem, Comment, DictEntry, Entity, Issue, Link, Page, Person,
22    RemoteLink, User, Worklog,
23};
24use crate::config::OrgKind;
25
26/// Default API root. Overridable so tests can point at a `wiremock` server.
27pub const DEFAULT_BASE_URL: &str = "https://api.tracker.yandex.net";
28
29/// Entity fields we ask for. Requesting an explicit set keeps the response small
30/// and its shape predictable; the endpoints return only identity otherwise.
31/// `entityType` is deliberately absent: it is an attribute of the entity, not
32/// one of its fields, and asking for it makes Tracker refuse the whole request
33/// with `поля [entityType] не существуют`. It comes back regardless.
34const ENTITY_FIELDS: &str = "summary,description,entityStatus,start,end,lead,author,parentEntity";
35
36/// The host part of a URL, for comparing two of them.
37fn host_of(url: &str) -> Option<String> {
38    let without_scheme = url.split_once("://")?.1;
39    let authority = without_scheme
40        .split(['/', '?', '#'])
41        .next()
42        .unwrap_or(without_scheme);
43    Some(authority.to_ascii_lowercase())
44}
45
46/// Everything the client needs to address one organisation as one account.
47#[derive(Debug, Clone)]
48pub struct ClientConfig {
49    pub base_url: String,
50    pub token: String,
51    pub org_id: String,
52    pub org_kind: OrgKind,
53    pub timeout: Duration,
54    /// Retry attempts for transport errors, 429 and 5xx. Client errors are never retried.
55    pub retries: usize,
56}
57
58impl ClientConfig {
59    #[must_use]
60    pub fn new(token: String, org_id: String, org_kind: OrgKind) -> Self {
61        Self {
62            base_url: DEFAULT_BASE_URL.to_owned(),
63            token,
64            org_id,
65            org_kind,
66            timeout: Duration::from_secs(30),
67            retries: 3,
68        }
69    }
70}
71
72/// A configured Tracker client.
73#[derive(Debug, Clone)]
74pub struct Client {
75    http: reqwest::Client,
76    base_url: String,
77    retries: usize,
78    /// Which organisation this client talks to.
79    ///
80    /// The headers carry it already, but nothing can read them back, and one
81    /// command can hold two clients: keys resolve per profile, and two profiles
82    /// can be two organisations. A bulk change is one request to one of them.
83    org: String,
84}
85
86impl Client {
87    pub fn new(config: &ClientConfig) -> Result<Self, ApiError> {
88        let mut headers = HeaderMap::new();
89        headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
90        headers.insert(
91            USER_AGENT,
92            HeaderValue::from_static(concat!("ytcli/", env!("CARGO_PKG_VERSION"))),
93        );
94
95        // A malformed token or org id must fail here, not as a confusing 401 later.
96        let mut auth = HeaderValue::try_from(format!("OAuth {}", config.token))
97            .map_err(|_| ApiError::Unauthorized)?;
98        auth.set_sensitive(true);
99        headers.insert(AUTHORIZATION, auth);
100
101        let org_header = HeaderName::from_static(config.org_kind.header_name());
102        let org_value =
103            HeaderValue::try_from(config.org_id.clone()).map_err(|_| ApiError::Forbidden)?;
104        headers.insert(org_header, org_value);
105
106        let http = reqwest::Client::builder()
107            .timeout(config.timeout)
108            .default_headers(headers)
109            .build()?;
110
111        Ok(Self {
112            http,
113            base_url: config.base_url.trim_end_matches('/').to_owned(),
114            retries: config.retries,
115            org: config.org_id.clone(),
116        })
117    }
118
119    /// The organisation id this client was built for.
120    #[must_use]
121    pub fn org(&self) -> &str {
122        &self.org
123    }
124
125    /// `GET /v3/myself` — the cheapest call that proves the whole chain works:
126    /// token, organisation header, and network.
127    pub async fn myself(&self) -> Result<User, ApiError> {
128        let value = self.get_value("/v3/myself", "current user").await?;
129        Ok(User {
130            id: value
131                .get("uid")
132                .map_or_else(String::new, ToString::to_string),
133            login: value
134                .get("login")
135                .and_then(serde_json::Value::as_str)
136                .map(ToOwned::to_owned),
137            display: value
138                .get("display")
139                .and_then(serde_json::Value::as_str)
140                .map(ToOwned::to_owned),
141        })
142    }
143
144    /// One issue, both normalised and raw.
145    ///
146    /// The raw payload travels alongside so that `--json-raw` does not cost a
147    /// second request, and so that a field we do not model is still reachable.
148    pub async fn issue(&self, key: &str) -> Result<(Issue, Value), ApiError> {
149        let raw = self
150            .get_value(&format!("/v3/issues/{key}"), &format!("issue {key}"))
151            .await?;
152        let issue = parse::issue(&raw).ok_or_else(|| ApiError::NotFound(format!("issue {key}")))?;
153        Ok((issue, raw))
154    }
155
156    /// The links of an issue, with their direction resolved.
157    ///
158    /// Tracker keeps links on their own endpoint, so the compact issue view
159    /// costs two requests. Showing links is worth that: "what blocks this" is
160    /// the question that follows "what is this", and making the caller ask twice
161    /// costs more than one round trip (ADR 3).
162    pub async fn issue_links(&self, key: &str) -> Result<Vec<Link>, ApiError> {
163        let raw = self
164            .get_value(
165                &format!("/v3/issues/{key}/links"),
166                &format!("issue {key} links"),
167            )
168            .await?;
169
170        Ok(raw
171            .as_array()
172            .map(|entries| entries.iter().filter_map(parse::link).collect())
173            .unwrap_or_default())
174    }
175
176    /// `GET /v3/issues/{key}/remotelinks` — the links that leave Tracker.
177    ///
178    /// Its own request rather than a second section of [`Self::issue_links`]:
179    /// most issues have none, and making every `issue links` pay for a request
180    /// that usually answers `[]` is the wrong trade.
181    pub async fn issue_remote_links(&self, key: &str) -> Result<Vec<RemoteLink>, ApiError> {
182        let raw = self
183            .get_value(
184                &format!("/v3/issues/{key}/remotelinks"),
185                &format!("remote links of {key}"),
186            )
187            .await?;
188
189        Ok(raw
190            .as_array()
191            .map(|entries| entries.iter().filter_map(parse::remote_link).collect())
192            .unwrap_or_default())
193    }
194
195    /// One page of search results.
196    ///
197    /// Tracker reports the total in `X-Total-Count`. When it does not, the page
198    /// still has to be honest about whether more exists, which is why
199    /// [`Page::has_more`] falls back to "a full page probably is not the last".
200    pub async fn search(
201        &self,
202        query: &str,
203        page: u32,
204        per_page: u32,
205    ) -> Result<Page<Issue>, ApiError> {
206        let path = format!("/v3/issues/_search?page={page}&perPage={per_page}");
207        let body = serde_json::json!({ "query": query });
208        let (value, headers) = self.post_value(&path, &body, "issues").await?;
209
210        let items = value
211            .as_array()
212            .map(|entries| entries.iter().filter_map(parse::issue).collect())
213            .unwrap_or_default();
214
215        Ok(Page {
216            items,
217            page,
218            per_page,
219            total: headers
220                .get("x-total-count")
221                .and_then(|count| count.to_str().ok())
222                .and_then(|count| count.parse().ok()),
223        })
224    }
225
226    /// How many issues match, without fetching any of them.
227    pub async fn count(&self, query: &str) -> Result<u64, ApiError> {
228        let body = serde_json::json!({ "query": query });
229        let (value, _) = self
230            .post_value("/v3/issues/_count", &body, "issues")
231            .await?;
232
233        value
234            .as_u64()
235            .ok_or_else(|| ApiError::NotFound("issue count".to_owned()))
236    }
237
238    /// `POST /v3/issues/` — create an issue, returning it normalised.
239    pub async fn create_issue(&self, body: &Value) -> Result<Issue, ApiError> {
240        let (value, _) = self.post_value("/v3/issues/", body, "issue").await?;
241        parse::issue(&value).ok_or_else(|| ApiError::NotFound("created issue".to_owned()))
242    }
243
244    /// `PATCH /v3/issues/{key}` — change fields.
245    pub async fn update_issue(&self, key: &str, body: &Value) -> Result<Issue, ApiError> {
246        let value = self
247            .send_value(
248                reqwest::Method::PATCH,
249                &format!("/v3/issues/{key}"),
250                Some(body),
251                &format!("issue {key}"),
252            )
253            .await?
254            .0;
255        parse::issue(&value).ok_or_else(|| ApiError::NotFound(format!("issue {key}")))
256    }
257
258    /// `POST /v3/issues/{key}/comments` — add a comment.
259    pub async fn add_comment(&self, key: &str, text: &str) -> Result<Comment, ApiError> {
260        let body = serde_json::json!({ "text": text });
261        let (value, _) = self
262            .post_value(
263                &format!("/v3/issues/{key}/comments"),
264                &body,
265                &format!("issue {key}"),
266            )
267            .await?;
268        parse::comment(&value).ok_or_else(|| ApiError::NotFound("created comment".to_owned()))
269    }
270
271    /// Rewrite a comment that is already there.
272    ///
273    /// Tracker keeps no history of the previous text and shows the comment as
274    /// edited, so this replaces rather than appends: the old wording is gone.
275    pub async fn update_comment(
276        &self,
277        key: &str,
278        id: &str,
279        text: &str,
280    ) -> Result<Comment, ApiError> {
281        let body = serde_json::json!({ "text": text });
282        let (value, _) = self
283            .send_value(
284                reqwest::Method::PATCH,
285                &format!("/v3/issues/{key}/comments/{id}"),
286                Some(&body),
287                &format!("comment {id} of issue {key}"),
288            )
289            .await?;
290        parse::comment(&value).ok_or_else(|| ApiError::NotFound(format!("comment {id}")))
291    }
292
293    /// Remove a comment.
294    pub async fn delete_comment(&self, key: &str, id: &str) -> Result<(), ApiError> {
295        self.send_value(
296            reqwest::Method::DELETE,
297            &format!("/v3/issues/{key}/comments/{id}"),
298            None,
299            &format!("comment {id} of issue {key}"),
300        )
301        .await?;
302        Ok(())
303    }
304
305    /// Correct a worklog entry that is already recorded.
306    pub async fn update_worklog(
307        &self,
308        key: &str,
309        id: &str,
310        body: &Value,
311    ) -> Result<Worklog, ApiError> {
312        let (value, _) = self
313            .send_value(
314                reqwest::Method::PATCH,
315                &format!("/v3/issues/{key}/worklog/{id}"),
316                Some(body),
317                &format!("worklog {id} of issue {key}"),
318            )
319            .await?;
320        parse::worklog(&value).ok_or_else(|| ApiError::NotFound(format!("worklog {id}")))
321    }
322
323    /// `GET /v3/issues/{key}/worklog` — every entry, oldest first.
324    pub async fn worklogs(&self, key: &str) -> Result<Vec<Worklog>, ApiError> {
325        let raw = self
326            .get_value(
327                &format!("/v3/issues/{key}/worklog"),
328                &format!("issue {key} worklog"),
329            )
330            .await?;
331
332        Ok(raw
333            .as_array()
334            .map(|entries| entries.iter().filter_map(parse::worklog).collect())
335            .unwrap_or_default())
336    }
337
338    /// `POST /v3/issues/{key}/worklog` — record time spent.
339    pub async fn add_worklog(&self, key: &str, body: &Value) -> Result<Worklog, ApiError> {
340        let (value, _) = self
341            .post_value(
342                &format!("/v3/issues/{key}/worklog"),
343                body,
344                &format!("issue {key} worklog"),
345            )
346            .await?;
347        parse::worklog(&value).ok_or_else(|| ApiError::NotFound("created worklog".to_owned()))
348    }
349
350    /// `DELETE /v3/issues/{key}/worklog/{id}`.
351    pub async fn delete_worklog(&self, key: &str, id: &str) -> Result<(), ApiError> {
352        self.send_value(
353            reqwest::Method::DELETE,
354            &format!("/v3/issues/{key}/worklog/{id}"),
355            None,
356            &format!("worklog {id} of issue {key}"),
357        )
358        .await?;
359        Ok(())
360    }
361
362    /// `GET /v3/issues/{key}/checklistItems`.
363    pub async fn checklist(&self, key: &str) -> Result<Vec<ChecklistItem>, ApiError> {
364        let raw = self
365            .get_value(
366                &format!("/v3/issues/{key}/checklistItems"),
367                &format!("issue {key} checklist"),
368            )
369            .await?;
370
371        Ok(raw
372            .as_array()
373            .map(|entries| entries.iter().filter_map(parse::checklist_item).collect())
374            .unwrap_or_default())
375    }
376
377    /// `POST /v3/issues/{key}/checklistItems` — add a line.
378    ///
379    /// Tracker answers with the whole issue rather than the item, so the list
380    /// comes back out of the issue's own `checklistItems`.
381    pub async fn add_checklist_item(
382        &self,
383        key: &str,
384        body: &Value,
385    ) -> Result<Vec<ChecklistItem>, ApiError> {
386        let (value, _) = self
387            .post_value(
388                &format!("/v3/issues/{key}/checklistItems"),
389                body,
390                &format!("issue {key} checklist"),
391            )
392            .await?;
393        Ok(checklist_of(&value))
394    }
395
396    /// `PATCH /v3/issues/{key}/checklistItems/{id}` — tick, untick or reword.
397    pub async fn update_checklist_item(
398        &self,
399        key: &str,
400        id: &str,
401        body: &Value,
402    ) -> Result<Vec<ChecklistItem>, ApiError> {
403        let (value, _) = self
404            .send_value(
405                reqwest::Method::PATCH,
406                &format!("/v3/issues/{key}/checklistItems/{id}"),
407                Some(body),
408                &format!("checklist item {id} of issue {key}"),
409            )
410            .await?;
411        Ok(checklist_of(&value))
412    }
413
414    /// `DELETE /v3/issues/{key}/checklistItems/{id}`.
415    pub async fn delete_checklist_item(&self, key: &str, id: &str) -> Result<(), ApiError> {
416        self.send_value(
417            reqwest::Method::DELETE,
418            &format!("/v3/issues/{key}/checklistItems/{id}"),
419            None,
420            &format!("checklist item {id} of issue {key}"),
421        )
422        .await?;
423        Ok(())
424    }
425
426    /// `POST /v3/issues/{key}/links` — link two issues.
427    pub async fn add_link(
428        &self,
429        key: &str,
430        relationship: &str,
431        other: &str,
432    ) -> Result<(), ApiError> {
433        let body = serde_json::json!({ "relationship": relationship, "issue": other });
434        self.post_value(
435            &format!("/v3/issues/{key}/links"),
436            &body,
437            &format!("issue {key} links"),
438        )
439        .await?;
440        Ok(())
441    }
442
443    /// `DELETE /v3/issues/{key}/links/{id}`.
444    pub async fn delete_link(&self, key: &str, id: &str) -> Result<(), ApiError> {
445        self.send_value(
446            reqwest::Method::DELETE,
447            &format!("/v3/issues/{key}/links/{id}"),
448            None,
449            &format!("link {id} of issue {key}"),
450        )
451        .await?;
452        Ok(())
453    }
454
455    /// `DELETE /v3/issues/{key}/attachments/{id}` — remove an attachment.
456    ///
457    /// Tracker keeps no copy: the file is gone, and the comment or description
458    /// that pointed at it is left pointing at nothing.
459    pub async fn delete_attachment(&self, key: &str, id: &str) -> Result<(), ApiError> {
460        self.send_value(
461            reqwest::Method::DELETE,
462            &format!("/v3/issues/{key}/attachments/{id}"),
463            None,
464            &format!("attachment {id} of issue {key}"),
465        )
466        .await?;
467        Ok(())
468    }
469
470    /// Transitions available from the issue's current status.
471    pub async fn transitions(&self, key: &str) -> Result<Vec<Transition>, ApiError> {
472        let raw = self
473            .get_value(
474                &format!("/v3/issues/{key}/transitions"),
475                &format!("issue {key} transitions"),
476            )
477            .await?;
478
479        Ok(raw
480            .as_array()
481            .map(|entries| entries.iter().filter_map(Transition::parse).collect())
482            .unwrap_or_default())
483    }
484
485    /// Perform a transition.
486    pub async fn execute_transition(
487        &self,
488        key: &str,
489        transition: &str,
490        body: &Value,
491    ) -> Result<(), ApiError> {
492        self.post_value(
493            &format!("/v3/issues/{key}/transitions/{transition}/_execute"),
494            body,
495            &format!("transition {transition} of issue {key}"),
496        )
497        .await?;
498        Ok(())
499    }
500
501    /// Search projects, portfolios or goals.
502    ///
503    /// The entity endpoints answer with their own envelope (`values`, `hits`,
504    /// `pages`) rather than the header-based totals the issue endpoints use, so
505    /// the page is assembled from the body here.
506    pub async fn entities(
507        &self,
508        kind: &str,
509        input: Option<&str>,
510        page: u32,
511        per_page: u32,
512    ) -> Result<Page<Entity>, ApiError> {
513        let path = format!(
514            "/v3/entities/{kind}/_search?page={page}&perPage={per_page}&fields={ENTITY_FIELDS}"
515        );
516        let mut body = serde_json::Map::new();
517        if let Some(input) = input {
518            body.insert("input".to_owned(), Value::String(input.to_owned()));
519        }
520
521        let (value, _) = self
522            .post_value(&path, &Value::Object(body), &format!("{kind}s"))
523            .await?;
524
525        let items = value
526            .get("values")
527            .and_then(Value::as_array)
528            .map(|entries| entries.iter().filter_map(parse::entity).collect())
529            .unwrap_or_default();
530
531        Ok(Page {
532            items,
533            page,
534            per_page,
535            total: value.get("hits").and_then(Value::as_u64),
536        })
537    }
538
539    /// What a portfolio contains: the portfolios and projects under it.
540    ///
541    /// Two requests, because the entity endpoints are typed and containment is
542    /// not: a portfolio holds both. The tally sums the two totals, so `shown N
543    /// of M` is the real count even though a page is a page of each.
544    pub async fn entities_in(
545        &self,
546        parent: &str,
547        page: u32,
548        per_page: u32,
549    ) -> Result<Page<Entity>, ApiError> {
550        let mut items = Vec::new();
551        let mut total = 0;
552
553        for kind in ["portfolio", "project"] {
554            let path = format!(
555                "/v3/entities/{kind}/_search?page={page}&perPage={per_page}&fields={ENTITY_FIELDS}"
556            );
557            let body = serde_json::json!({ "filter": { "parentEntity": parent } });
558            let (value, _) = self
559                .post_value(&path, &body, &format!("{kind}s in {parent}"))
560                .await?;
561
562            if let Some(entries) = value.get("values").and_then(Value::as_array) {
563                items.extend(entries.iter().filter_map(parse::entity));
564            }
565            total += value.get("hits").and_then(Value::as_u64).unwrap_or(0);
566        }
567
568        Ok(Page {
569            items,
570            page,
571            per_page,
572            total: Some(total),
573        })
574    }
575
576    /// One project, portfolio or goal, by the id the entity endpoints use.
577    pub async fn entity(&self, kind: &str, id: &str) -> Result<Entity, ApiError> {
578        let raw = self
579            .get_value(
580                &format!("/v3/entities/{kind}/{id}?fields={ENTITY_FIELDS}"),
581                &format!("{kind} {id}"),
582            )
583            .await?;
584
585        parse::entity(&raw).ok_or_else(|| ApiError::NotFound(format!("{kind} {id}")))
586    }
587
588    /// The attachments of an issue.
589    pub async fn attachments(&self, key: &str) -> Result<Vec<Attachment>, ApiError> {
590        let raw = self
591            .get_value(
592                &format!("/v3/issues/{key}/attachments"),
593                &format!("issue {key} attachments"),
594            )
595            .await?;
596
597        Ok(raw
598            .as_array()
599            .map(|entries| entries.iter().filter_map(parse::attachment).collect())
600            .unwrap_or_default())
601    }
602
603    /// Download an attachment's bytes.
604    ///
605    /// The download URL comes out of the payload, which means it is supplied by
606    /// the server rather than chosen by us. It is checked against the configured
607    /// API host before being followed: a crafted `content` URL must not be able
608    /// to send this client, carrying its OAuth header, to somewhere else.
609    pub async fn download(&self, url: &str) -> Result<Vec<u8>, ApiError> {
610        let expected = host_of(&self.base_url);
611        if host_of(url) != expected {
612            return Err(ApiError::Rejected {
613                status: reqwest::StatusCode::BAD_REQUEST,
614                message: format!(
615                    "attachment points at `{}`, which is not the configured Tracker host `{}`",
616                    host_of(url).unwrap_or_default(),
617                    expected.unwrap_or_default(),
618                ),
619            });
620        }
621
622        let response = self.http.get(url).send().await?;
623        let status = response.status();
624        if !status.is_success() {
625            return Err(match status.as_u16() {
626                401 => ApiError::Unauthorized,
627                403 => ApiError::Forbidden,
628                404 => ApiError::NotFound("attachment".to_owned()),
629                _ => ApiError::Rejected {
630                    status,
631                    message: String::new(),
632                },
633            });
634        }
635
636        Ok(response.bytes().await?.to_vec())
637    }
638
639    /// Upload a file to an issue.
640    pub async fn upload(
641        &self,
642        key: &str,
643        filename: &str,
644        bytes: Vec<u8>,
645    ) -> Result<Attachment, ApiError> {
646        let part = reqwest::multipart::Part::bytes(bytes).file_name(filename.to_owned());
647        let form = reqwest::multipart::Form::new().part("file", part);
648
649        let url = format!("{}/v3/issues/{key}/attachments/", self.base_url);
650        let response = self.http.post(&url).multipart(form).send().await?;
651        let text = classify(response, &format!("issue {key}")).await?;
652
653        let value: Value = serde_json::from_str(&text).map_err(ApiError::Decode)?;
654        parse::attachment(&value)
655            .ok_or_else(|| ApiError::NotFound("uploaded attachment".to_owned()))
656    }
657
658    /// Queues visible to the active profile.
659    ///
660    /// Tracker paginates this endpoint; the ceiling is deliberately generous
661    /// because "how many queues can I see" is a question with a small answer,
662    /// and a second page here would be surprising.
663    pub async fn queues(&self) -> Result<Vec<Queue>, ApiError> {
664        let raw = self.get_value("/v3/queues?perPage=1000", "queues").await?;
665
666        Ok(raw
667            .as_array()
668            .map(|entries| entries.iter().filter_map(Queue::parse).collect())
669            .unwrap_or_default())
670    }
671
672    /// Worklog entries across the whole organisation.
673    ///
674    /// `createdBy` takes a login or a uid and **not** `me`: Tracker reads it as
675    /// a login and answers 422 saying no such user exists. Resolving `me` is
676    /// the caller's job, with one extra request to `myself`.
677    pub async fn worklog_search(
678        &self,
679        who: Option<&str>,
680        since: Option<&str>,
681        until: Option<&str>,
682        per_page: u32,
683    ) -> Result<Vec<Worklog>, ApiError> {
684        use std::fmt::Write as _;
685
686        let mut query = format!("perPage={per_page}");
687        if let Some(who) = who {
688            let _ = write!(query, "&createdBy={who}");
689        }
690        // One parameter carries both ends of the range, and Tracker accepts
691        // either half on its own.
692        match (since, until) {
693            (Some(since), Some(until)) => {
694                let _ = write!(query, "&createdAt=from:{since},to:{until}");
695            }
696            (Some(since), None) => {
697                let _ = write!(query, "&createdAt=from:{since}");
698            }
699            (None, Some(until)) => {
700                let _ = write!(query, "&createdAt=to:{until}");
701            }
702            (None, None) => {}
703        }
704
705        let raw = self
706            .get_value(&format!("/v3/worklog?{query}"), "worklog")
707            .await?;
708
709        Ok(raw
710            .as_array()
711            .map(|entries| entries.iter().filter_map(parse::worklog).collect())
712            .unwrap_or_default())
713    }
714
715    /// Move an issue to another queue.
716    ///
717    /// The issue keeps its identity and loses its name: `PROJ-42` becomes
718    /// `OTHER-17`, and there is no request that undoes it. Tracker drops fields
719    /// the target queue does not define unless `moveAllFields` says otherwise,
720    /// so that choice is the caller's rather than a default we picked for them.
721    pub async fn move_issue(
722        &self,
723        key: &str,
724        queue: &str,
725        keep_fields: bool,
726        initial_status: bool,
727    ) -> Result<Issue, ApiError> {
728        let path = format!(
729            "/v3/issues/{key}/_move?queue={queue}&moveAllFields={keep_fields}&initialStatus={initial_status}"
730        );
731        let (raw, _) = self
732            .send_value(
733                reqwest::Method::POST,
734                &path,
735                Some(&serde_json::json!({})),
736                &format!("move {key} to {queue}"),
737            )
738            .await?;
739
740        parse::issue(&raw).ok_or_else(|| ApiError::NotFound(format!("issue {key} after the move")))
741    }
742
743    /// What changed on an issue, newest last.
744    ///
745    /// Tracker pages this with an opaque cursor rather than page numbers, and
746    /// the cursor is only worth spending when somebody asks for more than the
747    /// first page — which nobody has yet. So this asks for one page, and the
748    /// caller says how big.
749    pub async fn changelog(&self, key: &str, per_page: u32) -> Result<Vec<Change>, ApiError> {
750        let raw = self
751            .get_value(
752                &format!("/v3/issues/{key}/changelog?perPage={per_page}"),
753                &format!("changelog of {key}"),
754            )
755            .await?;
756
757        Ok(raw
758            .as_array()
759            .map(|entries| entries.iter().filter_map(parse::change).collect())
760            .unwrap_or_default())
761    }
762
763    /// The versions a queue defines.
764    ///
765    /// This is what an issue's `fixVersions` refers to; without it that field
766    /// is an id with no meaning.
767    pub async fn queue_versions(&self, key: &str) -> Result<Vec<Version>, ApiError> {
768        let raw = self
769            .get_value(
770                &format!("/v3/queues/{key}/versions"),
771                &format!("versions of queue {key}"),
772            )
773            .await?;
774
775        Ok(raw
776            .as_array()
777            .map(|entries| entries.iter().filter_map(Version::parse).collect())
778            .unwrap_or_default())
779    }
780
781    /// The tags in use in a queue.
782    pub async fn queue_tags(&self, key: &str) -> Result<Vec<String>, ApiError> {
783        let raw = self
784            .get_value(
785                &format!("/v3/queues/{key}/tags?perPage=1000"),
786                &format!("tags of queue {key}"),
787            )
788            .await?;
789
790        // Both shapes are accepted because the organisation this was written
791        // against has no tags to answer with, and a listing that silently drops
792        // every row is worse than one that reads a member it did not need.
793        Ok(raw
794            .as_array()
795            .map(|entries| {
796                entries
797                    .iter()
798                    .filter_map(|entry| match entry {
799                        Value::String(name) => Some(name.clone()),
800                        other => other
801                            .get("name")
802                            .and_then(Value::as_str)
803                            .map(ToOwned::to_owned),
804                    })
805                    .collect()
806            })
807            .unwrap_or_default())
808    }
809
810    /// Everything that changes issues in a queue on its own.
811    ///
812    /// Three requests, and a refusal of one of them is an answer rather than a
813    /// failure: triggers need queue-owner rights, so a member of the queue gets
814    /// two sections and Tracker's own words about the third. All three failing
815    /// is a different thing — a queue that is not there, or a token that is not
816    /// allowed — and is reported as the error it is.
817    pub async fn queue_automation(&self, key: &str) -> Result<Automation, ApiError> {
818        let mut unreadable = Vec::new();
819        let mut refused = None;
820
821        let mut section = |name: &'static str, result: Result<Value, ApiError>| match result {
822            Ok(value) => value.as_array().cloned().unwrap_or_default(),
823            Err(error) => {
824                unreadable.push(Unreadable {
825                    // Tracker answers a 403 here with the queue owner's record
826                    // and no message at all, so there are no words of its own
827                    // to pass through. Saying which right is missing is the
828                    // useful sentence, and our generic 403 — which also blames
829                    // the organisation header — is not it.
830                    section: name,
831                    reason: match error {
832                        ApiError::Forbidden => {
833                            format!("{name} are readable by the queue owner only (403)")
834                        }
835                        ref other => other.to_string(),
836                    },
837                });
838                refused.get_or_insert(error);
839                Vec::new()
840            }
841        };
842
843        let macros = section(
844            "macros",
845            self.get_value(
846                &format!("/v3/queues/{key}/macros"),
847                &format!("macros of queue {key}"),
848            )
849            .await,
850        );
851        let autoactions = section(
852            "autoactions",
853            self.get_value(
854                &format!("/v3/queues/{key}/autoactions"),
855                &format!("autoactions of queue {key}"),
856            )
857            .await,
858        );
859        let triggers = section(
860            "triggers",
861            self.get_value(
862                &format!("/v3/queues/{key}/triggers"),
863                &format!("triggers of queue {key}"),
864            )
865            .await,
866        );
867
868        if unreadable.len() == 3 {
869            return Err(refused.unwrap_or(ApiError::NotFound(format!("queue {key}"))));
870        }
871
872        Ok(Automation {
873            macros: macros.iter().filter_map(Macro::parse).collect(),
874            autoactions: autoactions.iter().filter_map(AutoAction::parse).collect(),
875            triggers: triggers.iter().filter_map(Trigger::parse).collect(),
876            unreadable,
877        })
878    }
879
880    /// The components of one queue, or of the whole organisation.
881    ///
882    /// Tracker filters by queue itself, so `--queue` is a different path rather
883    /// than a listing narrowed here: asking for every component in order to
884    /// throw most of them away is the kind of cost this tool exists to avoid.
885    pub async fn components(&self, queue: Option<&str>) -> Result<Vec<Component>, ApiError> {
886        let (path, what) = match queue {
887            Some(queue) => (
888                format!("/v3/queues/{queue}/components"),
889                format!("components of queue {queue}"),
890            ),
891            None => ("/v3/components".to_owned(), "components".to_owned()),
892        };
893        let raw = self.get_value(&path, &what).await?;
894
895        Ok(raw
896            .as_array()
897            .map(|entries| entries.iter().filter_map(Component::parse).collect())
898            .unwrap_or_default())
899    }
900
901    /// Every kind of link two issues can have.
902    ///
903    /// Small, fixed and organisation-wide — six entries in the organisation
904    /// this was checked against, `cloners` among them, which no write in this
905    /// tool can produce.
906    pub async fn link_types(&self) -> Result<Vec<LinkType>, ApiError> {
907        let raw = self.get_value("/v3/linktypes", "link types").await?;
908
909        Ok(raw
910            .as_array()
911            .map(|entries| entries.iter().filter_map(LinkType::parse).collect())
912            .unwrap_or_default())
913    }
914
915    /// Who may do what in a queue.
916    ///
917    /// Two endpoints saying two different things. `permissions` is the rule as
918    /// somebody configured it — named people, groups and *roles*; `access` is
919    /// the list of people it comes out as. A role like "assignee" resolves per
920    /// issue, so only the second answers "am I one of them" on its own.
921    ///
922    /// Both are refused together in the organisation this was checked against —
923    /// one right governs the pair — but they are separate endpoints, and a
924    /// section refused is still an answer while the other one stands.
925    pub async fn queue_access(&self, key: &str) -> Result<QueueAccess, ApiError> {
926        let mut unreadable = Vec::new();
927        let mut refused = None;
928
929        let mut section = |name: &'static str, result: Result<Value, ApiError>| match result {
930            Ok(value) => Permission::parse_all(&value),
931            Err(error) => {
932                unreadable.push(Unreadable {
933                    section: name,
934                    // Tracker does say why here — "you have no right to view the
935                    // queue's access rights" — but our 403 flattens that into a
936                    // sentence that also blames the organisation header, which
937                    // is the wrong suspect for this endpoint.
938                    reason: match error {
939                        ApiError::Forbidden => {
940                            format!(
941                                "{name} are readable only by those who may see queue rights (403)"
942                            )
943                        }
944                        ref other => other.to_string(),
945                    },
946                });
947                refused.get_or_insert(error);
948                Vec::new()
949            }
950        };
951
952        let permissions = section(
953            "permissions",
954            self.get_value(
955                &format!("/v3/queues/{key}/permissions"),
956                &format!("permissions of queue {key}"),
957            )
958            .await,
959        );
960        let access = section(
961            "access",
962            self.get_value(
963                &format!("/v3/queues/{key}/access"),
964                &format!("access of queue {key}"),
965            )
966            .await,
967        );
968
969        if unreadable.len() == 2 {
970            return Err(match refused {
971                // Both sections missing means the queue is, and saying so about
972                // the queue reads better than about the first endpoint tried.
973                Some(ApiError::NotFound(_)) | None => ApiError::NotFound(format!("queue {key}")),
974                Some(other) => other,
975            });
976        }
977
978        // Whose rights these are compared against. A failure here loses the
979        // `you` column and nothing else, so it is not worth failing the command
980        // that did answer.
981        let you = match self.myself().await {
982            Ok(user) => Some(user.id),
983            Err(_) => None,
984        };
985
986        Ok(QueueAccess {
987            permissions,
988            access,
989            you,
990            unreadable,
991        })
992    }
993
994    /// `POST /v3/bulkchange/_update` — change many issues in one request.
995    ///
996    /// Tracker requires the keys: a query is refused with
997    /// `issues: Требуется параметр`, so what this touches is exactly what the
998    /// caller named and the confirmation that names them is the whole story.
999    /// Unknown keys are refused before anything is written, naming them — which
1000    /// is better than the issue-at-a-time path, where the first few have already
1001    /// been changed by the time a later one turns out not to exist.
1002    ///
1003    /// The answer is an operation to poll, not a result: see [`Self::bulk_change`].
1004    pub async fn bulk_update(
1005        &self,
1006        keys: &[String],
1007        values: &Value,
1008    ) -> Result<BulkChange, ApiError> {
1009        let body = serde_json::json!({ "issues": keys, "values": values });
1010        let (value, _) = self
1011            .post_value("/v3/bulkchange/_update", &body, "bulk change")
1012            .await?;
1013        BulkChange::parse(&value).ok_or_else(|| ApiError::NotFound("bulk change".to_owned()))
1014    }
1015
1016    /// `POST /v3/bulkchange/_transition` — one workflow step, many issues.
1017    ///
1018    /// `values` carries what the transition demands — a resolution, usually —
1019    /// exactly as the single-issue path sends it, and is omitted when empty
1020    /// rather than sent as `{}`.
1021    pub async fn bulk_transition(
1022        &self,
1023        keys: &[String],
1024        transition: &str,
1025        values: &Value,
1026    ) -> Result<BulkChange, ApiError> {
1027        let mut body = serde_json::json!({ "issues": keys, "transition": transition });
1028        if !values.as_object().is_some_and(serde_json::Map::is_empty)
1029            && let Some(object) = body.as_object_mut()
1030        {
1031            object.insert("values".to_owned(), values.clone());
1032        }
1033        let (value, _) = self
1034            .post_value("/v3/bulkchange/_transition", &body, "bulk change")
1035            .await?;
1036        BulkChange::parse(&value).ok_or_else(|| ApiError::NotFound("bulk change".to_owned()))
1037    }
1038
1039    /// `POST /v3/bulkchange/_move` — many issues into one queue.
1040    ///
1041    /// Every key in the list changes, and nothing undoes that; the gate this
1042    /// goes through asks for `--yes` even for a single issue for that reason.
1043    pub async fn bulk_move(
1044        &self,
1045        keys: &[String],
1046        queue: &str,
1047        keep_fields: bool,
1048        initial_status: bool,
1049    ) -> Result<BulkChange, ApiError> {
1050        let body = serde_json::json!({
1051            "issues": keys,
1052            "queue": queue,
1053            "moveAllFields": keep_fields,
1054            "initialStatus": initial_status,
1055        });
1056        let (value, _) = self
1057            .post_value("/v3/bulkchange/_move", &body, "bulk change")
1058            .await?;
1059        BulkChange::parse(&value).ok_or_else(|| ApiError::NotFound("bulk change".to_owned()))
1060    }
1061
1062    /// `GET /v3/bulkchange/{id}` — how far a bulk change got.
1063    pub async fn bulk_change(&self, id: &str) -> Result<BulkChange, ApiError> {
1064        let value = self
1065            .get_value(
1066                &format!("/v3/bulkchange/{id}"),
1067                &format!("bulk change {id}"),
1068            )
1069            .await?;
1070        BulkChange::parse(&value).ok_or_else(|| ApiError::NotFound(format!("bulk change {id}")))
1071    }
1072
1073    /// `GET /v3/bulkchange/{id}/issues` — what happened to each issue.
1074    ///
1075    /// Only worth a request when the counts do not already say everything: the
1076    /// point of a bulk change is one request instead of fifty, and printing a
1077    /// line per issue that succeeded would spend the saving on the output.
1078    pub async fn bulk_change_issues(&self, id: &str) -> Result<Vec<BulkOutcome>, ApiError> {
1079        let raw = self
1080            .get_value(
1081                &format!("/v3/bulkchange/{id}/issues"),
1082                &format!("bulk change {id}"),
1083            )
1084            .await?;
1085        Ok(raw
1086            .as_array()
1087            .map(|entries| entries.iter().filter_map(BulkOutcome::parse).collect())
1088            .unwrap_or_default())
1089    }
1090
1091    /// One of the four organisation-wide dictionaries.
1092    ///
1093    /// Small and unpaged — the largest of the four is statuses, in the dozens —
1094    /// so this asks for the whole thing and says nothing about pages.
1095    pub async fn dictionary(&self, kind: Dictionary) -> Result<Vec<DictEntry>, ApiError> {
1096        let raw = self
1097            .get_value(&format!("/v3/{}", kind.path()), kind.path())
1098            .await?;
1099
1100        Ok(raw
1101            .as_array()
1102            .map(|entries| entries.iter().filter_map(parse::dict_entry).collect())
1103            .unwrap_or_default())
1104    }
1105
1106    /// One page of the organisation's directory.
1107    ///
1108    /// Paged, unlike the dictionaries: an organisation has as many people in it
1109    /// as it has people, and the one this was written against already answers
1110    /// with a three-figure total.
1111    pub async fn users(&self, page: u32, per_page: u32) -> Result<Page<Person>, ApiError> {
1112        let path = format!("/v3/users?page={page}&perPage={per_page}");
1113        let (value, headers) = self
1114            .send_value(reqwest::Method::GET, &path, None, "users")
1115            .await?;
1116
1117        let items = value
1118            .as_array()
1119            .map(|entries| entries.iter().filter_map(parse::person).collect())
1120            .unwrap_or_default();
1121
1122        Ok(Page {
1123            items,
1124            page,
1125            per_page,
1126            total: headers
1127                .get("x-total-count")
1128                .and_then(|count| count.to_str().ok())
1129                .and_then(|count| count.parse().ok()),
1130        })
1131    }
1132
1133    /// One person, by login or by uid.
1134    ///
1135    /// There is no `users/me`: Tracker answers 404 for it, and `myself` is the
1136    /// endpoint that question belongs to.
1137    pub async fn user(&self, who: &str) -> Result<Person, ApiError> {
1138        let raw = self
1139            .get_value(&format!("/v3/users/{who}"), &format!("user {who}"))
1140            .await?;
1141
1142        parse::person(&raw).ok_or_else(|| ApiError::NotFound(format!("user {who}")))
1143    }
1144
1145    /// Boards visible to the active profile.
1146    ///
1147    /// Not paginated by the endpoint, and not by us: an organisation has boards
1148    /// in the dozens, not the thousands.
1149    pub async fn boards(&self) -> Result<Vec<Board>, ApiError> {
1150        let raw = self.get_value("/v3/boards", "boards").await?;
1151
1152        Ok(raw
1153            .as_array()
1154            .map(|entries| entries.iter().filter_map(Board::parse).collect())
1155            .unwrap_or_default())
1156    }
1157
1158    /// One board.
1159    pub async fn board(&self, id: &str) -> Result<Board, ApiError> {
1160        let raw = self
1161            .get_value(&format!("/v3/boards/{id}"), &format!("board {id}"))
1162            .await?;
1163
1164        Board::parse(&raw).ok_or_else(|| ApiError::NotFound(format!("board {id}")))
1165    }
1166
1167    /// The sprints of a board.
1168    ///
1169    /// A board that cannot have sprints answers with a refusal rather than an
1170    /// empty list, and that refusal is passed through as Tracker worded it: a
1171    /// kanban board having no sprints is Tracker's answer to the question, not
1172    /// a failure of the command, and inventing an empty list here would hide
1173    /// which of the two happened.
1174    pub async fn sprints(&self, board: &str) -> Result<Vec<Sprint>, ApiError> {
1175        let raw = self
1176            .get_value(
1177                &format!("/v3/boards/{board}/sprints"),
1178                &format!("board {board} sprints"),
1179            )
1180            .await?;
1181
1182        Ok(raw
1183            .as_array()
1184            .map(|entries| entries.iter().filter_map(Sprint::parse).collect())
1185            .unwrap_or_default())
1186    }
1187
1188    /// `GET /v3/sprints/{id}` — one sprint.
1189    ///
1190    /// How far through it is takes two counts on top of this, which is why it
1191    /// is a command of its own rather than a column in the listing: in a
1192    /// listing it would be two requests per row.
1193    pub async fn sprint(&self, id: &str) -> Result<Sprint, ApiError> {
1194        let raw = self
1195            .get_value(&format!("/v3/sprints/{id}"), &format!("sprint {id}"))
1196            .await?;
1197        Sprint::parse(&raw).ok_or_else(|| ApiError::NotFound(format!("sprint {id}")))
1198    }
1199
1200    /// Every sprint in the organisation.
1201    ///
1202    /// `board sprints ID` needs the board first, and a sprint name is a thing
1203    /// people say without knowing which board it belongs to. This is the same
1204    /// records with the board named on each.
1205    pub async fn all_sprints(&self) -> Result<Vec<Sprint>, ApiError> {
1206        let raw = self.get_value("/v3/sprints", "sprints").await?;
1207
1208        Ok(raw
1209            .as_array()
1210            .map(|entries| entries.iter().filter_map(Sprint::parse).collect())
1211            .unwrap_or_default())
1212    }
1213
1214    /// The fields a queue defines itself.
1215    ///
1216    /// Not a subset of [`Self::queue_fields`], which lists everything the queue
1217    /// can use: a local field belongs to the queue, is invisible to the
1218    /// organisation-wide listing, and cannot be fetched through `/v3/fields` at
1219    /// all. So these carry their full definition — what they accept included —
1220    /// because there is no second command that could answer that for them.
1221    pub async fn queue_local_fields(&self, key: &str) -> Result<Vec<FieldSpec>, ApiError> {
1222        let raw = self
1223            .get_value(
1224                &format!("/v3/queues/{key}/localFields"),
1225                &format!("local fields of queue {key}"),
1226            )
1227            .await?;
1228
1229        Ok(raw
1230            .as_array()
1231            .map(|entries| entries.iter().filter_map(FieldSpec::parse).collect())
1232            .unwrap_or_default())
1233    }
1234
1235    /// Create a project, portfolio or goal with nothing but a name.
1236    ///
1237    /// Everything else about an entity is optional, and a command line is not
1238    /// where a portfolio's description gets written.
1239    pub async fn create_entity(&self, kind: &str, fields: &Value) -> Result<Entity, ApiError> {
1240        let body = serde_json::json!({ "fields": fields });
1241        let (value, _) = self
1242            .post_value(
1243                &format!("/v3/entities/{kind}?fields={ENTITY_FIELDS}"),
1244                &body,
1245                kind,
1246            )
1247            .await?;
1248
1249        parse::entity(&value).ok_or_else(|| ApiError::NotFound(kind.to_owned()))
1250    }
1251
1252    /// Delete a project, portfolio or goal.
1253    ///
1254    /// Entities can be deleted; issues cannot. That asymmetry is why the live
1255    /// suite may write entities and may not write issues without being told a
1256    /// queue to sacrifice.
1257    pub async fn delete_entity(&self, kind: &str, id: &str) -> Result<(), ApiError> {
1258        self.send_value(
1259            reqwest::Method::DELETE,
1260            &format!("/v3/entities/{kind}/{id}"),
1261            None,
1262            &format!("{kind} {id}"),
1263        )
1264        .await?;
1265        Ok(())
1266    }
1267
1268    /// Change the fields of a project, portfolio or goal.
1269    ///
1270    /// Quotes the version for the same reason [`Self::place_entity`] does: a
1271    /// write without one lands on top of whatever happened in between.
1272    pub async fn update_entity(
1273        &self,
1274        kind: &str,
1275        id: &str,
1276        fields: &Value,
1277        version: Option<u64>,
1278    ) -> Result<Entity, ApiError> {
1279        let path = match version {
1280            Some(version) => {
1281                format!("/v3/entities/{kind}/{id}?version={version}&fields={ENTITY_FIELDS}")
1282            }
1283            None => format!("/v3/entities/{kind}/{id}?fields={ENTITY_FIELDS}"),
1284        };
1285        let body = serde_json::json!({ "fields": fields });
1286
1287        let (value, _) = self
1288            .send_value(
1289                reqwest::Method::PATCH,
1290                &path,
1291                Some(&body),
1292                &format!("{kind} {id}"),
1293            )
1294            .await?;
1295
1296        parse::entity(&value).ok_or_else(|| ApiError::NotFound(format!("{kind} {id}")))
1297    }
1298
1299    /// Put an entity inside a portfolio, or take it out of one.
1300    ///
1301    /// `version` is Tracker's optimistic-concurrency counter and is quoted on
1302    /// purpose: without it the write lands whatever happened in between, and
1303    /// with it a portfolio that moved under us answers 412 instead of being
1304    /// silently overwritten.
1305    pub async fn place_entity(
1306        &self,
1307        kind: &str,
1308        id: &str,
1309        parent: Option<&str>,
1310        version: Option<u64>,
1311    ) -> Result<Entity, ApiError> {
1312        // The response is the entity as it now stands, but only of the fields
1313        // asked for — without this it comes back with an empty `fields` and the
1314        // command prints a blank summary after a write that worked.
1315        let path = match version {
1316            Some(version) => {
1317                format!("/v3/entities/{kind}/{id}?version={version}&fields={ENTITY_FIELDS}")
1318            }
1319            None => format!("/v3/entities/{kind}/{id}?fields={ENTITY_FIELDS}"),
1320        };
1321        let body = serde_json::json!({
1322            "fields": { "parentEntity": place_body(parent) }
1323        });
1324
1325        let (value, _) = self
1326            .send_value(
1327                reqwest::Method::PATCH,
1328                &path,
1329                Some(&body),
1330                &format!("{kind} {id}"),
1331            )
1332            .await?;
1333
1334        parse::entity(&value).ok_or_else(|| ApiError::NotFound(format!("{kind} {id}")))
1335    }
1336
1337    /// One queue and its settings.
1338    pub async fn queue(&self, key: &str) -> Result<QueueSettings, ApiError> {
1339        let raw = self
1340            .get_value(&format!("/v3/queues/{key}"), &format!("queue {key}"))
1341            .await?;
1342
1343        QueueSettings::parse(&raw).ok_or_else(|| ApiError::NotFound(format!("queue {key}")))
1344    }
1345
1346    /// The parts of a queue that another queue can be built from.
1347    ///
1348    /// `issueTypesConfig` pairs each issue type with a workflow and a set of
1349    /// resolutions, and workflow ids are organisation-specific strings nobody
1350    /// has memorised. Copying them from a queue that already works is the only
1351    /// way to create one from a command line without asking for internals.
1352    pub async fn queue_blueprint(&self, key: &str) -> Result<Blueprint, ApiError> {
1353        let raw = self
1354            .get_value(
1355                &format!("/v3/queues/{key}?expand=all"),
1356                &format!("queue {key}"),
1357            )
1358            .await?;
1359
1360        let named = |name: &str| {
1361            raw.get(name)
1362                .and_then(|field| field.get("key"))
1363                .and_then(Value::as_str)
1364                .map(ToOwned::to_owned)
1365        };
1366
1367        let types = raw
1368            .get("issueTypesConfig")
1369            .and_then(Value::as_array)
1370            .map(|entries| {
1371                entries
1372                    .iter()
1373                    .filter_map(|entry| {
1374                        Some(serde_json::json!({
1375                            "issueType": entry.get("issueType")?.get("key")?.as_str()?,
1376                            "workflow": entry.get("workflow")?.get("id")?.as_str()?,
1377                            "resolutions": entry
1378                                .get("resolutions")
1379                                .and_then(Value::as_array)
1380                                .map(|resolutions| {
1381                                    resolutions
1382                                        .iter()
1383                                        .filter_map(|resolution| {
1384                                            resolution.get("key").and_then(Value::as_str)
1385                                        })
1386                                        .collect::<Vec<_>>()
1387                                })
1388                                .unwrap_or_default(),
1389                        }))
1390                    })
1391                    .collect::<Vec<_>>()
1392            })
1393            .unwrap_or_default();
1394
1395        if types.is_empty() {
1396            return Err(ApiError::NotFound(format!("issue types of queue {key}")));
1397        }
1398
1399        Ok(Blueprint {
1400            default_type: named("defaultType"),
1401            default_priority: named("defaultPriority"),
1402            issue_types: types,
1403        })
1404    }
1405
1406    /// Create a queue.
1407    pub async fn create_queue(&self, body: &Value) -> Result<QueueSettings, ApiError> {
1408        let (value, _) = self.post_value("/v3/queues", body, "queue").await?;
1409
1410        QueueSettings::parse(&value)
1411            .ok_or_else(|| ApiError::NotFound("the created queue".to_owned()))
1412    }
1413
1414    /// Every field defined in the organisation, not just one queue's.
1415    ///
1416    /// `queue fields` answers "what can I set on an issue here"; this answers
1417    /// "what exists at all", which is the question behind a field that a queue
1418    /// does not show.
1419    pub async fn fields(&self) -> Result<Vec<QueueField>, ApiError> {
1420        let raw = self.get_value("/v3/fields", "fields").await?;
1421
1422        Ok(raw
1423            .as_array()
1424            .map(|entries| entries.iter().filter_map(QueueField::parse).collect())
1425            .unwrap_or_default())
1426    }
1427
1428    /// One field's definition, by the key `queue fields` prints.
1429    ///
1430    /// A local field defined inside one queue is not reachable here — it lives
1431    /// under the queue — and Tracker answers 404 for it, which is the honest
1432    /// answer rather than one worth papering over.
1433    pub async fn field(&self, key: &str) -> Result<FieldSpec, ApiError> {
1434        let raw = self
1435            .get_value(&format!("/v3/fields/{key}"), &format!("field {key}"))
1436            .await?;
1437
1438        FieldSpec::parse(&raw).ok_or_else(|| ApiError::NotFound(format!("field {key}")))
1439    }
1440
1441    /// Issue or comment templates.
1442    ///
1443    /// The path is `issueTemplates` and `commentTemplates`; there is no
1444    /// `_templates` collection, which is worth writing down because every
1445    /// plausible guess at one answers 400 or 404.
1446    pub async fn templates(&self, kind: TemplateKind) -> Result<Vec<Template>, ApiError> {
1447        let raw = self
1448            .get_value(&format!("/v3/{}", kind.path()), kind.path())
1449            .await?;
1450
1451        Ok(raw
1452            .as_array()
1453            .map(|entries| entries.iter().filter_map(Template::parse).collect())
1454            .unwrap_or_default())
1455    }
1456
1457    /// The comments of an issue.
1458    ///
1459    /// Fetched in one generous page: an issue with more than a hundred comments
1460    /// is rare enough that paginating here would cost more in complexity than it
1461    /// saves anyone.
1462    pub async fn issue_comments(&self, key: &str) -> Result<Vec<Comment>, ApiError> {
1463        let raw = self
1464            .get_value(
1465                &format!("/v3/issues/{key}/comments?perPage=100"),
1466                &format!("issue {key} comments"),
1467            )
1468            .await?;
1469
1470        Ok(raw
1471            .as_array()
1472            .map(|entries| entries.iter().filter_map(parse::comment).collect())
1473            .unwrap_or_default())
1474    }
1475
1476    /// The fields of a queue, including custom ones, as `(key, name, type)`.
1477    pub async fn queue_fields(&self, key: &str) -> Result<Vec<QueueField>, ApiError> {
1478        let raw = self
1479            .get_value(
1480                &format!("/v3/queues/{key}/fields"),
1481                &format!("queue {key} fields"),
1482            )
1483            .await?;
1484
1485        Ok(raw
1486            .as_array()
1487            .map(|entries| entries.iter().filter_map(QueueField::parse).collect())
1488            .unwrap_or_default())
1489    }
1490
1491    /// A POST that also hands back the response headers, which is where Tracker
1492    /// puts the pagination totals.
1493    async fn post_value(
1494        &self,
1495        path: &str,
1496        body: &Value,
1497        what: &str,
1498    ) -> Result<(Value, reqwest::header::HeaderMap), ApiError> {
1499        self.send_value(reqwest::Method::POST, path, Some(body), what)
1500            .await
1501    }
1502
1503    async fn send_value(
1504        &self,
1505        method: reqwest::Method,
1506        path: &str,
1507        body: Option<&Value>,
1508        what: &str,
1509    ) -> Result<(Value, reqwest::header::HeaderMap), ApiError> {
1510        let url = format!("{}{path}", self.base_url);
1511
1512        let send = || async {
1513            let mut request = self.http.request(method.clone(), &url);
1514            if let Some(body) = body {
1515                request = request.json(body);
1516            }
1517            let response = request.send().await?;
1518            let headers = response.headers().clone();
1519            let text = classify(response, what).await?;
1520            Ok((text, headers))
1521        };
1522
1523        // Only idempotent work is retried. Re-sending a create after a timeout
1524        // would risk a duplicate issue, which is worse than a clear failure.
1525        let (text, headers) = if method == reqwest::Method::GET {
1526            send.retry(
1527                ExponentialBuilder::default()
1528                    .with_max_times(self.retries)
1529                    .with_jitter(),
1530            )
1531            .when(is_retryable)
1532            .await?
1533        } else {
1534            send().await?
1535        };
1536
1537        // A successful write may answer with an empty body.
1538        let value = if text.trim().is_empty() {
1539            Value::Null
1540        } else {
1541            serde_json::from_str(&text).map_err(ApiError::Decode)?
1542        };
1543        Ok((value, headers))
1544    }
1545
1546    async fn get_value(&self, path: &str, what: &str) -> Result<Value, ApiError> {
1547        Ok(self
1548            .send_value(reqwest::Method::GET, path, None, what)
1549            .await?
1550            .0)
1551    }
1552}
1553
1554/// Which organisation-wide dictionary to read.
1555///
1556/// The four endpoints answer with the same shape but are not spelled the way
1557/// the values are: the endpoint is `issuetypes`, the field on an issue is
1558/// `type`, and the flag people reach for is `--type`.
1559#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1560pub enum Dictionary {
1561    Types,
1562    Priorities,
1563    Statuses,
1564    Resolutions,
1565}
1566
1567impl Dictionary {
1568    /// Every dictionary, in the order a listing shows them: what an issue *is*,
1569    /// then how urgent, then where it stands, then how it ended.
1570    pub const ALL: [Self; 4] = [
1571        Self::Types,
1572        Self::Priorities,
1573        Self::Statuses,
1574        Self::Resolutions,
1575    ];
1576
1577    #[must_use]
1578    pub fn path(self) -> &'static str {
1579        match self {
1580            Self::Types => "issuetypes",
1581            Self::Priorities => "priorities",
1582            Self::Statuses => "statuses",
1583            Self::Resolutions => "resolutions",
1584        }
1585    }
1586
1587    /// What to call it in output, singular-free: these are always lists.
1588    #[must_use]
1589    pub fn label(self) -> &'static str {
1590        match self {
1591            Self::Types => "types",
1592            Self::Priorities => "priorities",
1593            Self::Statuses => "statuses",
1594            Self::Resolutions => "resolutions",
1595        }
1596    }
1597}
1598
1599/// A workflow transition available from the current status.
1600#[derive(Debug, Clone, serde::Serialize)]
1601pub struct Transition {
1602    pub id: String,
1603    pub name: String,
1604    /// The status the issue lands in.
1605    pub to: Option<String>,
1606    /// That status's key, which unlike `to` is the same in every organisation.
1607    ///
1608    /// Transition ids are per workflow — `close`, `closed` and `close_issue`
1609    /// are all real — so this is what lets a caller ask for a *status* and have
1610    /// the id found for them.
1611    #[serde(skip_serializing_if = "Option::is_none")]
1612    pub to_key: Option<String>,
1613}
1614
1615impl Transition {
1616    fn parse(value: &Value) -> Option<Self> {
1617        Some(Self {
1618            id: value.get("id").and_then(Value::as_str)?.to_owned(),
1619            name: value
1620                .get("display")
1621                .and_then(Value::as_str)
1622                .unwrap_or_default()
1623                .to_owned(),
1624            to: value
1625                .get("to")
1626                .and_then(|to| to.get("display").or_else(|| to.get("key")))
1627                .and_then(Value::as_str)
1628                .map(ToOwned::to_owned),
1629            to_key: value
1630                .get("to")
1631                .and_then(|to| to.get("key"))
1632                .and_then(Value::as_str)
1633                .map(ToOwned::to_owned),
1634        })
1635    }
1636}
1637
1638/// A queue, reduced to what a listing shows.
1639#[derive(Debug, Clone, serde::Serialize)]
1640pub struct Queue {
1641    pub key: String,
1642    pub name: String,
1643    pub lead: Option<String>,
1644}
1645
1646impl Queue {
1647    fn parse(value: &Value) -> Option<Self> {
1648        Some(Self {
1649            key: value.get("key").and_then(Value::as_str)?.to_owned(),
1650            name: value
1651                .get("name")
1652                .and_then(Value::as_str)
1653                .unwrap_or_default()
1654                .to_owned(),
1655            lead: value
1656                .get("lead")
1657                .and_then(|lead| {
1658                    lead.get("login")
1659                        .or_else(|| lead.get("display"))
1660                        .or_else(|| lead.get("id"))
1661                })
1662                .and_then(Value::as_str)
1663                .map(ToOwned::to_owned),
1664        })
1665    }
1666}
1667
1668/// A release a queue tracks work against.
1669#[derive(Debug, Clone, serde::Serialize)]
1670pub struct Version {
1671    pub id: String,
1672    pub name: String,
1673    pub description: Option<String>,
1674    /// `released`, `archived`, or `open` when it is neither.
1675    pub state: &'static str,
1676    pub due: Option<String>,
1677}
1678
1679impl Version {
1680    fn parse(value: &Value) -> Option<Self> {
1681        let flag = |member: &str| value.get(member).and_then(Value::as_bool).unwrap_or(false);
1682
1683        Some(Self {
1684            id: match value.get("id")? {
1685                Value::String(id) => id.clone(),
1686                other => other.to_string(),
1687            },
1688            name: value
1689                .get("name")
1690                .and_then(Value::as_str)
1691                .unwrap_or_default()
1692                .to_owned(),
1693            description: value
1694                .get("description")
1695                .and_then(Value::as_str)
1696                .filter(|text| !text.is_empty())
1697                .map(ToOwned::to_owned),
1698            // Archived wins over released: an archived version is out of use
1699            // whether or not it ever shipped.
1700            state: if flag("archived") {
1701                "archived"
1702            } else if flag("released") {
1703                "released"
1704            } else {
1705                "open"
1706            },
1707            due: value
1708                .get("dueDate")
1709                .and_then(Value::as_str)
1710                .map(ToOwned::to_owned),
1711        })
1712    }
1713}
1714
1715/// A board, reduced to what a listing shows.
1716///
1717/// Columns are the reason to look at a board from a command line: they are the
1718/// statuses the board arranges work by, in the order it arranges them.
1719#[derive(Debug, Clone, serde::Serialize)]
1720pub struct Board {
1721    pub id: String,
1722    pub name: String,
1723    pub columns: Vec<String>,
1724    /// The field the board estimates by, when it estimates.
1725    pub estimate_by: Option<String>,
1726    pub owner: Option<String>,
1727}
1728
1729impl Board {
1730    fn parse(value: &Value) -> Option<Self> {
1731        Some(Self {
1732            id: match value.get("id")? {
1733                Value::String(id) => id.clone(),
1734                other => other.to_string(),
1735            },
1736            name: value
1737                .get("name")
1738                .and_then(Value::as_str)
1739                .unwrap_or_default()
1740                .to_owned(),
1741            columns: value
1742                .get("columns")
1743                .and_then(Value::as_array)
1744                .map(|columns| {
1745                    columns
1746                        .iter()
1747                        .filter_map(|column| {
1748                            column
1749                                .get("display")
1750                                .or_else(|| column.get("id"))
1751                                .and_then(Value::as_str)
1752                                .map(ToOwned::to_owned)
1753                        })
1754                        .collect()
1755                })
1756                .unwrap_or_default(),
1757            estimate_by: value
1758                .get("estimateBy")
1759                .and_then(|field| field.get("id").or_else(|| field.get("display")))
1760                .and_then(Value::as_str)
1761                .map(ToOwned::to_owned),
1762            // Boards carry `createdBy`, not a lead, and a real organisation
1763            // showed that user has a display name and no login.
1764            owner: value
1765                .get("createdBy")
1766                .and_then(|user| {
1767                    user.get("login")
1768                        .or_else(|| user.get("display"))
1769                        .or_else(|| user.get("id"))
1770                })
1771                .and_then(Value::as_str)
1772                .map(ToOwned::to_owned),
1773        })
1774    }
1775}
1776
1777/// One sprint of a board.
1778#[derive(Debug, Clone, serde::Serialize)]
1779pub struct Sprint {
1780    pub id: String,
1781    pub name: String,
1782    pub status: Option<String>,
1783    pub start: Option<String>,
1784    pub end: Option<String>,
1785    /// Which board it belongs to. Absent when the sprint was read through that
1786    /// board, which already named it, and present when it was listed across the
1787    /// organisation, where it is what makes two sprints called "Sprint 1"
1788    /// tellable apart.
1789    #[serde(skip_serializing_if = "Option::is_none")]
1790    pub board: Option<String>,
1791}
1792
1793impl Sprint {
1794    fn parse(value: &Value) -> Option<Self> {
1795        Some(Self {
1796            id: match value.get("id")? {
1797                Value::String(id) => id.clone(),
1798                other => other.to_string(),
1799            },
1800            name: value
1801                .get("name")
1802                .and_then(Value::as_str)
1803                .unwrap_or_default()
1804                .to_owned(),
1805            status: value
1806                .get("status")
1807                .and_then(Value::as_str)
1808                .map(ToOwned::to_owned),
1809            start: value
1810                .get("startDate")
1811                .and_then(Value::as_str)
1812                .map(ToOwned::to_owned),
1813            end: value
1814                .get("endDate")
1815                .and_then(Value::as_str)
1816                .map(ToOwned::to_owned),
1817            board: value
1818                .get("board")
1819                .and_then(|board| board.get("display").or_else(|| board.get("id")))
1820                .and_then(Value::as_str)
1821                .map(ToOwned::to_owned),
1822        })
1823    }
1824}
1825
1826/// The parts of an existing queue a new one can be built from.
1827#[derive(Debug, Clone)]
1828pub struct Blueprint {
1829    pub default_type: Option<String>,
1830    pub default_priority: Option<String>,
1831    /// `issueTypesConfig` as the create endpoint takes it: keys and ids, not
1832    /// the expanded objects the read answers with.
1833    pub issue_types: Vec<Value>,
1834}
1835
1836/// What `parentEntity` is set to: a portfolio, or nothing.
1837///
1838/// Removing is `null`, not an empty object — an empty object is a change
1839/// Tracker accepts and ignores, which reads as success and is not.
1840fn place_body(parent: Option<&str>) -> Value {
1841    match parent {
1842        Some(parent) => serde_json::json!({ "primary": parent }),
1843        None => Value::Null,
1844    }
1845}
1846
1847/// A queue with the settings that decide what an issue in it starts as.
1848///
1849/// The defaults are the point: `issue create -q PROJ` without a type or a
1850/// priority gets these, and nothing else says what they are.
1851#[derive(Debug, Clone, serde::Serialize)]
1852pub struct QueueSettings {
1853    pub key: String,
1854    pub name: String,
1855    pub lead: Option<String>,
1856    pub default_type: Option<String>,
1857    pub default_priority: Option<String>,
1858    pub version: Option<u64>,
1859}
1860
1861impl QueueSettings {
1862    fn parse(value: &Value) -> Option<Self> {
1863        let named = |name: &str| {
1864            value
1865                .get(name)
1866                .and_then(|field| field.get("key").or_else(|| field.get("display")))
1867                .and_then(Value::as_str)
1868                .map(ToOwned::to_owned)
1869        };
1870
1871        Some(Self {
1872            key: value.get("key").and_then(Value::as_str)?.to_owned(),
1873            name: value
1874                .get("name")
1875                .and_then(Value::as_str)
1876                .unwrap_or_default()
1877                .to_owned(),
1878            lead: value
1879                .get("lead")
1880                .and_then(|lead| {
1881                    lead.get("login")
1882                        .or_else(|| lead.get("display"))
1883                        .or_else(|| lead.get("id"))
1884                })
1885                .and_then(Value::as_str)
1886                .map(ToOwned::to_owned),
1887            default_type: named("defaultType"),
1888            default_priority: named("defaultPriority"),
1889            version: value.get("version").and_then(Value::as_u64),
1890        })
1891    }
1892}
1893
1894/// Which templates are being asked for.
1895#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1896pub enum TemplateKind {
1897    Issue,
1898    Comment,
1899}
1900
1901impl TemplateKind {
1902    #[must_use]
1903    pub const fn path(self) -> &'static str {
1904        match self {
1905            Self::Issue => "issueTemplates",
1906            Self::Comment => "commentTemplates",
1907        }
1908    }
1909}
1910
1911/// One template, reduced to what a listing shows.
1912#[derive(Debug, Clone, serde::Serialize)]
1913pub struct Template {
1914    pub id: String,
1915    pub name: String,
1916    /// The queue a template belongs to, when it belongs to one.
1917    pub queue: Option<String>,
1918    pub author: Option<String>,
1919}
1920
1921impl Template {
1922    fn parse(value: &Value) -> Option<Self> {
1923        Some(Self {
1924            id: match value.get("id")? {
1925                Value::String(id) => id.clone(),
1926                other => other.to_string(),
1927            },
1928            name: value
1929                .get("name")
1930                .or_else(|| value.get("summary"))
1931                .and_then(Value::as_str)
1932                .unwrap_or_default()
1933                .to_owned(),
1934            queue: value
1935                .get("queue")
1936                .and_then(|queue| queue.get("key").or_else(|| queue.get("id")).or(Some(queue)))
1937                .and_then(Value::as_str)
1938                .map(ToOwned::to_owned),
1939            author: value
1940                .get("createdBy")
1941                .or_else(|| value.get("author"))
1942                .and_then(|user| {
1943                    user.get("login")
1944                        .or_else(|| user.get("display"))
1945                        .or_else(|| user.get("id"))
1946                })
1947                .and_then(Value::as_str)
1948                .map(ToOwned::to_owned),
1949        })
1950    }
1951}
1952
1953/// One field of a queue. `queue fields` is how a caller learns the keys that
1954/// `--fields` and `--set` accept, so the key matters more than the name here.
1955#[derive(Debug, Clone, serde::Serialize)]
1956pub struct QueueField {
1957    pub key: String,
1958    pub name: String,
1959    pub field_type: String,
1960    /// A field Tracker ships with, as opposed to one this queue defines.
1961    pub system: bool,
1962}
1963
1964impl QueueField {
1965    fn parse(value: &Value) -> Option<Self> {
1966        let id = value.get("id").and_then(Value::as_str)?;
1967        Some(Self {
1968            // Custom fields are addressed by the trailing segment of a
1969            // dotted id (`60...--storyPoints`), which is what the API accepts
1970            // back and what a caller can reasonably type.
1971            key: id.rsplit("--").next().unwrap_or(id).to_owned(),
1972            name: value
1973                .get("name")
1974                .and_then(Value::as_str)
1975                .unwrap_or(id)
1976                .to_owned(),
1977            field_type: value
1978                .get("schema")
1979                .and_then(|schema| schema.get("type"))
1980                .and_then(Value::as_str)
1981                .unwrap_or("unknown")
1982                .to_owned(),
1983            system: !id.contains("--"),
1984        })
1985    }
1986}
1987
1988/// One kind of relationship two issues can have.
1989///
1990/// Deliberately not folded into [`Dictionary`]: the four dictionaries are values
1991/// a *field* takes and share one shape, and this has neither a key nor a name —
1992/// it has an id and two labels, one per direction. It is also not the vocabulary
1993/// a write takes, which is the whole reason it is worth printing.
1994#[derive(Debug, Clone, serde::Serialize)]
1995pub struct LinkType {
1996    pub id: String,
1997    /// Tracker's wording for the end the link points away from.
1998    pub outward: Option<String>,
1999    /// Tracker's wording for the end it points at.
2000    pub inward: Option<String>,
2001}
2002
2003impl BulkChange {
2004    /// Whether Tracker is done with it, one way or the other.
2005    #[must_use]
2006    pub fn finished(&self) -> bool {
2007        matches!(self.status.as_str(), "COMPLETE" | "FAILED")
2008    }
2009
2010    /// Whether every issue it was given actually changed.
2011    ///
2012    /// `COMPLETE` alone does not say this: a change can finish having changed
2013    /// nothing, and that must not exit zero.
2014    #[must_use]
2015    pub fn succeeded(&self) -> bool {
2016        self.status == "COMPLETE" && self.done.is_some() && self.done == self.total
2017    }
2018
2019    fn parse(value: &Value) -> Option<Self> {
2020        Some(Self {
2021            id: value.get("id").and_then(Value::as_str)?.to_owned(),
2022            status: value
2023                .get("status")
2024                .and_then(Value::as_str)
2025                .unwrap_or_default()
2026                .to_owned(),
2027            status_text: value
2028                .get("statusText")
2029                .and_then(Value::as_str)
2030                .unwrap_or_default()
2031                .to_owned(),
2032            total: value.get("totalIssues").and_then(Value::as_u64),
2033            done: value.get("totalCompletedIssues").and_then(Value::as_u64),
2034        })
2035    }
2036}
2037
2038impl BulkOutcome {
2039    fn parse(value: &Value) -> Option<Self> {
2040        Some(Self {
2041            key: value
2042                .get("issue")
2043                .and_then(|issue| issue.get("key"))
2044                .and_then(Value::as_str)?
2045                .to_owned(),
2046            status: value
2047                .get("status")
2048                .and_then(Value::as_str)
2049                .unwrap_or_default()
2050                .to_owned(),
2051            error: value.get("error").and_then(field_errors),
2052        })
2053    }
2054}
2055
2056/// Tracker's per-field complaints, joined into one sentence.
2057///
2058/// The shape is the same envelope every rejection uses — `errors` keyed by
2059/// field, `errorMessages` for the rest — and both halves are passed through as
2060/// written. A message about somebody's field is theirs, not ours to reword.
2061fn field_errors(error: &Value) -> Option<String> {
2062    let mut parts: Vec<String> = error
2063        .get("errors")
2064        .and_then(Value::as_object)
2065        .map(|fields| {
2066            fields
2067                .iter()
2068                .filter_map(|(field, message)| {
2069                    message
2070                        .as_str()
2071                        .map(|message| format!("{field}: {message}"))
2072                })
2073                .collect()
2074        })
2075        .unwrap_or_default();
2076    parts.extend(
2077        error
2078            .get("errorMessages")
2079            .and_then(Value::as_array)
2080            .map(|messages| {
2081                messages
2082                    .iter()
2083                    .filter_map(Value::as_str)
2084                    .map(ToOwned::to_owned)
2085                    .collect::<Vec<_>>()
2086            })
2087            .unwrap_or_default(),
2088    );
2089
2090    if parts.is_empty() {
2091        None
2092    } else {
2093        Some(parts.join("; "))
2094    }
2095}
2096
2097impl Permission {
2098    /// Every operation in the answer, in a fixed order.
2099    ///
2100    /// Tracker's own order is whatever the JSON object happened to have, and
2101    /// the order of these columns is a contract. `create` before `read` before
2102    /// the two kinds of `write` before `grant` runs from the least to the most
2103    /// a right lets somebody do; anything Tracker adds later lands after them
2104    /// rather than silently between them.
2105    fn parse_all(value: &Value) -> Vec<Self> {
2106        const ORDER: [&str; 5] = ["create", "read", "write", "writeNoAssign", "grant"];
2107
2108        let Some(object) = value.as_object() else {
2109            return Vec::new();
2110        };
2111
2112        let known = ORDER
2113            .iter()
2114            .filter_map(|name| object.get(*name).map(|entry| Self::parse(name, entry)));
2115        let rest = object
2116            .iter()
2117            .filter(|(name, entry)| !ORDER.contains(&name.as_str()) && entry.is_object())
2118            // `self` and `version` are the answer's own metadata, not
2119            // operations, and they are objects nowhere — but the filter above
2120            // is about names, so they are named here too.
2121            .filter(|(name, _)| !matches!(name.as_str(), "self" | "version"))
2122            .map(|(name, entry)| Self::parse(name, entry));
2123
2124        known.chain(rest).collect()
2125    }
2126
2127    fn parse(operation: &str, value: &Value) -> Self {
2128        let holders = |member: &str| {
2129            value
2130                .get(member)
2131                .and_then(Value::as_array)
2132                .map(|entries| entries.iter().filter_map(Holder::parse).collect())
2133                .unwrap_or_default()
2134        };
2135        Self {
2136            operation: operation.to_owned(),
2137            users: holders("users"),
2138            groups: holders("groups"),
2139            roles: holders("roles"),
2140        }
2141    }
2142}
2143
2144impl Holder {
2145    fn parse(value: &Value) -> Option<Self> {
2146        let id = id_of(value)?;
2147        Some(Self {
2148            display: value
2149                .get("display")
2150                .and_then(Value::as_str)
2151                // A holder with no display is still a holder; the id is a worse
2152                // name than the display and a better one than nothing.
2153                .map_or_else(|| id.clone(), ToOwned::to_owned),
2154            id,
2155        })
2156    }
2157}
2158
2159impl LinkType {
2160    fn parse(value: &Value) -> Option<Self> {
2161        let text = |member: &str| {
2162            value
2163                .get(member)
2164                .and_then(Value::as_str)
2165                .map(str::to_lowercase)
2166        };
2167        Some(Self {
2168            id: value.get("id").and_then(Value::as_str)?.to_owned(),
2169            outward: text("outward"),
2170            inward: text("inward"),
2171        })
2172    }
2173}
2174
2175/// A part of the product a queue splits its work by.
2176///
2177/// `components` is a field on every issue, and until it could be listed a write
2178/// to it was a guess. Half of them have no lead, so that column is genuinely
2179/// optional rather than defensively so.
2180#[derive(Debug, Clone, serde::Serialize)]
2181pub struct Component {
2182    pub id: String,
2183    pub name: String,
2184    /// The queue it belongs to. A component belongs to exactly one.
2185    pub queue: Option<String>,
2186    pub lead: Option<String>,
2187    /// Whether adding this component assigns the issue to its lead. It changes
2188    /// what a write does, which is why it is a column and not a detail.
2189    pub assign_auto: bool,
2190    pub description: Option<String>,
2191}
2192
2193impl Component {
2194    fn parse(value: &Value) -> Option<Self> {
2195        Some(Self {
2196            id: id_of(value)?,
2197            name: named(value),
2198            queue: value
2199                .get("queue")
2200                .and_then(|queue| queue.get("key").or_else(|| queue.get("display")))
2201                .and_then(Value::as_str)
2202                .map(ToOwned::to_owned),
2203            lead: value
2204                .get("lead")
2205                .and_then(|lead| {
2206                    lead.get("login")
2207                        .or_else(|| lead.get("display"))
2208                        .or_else(|| lead.get("id"))
2209                })
2210                .and_then(Value::as_str)
2211                .map(ToOwned::to_owned),
2212            assign_auto: value
2213                .get("assignAuto")
2214                .and_then(Value::as_bool)
2215                .unwrap_or(false),
2216            description: value
2217                .get("description")
2218                .and_then(Value::as_str)
2219                .filter(|text| !text.is_empty())
2220                .map(ToOwned::to_owned),
2221        })
2222    }
2223}
2224
2225/// What changes issues in a queue without anybody touching them.
2226///
2227/// One answer assembled from three endpoints, because they are three halves of
2228/// one question: an issue whose changelog says it was updated by the Tracker
2229/// robot was changed by one of these.
2230#[derive(Debug, Clone, serde::Serialize)]
2231pub struct Automation {
2232    pub macros: Vec<Macro>,
2233    pub autoactions: Vec<AutoAction>,
2234    pub triggers: Vec<Trigger>,
2235    /// The parts Tracker would not show, in its own words.
2236    ///
2237    /// Triggers need queue-owner rights and answer 403 to everybody else. Two
2238    /// sections out of three is a useful answer, and failing the whole command
2239    /// because of the third would throw them away.
2240    pub unreadable: Vec<Unreadable>,
2241}
2242
2243/// A change to many issues at once, which Tracker performs in the background.
2244#[derive(Debug, Clone, serde::Serialize)]
2245pub struct BulkChange {
2246    pub id: String,
2247    /// `CREATED`, `COMPLETE`, `FAILED` are the ones this has seen. Anything else
2248    /// is treated as still running rather than as an outcome, because guessing
2249    /// which way an unknown status went is the one thing worth not doing here.
2250    pub status: String,
2251    /// Tracker's own sentence, in the organisation's language.
2252    pub status_text: String,
2253    /// How many issues the change is about, once Tracker has counted them.
2254    pub total: Option<u64>,
2255    /// How many of them it finished. The tally a bulk change ends with.
2256    pub done: Option<u64>,
2257}
2258
2259/// What happened to one issue in a bulk change.
2260#[derive(Debug, Clone, serde::Serialize)]
2261pub struct BulkOutcome {
2262    pub key: String,
2263    pub status: String,
2264    /// Tracker's own words about why this one did not change.
2265    pub error: Option<String>,
2266}
2267
2268/// Who may do what in a queue: the rules, and the people they come out as.
2269#[derive(Debug, Clone, serde::Serialize)]
2270pub struct QueueAccess {
2271    /// The rule per operation: named holders and roles.
2272    pub permissions: Vec<Permission>,
2273    /// The people per operation, with the roles already resolved.
2274    pub access: Vec<Permission>,
2275    /// The id of the user the token belongs to, when it could be read. What
2276    /// makes "who is allowed" into "am I allowed".
2277    pub you: Option<String>,
2278    pub unreadable: Vec<Unreadable>,
2279}
2280
2281/// One operation, and everybody who holds it.
2282#[derive(Debug, Clone, serde::Serialize)]
2283pub struct Permission {
2284    /// `create`, `read`, `write`, `writeNoAssign`, `grant`.
2285    pub operation: String,
2286    pub users: Vec<Holder>,
2287    /// Documented, and absent from every queue this was checked against — so
2288    /// parsed, printed when present, and claimed about no further than that.
2289    pub groups: Vec<Holder>,
2290    /// `queue-lead`, `assignee`, `author`, `follower`, `access`. A role is not
2291    /// a set of people: which issue is being touched decides who is in it.
2292    pub roles: Vec<Holder>,
2293}
2294
2295/// Somebody or something that holds a right.
2296#[derive(Debug, Clone, serde::Serialize)]
2297pub struct Holder {
2298    pub id: String,
2299    /// Tracker's own wording, in the organisation's language.
2300    pub display: String,
2301}
2302
2303/// One section that could not be read, and why.
2304#[derive(Debug, Clone, serde::Serialize)]
2305pub struct Unreadable {
2306    pub section: &'static str,
2307    pub reason: String,
2308}
2309
2310/// A canned change somebody applies by hand from the issue page.
2311#[derive(Debug, Clone, serde::Serialize)]
2312pub struct Macro {
2313    pub id: String,
2314    pub name: String,
2315    /// The comment it posts, when it posts one.
2316    pub body: Option<String>,
2317    /// Which fields it writes. The keys, not the localised names, because the
2318    /// keys are what every other command here takes.
2319    pub updates: Vec<String>,
2320}
2321
2322/// A change Tracker applies on a schedule to whatever matches a filter.
2323#[derive(Debug, Clone, serde::Serialize)]
2324pub struct AutoAction {
2325    pub id: String,
2326    pub name: String,
2327    pub active: bool,
2328    /// The kinds of action it performs — `Transition`, `Update`, and the rest.
2329    pub actions: Vec<String>,
2330    /// How often it runs, in seconds.
2331    pub interval: Option<u64>,
2332}
2333
2334/// A change Tracker applies the moment something happens to an issue.
2335#[derive(Debug, Clone, serde::Serialize)]
2336pub struct Trigger {
2337    pub id: String,
2338    pub name: String,
2339    pub active: bool,
2340    pub actions: Vec<String>,
2341    /// How many conditions have to hold. The conditions themselves are a tree
2342    /// of Tracker's own classes, and printing it would be longer than it is
2343    /// useful.
2344    pub conditions: usize,
2345}
2346
2347/// The `id` of anything under a queue, whether Tracker sent it as a number or a
2348/// string.
2349fn id_of(value: &Value) -> Option<String> {
2350    Some(match value.get("id")? {
2351        Value::String(id) => id.clone(),
2352        other => other.to_string(),
2353    })
2354}
2355
2356/// The `type` of each entry of an array, which is how Tracker names an action.
2357fn types_in(value: Option<&Value>) -> Vec<String> {
2358    value
2359        .and_then(Value::as_array)
2360        .map(|entries| {
2361            entries
2362                .iter()
2363                .filter_map(|entry| entry.get("type").and_then(Value::as_str))
2364                .map(ToOwned::to_owned)
2365                .collect()
2366        })
2367        .unwrap_or_default()
2368}
2369
2370fn named(value: &Value) -> String {
2371    value
2372        .get("name")
2373        .and_then(Value::as_str)
2374        .unwrap_or_default()
2375        .to_owned()
2376}
2377
2378impl Macro {
2379    fn parse(value: &Value) -> Option<Self> {
2380        Some(Self {
2381            id: id_of(value)?,
2382            name: named(value),
2383            body: value
2384                .get("body")
2385                .and_then(Value::as_str)
2386                .filter(|text| !text.is_empty())
2387                .map(ToOwned::to_owned),
2388            updates: value
2389                .get("issueUpdate")
2390                .and_then(Value::as_array)
2391                .map(|updates| {
2392                    updates
2393                        .iter()
2394                        .filter_map(|update| {
2395                            update
2396                                .get("field")
2397                                .and_then(|field| field.get("id"))
2398                                .and_then(Value::as_str)
2399                        })
2400                        .map(|id| id.rsplit("--").next().unwrap_or(id).to_owned())
2401                        .collect()
2402                })
2403                .unwrap_or_default(),
2404        })
2405    }
2406}
2407
2408impl AutoAction {
2409    fn parse(value: &Value) -> Option<Self> {
2410        Some(Self {
2411            id: id_of(value)?,
2412            name: named(value),
2413            active: value
2414                .get("active")
2415                .and_then(Value::as_bool)
2416                .unwrap_or(false),
2417            actions: types_in(value.get("actions")),
2418            // Milliseconds on the wire; seconds is what a person says out loud.
2419            interval: value
2420                .get("intervalMillis")
2421                .and_then(Value::as_u64)
2422                .map(|millis| millis / 1000),
2423        })
2424    }
2425}
2426
2427impl Trigger {
2428    fn parse(value: &Value) -> Option<Self> {
2429        Some(Self {
2430            id: id_of(value)?,
2431            name: named(value),
2432            active: value
2433                .get("active")
2434                .and_then(Value::as_bool)
2435                .unwrap_or(false),
2436            actions: types_in(value.get("actions")),
2437            conditions: value
2438                .get("conditions")
2439                .and_then(Value::as_array)
2440                .map_or(0, Vec::len),
2441        })
2442    }
2443}
2444
2445/// One field's definition: what it holds, whether it can be written, and what
2446/// values it accepts.
2447///
2448/// `queue fields` lists the keys; this answers the question that follows, which
2449/// is the one `--set` is otherwise guessing at.
2450#[derive(Debug, Clone, serde::Serialize)]
2451pub struct FieldSpec {
2452    pub key: String,
2453    pub name: String,
2454    /// `string`, `float`, `user`, `datetime` — Tracker's own vocabulary, which
2455    /// is what its error messages quote back.
2456    pub field_type: String,
2457    /// What one element is, when the field holds several of them. `None` means
2458    /// the field takes a single value.
2459    pub items: Option<String>,
2460    pub required: bool,
2461    pub readonly: bool,
2462    /// Where Tracker files the field: `Системные`, `Agile`, and whatever the
2463    /// organisation added. In the organisation's own language.
2464    pub category: Option<String>,
2465    /// How the accepted values are decided, when they are decided at all.
2466    pub options: Option<FieldOptions>,
2467}
2468
2469/// What a constrained field will accept.
2470///
2471/// Two cases, and telling them apart is the point: a fixed list carries its
2472/// values here, and everything else names a provider that answers from
2473/// somewhere else in the organisation — the directory, the queue, the board.
2474#[derive(Debug, Clone, serde::Serialize)]
2475pub struct FieldOptions {
2476    /// Tracker's class name for the provider, passed through unchanged: an
2477    /// unrecognised one still says something, and inventing a friendlier word
2478    /// for it would only be a word we would have to keep in step.
2479    pub provider: String,
2480    pub values: Vec<String>,
2481}
2482
2483impl FieldSpec {
2484    fn parse(value: &Value) -> Option<Self> {
2485        let id = value.get("id").and_then(Value::as_str)?;
2486        let schema = value.get("schema");
2487        let string_at = |parent: Option<&Value>, member: &str| {
2488            parent
2489                .and_then(|parent| parent.get(member))
2490                .and_then(Value::as_str)
2491                .map(ToOwned::to_owned)
2492        };
2493
2494        let options = value.get("optionsProvider").map(|provider| FieldOptions {
2495            provider: provider
2496                .get("type")
2497                .and_then(Value::as_str)
2498                .unwrap_or("unknown")
2499                .to_owned(),
2500            // Values arrive as whatever the field holds — the numbers 0 and 1
2501            // for a flag, strings for a list — and a caller has to type them
2502            // back either way.
2503            values: provider
2504                .get("values")
2505                .and_then(Value::as_array)
2506                .map(|values| {
2507                    values
2508                        .iter()
2509                        .map(|value| match value {
2510                            Value::String(text) => text.clone(),
2511                            other => other.to_string(),
2512                        })
2513                        .collect()
2514                })
2515                .unwrap_or_default(),
2516        });
2517
2518        Some(Self {
2519            key: id.rsplit("--").next().unwrap_or(id).to_owned(),
2520            name: value
2521                .get("name")
2522                .and_then(Value::as_str)
2523                .unwrap_or(id)
2524                .to_owned(),
2525            field_type: string_at(schema, "type").unwrap_or_else(|| "unknown".to_owned()),
2526            items: string_at(schema, "items"),
2527            required: schema
2528                .and_then(|schema| schema.get("required"))
2529                .and_then(Value::as_bool)
2530                .unwrap_or(false),
2531            readonly: value
2532                .get("readonly")
2533                .and_then(Value::as_bool)
2534                .unwrap_or(false),
2535            category: string_at(value.get("category"), "display"),
2536            options,
2537        })
2538    }
2539}
2540
2541/// The checklist out of whatever Tracker answered a checklist write with.
2542///
2543/// It replies with the issue, not the item, so the list is under
2544/// `checklistItems`; a bare array is accepted too, because an endpoint that
2545/// changes its mind about the envelope should not empty somebody's checklist.
2546fn checklist_of(value: &Value) -> Vec<ChecklistItem> {
2547    let entries = value
2548        .get("checklistItems")
2549        .and_then(Value::as_array)
2550        .or_else(|| value.as_array());
2551
2552    entries
2553        .map(|entries| entries.iter().filter_map(parse::checklist_item).collect())
2554        .unwrap_or_default()
2555}
2556
2557/// Turn a response into either its body or a typed error.
2558///
2559/// `what` names the thing being fetched so a 404 can say which one, rather than
2560/// leaving the caller to guess between the issue and one of its subresources.
2561async fn classify(response: reqwest::Response, what: &str) -> Result<String, ApiError> {
2562    let status = response.status();
2563    if status.is_success() {
2564        return Ok(response.text().await?);
2565    }
2566
2567    let message = response.text().await.unwrap_or_default();
2568    Err(match status.as_u16() {
2569        401 => ApiError::Unauthorized,
2570        403 => ApiError::Forbidden,
2571        404 => ApiError::NotFound(what.to_owned()),
2572        429 => ApiError::RateLimited,
2573        _ => ApiError::Rejected {
2574            status,
2575            message: complaint(&message),
2576        },
2577    })
2578}
2579
2580/// What Tracker actually said, out of the envelope it says it in.
2581///
2582/// A rejection arrives as `{"errors": …, "errorMessages": […], "statusCode": …}`,
2583/// and printing the whole envelope buries the one sentence a caller can act on
2584/// under punctuation it cannot. The body is kept verbatim when it is not that
2585/// shape, since an unrecognised error is exactly when guessing is worst.
2586fn complaint(body: &str) -> String {
2587    let messages = serde_json::from_str::<Value>(body)
2588        .ok()
2589        .and_then(|value| {
2590            let mut said: Vec<String> = value
2591                .get("errorMessages")
2592                .and_then(Value::as_array)
2593                .map(|entries| {
2594                    entries
2595                        .iter()
2596                        .filter_map(Value::as_str)
2597                        .map(ToOwned::to_owned)
2598                        .collect()
2599                })
2600                .unwrap_or_default();
2601            // `errors` is keyed by field, and a field-level complaint is the
2602            // most specific thing in the envelope when it is there.
2603            if let Some(errors) = value.get("errors").and_then(Value::as_object) {
2604                said.extend(
2605                    errors
2606                        .iter()
2607                        .filter_map(|(field, text)| Some(format!("{field}: {}", text.as_str()?))),
2608                );
2609            }
2610            (!said.is_empty()).then(|| said.join("; "))
2611        })
2612        .unwrap_or_else(|| body.to_owned());
2613
2614    messages.chars().take(400).collect()
2615}
2616
2617/// Retry transport hiccups and server-side backpressure; never retry a request
2618/// the server has already judged invalid.
2619fn is_retryable(error: &ApiError) -> bool {
2620    match error {
2621        ApiError::RateLimited => true,
2622        ApiError::Transport(err) => err.is_timeout() || err.is_connect(),
2623        ApiError::Rejected { status, .. } => status.is_server_error(),
2624        _ => false,
2625    }
2626}
2627
2628#[cfg(test)]
2629mod tests {
2630    use super::*;
2631
2632    /// The sentence a caller can act on, not the envelope it arrived in.
2633    #[test]
2634    fn a_rejection_reads_as_what_tracker_said() {
2635        assert_eq!(
2636            complaint(
2637                r#"{"errors":{},"errorMessages":["A board of this type cannot have sprints."],"statusCode":400}"#
2638            ),
2639            "A board of this type cannot have sprints."
2640        );
2641    }
2642
2643    /// A field-level complaint names its field: `summary` being required is a
2644    /// different fix from `queue` being wrong.
2645    #[test]
2646    fn a_field_complaint_keeps_its_field() {
2647        assert_eq!(
2648            complaint(r#"{"errors":{"summary":"cannot be empty"},"errorMessages":[]}"#),
2649            "summary: cannot be empty"
2650        );
2651    }
2652
2653    /// An unrecognised body is passed through: guessing is worst precisely when
2654    /// the error is one we have not seen.
2655    #[test]
2656    fn an_unfamiliar_body_survives_untouched() {
2657        assert_eq!(
2658            complaint("<html>gateway timeout</html>"),
2659            "<html>gateway timeout</html>"
2660        );
2661        assert_eq!(complaint("{}"), "{}");
2662    }
2663
2664    #[test]
2665    fn host_comparison_ignores_scheme_path_and_case() {
2666        assert_eq!(
2667            host_of("https://API.tracker.yandex.net/v3/issues/PROJ-1"),
2668            host_of("https://api.tracker.yandex.net")
2669        );
2670    }
2671
2672    /// The download URL is server-supplied. A different host must not match, or
2673    /// a crafted attachment could send this client — and its OAuth header —
2674    /// somewhere else entirely.
2675    #[test]
2676    fn a_different_host_does_not_match() {
2677        assert_ne!(
2678            host_of("https://evil.example.com/steal"),
2679            host_of("https://api.tracker.yandex.net")
2680        );
2681    }
2682
2683    /// Nor a host that merely starts the same way.
2684    #[test]
2685    fn a_prefix_of_the_real_host_does_not_match() {
2686        assert_ne!(
2687            host_of("https://api.tracker.yandex.net.evil.com/steal"),
2688            host_of("https://api.tracker.yandex.net")
2689        );
2690    }
2691
2692    #[test]
2693    fn a_port_is_part_of_the_host() {
2694        assert_ne!(
2695            host_of("http://127.0.0.1:9999/x"),
2696            host_of("http://127.0.0.1:8888")
2697        );
2698    }
2699}