Skip to main content

ytcli/api/
mod.rs

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