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