1pub mod duration;
7pub mod error;
8pub mod models;
9pub mod parse;
10pub mod query;
11pub mod wiki;
12
13use std::time::Duration;
14
15use backon::{ExponentialBuilder, Retryable};
16use reqwest::header::{ACCEPT, AUTHORIZATION, HeaderMap, HeaderName, HeaderValue, USER_AGENT};
17
18use serde_json::Value;
19
20use crate::api::error::ApiError;
21use crate::api::models::{
22 Attachment, Change, ChecklistItem, Comment, DictEntry, Entity, Issue, Link, Page, Person,
23 RemoteLink, User, Worklog,
24};
25use crate::config::OrgKind;
26
27pub const DEFAULT_BASE_URL: &str = "https://api.tracker.yandex.net";
29
30const ENTITY_FIELDS: &str = "summary,description,entityStatus,start,end,lead,author,parentEntity";
36
37fn host_of(url: &str) -> Option<String> {
39 let without_scheme = url.split_once("://")?.1;
40 let authority = without_scheme
41 .split(['/', '?', '#'])
42 .next()
43 .unwrap_or(without_scheme);
44 Some(authority.to_ascii_lowercase())
45}
46
47#[derive(Debug, Clone)]
49pub struct ClientConfig {
50 pub base_url: String,
51 pub wiki_url: String,
53 pub token: String,
54 pub org_id: String,
55 pub org_kind: OrgKind,
56 pub timeout: Duration,
57 pub retries: usize,
59}
60
61impl ClientConfig {
62 #[must_use]
63 pub fn new(token: String, org_id: String, org_kind: OrgKind) -> Self {
64 Self {
65 base_url: DEFAULT_BASE_URL.to_owned(),
66 wiki_url: wiki::DEFAULT_WIKI_URL.to_owned(),
67 token,
68 org_id,
69 org_kind,
70 timeout: Duration::from_secs(30),
71 retries: 3,
72 }
73 }
74}
75
76#[derive(Debug, Clone)]
78pub struct Client {
79 http: reqwest::Client,
80 base_url: String,
81 wiki_url: String,
82 retries: usize,
83 org: String,
89}
90
91impl Client {
92 pub fn new(config: &ClientConfig) -> Result<Self, ApiError> {
93 let mut headers = HeaderMap::new();
94 headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
95 headers.insert(
96 USER_AGENT,
97 HeaderValue::from_static(concat!("ytcli/", env!("CARGO_PKG_VERSION"))),
98 );
99
100 let mut auth = HeaderValue::try_from(format!("OAuth {}", config.token))
102 .map_err(|_| ApiError::Unauthorized)?;
103 auth.set_sensitive(true);
104 headers.insert(AUTHORIZATION, auth);
105
106 let org_header = HeaderName::from_static(config.org_kind.header_name());
107 let org_value =
108 HeaderValue::try_from(config.org_id.clone()).map_err(|_| ApiError::Forbidden)?;
109 headers.insert(org_header, org_value);
110
111 let http = reqwest::Client::builder()
112 .timeout(config.timeout)
113 .default_headers(headers)
114 .build()?;
115
116 Ok(Self {
117 http,
118 base_url: config.base_url.trim_end_matches('/').to_owned(),
119 wiki_url: config.wiki_url.trim_end_matches('/').to_owned(),
120 retries: config.retries,
121 org: config.org_id.clone(),
122 })
123 }
124
125 #[must_use]
127 pub fn org(&self) -> &str {
128 &self.org
129 }
130
131 pub async fn myself(&self) -> Result<User, ApiError> {
134 let value = self.get_value("/v3/myself", "current user").await?;
135 Ok(User {
136 id: value
137 .get("uid")
138 .map_or_else(String::new, ToString::to_string),
139 login: value
140 .get("login")
141 .and_then(serde_json::Value::as_str)
142 .map(ToOwned::to_owned),
143 display: value
144 .get("display")
145 .and_then(serde_json::Value::as_str)
146 .map(ToOwned::to_owned),
147 })
148 }
149
150 pub async fn issue(&self, key: &str) -> Result<(Issue, Value), ApiError> {
155 let raw = self
156 .get_value(&format!("/v3/issues/{key}"), &format!("issue {key}"))
157 .await?;
158 let issue = parse::issue(&raw).ok_or_else(|| ApiError::NotFound(format!("issue {key}")))?;
159 Ok((issue, raw))
160 }
161
162 pub async fn issue_links(&self, key: &str) -> Result<Vec<Link>, ApiError> {
169 let raw = self
170 .get_value(
171 &format!("/v3/issues/{key}/links"),
172 &format!("issue {key} links"),
173 )
174 .await?;
175
176 Ok(raw
177 .as_array()
178 .map(|entries| entries.iter().filter_map(parse::link).collect())
179 .unwrap_or_default())
180 }
181
182 pub async fn issue_remote_links(&self, key: &str) -> Result<Vec<RemoteLink>, ApiError> {
188 let raw = self
189 .get_value(
190 &format!("/v3/issues/{key}/remotelinks"),
191 &format!("remote links of {key}"),
192 )
193 .await?;
194
195 Ok(raw
196 .as_array()
197 .map(|entries| entries.iter().filter_map(parse::remote_link).collect())
198 .unwrap_or_default())
199 }
200
201 pub async fn search(
207 &self,
208 query: &str,
209 page: u32,
210 per_page: u32,
211 ) -> Result<Page<Issue>, ApiError> {
212 let path = format!("/v3/issues/_search?page={page}&perPage={per_page}");
213 let body = serde_json::json!({ "query": query });
214 let (value, headers) = self.post_value(&path, &body, "issues").await?;
215
216 let items = value
217 .as_array()
218 .map(|entries| entries.iter().filter_map(parse::issue).collect())
219 .unwrap_or_default();
220
221 Ok(Page {
222 items,
223 page,
224 per_page,
225 total: headers
226 .get("x-total-count")
227 .and_then(|count| count.to_str().ok())
228 .and_then(|count| count.parse().ok()),
229 })
230 }
231
232 pub async fn count(&self, query: &str) -> Result<u64, ApiError> {
234 let body = serde_json::json!({ "query": query });
235 let (value, _) = self
236 .post_value("/v3/issues/_count", &body, "issues")
237 .await?;
238
239 value
240 .as_u64()
241 .ok_or_else(|| ApiError::NotFound("issue count".to_owned()))
242 }
243
244 pub async fn create_issue(&self, body: &Value) -> Result<Issue, ApiError> {
246 let (value, _) = self.post_value("/v3/issues/", body, "issue").await?;
247 parse::issue(&value).ok_or_else(|| ApiError::NotFound("created issue".to_owned()))
248 }
249
250 pub async fn update_issue(&self, key: &str, body: &Value) -> Result<Issue, ApiError> {
252 let value = self
253 .send_value(
254 reqwest::Method::PATCH,
255 &format!("/v3/issues/{key}"),
256 Some(body),
257 &format!("issue {key}"),
258 )
259 .await?
260 .0;
261 parse::issue(&value).ok_or_else(|| ApiError::NotFound(format!("issue {key}")))
262 }
263
264 pub async fn add_comment(&self, key: &str, text: &str) -> Result<Comment, ApiError> {
266 let body = serde_json::json!({ "text": text });
267 let (value, _) = self
268 .post_value(
269 &format!("/v3/issues/{key}/comments"),
270 &body,
271 &format!("issue {key}"),
272 )
273 .await?;
274 parse::comment(&value).ok_or_else(|| ApiError::NotFound("created comment".to_owned()))
275 }
276
277 #[cfg(feature = "live")]
284 pub async fn probe_get(&self, path: &str) -> Result<Value, ApiError> {
285 self.get_value(path, path).await
286 }
287
288 #[cfg(feature = "live")]
290 pub async fn probe_post(&self, path: &str, body: &Value) -> Result<Value, ApiError> {
291 let (value, _) = self.post_value(path, body, path).await?;
292 Ok(value)
293 }
294
295 #[cfg(feature = "live")]
297 pub async fn probe_patch(&self, path: &str, body: &Value) -> Result<Value, ApiError> {
298 let (value, _) = self
299 .send_value(reqwest::Method::PATCH, path, Some(body), path)
300 .await?;
301 Ok(value)
302 }
303
304 #[cfg(feature = "live")]
306 pub async fn probe_delete(&self, path: &str) -> Result<Value, ApiError> {
307 let (value, _) = self
308 .send_value(reqwest::Method::DELETE, path, None, path)
309 .await?;
310 Ok(value)
311 }
312
313 pub async fn update_comment(
318 &self,
319 key: &str,
320 id: &str,
321 text: &str,
322 ) -> Result<Comment, ApiError> {
323 let body = serde_json::json!({ "text": text });
324 let (value, _) = self
325 .send_value(
326 reqwest::Method::PATCH,
327 &format!("/v3/issues/{key}/comments/{id}"),
328 Some(&body),
329 &format!("comment {id} of issue {key}"),
330 )
331 .await?;
332 parse::comment(&value).ok_or_else(|| ApiError::NotFound(format!("comment {id}")))
333 }
334
335 pub async fn delete_comment(&self, key: &str, id: &str) -> Result<(), ApiError> {
337 self.send_value(
338 reqwest::Method::DELETE,
339 &format!("/v3/issues/{key}/comments/{id}"),
340 None,
341 &format!("comment {id} of issue {key}"),
342 )
343 .await?;
344 Ok(())
345 }
346
347 pub async fn update_worklog(
349 &self,
350 key: &str,
351 id: &str,
352 body: &Value,
353 ) -> Result<Worklog, ApiError> {
354 let (value, _) = self
355 .send_value(
356 reqwest::Method::PATCH,
357 &format!("/v3/issues/{key}/worklog/{id}"),
358 Some(body),
359 &format!("worklog {id} of issue {key}"),
360 )
361 .await?;
362 parse::worklog(&value).ok_or_else(|| ApiError::NotFound(format!("worklog {id}")))
363 }
364
365 pub async fn worklogs(&self, key: &str) -> Result<Vec<Worklog>, ApiError> {
367 let raw = self
368 .get_value(
369 &format!("/v3/issues/{key}/worklog"),
370 &format!("issue {key} worklog"),
371 )
372 .await?;
373
374 Ok(raw
375 .as_array()
376 .map(|entries| entries.iter().filter_map(parse::worklog).collect())
377 .unwrap_or_default())
378 }
379
380 pub async fn add_worklog(&self, key: &str, body: &Value) -> Result<Worklog, ApiError> {
382 let (value, _) = self
383 .post_value(
384 &format!("/v3/issues/{key}/worklog"),
385 body,
386 &format!("issue {key} worklog"),
387 )
388 .await?;
389 parse::worklog(&value).ok_or_else(|| ApiError::NotFound("created worklog".to_owned()))
390 }
391
392 pub async fn delete_worklog(&self, key: &str, id: &str) -> Result<(), ApiError> {
394 self.send_value(
395 reqwest::Method::DELETE,
396 &format!("/v3/issues/{key}/worklog/{id}"),
397 None,
398 &format!("worklog {id} of issue {key}"),
399 )
400 .await?;
401 Ok(())
402 }
403
404 pub async fn checklist(&self, key: &str) -> Result<Vec<ChecklistItem>, ApiError> {
406 let raw = self
407 .get_value(
408 &format!("/v3/issues/{key}/checklistItems"),
409 &format!("issue {key} checklist"),
410 )
411 .await?;
412
413 Ok(raw
414 .as_array()
415 .map(|entries| entries.iter().filter_map(parse::checklist_item).collect())
416 .unwrap_or_default())
417 }
418
419 pub async fn add_checklist_item(
424 &self,
425 key: &str,
426 body: &Value,
427 ) -> Result<Vec<ChecklistItem>, ApiError> {
428 let (value, _) = self
429 .post_value(
430 &format!("/v3/issues/{key}/checklistItems"),
431 body,
432 &format!("issue {key} checklist"),
433 )
434 .await?;
435 Ok(checklist_of(&value))
436 }
437
438 pub async fn update_checklist_item(
440 &self,
441 key: &str,
442 id: &str,
443 body: &Value,
444 ) -> Result<Vec<ChecklistItem>, ApiError> {
445 let (value, _) = self
446 .send_value(
447 reqwest::Method::PATCH,
448 &format!("/v3/issues/{key}/checklistItems/{id}"),
449 Some(body),
450 &format!("checklist item {id} of issue {key}"),
451 )
452 .await?;
453 Ok(checklist_of(&value))
454 }
455
456 pub async fn delete_checklist_item(&self, key: &str, id: &str) -> Result<(), ApiError> {
458 self.send_value(
459 reqwest::Method::DELETE,
460 &format!("/v3/issues/{key}/checklistItems/{id}"),
461 None,
462 &format!("checklist item {id} of issue {key}"),
463 )
464 .await?;
465 Ok(())
466 }
467
468 pub async fn add_link(
470 &self,
471 key: &str,
472 relationship: &str,
473 other: &str,
474 ) -> Result<(), ApiError> {
475 let body = serde_json::json!({ "relationship": relationship, "issue": other });
476 self.post_value(
477 &format!("/v3/issues/{key}/links"),
478 &body,
479 &format!("issue {key} links"),
480 )
481 .await?;
482 Ok(())
483 }
484
485 pub async fn delete_link(&self, key: &str, id: &str) -> Result<(), ApiError> {
487 self.send_value(
488 reqwest::Method::DELETE,
489 &format!("/v3/issues/{key}/links/{id}"),
490 None,
491 &format!("link {id} of issue {key}"),
492 )
493 .await?;
494 Ok(())
495 }
496
497 pub async fn delete_attachment(&self, key: &str, id: &str) -> Result<(), ApiError> {
502 self.send_value(
503 reqwest::Method::DELETE,
504 &format!("/v3/issues/{key}/attachments/{id}"),
505 None,
506 &format!("attachment {id} of issue {key}"),
507 )
508 .await?;
509 Ok(())
510 }
511
512 pub async fn transitions(&self, key: &str) -> Result<Vec<Transition>, ApiError> {
514 let raw = self
515 .get_value(
516 &format!("/v3/issues/{key}/transitions"),
517 &format!("issue {key} transitions"),
518 )
519 .await?;
520
521 Ok(raw
522 .as_array()
523 .map(|entries| entries.iter().filter_map(Transition::parse).collect())
524 .unwrap_or_default())
525 }
526
527 pub async fn execute_transition(
529 &self,
530 key: &str,
531 transition: &str,
532 body: &Value,
533 ) -> Result<(), ApiError> {
534 self.post_value(
535 &format!("/v3/issues/{key}/transitions/{transition}/_execute"),
536 body,
537 &format!("transition {transition} of issue {key}"),
538 )
539 .await?;
540 Ok(())
541 }
542
543 pub async fn entities(
549 &self,
550 kind: &str,
551 input: Option<&str>,
552 page: u32,
553 per_page: u32,
554 ) -> Result<Page<Entity>, ApiError> {
555 let path = format!(
556 "/v3/entities/{kind}/_search?page={page}&perPage={per_page}&fields={ENTITY_FIELDS}"
557 );
558 let mut body = serde_json::Map::new();
559 if let Some(input) = input {
560 body.insert("input".to_owned(), Value::String(input.to_owned()));
561 }
562
563 let (value, _) = self
564 .post_value(&path, &Value::Object(body), &format!("{kind}s"))
565 .await?;
566
567 let items = value
568 .get("values")
569 .and_then(Value::as_array)
570 .map(|entries| entries.iter().filter_map(parse::entity).collect())
571 .unwrap_or_default();
572
573 Ok(Page {
574 items,
575 page,
576 per_page,
577 total: value.get("hits").and_then(Value::as_u64),
578 })
579 }
580
581 pub async fn entities_in(
587 &self,
588 parent: &str,
589 page: u32,
590 per_page: u32,
591 ) -> Result<Page<Entity>, ApiError> {
592 let mut items = Vec::new();
593 let mut total = 0;
594
595 for kind in ["portfolio", "project"] {
596 let path = format!(
597 "/v3/entities/{kind}/_search?page={page}&perPage={per_page}&fields={ENTITY_FIELDS}"
598 );
599 let body = serde_json::json!({ "filter": { "parentEntity": parent } });
600 let (value, _) = self
601 .post_value(&path, &body, &format!("{kind}s in {parent}"))
602 .await?;
603
604 if let Some(entries) = value.get("values").and_then(Value::as_array) {
605 items.extend(entries.iter().filter_map(parse::entity));
606 }
607 total += value.get("hits").and_then(Value::as_u64).unwrap_or(0);
608 }
609
610 Ok(Page {
611 items,
612 page,
613 per_page,
614 total: Some(total),
615 })
616 }
617
618 pub async fn entity(&self, kind: &str, id: &str) -> Result<Entity, ApiError> {
620 let raw = self
621 .get_value(
622 &format!("/v3/entities/{kind}/{id}?fields={ENTITY_FIELDS}"),
623 &format!("{kind} {id}"),
624 )
625 .await?;
626
627 parse::entity(&raw).ok_or_else(|| ApiError::NotFound(format!("{kind} {id}")))
628 }
629
630 pub async fn attachments(&self, key: &str) -> Result<Vec<Attachment>, ApiError> {
632 let raw = self
633 .get_value(
634 &format!("/v3/issues/{key}/attachments"),
635 &format!("issue {key} attachments"),
636 )
637 .await?;
638
639 Ok(raw
640 .as_array()
641 .map(|entries| entries.iter().filter_map(parse::attachment).collect())
642 .unwrap_or_default())
643 }
644
645 pub async fn download(&self, url: &str) -> Result<Vec<u8>, ApiError> {
652 let expected = host_of(&self.base_url);
653 if host_of(url) != expected {
654 return Err(ApiError::Rejected {
655 status: reqwest::StatusCode::BAD_REQUEST,
656 message: format!(
657 "attachment points at `{}`, which is not the configured Tracker host `{}`",
658 host_of(url).unwrap_or_default(),
659 expected.unwrap_or_default(),
660 ),
661 });
662 }
663
664 let response = self.http.get(url).send().await?;
665 let status = response.status();
666 if !status.is_success() {
667 return Err(match status.as_u16() {
668 401 => ApiError::Unauthorized,
669 403 => ApiError::Forbidden,
670 404 => ApiError::NotFound("attachment".to_owned()),
671 _ => ApiError::Rejected {
672 status,
673 message: String::new(),
674 },
675 });
676 }
677
678 Ok(response.bytes().await?.to_vec())
679 }
680
681 pub async fn upload(
683 &self,
684 key: &str,
685 filename: &str,
686 bytes: Vec<u8>,
687 ) -> Result<Attachment, ApiError> {
688 let part = reqwest::multipart::Part::bytes(bytes).file_name(filename.to_owned());
689 let form = reqwest::multipart::Form::new().part("file", part);
690
691 let url = format!("{}/v3/issues/{key}/attachments/", self.base_url);
692 let response = self.http.post(&url).multipart(form).send().await?;
693 let text = classify(response, &format!("issue {key}")).await?;
694
695 let value: Value = serde_json::from_str(&text).map_err(ApiError::Decode)?;
696 parse::attachment(&value)
697 .ok_or_else(|| ApiError::NotFound("uploaded attachment".to_owned()))
698 }
699
700 pub async fn queues(&self) -> Result<Vec<Queue>, ApiError> {
706 let raw = self.get_value("/v3/queues?perPage=1000", "queues").await?;
707
708 Ok(raw
709 .as_array()
710 .map(|entries| entries.iter().filter_map(Queue::parse).collect())
711 .unwrap_or_default())
712 }
713
714 pub async fn worklog_search(
720 &self,
721 who: Option<&str>,
722 since: Option<&str>,
723 until: Option<&str>,
724 per_page: u32,
725 ) -> Result<Vec<Worklog>, ApiError> {
726 use std::fmt::Write as _;
727
728 let mut query = format!("perPage={per_page}");
729 if let Some(who) = who {
730 let _ = write!(query, "&createdBy={who}");
731 }
732 match (since, until) {
735 (Some(since), Some(until)) => {
736 let _ = write!(query, "&createdAt=from:{since},to:{until}");
737 }
738 (Some(since), None) => {
739 let _ = write!(query, "&createdAt=from:{since}");
740 }
741 (None, Some(until)) => {
742 let _ = write!(query, "&createdAt=to:{until}");
743 }
744 (None, None) => {}
745 }
746
747 let raw = self
748 .get_value(&format!("/v3/worklog?{query}"), "worklog")
749 .await?;
750
751 Ok(raw
752 .as_array()
753 .map(|entries| entries.iter().filter_map(parse::worklog).collect())
754 .unwrap_or_default())
755 }
756
757 pub async fn move_issue(
764 &self,
765 key: &str,
766 queue: &str,
767 keep_fields: bool,
768 initial_status: bool,
769 ) -> Result<Issue, ApiError> {
770 let path = format!(
771 "/v3/issues/{key}/_move?queue={queue}&moveAllFields={keep_fields}&initialStatus={initial_status}"
772 );
773 let (raw, _) = self
774 .send_value(
775 reqwest::Method::POST,
776 &path,
777 Some(&serde_json::json!({})),
778 &format!("move {key} to {queue}"),
779 )
780 .await?;
781
782 parse::issue(&raw).ok_or_else(|| ApiError::NotFound(format!("issue {key} after the move")))
783 }
784
785 pub async fn changelog(&self, key: &str, per_page: u32) -> Result<Vec<Change>, ApiError> {
792 let raw = self
793 .get_value(
794 &format!("/v3/issues/{key}/changelog?perPage={per_page}"),
795 &format!("changelog of {key}"),
796 )
797 .await?;
798
799 Ok(raw
800 .as_array()
801 .map(|entries| entries.iter().filter_map(parse::change).collect())
802 .unwrap_or_default())
803 }
804
805 pub async fn queue_versions(&self, key: &str) -> Result<Vec<Version>, ApiError> {
810 let raw = self
811 .get_value(
812 &format!("/v3/queues/{key}/versions"),
813 &format!("versions of queue {key}"),
814 )
815 .await?;
816
817 Ok(raw
818 .as_array()
819 .map(|entries| entries.iter().filter_map(Version::parse).collect())
820 .unwrap_or_default())
821 }
822
823 pub async fn queue_tags(&self, key: &str) -> Result<Vec<String>, ApiError> {
825 let raw = self
826 .get_value(
827 &format!("/v3/queues/{key}/tags?perPage=1000"),
828 &format!("tags of queue {key}"),
829 )
830 .await?;
831
832 Ok(raw
836 .as_array()
837 .map(|entries| {
838 entries
839 .iter()
840 .filter_map(|entry| match entry {
841 Value::String(name) => Some(name.clone()),
842 other => other
843 .get("name")
844 .and_then(Value::as_str)
845 .map(ToOwned::to_owned),
846 })
847 .collect()
848 })
849 .unwrap_or_default())
850 }
851
852 pub async fn queue_automation(&self, key: &str) -> Result<Automation, ApiError> {
860 let mut unreadable = Vec::new();
861 let mut refused = None;
862
863 let mut section = |name: &'static str, result: Result<Value, ApiError>| match result {
864 Ok(value) => value.as_array().cloned().unwrap_or_default(),
865 Err(error) => {
866 unreadable.push(Unreadable {
867 section: name,
873 reason: match error {
874 ApiError::Forbidden => {
875 format!("{name} are readable by the queue owner only (403)")
876 }
877 ref other => other.to_string(),
878 },
879 });
880 refused.get_or_insert(error);
881 Vec::new()
882 }
883 };
884
885 let macros = section(
886 "macros",
887 self.get_value(
888 &format!("/v3/queues/{key}/macros"),
889 &format!("macros of queue {key}"),
890 )
891 .await,
892 );
893 let autoactions = section(
894 "autoactions",
895 self.get_value(
896 &format!("/v3/queues/{key}/autoactions"),
897 &format!("autoactions of queue {key}"),
898 )
899 .await,
900 );
901 let triggers = section(
902 "triggers",
903 self.get_value(
904 &format!("/v3/queues/{key}/triggers"),
905 &format!("triggers of queue {key}"),
906 )
907 .await,
908 );
909
910 if unreadable.len() == 3 {
911 return Err(refused.unwrap_or(ApiError::NotFound(format!("queue {key}"))));
912 }
913
914 Ok(Automation {
915 macros: macros.iter().filter_map(Macro::parse).collect(),
916 autoactions: autoactions.iter().filter_map(AutoAction::parse).collect(),
917 triggers: triggers.iter().filter_map(Trigger::parse).collect(),
918 unreadable,
919 })
920 }
921
922 pub async fn components(&self, queue: Option<&str>) -> Result<Vec<Component>, ApiError> {
928 let (path, what) = match queue {
929 Some(queue) => (
930 format!("/v3/queues/{queue}/components"),
931 format!("components of queue {queue}"),
932 ),
933 None => ("/v3/components".to_owned(), "components".to_owned()),
934 };
935 let raw = self.get_value(&path, &what).await?;
936
937 Ok(raw
938 .as_array()
939 .map(|entries| entries.iter().filter_map(Component::parse).collect())
940 .unwrap_or_default())
941 }
942
943 pub async fn link_types(&self) -> Result<Vec<LinkType>, ApiError> {
949 let raw = self.get_value("/v3/linktypes", "link types").await?;
950
951 Ok(raw
952 .as_array()
953 .map(|entries| entries.iter().filter_map(LinkType::parse).collect())
954 .unwrap_or_default())
955 }
956
957 pub async fn queue_access(&self, key: &str) -> Result<QueueAccess, ApiError> {
968 let mut unreadable = Vec::new();
969 let mut refused = None;
970
971 let mut section = |name: &'static str, result: Result<Value, ApiError>| match result {
972 Ok(value) => Permission::parse_all(&value),
973 Err(error) => {
974 unreadable.push(Unreadable {
975 section: name,
976 reason: match error {
981 ApiError::Forbidden => {
982 format!(
983 "{name} are readable only by those who may see queue rights (403)"
984 )
985 }
986 ref other => other.to_string(),
987 },
988 });
989 refused.get_or_insert(error);
990 Vec::new()
991 }
992 };
993
994 let permissions = section(
995 "permissions",
996 self.get_value(
997 &format!("/v3/queues/{key}/permissions"),
998 &format!("permissions of queue {key}"),
999 )
1000 .await,
1001 );
1002 let access = section(
1003 "access",
1004 self.get_value(
1005 &format!("/v3/queues/{key}/access"),
1006 &format!("access of queue {key}"),
1007 )
1008 .await,
1009 );
1010
1011 if unreadable.len() == 2 {
1012 return Err(match refused {
1013 Some(ApiError::NotFound(_)) | None => ApiError::NotFound(format!("queue {key}")),
1016 Some(other) => other,
1017 });
1018 }
1019
1020 let you = match self.myself().await {
1024 Ok(user) => Some(user.id),
1025 Err(_) => None,
1026 };
1027
1028 Ok(QueueAccess {
1029 permissions,
1030 access,
1031 you,
1032 unreadable,
1033 })
1034 }
1035
1036 pub async fn bulk_update(
1047 &self,
1048 keys: &[String],
1049 values: &Value,
1050 ) -> Result<BulkChange, ApiError> {
1051 let body = serde_json::json!({ "issues": keys, "values": values });
1052 let (value, _) = self
1053 .post_value("/v3/bulkchange/_update", &body, "bulk change")
1054 .await?;
1055 BulkChange::parse(&value).ok_or_else(|| ApiError::NotFound("bulk change".to_owned()))
1056 }
1057
1058 pub async fn bulk_transition(
1064 &self,
1065 keys: &[String],
1066 transition: &str,
1067 values: &Value,
1068 ) -> Result<BulkChange, ApiError> {
1069 let mut body = serde_json::json!({ "issues": keys, "transition": transition });
1070 if !values.as_object().is_some_and(serde_json::Map::is_empty)
1071 && let Some(object) = body.as_object_mut()
1072 {
1073 object.insert("values".to_owned(), values.clone());
1074 }
1075 let (value, _) = self
1076 .post_value("/v3/bulkchange/_transition", &body, "bulk change")
1077 .await?;
1078 BulkChange::parse(&value).ok_or_else(|| ApiError::NotFound("bulk change".to_owned()))
1079 }
1080
1081 pub async fn bulk_move(
1086 &self,
1087 keys: &[String],
1088 queue: &str,
1089 keep_fields: bool,
1090 initial_status: bool,
1091 ) -> Result<BulkChange, ApiError> {
1092 let body = serde_json::json!({
1093 "issues": keys,
1094 "queue": queue,
1095 "moveAllFields": keep_fields,
1096 "initialStatus": initial_status,
1097 });
1098 let (value, _) = self
1099 .post_value("/v3/bulkchange/_move", &body, "bulk change")
1100 .await?;
1101 BulkChange::parse(&value).ok_or_else(|| ApiError::NotFound("bulk change".to_owned()))
1102 }
1103
1104 pub async fn bulk_change(&self, id: &str) -> Result<BulkChange, ApiError> {
1106 let value = self
1107 .get_value(
1108 &format!("/v3/bulkchange/{id}"),
1109 &format!("bulk change {id}"),
1110 )
1111 .await?;
1112 BulkChange::parse(&value).ok_or_else(|| ApiError::NotFound(format!("bulk change {id}")))
1113 }
1114
1115 pub async fn bulk_change_issues(&self, id: &str) -> Result<Vec<BulkOutcome>, ApiError> {
1121 let raw = self
1122 .get_value(
1123 &format!("/v3/bulkchange/{id}/issues"),
1124 &format!("bulk change {id}"),
1125 )
1126 .await?;
1127 Ok(raw
1128 .as_array()
1129 .map(|entries| entries.iter().filter_map(BulkOutcome::parse).collect())
1130 .unwrap_or_default())
1131 }
1132
1133 pub async fn dictionary(&self, kind: Dictionary) -> Result<Vec<DictEntry>, ApiError> {
1138 let raw = self
1139 .get_value(&format!("/v3/{}", kind.path()), kind.path())
1140 .await?;
1141
1142 Ok(raw
1143 .as_array()
1144 .map(|entries| entries.iter().filter_map(parse::dict_entry).collect())
1145 .unwrap_or_default())
1146 }
1147
1148 pub async fn users(&self, page: u32, per_page: u32) -> Result<Page<Person>, ApiError> {
1154 let path = format!("/v3/users?page={page}&perPage={per_page}");
1155 let (value, headers) = self
1156 .send_value(reqwest::Method::GET, &path, None, "users")
1157 .await?;
1158
1159 let items = value
1160 .as_array()
1161 .map(|entries| entries.iter().filter_map(parse::person).collect())
1162 .unwrap_or_default();
1163
1164 Ok(Page {
1165 items,
1166 page,
1167 per_page,
1168 total: headers
1169 .get("x-total-count")
1170 .and_then(|count| count.to_str().ok())
1171 .and_then(|count| count.parse().ok()),
1172 })
1173 }
1174
1175 pub async fn user(&self, who: &str) -> Result<Person, ApiError> {
1180 let raw = self
1181 .get_value(&format!("/v3/users/{who}"), &format!("user {who}"))
1182 .await?;
1183
1184 parse::person(&raw).ok_or_else(|| ApiError::NotFound(format!("user {who}")))
1185 }
1186
1187 pub async fn boards(&self) -> Result<Vec<Board>, ApiError> {
1192 let raw = self.get_value("/v3/boards", "boards").await?;
1193
1194 Ok(raw
1195 .as_array()
1196 .map(|entries| entries.iter().filter_map(Board::parse).collect())
1197 .unwrap_or_default())
1198 }
1199
1200 pub async fn board(&self, id: &str) -> Result<Board, ApiError> {
1202 let raw = self
1203 .get_value(&format!("/v3/boards/{id}"), &format!("board {id}"))
1204 .await?;
1205
1206 Board::parse(&raw).ok_or_else(|| ApiError::NotFound(format!("board {id}")))
1207 }
1208
1209 pub async fn sprints(&self, board: &str) -> Result<Vec<Sprint>, ApiError> {
1217 let raw = self
1218 .get_value(
1219 &format!("/v3/boards/{board}/sprints"),
1220 &format!("board {board} sprints"),
1221 )
1222 .await?;
1223
1224 Ok(raw
1225 .as_array()
1226 .map(|entries| entries.iter().filter_map(Sprint::parse).collect())
1227 .unwrap_or_default())
1228 }
1229
1230 pub async fn sprint(&self, id: &str) -> Result<Sprint, ApiError> {
1236 let raw = self
1237 .get_value(&format!("/v3/sprints/{id}"), &format!("sprint {id}"))
1238 .await?;
1239 Sprint::parse(&raw).ok_or_else(|| ApiError::NotFound(format!("sprint {id}")))
1240 }
1241
1242 pub async fn all_sprints(&self) -> Result<Vec<Sprint>, ApiError> {
1248 let raw = self.get_value("/v3/sprints", "sprints").await?;
1249
1250 Ok(raw
1251 .as_array()
1252 .map(|entries| entries.iter().filter_map(Sprint::parse).collect())
1253 .unwrap_or_default())
1254 }
1255
1256 pub async fn queue_local_fields(&self, key: &str) -> Result<Vec<FieldSpec>, ApiError> {
1264 let raw = self
1265 .get_value(
1266 &format!("/v3/queues/{key}/localFields"),
1267 &format!("local fields of queue {key}"),
1268 )
1269 .await?;
1270
1271 Ok(raw
1272 .as_array()
1273 .map(|entries| entries.iter().filter_map(FieldSpec::parse).collect())
1274 .unwrap_or_default())
1275 }
1276
1277 pub async fn create_entity(&self, kind: &str, fields: &Value) -> Result<Entity, ApiError> {
1282 let body = serde_json::json!({ "fields": fields });
1283 let (value, _) = self
1284 .post_value(
1285 &format!("/v3/entities/{kind}?fields={ENTITY_FIELDS}"),
1286 &body,
1287 kind,
1288 )
1289 .await?;
1290
1291 parse::entity(&value).ok_or_else(|| ApiError::NotFound(kind.to_owned()))
1292 }
1293
1294 pub async fn delete_entity(&self, kind: &str, id: &str) -> Result<(), ApiError> {
1300 self.send_value(
1301 reqwest::Method::DELETE,
1302 &format!("/v3/entities/{kind}/{id}"),
1303 None,
1304 &format!("{kind} {id}"),
1305 )
1306 .await?;
1307 Ok(())
1308 }
1309
1310 pub async fn update_entity(
1315 &self,
1316 kind: &str,
1317 id: &str,
1318 fields: &Value,
1319 version: Option<u64>,
1320 ) -> Result<Entity, ApiError> {
1321 let path = match version {
1322 Some(version) => {
1323 format!("/v3/entities/{kind}/{id}?version={version}&fields={ENTITY_FIELDS}")
1324 }
1325 None => format!("/v3/entities/{kind}/{id}?fields={ENTITY_FIELDS}"),
1326 };
1327 let body = serde_json::json!({ "fields": fields });
1328
1329 let (value, _) = self
1330 .send_value(
1331 reqwest::Method::PATCH,
1332 &path,
1333 Some(&body),
1334 &format!("{kind} {id}"),
1335 )
1336 .await?;
1337
1338 parse::entity(&value).ok_or_else(|| ApiError::NotFound(format!("{kind} {id}")))
1339 }
1340
1341 pub async fn place_entity(
1348 &self,
1349 kind: &str,
1350 id: &str,
1351 parent: Option<&str>,
1352 version: Option<u64>,
1353 ) -> Result<Entity, ApiError> {
1354 let path = match version {
1358 Some(version) => {
1359 format!("/v3/entities/{kind}/{id}?version={version}&fields={ENTITY_FIELDS}")
1360 }
1361 None => format!("/v3/entities/{kind}/{id}?fields={ENTITY_FIELDS}"),
1362 };
1363 let body = serde_json::json!({
1364 "fields": { "parentEntity": place_body(parent) }
1365 });
1366
1367 let (value, _) = self
1368 .send_value(
1369 reqwest::Method::PATCH,
1370 &path,
1371 Some(&body),
1372 &format!("{kind} {id}"),
1373 )
1374 .await?;
1375
1376 parse::entity(&value).ok_or_else(|| ApiError::NotFound(format!("{kind} {id}")))
1377 }
1378
1379 pub async fn queue(&self, key: &str) -> Result<QueueSettings, ApiError> {
1381 let raw = self
1382 .get_value(&format!("/v3/queues/{key}"), &format!("queue {key}"))
1383 .await?;
1384
1385 QueueSettings::parse(&raw).ok_or_else(|| ApiError::NotFound(format!("queue {key}")))
1386 }
1387
1388 pub async fn queue_blueprint(&self, key: &str) -> Result<Blueprint, ApiError> {
1395 let raw = self
1396 .get_value(
1397 &format!("/v3/queues/{key}?expand=all"),
1398 &format!("queue {key}"),
1399 )
1400 .await?;
1401
1402 let named = |name: &str| {
1403 raw.get(name)
1404 .and_then(|field| field.get("key"))
1405 .and_then(Value::as_str)
1406 .map(ToOwned::to_owned)
1407 };
1408
1409 let types = raw
1410 .get("issueTypesConfig")
1411 .and_then(Value::as_array)
1412 .map(|entries| {
1413 entries
1414 .iter()
1415 .filter_map(|entry| {
1416 Some(serde_json::json!({
1417 "issueType": entry.get("issueType")?.get("key")?.as_str()?,
1418 "workflow": entry.get("workflow")?.get("id")?.as_str()?,
1419 "resolutions": entry
1420 .get("resolutions")
1421 .and_then(Value::as_array)
1422 .map(|resolutions| {
1423 resolutions
1424 .iter()
1425 .filter_map(|resolution| {
1426 resolution.get("key").and_then(Value::as_str)
1427 })
1428 .collect::<Vec<_>>()
1429 })
1430 .unwrap_or_default(),
1431 }))
1432 })
1433 .collect::<Vec<_>>()
1434 })
1435 .unwrap_or_default();
1436
1437 if types.is_empty() {
1438 return Err(ApiError::NotFound(format!("issue types of queue {key}")));
1439 }
1440
1441 Ok(Blueprint {
1442 default_type: named("defaultType"),
1443 default_priority: named("defaultPriority"),
1444 issue_types: types,
1445 })
1446 }
1447
1448 pub async fn create_queue(&self, body: &Value) -> Result<QueueSettings, ApiError> {
1450 let (value, _) = self.post_value("/v3/queues", body, "queue").await?;
1451
1452 QueueSettings::parse(&value)
1453 .ok_or_else(|| ApiError::NotFound("the created queue".to_owned()))
1454 }
1455
1456 pub async fn fields(&self) -> Result<Vec<QueueField>, ApiError> {
1462 let raw = self.get_value("/v3/fields", "fields").await?;
1463
1464 Ok(raw
1465 .as_array()
1466 .map(|entries| entries.iter().filter_map(QueueField::parse).collect())
1467 .unwrap_or_default())
1468 }
1469
1470 pub async fn field(&self, key: &str) -> Result<FieldSpec, ApiError> {
1476 let raw = self
1477 .get_value(&format!("/v3/fields/{key}"), &format!("field {key}"))
1478 .await?;
1479
1480 FieldSpec::parse(&raw).ok_or_else(|| ApiError::NotFound(format!("field {key}")))
1481 }
1482
1483 pub async fn templates(&self, kind: TemplateKind) -> Result<Vec<Template>, ApiError> {
1489 let raw = self
1490 .get_value(&format!("/v3/{}", kind.path()), kind.path())
1491 .await?;
1492
1493 Ok(raw
1494 .as_array()
1495 .map(|entries| entries.iter().filter_map(Template::parse).collect())
1496 .unwrap_or_default())
1497 }
1498
1499 pub async fn issue_comments(&self, key: &str) -> Result<Vec<Comment>, ApiError> {
1505 let raw = self
1506 .get_value(
1507 &format!("/v3/issues/{key}/comments?perPage=100"),
1508 &format!("issue {key} comments"),
1509 )
1510 .await?;
1511
1512 Ok(raw
1513 .as_array()
1514 .map(|entries| entries.iter().filter_map(parse::comment).collect())
1515 .unwrap_or_default())
1516 }
1517
1518 pub async fn queue_fields(&self, key: &str) -> Result<Vec<QueueField>, ApiError> {
1520 let raw = self
1521 .get_value(
1522 &format!("/v3/queues/{key}/fields"),
1523 &format!("queue {key} fields"),
1524 )
1525 .await?;
1526
1527 Ok(raw
1528 .as_array()
1529 .map(|entries| entries.iter().filter_map(QueueField::parse).collect())
1530 .unwrap_or_default())
1531 }
1532
1533 async fn post_value(
1536 &self,
1537 path: &str,
1538 body: &Value,
1539 what: &str,
1540 ) -> Result<(Value, reqwest::header::HeaderMap), ApiError> {
1541 self.send_value(reqwest::Method::POST, path, Some(body), what)
1542 .await
1543 }
1544
1545 async fn send_value(
1546 &self,
1547 method: reqwest::Method,
1548 path: &str,
1549 body: Option<&Value>,
1550 what: &str,
1551 ) -> Result<(Value, reqwest::header::HeaderMap), ApiError> {
1552 let url = format!("{}{path}", self.base_url);
1553 self.send_url(method, &url, body, what).await
1554 }
1555
1556 async fn send_url(
1558 &self,
1559 method: reqwest::Method,
1560 url: &str,
1561 body: Option<&Value>,
1562 what: &str,
1563 ) -> Result<(Value, reqwest::header::HeaderMap), ApiError> {
1564 let send = || async {
1565 let mut request = self.http.request(method.clone(), url);
1566 if let Some(body) = body {
1567 request = request.json(body);
1568 }
1569 let response = request.send().await?;
1570 let headers = response.headers().clone();
1571 let text = classify(response, what).await?;
1572 Ok((text, headers))
1573 };
1574
1575 let (text, headers) = if method == reqwest::Method::GET {
1578 send.retry(
1579 ExponentialBuilder::default()
1580 .with_max_times(self.retries)
1581 .with_jitter(),
1582 )
1583 .when(is_retryable)
1584 .await?
1585 } else {
1586 send().await?
1587 };
1588
1589 let value = if text.trim().is_empty() {
1591 Value::Null
1592 } else {
1593 serde_json::from_str(&text).map_err(ApiError::Decode)?
1594 };
1595 Ok((value, headers))
1596 }
1597
1598 async fn get_value(&self, path: &str, what: &str) -> Result<Value, ApiError> {
1599 Ok(self
1600 .send_value(reqwest::Method::GET, path, None, what)
1601 .await?
1602 .0)
1603 }
1604}
1605
1606#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1612pub enum Dictionary {
1613 Types,
1614 Priorities,
1615 Statuses,
1616 Resolutions,
1617}
1618
1619impl Dictionary {
1620 pub const ALL: [Self; 4] = [
1623 Self::Types,
1624 Self::Priorities,
1625 Self::Statuses,
1626 Self::Resolutions,
1627 ];
1628
1629 #[must_use]
1630 pub fn path(self) -> &'static str {
1631 match self {
1632 Self::Types => "issuetypes",
1633 Self::Priorities => "priorities",
1634 Self::Statuses => "statuses",
1635 Self::Resolutions => "resolutions",
1636 }
1637 }
1638
1639 #[must_use]
1641 pub fn label(self) -> &'static str {
1642 match self {
1643 Self::Types => "types",
1644 Self::Priorities => "priorities",
1645 Self::Statuses => "statuses",
1646 Self::Resolutions => "resolutions",
1647 }
1648 }
1649}
1650
1651#[derive(Debug, Clone, serde::Serialize)]
1653pub struct Transition {
1654 pub id: String,
1655 pub name: String,
1656 pub to: Option<String>,
1658 #[serde(skip_serializing_if = "Option::is_none")]
1664 pub to_key: Option<String>,
1665}
1666
1667impl Transition {
1668 fn parse(value: &Value) -> Option<Self> {
1669 Some(Self {
1670 id: value.get("id").and_then(Value::as_str)?.to_owned(),
1671 name: value
1672 .get("display")
1673 .and_then(Value::as_str)
1674 .unwrap_or_default()
1675 .to_owned(),
1676 to: value
1677 .get("to")
1678 .and_then(|to| to.get("display").or_else(|| to.get("key")))
1679 .and_then(Value::as_str)
1680 .map(ToOwned::to_owned),
1681 to_key: value
1682 .get("to")
1683 .and_then(|to| to.get("key"))
1684 .and_then(Value::as_str)
1685 .map(ToOwned::to_owned),
1686 })
1687 }
1688}
1689
1690#[derive(Debug, Clone, serde::Serialize)]
1692pub struct Queue {
1693 pub key: String,
1694 pub name: String,
1695 pub lead: Option<String>,
1696}
1697
1698impl Queue {
1699 fn parse(value: &Value) -> Option<Self> {
1700 Some(Self {
1701 key: value.get("key").and_then(Value::as_str)?.to_owned(),
1702 name: value
1703 .get("name")
1704 .and_then(Value::as_str)
1705 .unwrap_or_default()
1706 .to_owned(),
1707 lead: value
1708 .get("lead")
1709 .and_then(|lead| {
1710 lead.get("login")
1711 .or_else(|| lead.get("display"))
1712 .or_else(|| lead.get("id"))
1713 })
1714 .and_then(Value::as_str)
1715 .map(ToOwned::to_owned),
1716 })
1717 }
1718}
1719
1720#[derive(Debug, Clone, serde::Serialize)]
1722pub struct Version {
1723 pub id: String,
1724 pub name: String,
1725 pub description: Option<String>,
1726 pub state: &'static str,
1728 pub due: Option<String>,
1729}
1730
1731impl Version {
1732 fn parse(value: &Value) -> Option<Self> {
1733 let flag = |member: &str| value.get(member).and_then(Value::as_bool).unwrap_or(false);
1734
1735 Some(Self {
1736 id: match value.get("id")? {
1737 Value::String(id) => id.clone(),
1738 other => other.to_string(),
1739 },
1740 name: value
1741 .get("name")
1742 .and_then(Value::as_str)
1743 .unwrap_or_default()
1744 .to_owned(),
1745 description: value
1746 .get("description")
1747 .and_then(Value::as_str)
1748 .filter(|text| !text.is_empty())
1749 .map(ToOwned::to_owned),
1750 state: if flag("archived") {
1753 "archived"
1754 } else if flag("released") {
1755 "released"
1756 } else {
1757 "open"
1758 },
1759 due: value
1760 .get("dueDate")
1761 .and_then(Value::as_str)
1762 .map(ToOwned::to_owned),
1763 })
1764 }
1765}
1766
1767#[derive(Debug, Clone, serde::Serialize)]
1772pub struct Board {
1773 pub id: String,
1774 pub name: String,
1775 pub columns: Vec<String>,
1776 pub estimate_by: Option<String>,
1778 pub owner: Option<String>,
1779}
1780
1781impl Board {
1782 fn parse(value: &Value) -> Option<Self> {
1783 Some(Self {
1784 id: match value.get("id")? {
1785 Value::String(id) => id.clone(),
1786 other => other.to_string(),
1787 },
1788 name: value
1789 .get("name")
1790 .and_then(Value::as_str)
1791 .unwrap_or_default()
1792 .to_owned(),
1793 columns: value
1794 .get("columns")
1795 .and_then(Value::as_array)
1796 .map(|columns| {
1797 columns
1798 .iter()
1799 .filter_map(|column| {
1800 column
1801 .get("display")
1802 .or_else(|| column.get("id"))
1803 .and_then(Value::as_str)
1804 .map(ToOwned::to_owned)
1805 })
1806 .collect()
1807 })
1808 .unwrap_or_default(),
1809 estimate_by: value
1810 .get("estimateBy")
1811 .and_then(|field| field.get("id").or_else(|| field.get("display")))
1812 .and_then(Value::as_str)
1813 .map(ToOwned::to_owned),
1814 owner: value
1817 .get("createdBy")
1818 .and_then(|user| {
1819 user.get("login")
1820 .or_else(|| user.get("display"))
1821 .or_else(|| user.get("id"))
1822 })
1823 .and_then(Value::as_str)
1824 .map(ToOwned::to_owned),
1825 })
1826 }
1827}
1828
1829#[derive(Debug, Clone, serde::Serialize)]
1831pub struct Sprint {
1832 pub id: String,
1833 pub name: String,
1834 pub status: Option<String>,
1835 pub start: Option<String>,
1836 pub end: Option<String>,
1837 #[serde(skip_serializing_if = "Option::is_none")]
1842 pub board: Option<String>,
1843}
1844
1845impl Sprint {
1846 fn parse(value: &Value) -> Option<Self> {
1847 Some(Self {
1848 id: match value.get("id")? {
1849 Value::String(id) => id.clone(),
1850 other => other.to_string(),
1851 },
1852 name: value
1853 .get("name")
1854 .and_then(Value::as_str)
1855 .unwrap_or_default()
1856 .to_owned(),
1857 status: value
1858 .get("status")
1859 .and_then(Value::as_str)
1860 .map(ToOwned::to_owned),
1861 start: value
1862 .get("startDate")
1863 .and_then(Value::as_str)
1864 .map(ToOwned::to_owned),
1865 end: value
1866 .get("endDate")
1867 .and_then(Value::as_str)
1868 .map(ToOwned::to_owned),
1869 board: value
1870 .get("board")
1871 .and_then(|board| board.get("display").or_else(|| board.get("id")))
1872 .and_then(Value::as_str)
1873 .map(ToOwned::to_owned),
1874 })
1875 }
1876}
1877
1878#[derive(Debug, Clone)]
1880pub struct Blueprint {
1881 pub default_type: Option<String>,
1882 pub default_priority: Option<String>,
1883 pub issue_types: Vec<Value>,
1886}
1887
1888fn place_body(parent: Option<&str>) -> Value {
1893 match parent {
1894 Some(parent) => serde_json::json!({ "primary": parent }),
1895 None => Value::Null,
1896 }
1897}
1898
1899#[derive(Debug, Clone, serde::Serialize)]
1904pub struct QueueSettings {
1905 pub key: String,
1906 pub name: String,
1907 pub lead: Option<String>,
1908 pub default_type: Option<String>,
1909 pub default_priority: Option<String>,
1910 pub version: Option<u64>,
1911}
1912
1913impl QueueSettings {
1914 fn parse(value: &Value) -> Option<Self> {
1915 let named = |name: &str| {
1916 value
1917 .get(name)
1918 .and_then(|field| field.get("key").or_else(|| field.get("display")))
1919 .and_then(Value::as_str)
1920 .map(ToOwned::to_owned)
1921 };
1922
1923 Some(Self {
1924 key: value.get("key").and_then(Value::as_str)?.to_owned(),
1925 name: value
1926 .get("name")
1927 .and_then(Value::as_str)
1928 .unwrap_or_default()
1929 .to_owned(),
1930 lead: value
1931 .get("lead")
1932 .and_then(|lead| {
1933 lead.get("login")
1934 .or_else(|| lead.get("display"))
1935 .or_else(|| lead.get("id"))
1936 })
1937 .and_then(Value::as_str)
1938 .map(ToOwned::to_owned),
1939 default_type: named("defaultType"),
1940 default_priority: named("defaultPriority"),
1941 version: value.get("version").and_then(Value::as_u64),
1942 })
1943 }
1944}
1945
1946#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1948pub enum TemplateKind {
1949 Issue,
1950 Comment,
1951}
1952
1953impl TemplateKind {
1954 #[must_use]
1955 pub const fn path(self) -> &'static str {
1956 match self {
1957 Self::Issue => "issueTemplates",
1958 Self::Comment => "commentTemplates",
1959 }
1960 }
1961}
1962
1963#[derive(Debug, Clone, serde::Serialize)]
1965pub struct Template {
1966 pub id: String,
1967 pub name: String,
1968 pub queue: Option<String>,
1970 pub author: Option<String>,
1971}
1972
1973impl Template {
1974 fn parse(value: &Value) -> Option<Self> {
1975 Some(Self {
1976 id: match value.get("id")? {
1977 Value::String(id) => id.clone(),
1978 other => other.to_string(),
1979 },
1980 name: value
1981 .get("name")
1982 .or_else(|| value.get("summary"))
1983 .and_then(Value::as_str)
1984 .unwrap_or_default()
1985 .to_owned(),
1986 queue: value
1987 .get("queue")
1988 .and_then(|queue| queue.get("key").or_else(|| queue.get("id")).or(Some(queue)))
1989 .and_then(Value::as_str)
1990 .map(ToOwned::to_owned),
1991 author: value
1992 .get("createdBy")
1993 .or_else(|| value.get("author"))
1994 .and_then(|user| {
1995 user.get("login")
1996 .or_else(|| user.get("display"))
1997 .or_else(|| user.get("id"))
1998 })
1999 .and_then(Value::as_str)
2000 .map(ToOwned::to_owned),
2001 })
2002 }
2003}
2004
2005#[derive(Debug, Clone, serde::Serialize)]
2008pub struct QueueField {
2009 pub key: String,
2010 pub name: String,
2011 pub field_type: String,
2012 pub system: bool,
2014}
2015
2016impl QueueField {
2017 fn parse(value: &Value) -> Option<Self> {
2018 let id = value.get("id").and_then(Value::as_str)?;
2019 Some(Self {
2020 key: id.rsplit("--").next().unwrap_or(id).to_owned(),
2024 name: value
2025 .get("name")
2026 .and_then(Value::as_str)
2027 .unwrap_or(id)
2028 .to_owned(),
2029 field_type: value
2030 .get("schema")
2031 .and_then(|schema| schema.get("type"))
2032 .and_then(Value::as_str)
2033 .unwrap_or("unknown")
2034 .to_owned(),
2035 system: !id.contains("--"),
2036 })
2037 }
2038}
2039
2040#[derive(Debug, Clone, serde::Serialize)]
2047pub struct LinkType {
2048 pub id: String,
2049 pub outward: Option<String>,
2051 pub inward: Option<String>,
2053}
2054
2055impl BulkChange {
2056 #[must_use]
2058 pub fn finished(&self) -> bool {
2059 matches!(self.status.as_str(), "COMPLETE" | "FAILED")
2060 }
2061
2062 #[must_use]
2067 pub fn succeeded(&self) -> bool {
2068 self.status == "COMPLETE" && self.done.is_some() && self.done == self.total
2069 }
2070
2071 fn parse(value: &Value) -> Option<Self> {
2072 Some(Self {
2073 id: value.get("id").and_then(Value::as_str)?.to_owned(),
2074 status: value
2075 .get("status")
2076 .and_then(Value::as_str)
2077 .unwrap_or_default()
2078 .to_owned(),
2079 status_text: value
2080 .get("statusText")
2081 .and_then(Value::as_str)
2082 .unwrap_or_default()
2083 .to_owned(),
2084 total: value.get("totalIssues").and_then(Value::as_u64),
2085 done: value.get("totalCompletedIssues").and_then(Value::as_u64),
2086 })
2087 }
2088}
2089
2090impl BulkOutcome {
2091 fn parse(value: &Value) -> Option<Self> {
2092 Some(Self {
2093 key: value
2094 .get("issue")
2095 .and_then(|issue| issue.get("key"))
2096 .and_then(Value::as_str)?
2097 .to_owned(),
2098 status: value
2099 .get("status")
2100 .and_then(Value::as_str)
2101 .unwrap_or_default()
2102 .to_owned(),
2103 error: value.get("error").and_then(field_errors),
2104 })
2105 }
2106}
2107
2108fn field_errors(error: &Value) -> Option<String> {
2114 let mut parts: Vec<String> = error
2115 .get("errors")
2116 .and_then(Value::as_object)
2117 .map(|fields| {
2118 fields
2119 .iter()
2120 .filter_map(|(field, message)| {
2121 message
2122 .as_str()
2123 .map(|message| format!("{field}: {message}"))
2124 })
2125 .collect()
2126 })
2127 .unwrap_or_default();
2128 parts.extend(
2129 error
2130 .get("errorMessages")
2131 .and_then(Value::as_array)
2132 .map(|messages| {
2133 messages
2134 .iter()
2135 .filter_map(Value::as_str)
2136 .map(ToOwned::to_owned)
2137 .collect::<Vec<_>>()
2138 })
2139 .unwrap_or_default(),
2140 );
2141
2142 if parts.is_empty() {
2143 None
2144 } else {
2145 Some(parts.join("; "))
2146 }
2147}
2148
2149impl Permission {
2150 fn parse_all(value: &Value) -> Vec<Self> {
2158 const ORDER: [&str; 5] = ["create", "read", "write", "writeNoAssign", "grant"];
2159
2160 let Some(object) = value.as_object() else {
2161 return Vec::new();
2162 };
2163
2164 let known = ORDER
2165 .iter()
2166 .filter_map(|name| object.get(*name).map(|entry| Self::parse(name, entry)));
2167 let rest = object
2168 .iter()
2169 .filter(|(name, entry)| !ORDER.contains(&name.as_str()) && entry.is_object())
2170 .filter(|(name, _)| !matches!(name.as_str(), "self" | "version"))
2174 .map(|(name, entry)| Self::parse(name, entry));
2175
2176 known.chain(rest).collect()
2177 }
2178
2179 fn parse(operation: &str, value: &Value) -> Self {
2180 let holders = |member: &str| {
2181 value
2182 .get(member)
2183 .and_then(Value::as_array)
2184 .map(|entries| entries.iter().filter_map(Holder::parse).collect())
2185 .unwrap_or_default()
2186 };
2187 Self {
2188 operation: operation.to_owned(),
2189 users: holders("users"),
2190 groups: holders("groups"),
2191 roles: holders("roles"),
2192 }
2193 }
2194}
2195
2196impl Holder {
2197 fn parse(value: &Value) -> Option<Self> {
2198 let id = id_of(value)?;
2199 Some(Self {
2200 display: value
2201 .get("display")
2202 .and_then(Value::as_str)
2203 .map_or_else(|| id.clone(), ToOwned::to_owned),
2206 id,
2207 })
2208 }
2209}
2210
2211impl LinkType {
2212 fn parse(value: &Value) -> Option<Self> {
2213 let text = |member: &str| {
2214 value
2215 .get(member)
2216 .and_then(Value::as_str)
2217 .map(str::to_lowercase)
2218 };
2219 Some(Self {
2220 id: value.get("id").and_then(Value::as_str)?.to_owned(),
2221 outward: text("outward"),
2222 inward: text("inward"),
2223 })
2224 }
2225}
2226
2227#[derive(Debug, Clone, serde::Serialize)]
2233pub struct Component {
2234 pub id: String,
2235 pub name: String,
2236 pub queue: Option<String>,
2238 pub lead: Option<String>,
2239 pub assign_auto: bool,
2242 pub description: Option<String>,
2243}
2244
2245impl Component {
2246 fn parse(value: &Value) -> Option<Self> {
2247 Some(Self {
2248 id: id_of(value)?,
2249 name: named(value),
2250 queue: value
2251 .get("queue")
2252 .and_then(|queue| queue.get("key").or_else(|| queue.get("display")))
2253 .and_then(Value::as_str)
2254 .map(ToOwned::to_owned),
2255 lead: value
2256 .get("lead")
2257 .and_then(|lead| {
2258 lead.get("login")
2259 .or_else(|| lead.get("display"))
2260 .or_else(|| lead.get("id"))
2261 })
2262 .and_then(Value::as_str)
2263 .map(ToOwned::to_owned),
2264 assign_auto: value
2265 .get("assignAuto")
2266 .and_then(Value::as_bool)
2267 .unwrap_or(false),
2268 description: value
2269 .get("description")
2270 .and_then(Value::as_str)
2271 .filter(|text| !text.is_empty())
2272 .map(ToOwned::to_owned),
2273 })
2274 }
2275}
2276
2277#[derive(Debug, Clone, serde::Serialize)]
2283pub struct Automation {
2284 pub macros: Vec<Macro>,
2285 pub autoactions: Vec<AutoAction>,
2286 pub triggers: Vec<Trigger>,
2287 pub unreadable: Vec<Unreadable>,
2293}
2294
2295#[derive(Debug, Clone, serde::Serialize)]
2297pub struct BulkChange {
2298 pub id: String,
2299 pub status: String,
2303 pub status_text: String,
2305 pub total: Option<u64>,
2307 pub done: Option<u64>,
2309}
2310
2311#[derive(Debug, Clone, serde::Serialize)]
2313pub struct BulkOutcome {
2314 pub key: String,
2315 pub status: String,
2316 pub error: Option<String>,
2318}
2319
2320#[derive(Debug, Clone, serde::Serialize)]
2322pub struct QueueAccess {
2323 pub permissions: Vec<Permission>,
2325 pub access: Vec<Permission>,
2327 pub you: Option<String>,
2330 pub unreadable: Vec<Unreadable>,
2331}
2332
2333#[derive(Debug, Clone, serde::Serialize)]
2335pub struct Permission {
2336 pub operation: String,
2338 pub users: Vec<Holder>,
2339 pub groups: Vec<Holder>,
2342 pub roles: Vec<Holder>,
2345}
2346
2347#[derive(Debug, Clone, serde::Serialize)]
2349pub struct Holder {
2350 pub id: String,
2351 pub display: String,
2353}
2354
2355#[derive(Debug, Clone, serde::Serialize)]
2357pub struct Unreadable {
2358 pub section: &'static str,
2359 pub reason: String,
2360}
2361
2362#[derive(Debug, Clone, serde::Serialize)]
2364pub struct Macro {
2365 pub id: String,
2366 pub name: String,
2367 pub body: Option<String>,
2369 pub updates: Vec<String>,
2372}
2373
2374#[derive(Debug, Clone, serde::Serialize)]
2376pub struct AutoAction {
2377 pub id: String,
2378 pub name: String,
2379 pub active: bool,
2380 pub actions: Vec<String>,
2382 pub interval: Option<u64>,
2384}
2385
2386#[derive(Debug, Clone, serde::Serialize)]
2388pub struct Trigger {
2389 pub id: String,
2390 pub name: String,
2391 pub active: bool,
2392 pub actions: Vec<String>,
2393 pub conditions: usize,
2397}
2398
2399fn id_of(value: &Value) -> Option<String> {
2402 Some(match value.get("id")? {
2403 Value::String(id) => id.clone(),
2404 other => other.to_string(),
2405 })
2406}
2407
2408fn types_in(value: Option<&Value>) -> Vec<String> {
2410 value
2411 .and_then(Value::as_array)
2412 .map(|entries| {
2413 entries
2414 .iter()
2415 .filter_map(|entry| entry.get("type").and_then(Value::as_str))
2416 .map(ToOwned::to_owned)
2417 .collect()
2418 })
2419 .unwrap_or_default()
2420}
2421
2422fn named(value: &Value) -> String {
2423 value
2424 .get("name")
2425 .and_then(Value::as_str)
2426 .unwrap_or_default()
2427 .to_owned()
2428}
2429
2430impl Macro {
2431 fn parse(value: &Value) -> Option<Self> {
2432 Some(Self {
2433 id: id_of(value)?,
2434 name: named(value),
2435 body: value
2436 .get("body")
2437 .and_then(Value::as_str)
2438 .filter(|text| !text.is_empty())
2439 .map(ToOwned::to_owned),
2440 updates: value
2441 .get("issueUpdate")
2442 .and_then(Value::as_array)
2443 .map(|updates| {
2444 updates
2445 .iter()
2446 .filter_map(|update| {
2447 update
2448 .get("field")
2449 .and_then(|field| field.get("id"))
2450 .and_then(Value::as_str)
2451 })
2452 .map(|id| id.rsplit("--").next().unwrap_or(id).to_owned())
2453 .collect()
2454 })
2455 .unwrap_or_default(),
2456 })
2457 }
2458}
2459
2460impl AutoAction {
2461 fn parse(value: &Value) -> Option<Self> {
2462 Some(Self {
2463 id: id_of(value)?,
2464 name: named(value),
2465 active: value
2466 .get("active")
2467 .and_then(Value::as_bool)
2468 .unwrap_or(false),
2469 actions: types_in(value.get("actions")),
2470 interval: value
2472 .get("intervalMillis")
2473 .and_then(Value::as_u64)
2474 .map(|millis| millis / 1000),
2475 })
2476 }
2477}
2478
2479impl Trigger {
2480 fn parse(value: &Value) -> Option<Self> {
2481 Some(Self {
2482 id: id_of(value)?,
2483 name: named(value),
2484 active: value
2485 .get("active")
2486 .and_then(Value::as_bool)
2487 .unwrap_or(false),
2488 actions: types_in(value.get("actions")),
2489 conditions: value
2490 .get("conditions")
2491 .and_then(Value::as_array)
2492 .map_or(0, Vec::len),
2493 })
2494 }
2495}
2496
2497#[derive(Debug, Clone, serde::Serialize)]
2503pub struct FieldSpec {
2504 pub key: String,
2505 pub name: String,
2506 pub field_type: String,
2509 pub items: Option<String>,
2512 pub required: bool,
2513 pub readonly: bool,
2514 pub category: Option<String>,
2517 pub options: Option<FieldOptions>,
2519}
2520
2521#[derive(Debug, Clone, serde::Serialize)]
2527pub struct FieldOptions {
2528 pub provider: String,
2532 pub values: Vec<String>,
2533}
2534
2535impl FieldSpec {
2536 fn parse(value: &Value) -> Option<Self> {
2537 let id = value.get("id").and_then(Value::as_str)?;
2538 let schema = value.get("schema");
2539 let string_at = |parent: Option<&Value>, member: &str| {
2540 parent
2541 .and_then(|parent| parent.get(member))
2542 .and_then(Value::as_str)
2543 .map(ToOwned::to_owned)
2544 };
2545
2546 let options = value.get("optionsProvider").map(|provider| FieldOptions {
2547 provider: provider
2548 .get("type")
2549 .and_then(Value::as_str)
2550 .unwrap_or("unknown")
2551 .to_owned(),
2552 values: provider
2556 .get("values")
2557 .and_then(Value::as_array)
2558 .map(|values| {
2559 values
2560 .iter()
2561 .map(|value| match value {
2562 Value::String(text) => text.clone(),
2563 other => other.to_string(),
2564 })
2565 .collect()
2566 })
2567 .unwrap_or_default(),
2568 });
2569
2570 Some(Self {
2571 key: id.rsplit("--").next().unwrap_or(id).to_owned(),
2572 name: value
2573 .get("name")
2574 .and_then(Value::as_str)
2575 .unwrap_or(id)
2576 .to_owned(),
2577 field_type: string_at(schema, "type").unwrap_or_else(|| "unknown".to_owned()),
2578 items: string_at(schema, "items"),
2579 required: schema
2580 .and_then(|schema| schema.get("required"))
2581 .and_then(Value::as_bool)
2582 .unwrap_or(false),
2583 readonly: value
2584 .get("readonly")
2585 .and_then(Value::as_bool)
2586 .unwrap_or(false),
2587 category: string_at(value.get("category"), "display"),
2588 options,
2589 })
2590 }
2591}
2592
2593fn checklist_of(value: &Value) -> Vec<ChecklistItem> {
2599 let entries = value
2600 .get("checklistItems")
2601 .and_then(Value::as_array)
2602 .or_else(|| value.as_array());
2603
2604 entries
2605 .map(|entries| entries.iter().filter_map(parse::checklist_item).collect())
2606 .unwrap_or_default()
2607}
2608
2609async fn classify(response: reqwest::Response, what: &str) -> Result<String, ApiError> {
2614 let status = response.status();
2615 if status.is_success() {
2616 return Ok(response.text().await?);
2617 }
2618
2619 let message = response.text().await.unwrap_or_default();
2620 tracing::debug!(%status, body = %message, "{what}: request refused");
2623 Err(match status.as_u16() {
2624 401 => ApiError::Unauthorized,
2625 403 if message.contains("FORCED_SYNC_REQUIRED") => ApiError::WikiNotEnabled,
2627 403 if wiki_explains(&message) => ApiError::Rejected {
2631 status,
2632 message: complaint(&message),
2633 },
2634 403 => ApiError::Forbidden,
2635 404 => ApiError::NotFound(what.to_owned()),
2636 429 => ApiError::RateLimited,
2637 _ => ApiError::Rejected {
2638 status,
2639 message: complaint(&message),
2640 },
2641 })
2642}
2643
2644fn complaint(body: &str) -> String {
2651 let messages = serde_json::from_str::<Value>(body)
2652 .ok()
2653 .and_then(|value| {
2654 let mut said: Vec<String> = value
2655 .get("errorMessages")
2656 .and_then(Value::as_array)
2657 .map(|entries| {
2658 entries
2659 .iter()
2660 .filter_map(Value::as_str)
2661 .map(ToOwned::to_owned)
2662 .collect()
2663 })
2664 .unwrap_or_default();
2665 if let Some(errors) = value.get("errors").and_then(Value::as_object) {
2668 said.extend(
2669 errors
2670 .iter()
2671 .filter_map(|(field, text)| Some(format!("{field}: {}", text.as_str()?))),
2672 );
2673 }
2674 if said.is_empty()
2679 && let Some(code) = value.get("error_code").and_then(Value::as_str)
2680 {
2681 let detail =
2682 wiki_detail(&value, "debug_message").or_else(|| wiki_detail(&value, "message"));
2683 said.push(
2684 detail.map_or_else(|| code.to_owned(), |detail| format!("{code}: {detail}")),
2685 );
2686 }
2687 (!said.is_empty()).then(|| said.join("; "))
2688 })
2689 .unwrap_or_else(|| body.to_owned());
2690
2691 messages.chars().take(400).collect()
2692}
2693
2694fn wiki_detail<'a>(value: &'a Value, key: &str) -> Option<&'a str> {
2696 value
2697 .get(key)
2698 .and_then(Value::as_str)
2699 .filter(|text| !text.trim().is_empty())
2700}
2701
2702fn wiki_explains(body: &str) -> bool {
2707 serde_json::from_str::<Value>(body)
2708 .ok()
2709 .is_some_and(|value| {
2710 value.get("error_code").and_then(Value::as_str).is_some()
2711 && (wiki_detail(&value, "debug_message").is_some()
2712 || wiki_detail(&value, "message").is_some())
2713 })
2714}
2715
2716fn is_retryable(error: &ApiError) -> bool {
2719 match error {
2720 ApiError::RateLimited => true,
2721 ApiError::Transport(err) => err.is_timeout() || err.is_connect(),
2722 ApiError::Rejected { status, .. } | ApiError::WikiRejected { status, .. } => {
2723 status.is_server_error()
2724 }
2725 _ => false,
2726 }
2727}
2728
2729#[cfg(test)]
2730mod tests {
2731 use super::*;
2732
2733 #[test]
2736 fn a_wiki_refusal_is_its_code_and_its_reason() {
2737 let body = r#"{"error_code": "NO_PARENT_PAGE", "debug_message": "Immediate parent page does not exist", "message": null, "details": {"slug": "a/b"}}"#;
2738 assert_eq!(
2739 complaint(body),
2740 "NO_PARENT_PAGE: Immediate parent page does not exist"
2741 );
2742 }
2743
2744 #[test]
2748 fn a_wiki_refusal_with_an_empty_debug_message_keeps_its_sentence() {
2749 let body = r#"{"error_code": "FORBIDDEN", "debug_message": "", "message": "No rights to create a page in this section", "details": null}"#;
2750 assert_eq!(
2751 complaint(body),
2752 "FORBIDDEN: No rights to create a page in this section"
2753 );
2754 assert!(wiki_explains(body));
2755 }
2756
2757 #[test]
2760 fn a_refusal_without_a_reason_is_not_an_explanation() {
2761 assert!(!wiki_explains(""));
2762 assert!(!wiki_explains(
2763 r#"{"error_code": "FORBIDDEN", "debug_message": "", "message": null}"#
2764 ));
2765 assert!(!wiki_explains(
2766 r#"{"errors":{},"errorMessages":["forbidden"]}"#
2767 ));
2768 }
2769
2770 #[test]
2772 fn a_rejection_reads_as_what_tracker_said() {
2773 assert_eq!(
2774 complaint(
2775 r#"{"errors":{},"errorMessages":["A board of this type cannot have sprints."],"statusCode":400}"#
2776 ),
2777 "A board of this type cannot have sprints."
2778 );
2779 }
2780
2781 #[test]
2784 fn a_field_complaint_keeps_its_field() {
2785 assert_eq!(
2786 complaint(r#"{"errors":{"summary":"cannot be empty"},"errorMessages":[]}"#),
2787 "summary: cannot be empty"
2788 );
2789 }
2790
2791 #[test]
2794 fn an_unfamiliar_body_survives_untouched() {
2795 assert_eq!(
2796 complaint("<html>gateway timeout</html>"),
2797 "<html>gateway timeout</html>"
2798 );
2799 assert_eq!(complaint("{}"), "{}");
2800 }
2801
2802 #[test]
2803 fn host_comparison_ignores_scheme_path_and_case() {
2804 assert_eq!(
2805 host_of("https://API.tracker.yandex.net/v3/issues/PROJ-1"),
2806 host_of("https://api.tracker.yandex.net")
2807 );
2808 }
2809
2810 #[test]
2814 fn a_different_host_does_not_match() {
2815 assert_ne!(
2816 host_of("https://evil.example.com/steal"),
2817 host_of("https://api.tracker.yandex.net")
2818 );
2819 }
2820
2821 #[test]
2823 fn a_prefix_of_the_real_host_does_not_match() {
2824 assert_ne!(
2825 host_of("https://api.tracker.yandex.net.evil.com/steal"),
2826 host_of("https://api.tracker.yandex.net")
2827 );
2828 }
2829
2830 #[test]
2831 fn a_port_is_part_of_the_host() {
2832 assert_ne!(
2833 host_of("http://127.0.0.1:9999/x"),
2834 host_of("http://127.0.0.1:8888")
2835 );
2836 }
2837}