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