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, User,
22    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}
79
80impl Client {
81    pub fn new(config: &ClientConfig) -> Result<Self, ApiError> {
82        let mut headers = HeaderMap::new();
83        headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
84        headers.insert(
85            USER_AGENT,
86            HeaderValue::from_static(concat!("ytcli/", env!("CARGO_PKG_VERSION"))),
87        );
88
89        // A malformed token or org id must fail here, not as a confusing 401 later.
90        let mut auth = HeaderValue::try_from(format!("OAuth {}", config.token))
91            .map_err(|_| ApiError::Unauthorized)?;
92        auth.set_sensitive(true);
93        headers.insert(AUTHORIZATION, auth);
94
95        let org_header = HeaderName::from_static(config.org_kind.header_name());
96        let org_value =
97            HeaderValue::try_from(config.org_id.clone()).map_err(|_| ApiError::Forbidden)?;
98        headers.insert(org_header, org_value);
99
100        let http = reqwest::Client::builder()
101            .timeout(config.timeout)
102            .default_headers(headers)
103            .build()?;
104
105        Ok(Self {
106            http,
107            base_url: config.base_url.trim_end_matches('/').to_owned(),
108            retries: config.retries,
109        })
110    }
111
112    /// `GET /v3/myself` — the cheapest call that proves the whole chain works:
113    /// token, organisation header, and network.
114    pub async fn myself(&self) -> Result<User, ApiError> {
115        let value = self.get_value("/v3/myself", "current user").await?;
116        Ok(User {
117            id: value
118                .get("uid")
119                .map_or_else(String::new, ToString::to_string),
120            login: value
121                .get("login")
122                .and_then(serde_json::Value::as_str)
123                .map(ToOwned::to_owned),
124            display: value
125                .get("display")
126                .and_then(serde_json::Value::as_str)
127                .map(ToOwned::to_owned),
128        })
129    }
130
131    /// One issue, both normalised and raw.
132    ///
133    /// The raw payload travels alongside so that `--json-raw` does not cost a
134    /// second request, and so that a field we do not model is still reachable.
135    pub async fn issue(&self, key: &str) -> Result<(Issue, Value), ApiError> {
136        let raw = self
137            .get_value(&format!("/v3/issues/{key}"), &format!("issue {key}"))
138            .await?;
139        let issue = parse::issue(&raw).ok_or_else(|| ApiError::NotFound(format!("issue {key}")))?;
140        Ok((issue, raw))
141    }
142
143    /// The links of an issue, with their direction resolved.
144    ///
145    /// Tracker keeps links on their own endpoint, so the compact issue view
146    /// costs two requests. Showing links is worth that: "what blocks this" is
147    /// the question that follows "what is this", and making the caller ask twice
148    /// costs more than one round trip (ADR 3).
149    pub async fn issue_links(&self, key: &str) -> Result<Vec<Link>, ApiError> {
150        let raw = self
151            .get_value(
152                &format!("/v3/issues/{key}/links"),
153                &format!("issue {key} links"),
154            )
155            .await?;
156
157        Ok(raw
158            .as_array()
159            .map(|entries| entries.iter().filter_map(parse::link).collect())
160            .unwrap_or_default())
161    }
162
163    /// One page of search results.
164    ///
165    /// Tracker reports the total in `X-Total-Count`. When it does not, the page
166    /// still has to be honest about whether more exists, which is why
167    /// [`Page::has_more`] falls back to "a full page probably is not the last".
168    pub async fn search(
169        &self,
170        query: &str,
171        page: u32,
172        per_page: u32,
173    ) -> Result<Page<Issue>, ApiError> {
174        let path = format!("/v3/issues/_search?page={page}&perPage={per_page}");
175        let body = serde_json::json!({ "query": query });
176        let (value, headers) = self.post_value(&path, &body, "issues").await?;
177
178        let items = value
179            .as_array()
180            .map(|entries| entries.iter().filter_map(parse::issue).collect())
181            .unwrap_or_default();
182
183        Ok(Page {
184            items,
185            page,
186            per_page,
187            total: headers
188                .get("x-total-count")
189                .and_then(|count| count.to_str().ok())
190                .and_then(|count| count.parse().ok()),
191        })
192    }
193
194    /// How many issues match, without fetching any of them.
195    pub async fn count(&self, query: &str) -> Result<u64, ApiError> {
196        let body = serde_json::json!({ "query": query });
197        let (value, _) = self
198            .post_value("/v3/issues/_count", &body, "issues")
199            .await?;
200
201        value
202            .as_u64()
203            .ok_or_else(|| ApiError::NotFound("issue count".to_owned()))
204    }
205
206    /// `POST /v3/issues/` — create an issue, returning it normalised.
207    pub async fn create_issue(&self, body: &Value) -> Result<Issue, ApiError> {
208        let (value, _) = self.post_value("/v3/issues/", body, "issue").await?;
209        parse::issue(&value).ok_or_else(|| ApiError::NotFound("created issue".to_owned()))
210    }
211
212    /// `PATCH /v3/issues/{key}` — change fields.
213    pub async fn update_issue(&self, key: &str, body: &Value) -> Result<Issue, ApiError> {
214        let value = self
215            .send_value(
216                reqwest::Method::PATCH,
217                &format!("/v3/issues/{key}"),
218                Some(body),
219                &format!("issue {key}"),
220            )
221            .await?
222            .0;
223        parse::issue(&value).ok_or_else(|| ApiError::NotFound(format!("issue {key}")))
224    }
225
226    /// `POST /v3/issues/{key}/comments` — add a comment.
227    pub async fn add_comment(&self, key: &str, text: &str) -> Result<Comment, ApiError> {
228        let body = serde_json::json!({ "text": text });
229        let (value, _) = self
230            .post_value(
231                &format!("/v3/issues/{key}/comments"),
232                &body,
233                &format!("issue {key}"),
234            )
235            .await?;
236        parse::comment(&value).ok_or_else(|| ApiError::NotFound("created comment".to_owned()))
237    }
238
239    /// Rewrite a comment that is already there.
240    ///
241    /// Tracker keeps no history of the previous text and shows the comment as
242    /// edited, so this replaces rather than appends: the old wording is gone.
243    pub async fn update_comment(
244        &self,
245        key: &str,
246        id: &str,
247        text: &str,
248    ) -> Result<Comment, ApiError> {
249        let body = serde_json::json!({ "text": text });
250        let (value, _) = self
251            .send_value(
252                reqwest::Method::PATCH,
253                &format!("/v3/issues/{key}/comments/{id}"),
254                Some(&body),
255                &format!("comment {id} of issue {key}"),
256            )
257            .await?;
258        parse::comment(&value).ok_or_else(|| ApiError::NotFound(format!("comment {id}")))
259    }
260
261    /// Remove a comment.
262    pub async fn delete_comment(&self, key: &str, id: &str) -> Result<(), ApiError> {
263        self.send_value(
264            reqwest::Method::DELETE,
265            &format!("/v3/issues/{key}/comments/{id}"),
266            None,
267            &format!("comment {id} of issue {key}"),
268        )
269        .await?;
270        Ok(())
271    }
272
273    /// Correct a worklog entry that is already recorded.
274    pub async fn update_worklog(
275        &self,
276        key: &str,
277        id: &str,
278        body: &Value,
279    ) -> Result<Worklog, ApiError> {
280        let (value, _) = self
281            .send_value(
282                reqwest::Method::PATCH,
283                &format!("/v3/issues/{key}/worklog/{id}"),
284                Some(body),
285                &format!("worklog {id} of issue {key}"),
286            )
287            .await?;
288        parse::worklog(&value).ok_or_else(|| ApiError::NotFound(format!("worklog {id}")))
289    }
290
291    /// `GET /v3/issues/{key}/worklog` — every entry, oldest first.
292    pub async fn worklogs(&self, key: &str) -> Result<Vec<Worklog>, ApiError> {
293        let raw = self
294            .get_value(
295                &format!("/v3/issues/{key}/worklog"),
296                &format!("issue {key} worklog"),
297            )
298            .await?;
299
300        Ok(raw
301            .as_array()
302            .map(|entries| entries.iter().filter_map(parse::worklog).collect())
303            .unwrap_or_default())
304    }
305
306    /// `POST /v3/issues/{key}/worklog` — record time spent.
307    pub async fn add_worklog(&self, key: &str, body: &Value) -> Result<Worklog, ApiError> {
308        let (value, _) = self
309            .post_value(
310                &format!("/v3/issues/{key}/worklog"),
311                body,
312                &format!("issue {key} worklog"),
313            )
314            .await?;
315        parse::worklog(&value).ok_or_else(|| ApiError::NotFound("created worklog".to_owned()))
316    }
317
318    /// `DELETE /v3/issues/{key}/worklog/{id}`.
319    pub async fn delete_worklog(&self, key: &str, id: &str) -> Result<(), ApiError> {
320        self.send_value(
321            reqwest::Method::DELETE,
322            &format!("/v3/issues/{key}/worklog/{id}"),
323            None,
324            &format!("worklog {id} of issue {key}"),
325        )
326        .await?;
327        Ok(())
328    }
329
330    /// `GET /v3/issues/{key}/checklistItems`.
331    pub async fn checklist(&self, key: &str) -> Result<Vec<ChecklistItem>, ApiError> {
332        let raw = self
333            .get_value(
334                &format!("/v3/issues/{key}/checklistItems"),
335                &format!("issue {key} checklist"),
336            )
337            .await?;
338
339        Ok(raw
340            .as_array()
341            .map(|entries| entries.iter().filter_map(parse::checklist_item).collect())
342            .unwrap_or_default())
343    }
344
345    /// `POST /v3/issues/{key}/checklistItems` — add a line.
346    ///
347    /// Tracker answers with the whole issue rather than the item, so the list
348    /// comes back out of the issue's own `checklistItems`.
349    pub async fn add_checklist_item(
350        &self,
351        key: &str,
352        body: &Value,
353    ) -> Result<Vec<ChecklistItem>, ApiError> {
354        let (value, _) = self
355            .post_value(
356                &format!("/v3/issues/{key}/checklistItems"),
357                body,
358                &format!("issue {key} checklist"),
359            )
360            .await?;
361        Ok(checklist_of(&value))
362    }
363
364    /// `PATCH /v3/issues/{key}/checklistItems/{id}` — tick, untick or reword.
365    pub async fn update_checklist_item(
366        &self,
367        key: &str,
368        id: &str,
369        body: &Value,
370    ) -> Result<Vec<ChecklistItem>, ApiError> {
371        let (value, _) = self
372            .send_value(
373                reqwest::Method::PATCH,
374                &format!("/v3/issues/{key}/checklistItems/{id}"),
375                Some(body),
376                &format!("checklist item {id} of issue {key}"),
377            )
378            .await?;
379        Ok(checklist_of(&value))
380    }
381
382    /// `DELETE /v3/issues/{key}/checklistItems/{id}`.
383    pub async fn delete_checklist_item(&self, key: &str, id: &str) -> Result<(), ApiError> {
384        self.send_value(
385            reqwest::Method::DELETE,
386            &format!("/v3/issues/{key}/checklistItems/{id}"),
387            None,
388            &format!("checklist item {id} of issue {key}"),
389        )
390        .await?;
391        Ok(())
392    }
393
394    /// `POST /v3/issues/{key}/links` — link two issues.
395    pub async fn add_link(
396        &self,
397        key: &str,
398        relationship: &str,
399        other: &str,
400    ) -> Result<(), ApiError> {
401        let body = serde_json::json!({ "relationship": relationship, "issue": other });
402        self.post_value(
403            &format!("/v3/issues/{key}/links"),
404            &body,
405            &format!("issue {key} links"),
406        )
407        .await?;
408        Ok(())
409    }
410
411    /// `DELETE /v3/issues/{key}/links/{id}`.
412    pub async fn delete_link(&self, key: &str, id: &str) -> Result<(), ApiError> {
413        self.send_value(
414            reqwest::Method::DELETE,
415            &format!("/v3/issues/{key}/links/{id}"),
416            None,
417            &format!("link {id} of issue {key}"),
418        )
419        .await?;
420        Ok(())
421    }
422
423    /// Transitions available from the issue's current status.
424    pub async fn transitions(&self, key: &str) -> Result<Vec<Transition>, ApiError> {
425        let raw = self
426            .get_value(
427                &format!("/v3/issues/{key}/transitions"),
428                &format!("issue {key} transitions"),
429            )
430            .await?;
431
432        Ok(raw
433            .as_array()
434            .map(|entries| entries.iter().filter_map(Transition::parse).collect())
435            .unwrap_or_default())
436    }
437
438    /// Perform a transition.
439    pub async fn execute_transition(
440        &self,
441        key: &str,
442        transition: &str,
443        body: &Value,
444    ) -> Result<(), ApiError> {
445        self.post_value(
446            &format!("/v3/issues/{key}/transitions/{transition}/_execute"),
447            body,
448            &format!("transition {transition} of issue {key}"),
449        )
450        .await?;
451        Ok(())
452    }
453
454    /// Search projects, portfolios or goals.
455    ///
456    /// The entity endpoints answer with their own envelope (`values`, `hits`,
457    /// `pages`) rather than the header-based totals the issue endpoints use, so
458    /// the page is assembled from the body here.
459    pub async fn entities(
460        &self,
461        kind: &str,
462        input: Option<&str>,
463        page: u32,
464        per_page: u32,
465    ) -> Result<Page<Entity>, ApiError> {
466        let path = format!(
467            "/v3/entities/{kind}/_search?page={page}&perPage={per_page}&fields={ENTITY_FIELDS}"
468        );
469        let mut body = serde_json::Map::new();
470        if let Some(input) = input {
471            body.insert("input".to_owned(), Value::String(input.to_owned()));
472        }
473
474        let (value, _) = self
475            .post_value(&path, &Value::Object(body), &format!("{kind}s"))
476            .await?;
477
478        let items = value
479            .get("values")
480            .and_then(Value::as_array)
481            .map(|entries| entries.iter().filter_map(parse::entity).collect())
482            .unwrap_or_default();
483
484        Ok(Page {
485            items,
486            page,
487            per_page,
488            total: value.get("hits").and_then(Value::as_u64),
489        })
490    }
491
492    /// What a portfolio contains: the portfolios and projects under it.
493    ///
494    /// Two requests, because the entity endpoints are typed and containment is
495    /// not: a portfolio holds both. The tally sums the two totals, so `shown N
496    /// of M` is the real count even though a page is a page of each.
497    pub async fn entities_in(
498        &self,
499        parent: &str,
500        page: u32,
501        per_page: u32,
502    ) -> Result<Page<Entity>, ApiError> {
503        let mut items = Vec::new();
504        let mut total = 0;
505
506        for kind in ["portfolio", "project"] {
507            let path = format!(
508                "/v3/entities/{kind}/_search?page={page}&perPage={per_page}&fields={ENTITY_FIELDS}"
509            );
510            let body = serde_json::json!({ "filter": { "parentEntity": parent } });
511            let (value, _) = self
512                .post_value(&path, &body, &format!("{kind}s in {parent}"))
513                .await?;
514
515            if let Some(entries) = value.get("values").and_then(Value::as_array) {
516                items.extend(entries.iter().filter_map(parse::entity));
517            }
518            total += value.get("hits").and_then(Value::as_u64).unwrap_or(0);
519        }
520
521        Ok(Page {
522            items,
523            page,
524            per_page,
525            total: Some(total),
526        })
527    }
528
529    /// One project, portfolio or goal, by the id the entity endpoints use.
530    pub async fn entity(&self, kind: &str, id: &str) -> Result<Entity, ApiError> {
531        let raw = self
532            .get_value(
533                &format!("/v3/entities/{kind}/{id}?fields={ENTITY_FIELDS}"),
534                &format!("{kind} {id}"),
535            )
536            .await?;
537
538        parse::entity(&raw).ok_or_else(|| ApiError::NotFound(format!("{kind} {id}")))
539    }
540
541    /// The attachments of an issue.
542    pub async fn attachments(&self, key: &str) -> Result<Vec<Attachment>, ApiError> {
543        let raw = self
544            .get_value(
545                &format!("/v3/issues/{key}/attachments"),
546                &format!("issue {key} attachments"),
547            )
548            .await?;
549
550        Ok(raw
551            .as_array()
552            .map(|entries| entries.iter().filter_map(parse::attachment).collect())
553            .unwrap_or_default())
554    }
555
556    /// Download an attachment's bytes.
557    ///
558    /// The download URL comes out of the payload, which means it is supplied by
559    /// the server rather than chosen by us. It is checked against the configured
560    /// API host before being followed: a crafted `content` URL must not be able
561    /// to send this client, carrying its OAuth header, to somewhere else.
562    pub async fn download(&self, url: &str) -> Result<Vec<u8>, ApiError> {
563        let expected = host_of(&self.base_url);
564        if host_of(url) != expected {
565            return Err(ApiError::Rejected {
566                status: reqwest::StatusCode::BAD_REQUEST,
567                message: format!(
568                    "attachment points at `{}`, which is not the configured Tracker host `{}`",
569                    host_of(url).unwrap_or_default(),
570                    expected.unwrap_or_default(),
571                ),
572            });
573        }
574
575        let response = self.http.get(url).send().await?;
576        let status = response.status();
577        if !status.is_success() {
578            return Err(match status.as_u16() {
579                401 => ApiError::Unauthorized,
580                403 => ApiError::Forbidden,
581                404 => ApiError::NotFound("attachment".to_owned()),
582                _ => ApiError::Rejected {
583                    status,
584                    message: String::new(),
585                },
586            });
587        }
588
589        Ok(response.bytes().await?.to_vec())
590    }
591
592    /// Upload a file to an issue.
593    pub async fn upload(
594        &self,
595        key: &str,
596        filename: &str,
597        bytes: Vec<u8>,
598    ) -> Result<Attachment, ApiError> {
599        let part = reqwest::multipart::Part::bytes(bytes).file_name(filename.to_owned());
600        let form = reqwest::multipart::Form::new().part("file", part);
601
602        let url = format!("{}/v3/issues/{key}/attachments/", self.base_url);
603        let response = self.http.post(&url).multipart(form).send().await?;
604        let text = classify(response, &format!("issue {key}")).await?;
605
606        let value: Value = serde_json::from_str(&text).map_err(ApiError::Decode)?;
607        parse::attachment(&value)
608            .ok_or_else(|| ApiError::NotFound("uploaded attachment".to_owned()))
609    }
610
611    /// Queues visible to the active profile.
612    ///
613    /// Tracker paginates this endpoint; the ceiling is deliberately generous
614    /// because "how many queues can I see" is a question with a small answer,
615    /// and a second page here would be surprising.
616    pub async fn queues(&self) -> Result<Vec<Queue>, ApiError> {
617        let raw = self.get_value("/v3/queues?perPage=1000", "queues").await?;
618
619        Ok(raw
620            .as_array()
621            .map(|entries| entries.iter().filter_map(Queue::parse).collect())
622            .unwrap_or_default())
623    }
624
625    /// Worklog entries across the whole organisation.
626    ///
627    /// `createdBy` takes a login or a uid and **not** `me`: Tracker reads it as
628    /// a login and answers 422 saying no such user exists. Resolving `me` is
629    /// the caller's job, with one extra request to `myself`.
630    pub async fn worklog_search(
631        &self,
632        who: Option<&str>,
633        since: Option<&str>,
634        until: Option<&str>,
635        per_page: u32,
636    ) -> Result<Vec<Worklog>, ApiError> {
637        use std::fmt::Write as _;
638
639        let mut query = format!("perPage={per_page}");
640        if let Some(who) = who {
641            let _ = write!(query, "&createdBy={who}");
642        }
643        // One parameter carries both ends of the range, and Tracker accepts
644        // either half on its own.
645        match (since, until) {
646            (Some(since), Some(until)) => {
647                let _ = write!(query, "&createdAt=from:{since},to:{until}");
648            }
649            (Some(since), None) => {
650                let _ = write!(query, "&createdAt=from:{since}");
651            }
652            (None, Some(until)) => {
653                let _ = write!(query, "&createdAt=to:{until}");
654            }
655            (None, None) => {}
656        }
657
658        let raw = self
659            .get_value(&format!("/v3/worklog?{query}"), "worklog")
660            .await?;
661
662        Ok(raw
663            .as_array()
664            .map(|entries| entries.iter().filter_map(parse::worklog).collect())
665            .unwrap_or_default())
666    }
667
668    /// Move an issue to another queue.
669    ///
670    /// The issue keeps its identity and loses its name: `PROJ-42` becomes
671    /// `OTHER-17`, and there is no request that undoes it. Tracker drops fields
672    /// the target queue does not define unless `moveAllFields` says otherwise,
673    /// so that choice is the caller's rather than a default we picked for them.
674    pub async fn move_issue(
675        &self,
676        key: &str,
677        queue: &str,
678        keep_fields: bool,
679        initial_status: bool,
680    ) -> Result<Issue, ApiError> {
681        let path = format!(
682            "/v3/issues/{key}/_move?queue={queue}&moveAllFields={keep_fields}&initialStatus={initial_status}"
683        );
684        let (raw, _) = self
685            .send_value(
686                reqwest::Method::POST,
687                &path,
688                Some(&serde_json::json!({})),
689                &format!("move {key} to {queue}"),
690            )
691            .await?;
692
693        parse::issue(&raw).ok_or_else(|| ApiError::NotFound(format!("issue {key} after the move")))
694    }
695
696    /// What changed on an issue, newest last.
697    ///
698    /// Tracker pages this with an opaque cursor rather than page numbers, and
699    /// the cursor is only worth spending when somebody asks for more than the
700    /// first page — which nobody has yet. So this asks for one page, and the
701    /// caller says how big.
702    pub async fn changelog(&self, key: &str, per_page: u32) -> Result<Vec<Change>, ApiError> {
703        let raw = self
704            .get_value(
705                &format!("/v3/issues/{key}/changelog?perPage={per_page}"),
706                &format!("changelog of {key}"),
707            )
708            .await?;
709
710        Ok(raw
711            .as_array()
712            .map(|entries| entries.iter().filter_map(parse::change).collect())
713            .unwrap_or_default())
714    }
715
716    /// The versions a queue defines.
717    ///
718    /// This is what an issue's `fixVersions` refers to; without it that field
719    /// is an id with no meaning.
720    pub async fn queue_versions(&self, key: &str) -> Result<Vec<Version>, ApiError> {
721        let raw = self
722            .get_value(
723                &format!("/v3/queues/{key}/versions"),
724                &format!("versions of queue {key}"),
725            )
726            .await?;
727
728        Ok(raw
729            .as_array()
730            .map(|entries| entries.iter().filter_map(Version::parse).collect())
731            .unwrap_or_default())
732    }
733
734    /// The tags in use in a queue.
735    pub async fn queue_tags(&self, key: &str) -> Result<Vec<String>, ApiError> {
736        let raw = self
737            .get_value(
738                &format!("/v3/queues/{key}/tags?perPage=1000"),
739                &format!("tags of queue {key}"),
740            )
741            .await?;
742
743        // Both shapes are accepted because the organisation this was written
744        // against has no tags to answer with, and a listing that silently drops
745        // every row is worse than one that reads a member it did not need.
746        Ok(raw
747            .as_array()
748            .map(|entries| {
749                entries
750                    .iter()
751                    .filter_map(|entry| match entry {
752                        Value::String(name) => Some(name.clone()),
753                        other => other
754                            .get("name")
755                            .and_then(Value::as_str)
756                            .map(ToOwned::to_owned),
757                    })
758                    .collect()
759            })
760            .unwrap_or_default())
761    }
762
763    /// One of the four organisation-wide dictionaries.
764    ///
765    /// Small and unpaged — the largest of the four is statuses, in the dozens —
766    /// so this asks for the whole thing and says nothing about pages.
767    pub async fn dictionary(&self, kind: Dictionary) -> Result<Vec<DictEntry>, ApiError> {
768        let raw = self
769            .get_value(&format!("/v3/{}", kind.path()), kind.path())
770            .await?;
771
772        Ok(raw
773            .as_array()
774            .map(|entries| entries.iter().filter_map(parse::dict_entry).collect())
775            .unwrap_or_default())
776    }
777
778    /// One page of the organisation's directory.
779    ///
780    /// Paged, unlike the dictionaries: an organisation has as many people in it
781    /// as it has people, and the one this was written against already answers
782    /// with a three-figure total.
783    pub async fn users(&self, page: u32, per_page: u32) -> Result<Page<Person>, ApiError> {
784        let path = format!("/v3/users?page={page}&perPage={per_page}");
785        let (value, headers) = self
786            .send_value(reqwest::Method::GET, &path, None, "users")
787            .await?;
788
789        let items = value
790            .as_array()
791            .map(|entries| entries.iter().filter_map(parse::person).collect())
792            .unwrap_or_default();
793
794        Ok(Page {
795            items,
796            page,
797            per_page,
798            total: headers
799                .get("x-total-count")
800                .and_then(|count| count.to_str().ok())
801                .and_then(|count| count.parse().ok()),
802        })
803    }
804
805    /// One person, by login or by uid.
806    ///
807    /// There is no `users/me`: Tracker answers 404 for it, and `myself` is the
808    /// endpoint that question belongs to.
809    pub async fn user(&self, who: &str) -> Result<Person, ApiError> {
810        let raw = self
811            .get_value(&format!("/v3/users/{who}"), &format!("user {who}"))
812            .await?;
813
814        parse::person(&raw).ok_or_else(|| ApiError::NotFound(format!("user {who}")))
815    }
816
817    /// Boards visible to the active profile.
818    ///
819    /// Not paginated by the endpoint, and not by us: an organisation has boards
820    /// in the dozens, not the thousands.
821    pub async fn boards(&self) -> Result<Vec<Board>, ApiError> {
822        let raw = self.get_value("/v3/boards", "boards").await?;
823
824        Ok(raw
825            .as_array()
826            .map(|entries| entries.iter().filter_map(Board::parse).collect())
827            .unwrap_or_default())
828    }
829
830    /// One board.
831    pub async fn board(&self, id: &str) -> Result<Board, ApiError> {
832        let raw = self
833            .get_value(&format!("/v3/boards/{id}"), &format!("board {id}"))
834            .await?;
835
836        Board::parse(&raw).ok_or_else(|| ApiError::NotFound(format!("board {id}")))
837    }
838
839    /// The sprints of a board.
840    ///
841    /// A board that cannot have sprints answers with a refusal rather than an
842    /// empty list, and that refusal is passed through as Tracker worded it: a
843    /// kanban board having no sprints is Tracker's answer to the question, not
844    /// a failure of the command, and inventing an empty list here would hide
845    /// which of the two happened.
846    pub async fn sprints(&self, board: &str) -> Result<Vec<Sprint>, ApiError> {
847        let raw = self
848            .get_value(
849                &format!("/v3/boards/{board}/sprints"),
850                &format!("board {board} sprints"),
851            )
852            .await?;
853
854        Ok(raw
855            .as_array()
856            .map(|entries| entries.iter().filter_map(Sprint::parse).collect())
857            .unwrap_or_default())
858    }
859
860    /// Create a project, portfolio or goal with nothing but a name.
861    ///
862    /// Everything else about an entity is optional, and a command line is not
863    /// where a portfolio's description gets written.
864    pub async fn create_entity(&self, kind: &str, fields: &Value) -> Result<Entity, ApiError> {
865        let body = serde_json::json!({ "fields": fields });
866        let (value, _) = self
867            .post_value(
868                &format!("/v3/entities/{kind}?fields={ENTITY_FIELDS}"),
869                &body,
870                kind,
871            )
872            .await?;
873
874        parse::entity(&value).ok_or_else(|| ApiError::NotFound(kind.to_owned()))
875    }
876
877    /// Delete a project, portfolio or goal.
878    ///
879    /// Entities can be deleted; issues cannot. That asymmetry is why the live
880    /// suite may write entities and may not write issues without being told a
881    /// queue to sacrifice.
882    pub async fn delete_entity(&self, kind: &str, id: &str) -> Result<(), ApiError> {
883        self.send_value(
884            reqwest::Method::DELETE,
885            &format!("/v3/entities/{kind}/{id}"),
886            None,
887            &format!("{kind} {id}"),
888        )
889        .await?;
890        Ok(())
891    }
892
893    /// Change the fields of a project, portfolio or goal.
894    ///
895    /// Quotes the version for the same reason [`Self::place_entity`] does: a
896    /// write without one lands on top of whatever happened in between.
897    pub async fn update_entity(
898        &self,
899        kind: &str,
900        id: &str,
901        fields: &Value,
902        version: Option<u64>,
903    ) -> Result<Entity, ApiError> {
904        let path = match version {
905            Some(version) => {
906                format!("/v3/entities/{kind}/{id}?version={version}&fields={ENTITY_FIELDS}")
907            }
908            None => format!("/v3/entities/{kind}/{id}?fields={ENTITY_FIELDS}"),
909        };
910        let body = serde_json::json!({ "fields": fields });
911
912        let (value, _) = self
913            .send_value(
914                reqwest::Method::PATCH,
915                &path,
916                Some(&body),
917                &format!("{kind} {id}"),
918            )
919            .await?;
920
921        parse::entity(&value).ok_or_else(|| ApiError::NotFound(format!("{kind} {id}")))
922    }
923
924    /// Put an entity inside a portfolio, or take it out of one.
925    ///
926    /// `version` is Tracker's optimistic-concurrency counter and is quoted on
927    /// purpose: without it the write lands whatever happened in between, and
928    /// with it a portfolio that moved under us answers 412 instead of being
929    /// silently overwritten.
930    pub async fn place_entity(
931        &self,
932        kind: &str,
933        id: &str,
934        parent: Option<&str>,
935        version: Option<u64>,
936    ) -> Result<Entity, ApiError> {
937        // The response is the entity as it now stands, but only of the fields
938        // asked for — without this it comes back with an empty `fields` and the
939        // command prints a blank summary after a write that worked.
940        let path = match version {
941            Some(version) => {
942                format!("/v3/entities/{kind}/{id}?version={version}&fields={ENTITY_FIELDS}")
943            }
944            None => format!("/v3/entities/{kind}/{id}?fields={ENTITY_FIELDS}"),
945        };
946        let body = serde_json::json!({
947            "fields": { "parentEntity": place_body(parent) }
948        });
949
950        let (value, _) = self
951            .send_value(
952                reqwest::Method::PATCH,
953                &path,
954                Some(&body),
955                &format!("{kind} {id}"),
956            )
957            .await?;
958
959        parse::entity(&value).ok_or_else(|| ApiError::NotFound(format!("{kind} {id}")))
960    }
961
962    /// One queue and its settings.
963    pub async fn queue(&self, key: &str) -> Result<QueueSettings, ApiError> {
964        let raw = self
965            .get_value(&format!("/v3/queues/{key}"), &format!("queue {key}"))
966            .await?;
967
968        QueueSettings::parse(&raw).ok_or_else(|| ApiError::NotFound(format!("queue {key}")))
969    }
970
971    /// The parts of a queue that another queue can be built from.
972    ///
973    /// `issueTypesConfig` pairs each issue type with a workflow and a set of
974    /// resolutions, and workflow ids are organisation-specific strings nobody
975    /// has memorised. Copying them from a queue that already works is the only
976    /// way to create one from a command line without asking for internals.
977    pub async fn queue_blueprint(&self, key: &str) -> Result<Blueprint, ApiError> {
978        let raw = self
979            .get_value(
980                &format!("/v3/queues/{key}?expand=all"),
981                &format!("queue {key}"),
982            )
983            .await?;
984
985        let named = |name: &str| {
986            raw.get(name)
987                .and_then(|field| field.get("key"))
988                .and_then(Value::as_str)
989                .map(ToOwned::to_owned)
990        };
991
992        let types = raw
993            .get("issueTypesConfig")
994            .and_then(Value::as_array)
995            .map(|entries| {
996                entries
997                    .iter()
998                    .filter_map(|entry| {
999                        Some(serde_json::json!({
1000                            "issueType": entry.get("issueType")?.get("key")?.as_str()?,
1001                            "workflow": entry.get("workflow")?.get("id")?.as_str()?,
1002                            "resolutions": entry
1003                                .get("resolutions")
1004                                .and_then(Value::as_array)
1005                                .map(|resolutions| {
1006                                    resolutions
1007                                        .iter()
1008                                        .filter_map(|resolution| {
1009                                            resolution.get("key").and_then(Value::as_str)
1010                                        })
1011                                        .collect::<Vec<_>>()
1012                                })
1013                                .unwrap_or_default(),
1014                        }))
1015                    })
1016                    .collect::<Vec<_>>()
1017            })
1018            .unwrap_or_default();
1019
1020        if types.is_empty() {
1021            return Err(ApiError::NotFound(format!("issue types of queue {key}")));
1022        }
1023
1024        Ok(Blueprint {
1025            default_type: named("defaultType"),
1026            default_priority: named("defaultPriority"),
1027            issue_types: types,
1028        })
1029    }
1030
1031    /// Create a queue.
1032    pub async fn create_queue(&self, body: &Value) -> Result<QueueSettings, ApiError> {
1033        let (value, _) = self.post_value("/v3/queues", body, "queue").await?;
1034
1035        QueueSettings::parse(&value)
1036            .ok_or_else(|| ApiError::NotFound("the created queue".to_owned()))
1037    }
1038
1039    /// Every field defined in the organisation, not just one queue's.
1040    ///
1041    /// `queue fields` answers "what can I set on an issue here"; this answers
1042    /// "what exists at all", which is the question behind a field that a queue
1043    /// does not show.
1044    pub async fn fields(&self) -> Result<Vec<QueueField>, ApiError> {
1045        let raw = self.get_value("/v3/fields", "fields").await?;
1046
1047        Ok(raw
1048            .as_array()
1049            .map(|entries| entries.iter().filter_map(QueueField::parse).collect())
1050            .unwrap_or_default())
1051    }
1052
1053    /// Issue or comment templates.
1054    ///
1055    /// The path is `issueTemplates` and `commentTemplates`; there is no
1056    /// `_templates` collection, which is worth writing down because every
1057    /// plausible guess at one answers 400 or 404.
1058    pub async fn templates(&self, kind: TemplateKind) -> Result<Vec<Template>, ApiError> {
1059        let raw = self
1060            .get_value(&format!("/v3/{}", kind.path()), kind.path())
1061            .await?;
1062
1063        Ok(raw
1064            .as_array()
1065            .map(|entries| entries.iter().filter_map(Template::parse).collect())
1066            .unwrap_or_default())
1067    }
1068
1069    /// The comments of an issue.
1070    ///
1071    /// Fetched in one generous page: an issue with more than a hundred comments
1072    /// is rare enough that paginating here would cost more in complexity than it
1073    /// saves anyone.
1074    pub async fn issue_comments(&self, key: &str) -> Result<Vec<Comment>, ApiError> {
1075        let raw = self
1076            .get_value(
1077                &format!("/v3/issues/{key}/comments?perPage=100"),
1078                &format!("issue {key} comments"),
1079            )
1080            .await?;
1081
1082        Ok(raw
1083            .as_array()
1084            .map(|entries| entries.iter().filter_map(parse::comment).collect())
1085            .unwrap_or_default())
1086    }
1087
1088    /// The fields of a queue, including custom ones, as `(key, name, type)`.
1089    pub async fn queue_fields(&self, key: &str) -> Result<Vec<QueueField>, ApiError> {
1090        let raw = self
1091            .get_value(
1092                &format!("/v3/queues/{key}/fields"),
1093                &format!("queue {key} fields"),
1094            )
1095            .await?;
1096
1097        Ok(raw
1098            .as_array()
1099            .map(|entries| entries.iter().filter_map(QueueField::parse).collect())
1100            .unwrap_or_default())
1101    }
1102
1103    /// A POST that also hands back the response headers, which is where Tracker
1104    /// puts the pagination totals.
1105    async fn post_value(
1106        &self,
1107        path: &str,
1108        body: &Value,
1109        what: &str,
1110    ) -> Result<(Value, reqwest::header::HeaderMap), ApiError> {
1111        self.send_value(reqwest::Method::POST, path, Some(body), what)
1112            .await
1113    }
1114
1115    async fn send_value(
1116        &self,
1117        method: reqwest::Method,
1118        path: &str,
1119        body: Option<&Value>,
1120        what: &str,
1121    ) -> Result<(Value, reqwest::header::HeaderMap), ApiError> {
1122        let url = format!("{}{path}", self.base_url);
1123
1124        let send = || async {
1125            let mut request = self.http.request(method.clone(), &url);
1126            if let Some(body) = body {
1127                request = request.json(body);
1128            }
1129            let response = request.send().await?;
1130            let headers = response.headers().clone();
1131            let text = classify(response, what).await?;
1132            Ok((text, headers))
1133        };
1134
1135        // Only idempotent work is retried. Re-sending a create after a timeout
1136        // would risk a duplicate issue, which is worse than a clear failure.
1137        let (text, headers) = if method == reqwest::Method::GET {
1138            send.retry(
1139                ExponentialBuilder::default()
1140                    .with_max_times(self.retries)
1141                    .with_jitter(),
1142            )
1143            .when(is_retryable)
1144            .await?
1145        } else {
1146            send().await?
1147        };
1148
1149        // A successful write may answer with an empty body.
1150        let value = if text.trim().is_empty() {
1151            Value::Null
1152        } else {
1153            serde_json::from_str(&text).map_err(ApiError::Decode)?
1154        };
1155        Ok((value, headers))
1156    }
1157
1158    async fn get_value(&self, path: &str, what: &str) -> Result<Value, ApiError> {
1159        Ok(self
1160            .send_value(reqwest::Method::GET, path, None, what)
1161            .await?
1162            .0)
1163    }
1164}
1165
1166/// Which organisation-wide dictionary to read.
1167///
1168/// The four endpoints answer with the same shape but are not spelled the way
1169/// the values are: the endpoint is `issuetypes`, the field on an issue is
1170/// `type`, and the flag people reach for is `--type`.
1171#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1172pub enum Dictionary {
1173    Types,
1174    Priorities,
1175    Statuses,
1176    Resolutions,
1177}
1178
1179impl Dictionary {
1180    /// Every dictionary, in the order a listing shows them: what an issue *is*,
1181    /// then how urgent, then where it stands, then how it ended.
1182    pub const ALL: [Self; 4] = [
1183        Self::Types,
1184        Self::Priorities,
1185        Self::Statuses,
1186        Self::Resolutions,
1187    ];
1188
1189    #[must_use]
1190    pub fn path(self) -> &'static str {
1191        match self {
1192            Self::Types => "issuetypes",
1193            Self::Priorities => "priorities",
1194            Self::Statuses => "statuses",
1195            Self::Resolutions => "resolutions",
1196        }
1197    }
1198
1199    /// What to call it in output, singular-free: these are always lists.
1200    #[must_use]
1201    pub fn label(self) -> &'static str {
1202        match self {
1203            Self::Types => "types",
1204            Self::Priorities => "priorities",
1205            Self::Statuses => "statuses",
1206            Self::Resolutions => "resolutions",
1207        }
1208    }
1209}
1210
1211/// A workflow transition available from the current status.
1212#[derive(Debug, Clone, serde::Serialize)]
1213pub struct Transition {
1214    pub id: String,
1215    pub name: String,
1216    /// The status the issue lands in.
1217    pub to: Option<String>,
1218}
1219
1220impl Transition {
1221    fn parse(value: &Value) -> Option<Self> {
1222        Some(Self {
1223            id: value.get("id").and_then(Value::as_str)?.to_owned(),
1224            name: value
1225                .get("display")
1226                .and_then(Value::as_str)
1227                .unwrap_or_default()
1228                .to_owned(),
1229            to: value
1230                .get("to")
1231                .and_then(|to| to.get("display").or_else(|| to.get("key")))
1232                .and_then(Value::as_str)
1233                .map(ToOwned::to_owned),
1234        })
1235    }
1236}
1237
1238/// A queue, reduced to what a listing shows.
1239#[derive(Debug, Clone, serde::Serialize)]
1240pub struct Queue {
1241    pub key: String,
1242    pub name: String,
1243    pub lead: Option<String>,
1244}
1245
1246impl Queue {
1247    fn parse(value: &Value) -> Option<Self> {
1248        Some(Self {
1249            key: value.get("key").and_then(Value::as_str)?.to_owned(),
1250            name: value
1251                .get("name")
1252                .and_then(Value::as_str)
1253                .unwrap_or_default()
1254                .to_owned(),
1255            lead: value
1256                .get("lead")
1257                .and_then(|lead| {
1258                    lead.get("login")
1259                        .or_else(|| lead.get("display"))
1260                        .or_else(|| lead.get("id"))
1261                })
1262                .and_then(Value::as_str)
1263                .map(ToOwned::to_owned),
1264        })
1265    }
1266}
1267
1268/// A release a queue tracks work against.
1269#[derive(Debug, Clone, serde::Serialize)]
1270pub struct Version {
1271    pub id: String,
1272    pub name: String,
1273    pub description: Option<String>,
1274    /// `released`, `archived`, or `open` when it is neither.
1275    pub state: &'static str,
1276    pub due: Option<String>,
1277}
1278
1279impl Version {
1280    fn parse(value: &Value) -> Option<Self> {
1281        let flag = |member: &str| value.get(member).and_then(Value::as_bool).unwrap_or(false);
1282
1283        Some(Self {
1284            id: match value.get("id")? {
1285                Value::String(id) => id.clone(),
1286                other => other.to_string(),
1287            },
1288            name: value
1289                .get("name")
1290                .and_then(Value::as_str)
1291                .unwrap_or_default()
1292                .to_owned(),
1293            description: value
1294                .get("description")
1295                .and_then(Value::as_str)
1296                .filter(|text| !text.is_empty())
1297                .map(ToOwned::to_owned),
1298            // Archived wins over released: an archived version is out of use
1299            // whether or not it ever shipped.
1300            state: if flag("archived") {
1301                "archived"
1302            } else if flag("released") {
1303                "released"
1304            } else {
1305                "open"
1306            },
1307            due: value
1308                .get("dueDate")
1309                .and_then(Value::as_str)
1310                .map(ToOwned::to_owned),
1311        })
1312    }
1313}
1314
1315/// A board, reduced to what a listing shows.
1316///
1317/// Columns are the reason to look at a board from a command line: they are the
1318/// statuses the board arranges work by, in the order it arranges them.
1319#[derive(Debug, Clone, serde::Serialize)]
1320pub struct Board {
1321    pub id: String,
1322    pub name: String,
1323    pub columns: Vec<String>,
1324    /// The field the board estimates by, when it estimates.
1325    pub estimate_by: Option<String>,
1326    pub owner: Option<String>,
1327}
1328
1329impl Board {
1330    fn parse(value: &Value) -> Option<Self> {
1331        Some(Self {
1332            id: match value.get("id")? {
1333                Value::String(id) => id.clone(),
1334                other => other.to_string(),
1335            },
1336            name: value
1337                .get("name")
1338                .and_then(Value::as_str)
1339                .unwrap_or_default()
1340                .to_owned(),
1341            columns: value
1342                .get("columns")
1343                .and_then(Value::as_array)
1344                .map(|columns| {
1345                    columns
1346                        .iter()
1347                        .filter_map(|column| {
1348                            column
1349                                .get("display")
1350                                .or_else(|| column.get("id"))
1351                                .and_then(Value::as_str)
1352                                .map(ToOwned::to_owned)
1353                        })
1354                        .collect()
1355                })
1356                .unwrap_or_default(),
1357            estimate_by: value
1358                .get("estimateBy")
1359                .and_then(|field| field.get("id").or_else(|| field.get("display")))
1360                .and_then(Value::as_str)
1361                .map(ToOwned::to_owned),
1362            // Boards carry `createdBy`, not a lead, and a real organisation
1363            // showed that user has a display name and no login.
1364            owner: value
1365                .get("createdBy")
1366                .and_then(|user| {
1367                    user.get("login")
1368                        .or_else(|| user.get("display"))
1369                        .or_else(|| user.get("id"))
1370                })
1371                .and_then(Value::as_str)
1372                .map(ToOwned::to_owned),
1373        })
1374    }
1375}
1376
1377/// One sprint of a board.
1378#[derive(Debug, Clone, serde::Serialize)]
1379pub struct Sprint {
1380    pub id: String,
1381    pub name: String,
1382    pub status: Option<String>,
1383    pub start: Option<String>,
1384    pub end: Option<String>,
1385}
1386
1387impl Sprint {
1388    fn parse(value: &Value) -> Option<Self> {
1389        Some(Self {
1390            id: match value.get("id")? {
1391                Value::String(id) => id.clone(),
1392                other => other.to_string(),
1393            },
1394            name: value
1395                .get("name")
1396                .and_then(Value::as_str)
1397                .unwrap_or_default()
1398                .to_owned(),
1399            status: value
1400                .get("status")
1401                .and_then(Value::as_str)
1402                .map(ToOwned::to_owned),
1403            start: value
1404                .get("startDate")
1405                .and_then(Value::as_str)
1406                .map(ToOwned::to_owned),
1407            end: value
1408                .get("endDate")
1409                .and_then(Value::as_str)
1410                .map(ToOwned::to_owned),
1411        })
1412    }
1413}
1414
1415/// The parts of an existing queue a new one can be built from.
1416#[derive(Debug, Clone)]
1417pub struct Blueprint {
1418    pub default_type: Option<String>,
1419    pub default_priority: Option<String>,
1420    /// `issueTypesConfig` as the create endpoint takes it: keys and ids, not
1421    /// the expanded objects the read answers with.
1422    pub issue_types: Vec<Value>,
1423}
1424
1425/// What `parentEntity` is set to: a portfolio, or nothing.
1426///
1427/// Removing is `null`, not an empty object — an empty object is a change
1428/// Tracker accepts and ignores, which reads as success and is not.
1429fn place_body(parent: Option<&str>) -> Value {
1430    match parent {
1431        Some(parent) => serde_json::json!({ "primary": parent }),
1432        None => Value::Null,
1433    }
1434}
1435
1436/// A queue with the settings that decide what an issue in it starts as.
1437///
1438/// The defaults are the point: `issue create -q PROJ` without a type or a
1439/// priority gets these, and nothing else says what they are.
1440#[derive(Debug, Clone, serde::Serialize)]
1441pub struct QueueSettings {
1442    pub key: String,
1443    pub name: String,
1444    pub lead: Option<String>,
1445    pub default_type: Option<String>,
1446    pub default_priority: Option<String>,
1447    pub version: Option<u64>,
1448}
1449
1450impl QueueSettings {
1451    fn parse(value: &Value) -> Option<Self> {
1452        let named = |name: &str| {
1453            value
1454                .get(name)
1455                .and_then(|field| field.get("key").or_else(|| field.get("display")))
1456                .and_then(Value::as_str)
1457                .map(ToOwned::to_owned)
1458        };
1459
1460        Some(Self {
1461            key: value.get("key").and_then(Value::as_str)?.to_owned(),
1462            name: value
1463                .get("name")
1464                .and_then(Value::as_str)
1465                .unwrap_or_default()
1466                .to_owned(),
1467            lead: value
1468                .get("lead")
1469                .and_then(|lead| {
1470                    lead.get("login")
1471                        .or_else(|| lead.get("display"))
1472                        .or_else(|| lead.get("id"))
1473                })
1474                .and_then(Value::as_str)
1475                .map(ToOwned::to_owned),
1476            default_type: named("defaultType"),
1477            default_priority: named("defaultPriority"),
1478            version: value.get("version").and_then(Value::as_u64),
1479        })
1480    }
1481}
1482
1483/// Which templates are being asked for.
1484#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1485pub enum TemplateKind {
1486    Issue,
1487    Comment,
1488}
1489
1490impl TemplateKind {
1491    #[must_use]
1492    pub const fn path(self) -> &'static str {
1493        match self {
1494            Self::Issue => "issueTemplates",
1495            Self::Comment => "commentTemplates",
1496        }
1497    }
1498}
1499
1500/// One template, reduced to what a listing shows.
1501#[derive(Debug, Clone, serde::Serialize)]
1502pub struct Template {
1503    pub id: String,
1504    pub name: String,
1505    /// The queue a template belongs to, when it belongs to one.
1506    pub queue: Option<String>,
1507    pub author: Option<String>,
1508}
1509
1510impl Template {
1511    fn parse(value: &Value) -> Option<Self> {
1512        Some(Self {
1513            id: match value.get("id")? {
1514                Value::String(id) => id.clone(),
1515                other => other.to_string(),
1516            },
1517            name: value
1518                .get("name")
1519                .or_else(|| value.get("summary"))
1520                .and_then(Value::as_str)
1521                .unwrap_or_default()
1522                .to_owned(),
1523            queue: value
1524                .get("queue")
1525                .and_then(|queue| queue.get("key").or_else(|| queue.get("id")).or(Some(queue)))
1526                .and_then(Value::as_str)
1527                .map(ToOwned::to_owned),
1528            author: value
1529                .get("createdBy")
1530                .or_else(|| value.get("author"))
1531                .and_then(|user| {
1532                    user.get("login")
1533                        .or_else(|| user.get("display"))
1534                        .or_else(|| user.get("id"))
1535                })
1536                .and_then(Value::as_str)
1537                .map(ToOwned::to_owned),
1538        })
1539    }
1540}
1541
1542/// One field of a queue. `queue fields` is how a caller learns the keys that
1543/// `--fields` and `--set` accept, so the key matters more than the name here.
1544#[derive(Debug, Clone, serde::Serialize)]
1545pub struct QueueField {
1546    pub key: String,
1547    pub name: String,
1548    pub field_type: String,
1549    /// A field Tracker ships with, as opposed to one this queue defines.
1550    pub system: bool,
1551}
1552
1553impl QueueField {
1554    fn parse(value: &Value) -> Option<Self> {
1555        let id = value.get("id").and_then(Value::as_str)?;
1556        Some(Self {
1557            // Custom fields are addressed by the trailing segment of a
1558            // dotted id (`60...--storyPoints`), which is what the API accepts
1559            // back and what a caller can reasonably type.
1560            key: id.rsplit("--").next().unwrap_or(id).to_owned(),
1561            name: value
1562                .get("name")
1563                .and_then(Value::as_str)
1564                .unwrap_or(id)
1565                .to_owned(),
1566            field_type: value
1567                .get("schema")
1568                .and_then(|schema| schema.get("type"))
1569                .and_then(Value::as_str)
1570                .unwrap_or("unknown")
1571                .to_owned(),
1572            system: !id.contains("--"),
1573        })
1574    }
1575}
1576
1577/// The checklist out of whatever Tracker answered a checklist write with.
1578///
1579/// It replies with the issue, not the item, so the list is under
1580/// `checklistItems`; a bare array is accepted too, because an endpoint that
1581/// changes its mind about the envelope should not empty somebody's checklist.
1582fn checklist_of(value: &Value) -> Vec<ChecklistItem> {
1583    let entries = value
1584        .get("checklistItems")
1585        .and_then(Value::as_array)
1586        .or_else(|| value.as_array());
1587
1588    entries
1589        .map(|entries| entries.iter().filter_map(parse::checklist_item).collect())
1590        .unwrap_or_default()
1591}
1592
1593/// Turn a response into either its body or a typed error.
1594///
1595/// `what` names the thing being fetched so a 404 can say which one, rather than
1596/// leaving the caller to guess between the issue and one of its subresources.
1597async fn classify(response: reqwest::Response, what: &str) -> Result<String, ApiError> {
1598    let status = response.status();
1599    if status.is_success() {
1600        return Ok(response.text().await?);
1601    }
1602
1603    let message = response.text().await.unwrap_or_default();
1604    Err(match status.as_u16() {
1605        401 => ApiError::Unauthorized,
1606        403 => ApiError::Forbidden,
1607        404 => ApiError::NotFound(what.to_owned()),
1608        429 => ApiError::RateLimited,
1609        _ => ApiError::Rejected {
1610            status,
1611            message: complaint(&message),
1612        },
1613    })
1614}
1615
1616/// What Tracker actually said, out of the envelope it says it in.
1617///
1618/// A rejection arrives as `{"errors": …, "errorMessages": […], "statusCode": …}`,
1619/// and printing the whole envelope buries the one sentence a caller can act on
1620/// under punctuation it cannot. The body is kept verbatim when it is not that
1621/// shape, since an unrecognised error is exactly when guessing is worst.
1622fn complaint(body: &str) -> String {
1623    let messages = serde_json::from_str::<Value>(body)
1624        .ok()
1625        .and_then(|value| {
1626            let mut said: Vec<String> = value
1627                .get("errorMessages")
1628                .and_then(Value::as_array)
1629                .map(|entries| {
1630                    entries
1631                        .iter()
1632                        .filter_map(Value::as_str)
1633                        .map(ToOwned::to_owned)
1634                        .collect()
1635                })
1636                .unwrap_or_default();
1637            // `errors` is keyed by field, and a field-level complaint is the
1638            // most specific thing in the envelope when it is there.
1639            if let Some(errors) = value.get("errors").and_then(Value::as_object) {
1640                said.extend(
1641                    errors
1642                        .iter()
1643                        .filter_map(|(field, text)| Some(format!("{field}: {}", text.as_str()?))),
1644                );
1645            }
1646            (!said.is_empty()).then(|| said.join("; "))
1647        })
1648        .unwrap_or_else(|| body.to_owned());
1649
1650    messages.chars().take(400).collect()
1651}
1652
1653/// Retry transport hiccups and server-side backpressure; never retry a request
1654/// the server has already judged invalid.
1655fn is_retryable(error: &ApiError) -> bool {
1656    match error {
1657        ApiError::RateLimited => true,
1658        ApiError::Transport(err) => err.is_timeout() || err.is_connect(),
1659        ApiError::Rejected { status, .. } => status.is_server_error(),
1660        _ => false,
1661    }
1662}
1663
1664#[cfg(test)]
1665mod tests {
1666    use super::*;
1667
1668    /// The sentence a caller can act on, not the envelope it arrived in.
1669    #[test]
1670    fn a_rejection_reads_as_what_tracker_said() {
1671        assert_eq!(
1672            complaint(
1673                r#"{"errors":{},"errorMessages":["A board of this type cannot have sprints."],"statusCode":400}"#
1674            ),
1675            "A board of this type cannot have sprints."
1676        );
1677    }
1678
1679    /// A field-level complaint names its field: `summary` being required is a
1680    /// different fix from `queue` being wrong.
1681    #[test]
1682    fn a_field_complaint_keeps_its_field() {
1683        assert_eq!(
1684            complaint(r#"{"errors":{"summary":"cannot be empty"},"errorMessages":[]}"#),
1685            "summary: cannot be empty"
1686        );
1687    }
1688
1689    /// An unrecognised body is passed through: guessing is worst precisely when
1690    /// the error is one we have not seen.
1691    #[test]
1692    fn an_unfamiliar_body_survives_untouched() {
1693        assert_eq!(
1694            complaint("<html>gateway timeout</html>"),
1695            "<html>gateway timeout</html>"
1696        );
1697        assert_eq!(complaint("{}"), "{}");
1698    }
1699
1700    #[test]
1701    fn host_comparison_ignores_scheme_path_and_case() {
1702        assert_eq!(
1703            host_of("https://API.tracker.yandex.net/v3/issues/PROJ-1"),
1704            host_of("https://api.tracker.yandex.net")
1705        );
1706    }
1707
1708    /// The download URL is server-supplied. A different host must not match, or
1709    /// a crafted attachment could send this client — and its OAuth header —
1710    /// somewhere else entirely.
1711    #[test]
1712    fn a_different_host_does_not_match() {
1713        assert_ne!(
1714            host_of("https://evil.example.com/steal"),
1715            host_of("https://api.tracker.yandex.net")
1716        );
1717    }
1718
1719    /// Nor a host that merely starts the same way.
1720    #[test]
1721    fn a_prefix_of_the_real_host_does_not_match() {
1722        assert_ne!(
1723            host_of("https://api.tracker.yandex.net.evil.com/steal"),
1724            host_of("https://api.tracker.yandex.net")
1725        );
1726    }
1727
1728    #[test]
1729    fn a_port_is_part_of_the_host() {
1730        assert_ne!(
1731            host_of("http://127.0.0.1:9999/x"),
1732            host_of("http://127.0.0.1:8888")
1733        );
1734    }
1735}