1use std::collections::HashMap;
7use std::time::Duration;
8
9use anyhow::{Context, Result};
10use base64::Engine;
11use reqwest::Client;
12use serde::{Deserialize, Serialize};
13
14use crate::atlassian::adf::AdfDocument;
15use crate::atlassian::convert::adf_to_markdown;
16use crate::atlassian::error::AtlassianError;
17
18const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
20
21const PAGE_SIZE: u32 = 100;
24
25const MAX_RETRIES: u32 = 3;
27
28const DEFAULT_RETRY_DELAY_SECS: u64 = 2;
30
31pub struct AtlassianClient {
33 client: Client,
34 instance_url: String,
35 auth_header: String,
36}
37
38#[derive(Debug, Clone, Serialize)]
40pub struct JiraIssue {
41 pub key: String,
43
44 pub summary: String,
46
47 pub description_adf: Option<serde_json::Value>,
49
50 pub status: Option<String>,
52
53 pub issue_type: Option<String>,
55
56 pub assignee: Option<String>,
58
59 pub priority: Option<String>,
61
62 pub labels: Vec<String>,
64
65 #[serde(default, skip_serializing_if = "Vec::is_empty")]
68 pub custom_fields: Vec<JiraCustomField>,
69}
70
71#[derive(Debug, Clone, Default)]
73pub enum FieldSelection {
74 #[default]
77 Standard,
78
79 Named(Vec<String>),
83
84 All,
86}
87
88#[derive(Debug, Clone, Serialize)]
90pub struct JiraCustomField {
91 pub id: String,
93
94 pub name: String,
97
98 pub value: serde_json::Value,
101}
102
103#[derive(Debug, Clone, Default)]
109pub struct EditMeta {
110 pub fields: std::collections::BTreeMap<String, EditMetaField>,
112}
113
114#[derive(Debug, Clone)]
116pub struct EditMetaField {
117 pub name: String,
119
120 pub schema: EditMetaSchema,
122}
123
124#[derive(Debug, Clone)]
126pub struct EditMetaSchema {
127 pub kind: String,
129
130 pub custom: Option<String>,
133}
134
135impl EditMetaField {
136 pub fn is_adf_rich_text(&self) -> bool {
138 matches!(
139 self.schema.custom.as_deref(),
140 Some("com.atlassian.jira.plugin.system.customfieldtypes:textarea")
141 )
142 }
143}
144
145#[derive(Debug, Clone, Serialize, Deserialize)]
147pub struct JiraUser {
148 #[serde(rename = "displayName")]
150 pub display_name: String,
151
152 #[serde(rename = "emailAddress")]
154 pub email_address: Option<String>,
155
156 #[serde(rename = "accountId")]
158 pub account_id: String,
159}
160
161#[derive(Debug, Clone, Serialize)]
163pub struct JiraWatcherList {
164 pub watchers: Vec<JiraUser>,
166
167 pub watch_count: u32,
169}
170
171#[derive(Debug, Clone, Serialize)]
173pub struct JiraCreatedIssue {
174 pub key: String,
176 pub id: String,
178 pub self_url: String,
180}
181
182#[derive(Debug, Clone, Serialize)]
184pub struct JiraSearchResult {
185 pub issues: Vec<JiraIssue>,
187
188 pub total: u32,
190}
191
192#[derive(Debug, Clone, Serialize)]
194pub struct ConfluenceSearchResult {
195 pub id: String,
197 pub title: String,
199 pub space_key: String,
201}
202
203#[derive(Debug, Clone, Serialize)]
205pub struct ConfluenceSearchResults {
206 pub results: Vec<ConfluenceSearchResult>,
208 pub total: u32,
210}
211
212#[derive(Debug, Clone, Serialize)]
214pub struct ConfluenceUserSearchResult {
215 #[serde(skip_serializing_if = "Option::is_none")]
218 pub account_id: Option<String>,
219 pub display_name: String,
221 #[serde(skip_serializing_if = "Option::is_none")]
223 pub email: Option<String>,
224}
225
226#[derive(Debug, Clone, Serialize)]
228pub struct ConfluenceUserSearchResults {
229 pub users: Vec<ConfluenceUserSearchResult>,
231 pub total: u32,
233}
234
235#[derive(Debug, Clone, Serialize)]
237pub struct JiraComment {
238 pub id: String,
240 pub author: String,
242 pub body_adf: Option<serde_json::Value>,
244 pub created: String,
246}
247
248#[derive(Debug, Clone, Serialize)]
250pub struct JiraProject {
251 pub id: String,
253 pub key: String,
255 pub name: String,
257 pub project_type: Option<String>,
259 pub lead: Option<String>,
261}
262
263#[derive(Debug, Clone, Serialize)]
265pub struct JiraProjectList {
266 pub projects: Vec<JiraProject>,
268 pub total: u32,
270}
271
272#[derive(Debug, Clone, Serialize)]
274pub struct JiraField {
275 pub id: String,
277 pub name: String,
279 pub custom: bool,
281 pub schema_type: Option<String>,
283}
284
285#[derive(Debug, Clone, Serialize)]
287pub struct JiraFieldOption {
288 pub id: String,
290 pub value: String,
292}
293
294#[derive(Debug, Clone, Serialize)]
296pub struct AgileBoard {
297 pub id: u64,
299 pub name: String,
301 pub board_type: String,
303 pub project_key: Option<String>,
305}
306
307#[derive(Debug, Clone, Serialize)]
309pub struct AgileBoardList {
310 pub boards: Vec<AgileBoard>,
312 pub total: u32,
314}
315
316#[derive(Debug, Clone, Serialize)]
318pub struct AgileSprint {
319 pub id: u64,
321 pub name: String,
323 pub state: String,
325 pub start_date: Option<String>,
327 pub end_date: Option<String>,
329 pub goal: Option<String>,
331}
332
333#[derive(Debug, Clone, Serialize)]
335pub struct AgileSprintList {
336 pub sprints: Vec<AgileSprint>,
338 pub total: u32,
340}
341
342#[derive(Debug, Clone, Serialize)]
344pub struct JiraChangelogEntry {
345 pub id: String,
347 pub author: String,
349 pub created: String,
351 pub items: Vec<JiraChangelogItem>,
353}
354
355#[derive(Debug, Clone, Serialize)]
357pub struct JiraChangelogItem {
358 pub field: String,
360 pub from_string: Option<String>,
362 pub to_string: Option<String>,
364}
365
366#[derive(Debug, Clone, Serialize)]
368pub struct JiraLinkType {
369 pub id: String,
371 pub name: String,
373 pub inward: String,
375 pub outward: String,
377}
378
379#[derive(Debug, Clone, Serialize)]
381pub struct JiraIssueLink {
382 pub id: String,
384 pub link_type: String,
386 pub direction: String,
388 pub linked_issue_key: String,
390 pub linked_issue_summary: String,
392}
393
394#[derive(Debug, Clone, Serialize)]
396pub struct JiraAttachment {
397 pub id: String,
399 pub filename: String,
401 pub mime_type: String,
403 pub size: u64,
405 pub content_url: String,
407}
408
409#[derive(Debug, Clone, Serialize)]
411pub struct JiraTransition {
412 pub id: String,
414 pub name: String,
416}
417
418#[derive(Debug, Clone, Serialize)]
420pub struct JiraDevPullRequest {
421 pub id: String,
423 pub name: String,
425 pub status: String,
427 pub url: String,
429 pub repository_name: String,
431 pub source_branch: String,
433 pub destination_branch: String,
435 #[serde(skip_serializing_if = "Option::is_none")]
437 pub author: Option<String>,
438 #[serde(skip_serializing_if = "Vec::is_empty")]
440 pub reviewers: Vec<String>,
441 #[serde(skip_serializing_if = "Option::is_none")]
443 pub comment_count: Option<u32>,
444 #[serde(skip_serializing_if = "Option::is_none")]
446 pub last_update: Option<String>,
447}
448
449#[derive(Debug, Clone, Serialize)]
451pub struct JiraDevCommit {
452 pub id: String,
454 pub display_id: String,
456 pub message: String,
458 #[serde(skip_serializing_if = "Option::is_none")]
460 pub author: Option<String>,
461 #[serde(skip_serializing_if = "Option::is_none")]
463 pub timestamp: Option<String>,
464 pub url: String,
466 pub file_count: u32,
468 pub merge: bool,
470}
471
472#[derive(Debug, Clone, Serialize)]
474pub struct JiraDevBranch {
475 pub name: String,
477 pub url: String,
479 pub repository_name: String,
481 #[serde(skip_serializing_if = "Option::is_none")]
483 pub create_pr_url: Option<String>,
484 #[serde(skip_serializing_if = "Option::is_none")]
486 pub last_commit: Option<JiraDevCommit>,
487}
488
489#[derive(Debug, Clone, Serialize)]
491pub struct JiraDevRepository {
492 pub name: String,
494 pub url: String,
496 #[serde(skip_serializing_if = "Vec::is_empty")]
498 pub commits: Vec<JiraDevCommit>,
499}
500
501#[derive(Debug, Clone, Serialize)]
503pub struct JiraDevStatus {
504 #[serde(skip_serializing_if = "Vec::is_empty")]
506 pub pull_requests: Vec<JiraDevPullRequest>,
507 #[serde(skip_serializing_if = "Vec::is_empty")]
509 pub branches: Vec<JiraDevBranch>,
510 #[serde(skip_serializing_if = "Vec::is_empty")]
512 pub repositories: Vec<JiraDevRepository>,
513}
514
515#[derive(Debug, Clone, Serialize)]
517pub struct JiraDevStatusCount {
518 pub count: u32,
520 pub providers: Vec<String>,
522}
523
524#[derive(Debug, Clone, Serialize)]
526pub struct JiraDevStatusSummary {
527 pub pullrequest: JiraDevStatusCount,
529 pub branch: JiraDevStatusCount,
531 pub repository: JiraDevStatusCount,
533}
534
535#[derive(Debug, Clone, Serialize)]
537pub struct JiraWorklog {
538 pub id: String,
540 pub author: String,
542 pub time_spent: String,
544 pub time_spent_seconds: u64,
546 pub started: String,
548 #[serde(skip_serializing_if = "Option::is_none")]
550 pub comment: Option<String>,
551}
552
553#[derive(Debug, Clone, Serialize)]
555pub struct JiraWorklogList {
556 pub worklogs: Vec<JiraWorklog>,
558 pub total: u32,
560}
561
562#[derive(Deserialize)]
565struct JiraIssueResponse {
566 key: String,
567 fields: JiraIssueFields,
568}
569
570#[derive(Deserialize)]
573struct JiraIssueEnvelope {
574 key: String,
575 #[serde(default)]
576 fields: std::collections::BTreeMap<String, serde_json::Value>,
577 #[serde(default)]
578 names: std::collections::BTreeMap<String, String>,
579}
580
581impl JiraIssueEnvelope {
582 fn into_issue(self, selection: &FieldSelection) -> JiraIssue {
583 let Self {
584 key,
585 mut fields,
586 names,
587 } = self;
588
589 let description_adf = fields.remove("description").filter(|v| !v.is_null());
590 let summary = fields
591 .remove("summary")
592 .and_then(|v| v.as_str().map(str::to_string))
593 .unwrap_or_default();
594 let status = extract_named_field(fields.remove("status"));
595 let issue_type = extract_named_field(fields.remove("issuetype"));
596 let assignee = extract_display_name(fields.remove("assignee"));
597 let priority = extract_named_field(fields.remove("priority"));
598 let labels = fields
599 .remove("labels")
600 .and_then(|v| serde_json::from_value::<Vec<String>>(v).ok())
601 .unwrap_or_default();
602
603 let collect_customs = !matches!(selection, FieldSelection::Standard);
604 let custom_fields = if collect_customs {
605 fields
606 .into_iter()
607 .filter(|(_, value)| !value.is_null())
608 .map(|(id, value)| {
609 let name = names.get(&id).cloned().unwrap_or_else(|| id.clone());
610 JiraCustomField { id, name, value }
611 })
612 .collect()
613 } else {
614 Vec::new()
615 };
616
617 JiraIssue {
618 key,
619 summary,
620 description_adf,
621 status,
622 issue_type,
623 assignee,
624 priority,
625 labels,
626 custom_fields,
627 }
628 }
629}
630
631fn extract_named_field(value: Option<serde_json::Value>) -> Option<String> {
632 value
633 .and_then(|v| v.get("name").cloned())
634 .and_then(|n| n.as_str().map(str::to_string))
635}
636
637fn extract_display_name(value: Option<serde_json::Value>) -> Option<String> {
638 value
639 .and_then(|v| v.get("displayName").cloned())
640 .and_then(|n| n.as_str().map(str::to_string))
641}
642
643#[derive(Deserialize)]
644struct JiraEditMetaResponse {
645 #[serde(default)]
646 fields: std::collections::BTreeMap<String, JiraEditMetaField>,
647}
648
649#[derive(Deserialize)]
650struct JiraEditMetaField {
651 #[serde(default)]
652 name: Option<String>,
653 #[serde(default)]
654 schema: Option<JiraEditMetaSchemaRaw>,
655}
656
657#[derive(Deserialize)]
658struct JiraEditMetaSchemaRaw {
659 #[serde(rename = "type", default)]
660 kind: Option<String>,
661 #[serde(default)]
662 custom: Option<String>,
663}
664
665#[derive(Deserialize)]
666struct JiraCreateMetaResponse {
667 #[serde(default)]
668 projects: Vec<JiraCreateMetaProject>,
669}
670
671#[derive(Deserialize)]
672struct JiraCreateMetaProject {
673 #[serde(default)]
674 issuetypes: Vec<JiraCreateMetaIssueType>,
675}
676
677#[derive(Deserialize)]
678struct JiraCreateMetaIssueType {
679 #[serde(default)]
680 fields: std::collections::BTreeMap<String, JiraEditMetaField>,
681}
682
683#[derive(Deserialize)]
684struct JiraIssueFields {
685 summary: Option<String>,
686 description: Option<serde_json::Value>,
687 status: Option<JiraNameField>,
688 issuetype: Option<JiraNameField>,
689 assignee: Option<JiraAssigneeField>,
690 priority: Option<JiraNameField>,
691 #[serde(default)]
692 labels: Vec<String>,
693}
694
695#[derive(Deserialize)]
696struct JiraNameField {
697 name: Option<String>,
698}
699
700#[derive(Deserialize)]
701struct JiraAssigneeField {
702 #[serde(rename = "displayName")]
703 display_name: Option<String>,
704}
705
706#[derive(Deserialize)]
707#[allow(dead_code)]
708struct JiraSearchResponse {
709 issues: Vec<JiraIssueResponse>,
710 #[serde(default)]
711 total: u32,
712 #[serde(rename = "nextPageToken", default)]
713 next_page_token: Option<String>,
714}
715
716#[derive(Deserialize)]
717struct JiraTransitionsResponse {
718 transitions: Vec<JiraTransitionEntry>,
719}
720
721#[derive(Deserialize)]
722struct JiraTransitionEntry {
723 id: String,
724 name: String,
725}
726
727#[derive(Deserialize)]
728struct JiraCommentsResponse {
729 #[serde(default)]
730 comments: Vec<JiraCommentEntry>,
731 #[serde(default)]
732 total: u32,
733 #[serde(rename = "startAt", default)]
734 start_at: u32,
735 #[serde(rename = "maxResults", default)]
736 #[allow(dead_code)]
737 max_results: u32,
738}
739
740#[derive(Deserialize)]
741struct JiraCommentEntry {
742 id: String,
743 author: Option<JiraCommentAuthor>,
744 body: Option<serde_json::Value>,
745 created: Option<String>,
746}
747
748#[derive(Deserialize)]
749struct JiraCommentAuthor {
750 #[serde(rename = "displayName")]
751 display_name: Option<String>,
752}
753
754#[derive(Deserialize)]
755struct JiraWorklogResponse {
756 #[serde(default)]
757 worklogs: Vec<JiraWorklogEntry>,
758 #[serde(default)]
759 total: u32,
760}
761
762#[derive(Deserialize)]
763struct JiraWorklogEntry {
764 id: String,
765 author: Option<JiraCommentAuthor>,
766 #[serde(rename = "timeSpent")]
767 time_spent: Option<String>,
768 #[serde(rename = "timeSpentSeconds", default)]
769 time_spent_seconds: u64,
770 started: Option<String>,
771 comment: Option<serde_json::Value>,
772}
773
774#[derive(Deserialize)]
775#[allow(dead_code)]
776struct ConfluenceContentSearchResponse {
777 results: Vec<ConfluenceContentSearchEntry>,
778 #[serde(default)]
779 size: u32,
780 #[serde(rename = "_links", default)]
781 links: Option<ConfluenceSearchLinks>,
782}
783
784#[derive(Deserialize, Default)]
785struct ConfluenceSearchLinks {
786 next: Option<String>,
787}
788
789#[derive(Deserialize)]
790struct ConfluenceContentSearchEntry {
791 id: String,
792 title: String,
793 #[serde(rename = "_expandable")]
794 expandable: Option<ConfluenceExpandable>,
795}
796
797#[derive(Deserialize)]
798struct ConfluenceExpandable {
799 space: Option<String>,
800}
801
802#[derive(Deserialize)]
805struct ConfluenceUserSearchResponse {
806 results: Vec<ConfluenceUserSearchEntry>,
807 #[serde(rename = "_links", default)]
808 links: Option<ConfluenceSearchLinks>,
809}
810
811#[derive(Deserialize)]
812struct ConfluenceUserSearchEntry {
813 #[serde(default)]
814 user: Option<ConfluenceSearchUser>,
815}
816
817#[derive(Deserialize)]
818struct ConfluenceSearchUser {
819 #[serde(rename = "accountId", default)]
820 account_id: Option<String>,
821 #[serde(rename = "displayName", default)]
822 display_name: Option<String>,
823 #[serde(default)]
824 email: Option<String>,
825 #[serde(rename = "publicName", default)]
826 public_name: Option<String>,
827}
828
829#[derive(Deserialize)]
832#[allow(dead_code)]
833struct AgileBoardListResponse {
834 values: Vec<AgileBoardEntry>,
835 #[serde(default)]
836 total: u32,
837 #[serde(rename = "isLast", default)]
838 is_last: bool,
839}
840
841#[derive(Deserialize)]
842struct AgileBoardEntry {
843 id: u64,
844 name: String,
845 #[serde(rename = "type")]
846 board_type: String,
847 location: Option<AgileBoardLocation>,
848}
849
850#[derive(Deserialize)]
851struct AgileBoardLocation {
852 #[serde(rename = "projectKey")]
853 project_key: Option<String>,
854}
855
856#[derive(Deserialize)]
857#[allow(dead_code)]
858struct AgileIssueListResponse {
859 issues: Vec<JiraIssueResponse>,
860 #[serde(default)]
861 total: u32,
862 #[serde(rename = "isLast", default)]
863 is_last: bool,
864}
865
866#[derive(Deserialize)]
867#[allow(dead_code)]
868struct AgileSprintListResponse {
869 values: Vec<AgileSprintEntry>,
870 #[serde(default)]
871 total: u32,
872 #[serde(rename = "isLast", default)]
873 is_last: bool,
874}
875
876#[derive(Deserialize)]
877struct AgileSprintEntry {
878 id: u64,
879 name: String,
880 state: String,
881 #[serde(rename = "startDate")]
882 start_date: Option<String>,
883 #[serde(rename = "endDate")]
884 end_date: Option<String>,
885 goal: Option<String>,
886}
887
888#[derive(Deserialize)]
889struct JiraIssueLinksResponse {
890 fields: JiraIssueLinksFields,
891}
892
893#[derive(Deserialize)]
894struct JiraIssueLinksFields {
895 #[serde(default)]
896 issuelinks: Vec<JiraIssueLinkEntry>,
897}
898
899#[derive(Deserialize)]
900struct JiraIssueLinkEntry {
901 id: String,
902 #[serde(rename = "type")]
903 link_type: JiraIssueLinkType,
904 #[serde(rename = "inwardIssue")]
905 inward_issue: Option<JiraIssueLinkIssue>,
906 #[serde(rename = "outwardIssue")]
907 outward_issue: Option<JiraIssueLinkIssue>,
908}
909
910#[derive(Deserialize)]
911struct JiraIssueLinkType {
912 name: String,
913}
914
915#[derive(Deserialize)]
916struct JiraIssueLinkIssue {
917 key: String,
918 fields: Option<JiraIssueLinkIssueFields>,
919}
920
921#[derive(Deserialize)]
922struct JiraIssueLinkIssueFields {
923 summary: Option<String>,
924}
925
926#[derive(Deserialize)]
927struct JiraLinkTypesResponse {
928 #[serde(rename = "issueLinkTypes")]
929 issue_link_types: Vec<JiraLinkTypeEntry>,
930}
931
932#[derive(Deserialize)]
933struct JiraLinkTypeEntry {
934 id: String,
935 name: String,
936 inward: String,
937 outward: String,
938}
939
940#[derive(Deserialize)]
941struct JiraAttachmentIssueResponse {
942 fields: JiraAttachmentFields,
943}
944
945#[derive(Deserialize)]
946struct JiraAttachmentFields {
947 #[serde(default)]
948 attachment: Vec<JiraAttachmentEntry>,
949}
950
951#[derive(Deserialize)]
952struct JiraAttachmentEntry {
953 id: String,
954 filename: String,
955 #[serde(rename = "mimeType")]
956 mime_type: String,
957 size: u64,
958 content: String,
959}
960
961#[derive(Deserialize)]
962#[allow(dead_code)]
963struct JiraChangelogResponse {
964 values: Vec<JiraChangelogEntryResponse>,
965 #[serde(default)]
966 total: u32,
967 #[serde(rename = "isLast", default)]
968 is_last: bool,
969}
970
971#[derive(Deserialize)]
972struct JiraChangelogEntryResponse {
973 id: String,
974 author: Option<JiraCommentAuthor>,
975 created: Option<String>,
976 #[serde(default)]
977 items: Vec<JiraChangelogItemResponse>,
978}
979
980#[derive(Deserialize)]
981struct JiraChangelogItemResponse {
982 field: String,
983 #[serde(rename = "fromString")]
984 from_string: Option<String>,
985 #[serde(rename = "toString")]
986 to_string: Option<String>,
987}
988
989#[derive(Deserialize)]
990struct JiraFieldEntry {
991 id: String,
992 name: String,
993 #[serde(default)]
994 custom: bool,
995 schema: Option<JiraFieldSchema>,
996}
997
998#[derive(Deserialize)]
999struct JiraFieldSchema {
1000 #[serde(rename = "type")]
1001 schema_type: Option<String>,
1002}
1003
1004#[derive(Deserialize)]
1005struct JiraFieldContextsResponse {
1006 values: Vec<JiraFieldContextEntry>,
1007}
1008
1009#[derive(Deserialize)]
1010struct JiraFieldContextEntry {
1011 id: String,
1012}
1013
1014#[derive(Deserialize)]
1015struct JiraFieldOptionsResponse {
1016 values: Vec<JiraFieldOptionEntry>,
1017}
1018
1019#[derive(Deserialize)]
1020struct JiraFieldOptionEntry {
1021 id: String,
1022 value: String,
1023}
1024
1025#[derive(Deserialize)]
1026#[allow(dead_code)]
1027struct JiraProjectSearchResponse {
1028 values: Vec<JiraProjectEntry>,
1029 total: u32,
1030 #[serde(rename = "isLast", default)]
1031 is_last: bool,
1032}
1033
1034#[derive(Deserialize)]
1035struct JiraProjectEntry {
1036 id: String,
1037 key: String,
1038 name: String,
1039 #[serde(rename = "projectTypeKey")]
1040 project_type_key: Option<String>,
1041 lead: Option<JiraProjectLead>,
1042}
1043
1044#[derive(Deserialize)]
1045struct JiraProjectLead {
1046 #[serde(rename = "displayName")]
1047 display_name: Option<String>,
1048}
1049
1050#[derive(Deserialize)]
1051struct JiraCreateResponse {
1052 key: String,
1053 id: String,
1054 #[serde(rename = "self")]
1055 self_url: String,
1056}
1057
1058#[derive(Deserialize)]
1062struct JiraIssueIdResponse {
1063 id: String,
1064}
1065
1066#[derive(Deserialize)]
1067struct DevStatusResponse {
1068 #[serde(default)]
1069 detail: Vec<DevStatusDetail>,
1070}
1071
1072#[derive(Deserialize)]
1073struct DevStatusDetail {
1074 #[serde(rename = "pullRequests", default)]
1075 pull_requests: Vec<DevStatusPullRequest>,
1076 #[serde(default)]
1077 branches: Vec<DevStatusBranch>,
1078 #[serde(default)]
1079 repositories: Vec<DevStatusRepositoryEntry>,
1080}
1081
1082#[derive(Deserialize)]
1083struct DevStatusPullRequest {
1084 #[serde(default)]
1085 id: String,
1086 #[serde(default)]
1087 name: String,
1088 #[serde(default)]
1089 status: String,
1090 #[serde(default)]
1091 url: String,
1092 #[serde(rename = "repositoryName", default)]
1093 repository_name: String,
1094 #[serde(default)]
1095 source: Option<DevStatusBranchRef>,
1096 #[serde(default)]
1097 destination: Option<DevStatusBranchRef>,
1098 #[serde(default)]
1099 author: Option<DevStatusAuthor>,
1100 #[serde(default)]
1101 reviewers: Vec<DevStatusReviewer>,
1102 #[serde(rename = "commentCount", default)]
1103 comment_count: Option<u32>,
1104 #[serde(rename = "lastUpdate", default)]
1105 last_update: Option<String>,
1106}
1107
1108#[derive(Deserialize)]
1109struct DevStatusBranchRef {
1110 #[serde(default)]
1111 branch: String,
1112}
1113
1114#[derive(Deserialize)]
1115struct DevStatusAuthor {
1116 #[serde(default)]
1117 name: String,
1118}
1119
1120#[derive(Deserialize)]
1121struct DevStatusReviewer {
1122 #[serde(default)]
1123 name: String,
1124}
1125
1126#[derive(Deserialize)]
1127struct DevStatusCommit {
1128 #[serde(default)]
1129 id: String,
1130 #[serde(rename = "displayId", default)]
1131 display_id: String,
1132 #[serde(default)]
1133 message: String,
1134 #[serde(default)]
1135 author: Option<DevStatusAuthor>,
1136 #[serde(rename = "authorTimestamp", default)]
1137 author_timestamp: Option<String>,
1138 #[serde(default)]
1139 url: String,
1140 #[serde(rename = "fileCount", default)]
1141 file_count: u32,
1142 #[serde(default)]
1143 merge: bool,
1144}
1145
1146#[derive(Deserialize)]
1147struct DevStatusBranch {
1148 #[serde(default)]
1149 name: String,
1150 #[serde(default)]
1151 url: String,
1152 #[serde(rename = "repositoryName", default)]
1153 repository_name: String,
1154 #[serde(rename = "createPullRequestUrl", default)]
1155 create_pr_url: Option<String>,
1156 #[serde(rename = "lastCommit", default)]
1157 last_commit: Option<DevStatusCommit>,
1158}
1159
1160#[derive(Deserialize)]
1161struct DevStatusRepositoryEntry {
1162 #[serde(default)]
1163 name: String,
1164 #[serde(default)]
1165 url: String,
1166 #[serde(default)]
1167 commits: Vec<DevStatusCommit>,
1168}
1169
1170#[derive(Deserialize)]
1173struct DevStatusSummaryResponse {
1174 #[serde(default)]
1175 summary: DevStatusSummaryData,
1176}
1177
1178#[derive(Deserialize, Default)]
1179struct DevStatusSummaryData {
1180 #[serde(default)]
1181 pullrequest: Option<DevStatusSummaryCategory>,
1182 #[serde(default)]
1183 branch: Option<DevStatusSummaryCategory>,
1184 #[serde(default)]
1185 repository: Option<DevStatusSummaryCategory>,
1186}
1187
1188#[derive(Deserialize)]
1189struct DevStatusSummaryCategory {
1190 overall: Option<DevStatusSummaryOverall>,
1191 #[serde(rename = "byInstanceType", default)]
1192 by_instance_type: HashMap<String, DevStatusSummaryInstance>,
1193}
1194
1195#[derive(Deserialize)]
1196struct DevStatusSummaryOverall {
1197 #[serde(default)]
1198 count: u32,
1199}
1200
1201#[derive(Deserialize)]
1202struct DevStatusSummaryInstance {
1203 #[serde(default)]
1204 name: String,
1205}
1206
1207#[cfg(test)]
1210#[allow(clippy::unwrap_used, clippy::expect_used)]
1211mod tests {
1212 use super::*;
1213
1214 #[test]
1215 fn new_client_strips_trailing_slash() {
1216 let client =
1217 AtlassianClient::new("https://org.atlassian.net/", "user@test.com", "token").unwrap();
1218 assert_eq!(client.instance_url(), "https://org.atlassian.net");
1219 }
1220
1221 #[test]
1222 fn new_client_preserves_clean_url() {
1223 let client =
1224 AtlassianClient::new("https://org.atlassian.net", "user@test.com", "token").unwrap();
1225 assert_eq!(client.instance_url(), "https://org.atlassian.net");
1226 }
1227
1228 #[test]
1229 fn new_client_sets_basic_auth() {
1230 let client =
1231 AtlassianClient::new("https://org.atlassian.net", "user@test.com", "token").unwrap();
1232 let expected_credentials = "user@test.com:token";
1233 let expected_encoded =
1234 base64::engine::general_purpose::STANDARD.encode(expected_credentials);
1235 assert_eq!(client.auth_header, format!("Basic {expected_encoded}"));
1236 }
1237
1238 #[test]
1239 fn from_credentials() {
1240 let creds = crate::atlassian::auth::AtlassianCredentials {
1241 instance_url: "https://org.atlassian.net".to_string(),
1242 email: "user@test.com".to_string(),
1243 api_token: "token123".to_string(),
1244 };
1245 let client = AtlassianClient::from_credentials(&creds).unwrap();
1246 assert_eq!(client.instance_url(), "https://org.atlassian.net");
1247 }
1248
1249 #[test]
1250 fn jira_issue_struct_fields() {
1251 let issue = JiraIssue {
1252 key: "TEST-1".to_string(),
1253 summary: "Test issue".to_string(),
1254 description_adf: None,
1255 status: Some("Open".to_string()),
1256 issue_type: Some("Bug".to_string()),
1257 assignee: Some("Alice".to_string()),
1258 priority: Some("High".to_string()),
1259 labels: vec!["backend".to_string()],
1260 custom_fields: Vec::new(),
1261 };
1262 assert_eq!(issue.key, "TEST-1");
1263 assert_eq!(issue.labels.len(), 1);
1264 }
1265
1266 #[test]
1267 fn jira_user_deserialization() {
1268 let json = r#"{
1269 "displayName": "Alice Smith",
1270 "emailAddress": "alice@example.com",
1271 "accountId": "abc123"
1272 }"#;
1273 let user: JiraUser = serde_json::from_str(json).unwrap();
1274 assert_eq!(user.display_name, "Alice Smith");
1275 assert_eq!(user.email_address.as_deref(), Some("alice@example.com"));
1276 assert_eq!(user.account_id, "abc123");
1277 }
1278
1279 #[test]
1280 fn jira_user_optional_email() {
1281 let json = r#"{
1282 "displayName": "Bot",
1283 "accountId": "bot123"
1284 }"#;
1285 let user: JiraUser = serde_json::from_str(json).unwrap();
1286 assert!(user.email_address.is_none());
1287 }
1288
1289 #[test]
1290 fn jira_issue_response_deserialization() {
1291 let json = r#"{
1292 "key": "PROJ-42",
1293 "fields": {
1294 "summary": "Test",
1295 "description": null,
1296 "status": {"name": "Open"},
1297 "issuetype": {"name": "Bug"},
1298 "assignee": {"displayName": "Bob"},
1299 "priority": {"name": "Medium"},
1300 "labels": ["frontend"]
1301 }
1302 }"#;
1303 let response: JiraIssueResponse = serde_json::from_str(json).unwrap();
1304 assert_eq!(response.key, "PROJ-42");
1305 assert_eq!(response.fields.summary.as_deref(), Some("Test"));
1306 assert_eq!(response.fields.labels, vec!["frontend"]);
1307 }
1308
1309 #[test]
1310 fn jira_issue_response_minimal_fields() {
1311 let json = r#"{
1312 "key": "PROJ-1",
1313 "fields": {
1314 "summary": null,
1315 "description": null,
1316 "status": null,
1317 "issuetype": null,
1318 "assignee": null,
1319 "priority": null,
1320 "labels": []
1321 }
1322 }"#;
1323 let response: JiraIssueResponse = serde_json::from_str(json).unwrap();
1324 assert_eq!(response.key, "PROJ-1");
1325 assert!(response.fields.summary.is_none());
1326 }
1327
1328 #[tokio::test]
1329 async fn get_json_retries_on_429() {
1330 let server = wiremock::MockServer::start().await;
1331
1332 wiremock::Mock::given(wiremock::matchers::method("GET"))
1334 .and(wiremock::matchers::path("/test"))
1335 .respond_with(wiremock::ResponseTemplate::new(429).append_header("Retry-After", "0"))
1336 .up_to_n_times(1)
1337 .mount(&server)
1338 .await;
1339
1340 wiremock::Mock::given(wiremock::matchers::method("GET"))
1342 .and(wiremock::matchers::path("/test"))
1343 .respond_with(
1344 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({"ok": true})),
1345 )
1346 .up_to_n_times(1)
1347 .mount(&server)
1348 .await;
1349
1350 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1351 let resp = client
1352 .get_json(&format!("{}/test", server.uri()))
1353 .await
1354 .unwrap();
1355 assert!(resp.status().is_success());
1356 }
1357
1358 #[tokio::test]
1359 async fn get_json_returns_429_after_max_retries() {
1360 let server = wiremock::MockServer::start().await;
1361
1362 wiremock::Mock::given(wiremock::matchers::method("GET"))
1364 .and(wiremock::matchers::path("/test"))
1365 .respond_with(wiremock::ResponseTemplate::new(429).append_header("Retry-After", "0"))
1366 .mount(&server)
1367 .await;
1368
1369 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1370 let resp = client
1371 .get_json(&format!("{}/test", server.uri()))
1372 .await
1373 .unwrap();
1374 assert_eq!(resp.status().as_u16(), 429);
1376 }
1377
1378 #[tokio::test]
1379 async fn post_json_retries_on_429() {
1380 let server = wiremock::MockServer::start().await;
1381
1382 wiremock::Mock::given(wiremock::matchers::method("POST"))
1383 .and(wiremock::matchers::path("/test"))
1384 .respond_with(wiremock::ResponseTemplate::new(429).append_header("Retry-After", "0"))
1385 .up_to_n_times(1)
1386 .mount(&server)
1387 .await;
1388
1389 wiremock::Mock::given(wiremock::matchers::method("POST"))
1390 .and(wiremock::matchers::path("/test"))
1391 .respond_with(wiremock::ResponseTemplate::new(201))
1392 .up_to_n_times(1)
1393 .mount(&server)
1394 .await;
1395
1396 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1397 let body = serde_json::json!({"key": "value"});
1398 let resp = client
1399 .post_json(&format!("{}/test", server.uri()), &body)
1400 .await
1401 .unwrap();
1402 assert_eq!(resp.status().as_u16(), 201);
1403 }
1404
1405 #[tokio::test]
1406 async fn delete_retries_on_429() {
1407 let server = wiremock::MockServer::start().await;
1408
1409 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
1410 .and(wiremock::matchers::path("/test"))
1411 .respond_with(wiremock::ResponseTemplate::new(429).append_header("Retry-After", "0"))
1412 .up_to_n_times(1)
1413 .mount(&server)
1414 .await;
1415
1416 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
1417 .and(wiremock::matchers::path("/test"))
1418 .respond_with(wiremock::ResponseTemplate::new(204))
1419 .up_to_n_times(1)
1420 .mount(&server)
1421 .await;
1422
1423 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1424 let resp = client
1425 .delete(&format!("{}/test", server.uri()))
1426 .await
1427 .unwrap();
1428 assert_eq!(resp.status().as_u16(), 204);
1429 }
1430
1431 #[tokio::test]
1432 async fn get_json_sends_auth_header() {
1433 let server = wiremock::MockServer::start().await;
1434
1435 wiremock::Mock::given(wiremock::matchers::method("GET"))
1436 .and(wiremock::matchers::header(
1437 "Authorization",
1438 "Basic dXNlckB0ZXN0LmNvbTp0b2tlbg==",
1439 ))
1440 .and(wiremock::matchers::header("Accept", "application/json"))
1441 .respond_with(
1442 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({"ok": true})),
1443 )
1444 .expect(1)
1445 .mount(&server)
1446 .await;
1447
1448 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1449 let resp = client
1450 .get_json(&format!("{}/test", server.uri()))
1451 .await
1452 .unwrap();
1453 assert!(resp.status().is_success());
1454 }
1455
1456 #[tokio::test]
1457 async fn put_json_sends_body_and_auth() {
1458 let server = wiremock::MockServer::start().await;
1459
1460 wiremock::Mock::given(wiremock::matchers::method("PUT"))
1461 .and(wiremock::matchers::header(
1462 "Authorization",
1463 "Basic dXNlckB0ZXN0LmNvbTp0b2tlbg==",
1464 ))
1465 .and(wiremock::matchers::header(
1466 "Content-Type",
1467 "application/json",
1468 ))
1469 .respond_with(wiremock::ResponseTemplate::new(200))
1470 .expect(1)
1471 .mount(&server)
1472 .await;
1473
1474 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1475 let body = serde_json::json!({"key": "value"});
1476 let resp = client
1477 .put_json(&format!("{}/test", server.uri()), &body)
1478 .await
1479 .unwrap();
1480 assert!(resp.status().is_success());
1481 }
1482
1483 #[tokio::test]
1484 async fn post_json_sends_body_and_auth() {
1485 let server = wiremock::MockServer::start().await;
1486
1487 wiremock::Mock::given(wiremock::matchers::method("POST"))
1488 .and(wiremock::matchers::header(
1489 "Authorization",
1490 "Basic dXNlckB0ZXN0LmNvbTp0b2tlbg==",
1491 ))
1492 .and(wiremock::matchers::header(
1493 "Content-Type",
1494 "application/json",
1495 ))
1496 .respond_with(
1497 wiremock::ResponseTemplate::new(201).set_body_json(serde_json::json!({"id": "1"})),
1498 )
1499 .expect(1)
1500 .mount(&server)
1501 .await;
1502
1503 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1504 let body = serde_json::json!({"name": "test"});
1505 let resp = client
1506 .post_json(&format!("{}/test", server.uri()), &body)
1507 .await
1508 .unwrap();
1509 assert_eq!(resp.status().as_u16(), 201);
1510 }
1511
1512 #[tokio::test]
1513 async fn post_json_error_response() {
1514 let server = wiremock::MockServer::start().await;
1515
1516 wiremock::Mock::given(wiremock::matchers::method("POST"))
1517 .respond_with(wiremock::ResponseTemplate::new(400).set_body_string("Bad Request"))
1518 .expect(1)
1519 .mount(&server)
1520 .await;
1521
1522 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1523 let body = serde_json::json!({});
1524 let resp = client
1525 .post_json(&format!("{}/test", server.uri()), &body)
1526 .await
1527 .unwrap();
1528 assert_eq!(resp.status().as_u16(), 400);
1529 }
1530
1531 #[tokio::test]
1532 async fn delete_sends_auth_header() {
1533 let server = wiremock::MockServer::start().await;
1534
1535 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
1536 .and(wiremock::matchers::header(
1537 "Authorization",
1538 "Basic dXNlckB0ZXN0LmNvbTp0b2tlbg==",
1539 ))
1540 .respond_with(wiremock::ResponseTemplate::new(204))
1541 .expect(1)
1542 .mount(&server)
1543 .await;
1544
1545 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1546 let resp = client
1547 .delete(&format!("{}/test", server.uri()))
1548 .await
1549 .unwrap();
1550 assert_eq!(resp.status().as_u16(), 204);
1551 }
1552
1553 #[tokio::test]
1554 async fn delete_error_response() {
1555 let server = wiremock::MockServer::start().await;
1556
1557 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
1558 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
1559 .expect(1)
1560 .mount(&server)
1561 .await;
1562
1563 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1564 let resp = client
1565 .delete(&format!("{}/test", server.uri()))
1566 .await
1567 .unwrap();
1568 assert_eq!(resp.status().as_u16(), 404);
1569 }
1570
1571 #[tokio::test]
1572 async fn get_issue_success() {
1573 let server = wiremock::MockServer::start().await;
1574
1575 let issue_json = serde_json::json!({
1576 "key": "PROJ-42",
1577 "fields": {
1578 "summary": "Fix the bug",
1579 "description": {
1580 "version": 1,
1581 "type": "doc",
1582 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Details"}]}]
1583 },
1584 "status": {"name": "Open"},
1585 "issuetype": {"name": "Bug"},
1586 "assignee": {"displayName": "Alice"},
1587 "priority": {"name": "High"},
1588 "labels": ["backend", "urgent"]
1589 }
1590 });
1591
1592 wiremock::Mock::given(wiremock::matchers::method("GET"))
1593 .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-42"))
1594 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&issue_json))
1595 .expect(1)
1596 .mount(&server)
1597 .await;
1598
1599 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1600 let issue = client.get_issue("PROJ-42").await.unwrap();
1601
1602 assert_eq!(issue.key, "PROJ-42");
1603 assert_eq!(issue.summary, "Fix the bug");
1604 assert_eq!(issue.status.as_deref(), Some("Open"));
1605 assert_eq!(issue.issue_type.as_deref(), Some("Bug"));
1606 assert_eq!(issue.assignee.as_deref(), Some("Alice"));
1607 assert_eq!(issue.priority.as_deref(), Some("High"));
1608 assert_eq!(issue.labels, vec!["backend", "urgent"]);
1609 assert!(issue.description_adf.is_some());
1610 }
1611
1612 #[tokio::test]
1613 async fn get_issue_api_error() {
1614 let server = wiremock::MockServer::start().await;
1615
1616 wiremock::Mock::given(wiremock::matchers::method("GET"))
1617 .and(wiremock::matchers::path("/rest/api/3/issue/NOPE-1"))
1618 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
1619 .expect(1)
1620 .mount(&server)
1621 .await;
1622
1623 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1624 let err = client.get_issue("NOPE-1").await.unwrap_err();
1625 assert!(err.to_string().contains("404"));
1626 }
1627
1628 #[tokio::test]
1629 async fn get_issue_with_fields_named_populates_custom_fields() {
1630 let server = wiremock::MockServer::start().await;
1631
1632 let issue_json = serde_json::json!({
1633 "key": "ACCS-1",
1634 "fields": {
1635 "summary": "S",
1636 "description": null,
1637 "status": {"name": "Open"},
1638 "issuetype": {"name": "Bug"},
1639 "assignee": null,
1640 "priority": null,
1641 "labels": [],
1642 "customfield_19300": {
1643 "type": "doc",
1644 "version": 1,
1645 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "AC"}]}]
1646 }
1647 },
1648 "names": {
1649 "customfield_19300": "Acceptance Criteria"
1650 }
1651 });
1652
1653 wiremock::Mock::given(wiremock::matchers::method("GET"))
1654 .and(wiremock::matchers::path("/rest/api/3/issue/ACCS-1"))
1655 .and(wiremock::matchers::query_param("expand", "names,schema"))
1656 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&issue_json))
1657 .expect(1)
1658 .mount(&server)
1659 .await;
1660
1661 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1662 let issue = client
1663 .get_issue_with_fields(
1664 "ACCS-1",
1665 FieldSelection::Named(vec!["customfield_19300".to_string()]),
1666 )
1667 .await
1668 .unwrap();
1669
1670 assert_eq!(issue.key, "ACCS-1");
1671 assert_eq!(issue.custom_fields.len(), 1);
1672 let cf = &issue.custom_fields[0];
1673 assert_eq!(cf.id, "customfield_19300");
1674 assert_eq!(cf.name, "Acceptance Criteria");
1675 assert_eq!(cf.value["type"], "doc");
1676 }
1677
1678 #[tokio::test]
1679 async fn get_issue_with_fields_standard_omits_custom_fields() {
1680 let server = wiremock::MockServer::start().await;
1681
1682 let issue_json = serde_json::json!({
1683 "key": "ACCS-1",
1684 "fields": {
1685 "summary": "S",
1686 "description": null,
1687 "status": null,
1688 "issuetype": null,
1689 "assignee": null,
1690 "priority": null,
1691 "labels": [],
1692 "customfield_19300": {"value": "Unplanned"}
1693 },
1694 "names": {
1695 "customfield_19300": "Planned / Unplanned Work"
1696 }
1697 });
1698
1699 wiremock::Mock::given(wiremock::matchers::method("GET"))
1700 .and(wiremock::matchers::path("/rest/api/3/issue/ACCS-1"))
1701 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&issue_json))
1702 .expect(1)
1703 .mount(&server)
1704 .await;
1705
1706 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1707 let issue = client
1708 .get_issue_with_fields("ACCS-1", FieldSelection::Standard)
1709 .await
1710 .unwrap();
1711
1712 assert!(issue.custom_fields.is_empty());
1713 }
1714
1715 #[tokio::test]
1716 async fn get_issue_with_fields_all_uses_star_param() {
1717 let server = wiremock::MockServer::start().await;
1718
1719 let issue_json = serde_json::json!({
1720 "key": "ACCS-1",
1721 "fields": {
1722 "summary": "S",
1723 "description": null,
1724 "status": null,
1725 "issuetype": null,
1726 "assignee": null,
1727 "priority": null,
1728 "labels": [],
1729 "customfield_10001": {"value": "Unplanned"},
1730 "customfield_10002": 42
1731 },
1732 "names": {
1733 "customfield_10001": "Planned / Unplanned Work",
1734 "customfield_10002": "Story points"
1735 }
1736 });
1737
1738 wiremock::Mock::given(wiremock::matchers::method("GET"))
1739 .and(wiremock::matchers::path("/rest/api/3/issue/ACCS-1"))
1740 .and(wiremock::matchers::query_param("fields", "*all"))
1741 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&issue_json))
1742 .expect(1)
1743 .mount(&server)
1744 .await;
1745
1746 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1747 let issue = client
1748 .get_issue_with_fields("ACCS-1", FieldSelection::All)
1749 .await
1750 .unwrap();
1751
1752 assert_eq!(issue.custom_fields.len(), 2);
1753 let names: Vec<&str> = issue
1754 .custom_fields
1755 .iter()
1756 .map(|c| c.name.as_str())
1757 .collect();
1758 assert!(names.contains(&"Planned / Unplanned Work"));
1759 assert!(names.contains(&"Story points"));
1760 }
1761
1762 #[tokio::test]
1763 async fn get_editmeta_parses_field_schema() {
1764 let server = wiremock::MockServer::start().await;
1765
1766 let editmeta_json = serde_json::json!({
1767 "fields": {
1768 "customfield_19300": {
1769 "name": "Acceptance Criteria",
1770 "schema": {
1771 "type": "string",
1772 "custom": "com.atlassian.jira.plugin.system.customfieldtypes:textarea",
1773 "customId": 19300
1774 }
1775 },
1776 "customfield_10001": {
1777 "name": "Planned / Unplanned Work",
1778 "schema": {
1779 "type": "option",
1780 "custom": "com.atlassian.jira.plugin.system.customfieldtypes:select",
1781 "customId": 10001
1782 }
1783 }
1784 }
1785 });
1786
1787 wiremock::Mock::given(wiremock::matchers::method("GET"))
1788 .and(wiremock::matchers::path(
1789 "/rest/api/3/issue/ACCS-1/editmeta",
1790 ))
1791 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&editmeta_json))
1792 .expect(1)
1793 .mount(&server)
1794 .await;
1795
1796 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1797 let meta = client.get_editmeta("ACCS-1").await.unwrap();
1798
1799 assert_eq!(meta.fields.len(), 2);
1800 let ac = meta.fields.get("customfield_19300").unwrap();
1801 assert_eq!(ac.name, "Acceptance Criteria");
1802 assert!(ac.is_adf_rich_text());
1803 let opt = meta.fields.get("customfield_10001").unwrap();
1804 assert_eq!(opt.schema.kind, "option");
1805 assert!(!opt.is_adf_rich_text());
1806 }
1807
1808 #[tokio::test]
1809 async fn get_editmeta_api_error_surfaces_status() {
1810 let server = wiremock::MockServer::start().await;
1811
1812 wiremock::Mock::given(wiremock::matchers::method("GET"))
1813 .and(wiremock::matchers::path(
1814 "/rest/api/3/issue/NOPE-1/editmeta",
1815 ))
1816 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("not found"))
1817 .mount(&server)
1818 .await;
1819
1820 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1821 let err = client.get_editmeta("NOPE-1").await.unwrap_err();
1822 assert!(err.to_string().contains("404"));
1823 }
1824
1825 #[tokio::test]
1826 async fn update_issue_with_custom_fields_merges_into_payload() {
1827 let server = wiremock::MockServer::start().await;
1828
1829 wiremock::Mock::given(wiremock::matchers::method("PUT"))
1830 .and(wiremock::matchers::path("/rest/api/3/issue/ACCS-1"))
1831 .and(wiremock::matchers::body_json(serde_json::json!({
1832 "fields": {
1833 "description": {"version": 1, "type": "doc", "content": []},
1834 "summary": "New title",
1835 "customfield_10001": {"value": "Unplanned"},
1836 "customfield_19300": {
1837 "type": "doc",
1838 "version": 1,
1839 "content": [{"type": "paragraph"}]
1840 }
1841 }
1842 })))
1843 .respond_with(wiremock::ResponseTemplate::new(204))
1844 .expect(1)
1845 .mount(&server)
1846 .await;
1847
1848 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1849 let adf = AdfDocument::new();
1850 let mut custom = std::collections::BTreeMap::new();
1851 custom.insert(
1852 "customfield_10001".to_string(),
1853 serde_json::json!({"value": "Unplanned"}),
1854 );
1855 custom.insert(
1856 "customfield_19300".to_string(),
1857 serde_json::json!({"type": "doc", "version": 1, "content": [{"type": "paragraph"}]}),
1858 );
1859 let result = client
1860 .update_issue_with_custom_fields("ACCS-1", &adf, Some("New title"), &custom)
1861 .await;
1862 assert!(result.is_ok());
1863 }
1864
1865 #[tokio::test]
1866 async fn update_issue_shim_sends_no_custom_fields() {
1867 let server = wiremock::MockServer::start().await;
1868
1869 wiremock::Mock::given(wiremock::matchers::method("PUT"))
1870 .and(wiremock::matchers::path("/rest/api/3/issue/ACCS-1"))
1871 .and(wiremock::matchers::body_json(serde_json::json!({
1872 "fields": {
1873 "description": {"version": 1, "type": "doc", "content": []}
1874 }
1875 })))
1876 .respond_with(wiremock::ResponseTemplate::new(204))
1877 .expect(1)
1878 .mount(&server)
1879 .await;
1880
1881 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1882 let adf = AdfDocument::new();
1883 client.update_issue("ACCS-1", &adf, None).await.unwrap();
1884 }
1885
1886 #[tokio::test]
1887 async fn get_issue_with_fields_falls_back_to_id_when_names_missing() {
1888 let server = wiremock::MockServer::start().await;
1889
1890 let issue_json = serde_json::json!({
1891 "key": "ACCS-1",
1892 "fields": {
1893 "summary": "S",
1894 "description": null,
1895 "status": null,
1896 "issuetype": null,
1897 "assignee": null,
1898 "priority": null,
1899 "labels": [],
1900 "customfield_99999": "raw"
1901 }
1902 });
1903
1904 wiremock::Mock::given(wiremock::matchers::method("GET"))
1905 .and(wiremock::matchers::path("/rest/api/3/issue/ACCS-1"))
1906 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&issue_json))
1907 .expect(1)
1908 .mount(&server)
1909 .await;
1910
1911 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1912 let issue = client
1913 .get_issue_with_fields("ACCS-1", FieldSelection::All)
1914 .await
1915 .unwrap();
1916
1917 assert_eq!(issue.custom_fields.len(), 1);
1918 assert_eq!(issue.custom_fields[0].name, "customfield_99999");
1919 }
1920
1921 #[tokio::test]
1922 async fn update_issue_success() {
1923 let server = wiremock::MockServer::start().await;
1924
1925 wiremock::Mock::given(wiremock::matchers::method("PUT"))
1926 .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-42"))
1927 .respond_with(wiremock::ResponseTemplate::new(204))
1928 .expect(1)
1929 .mount(&server)
1930 .await;
1931
1932 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1933 let adf = AdfDocument::new();
1934 let result = client
1935 .update_issue("PROJ-42", &adf, Some("New title"))
1936 .await;
1937 assert!(result.is_ok());
1938 }
1939
1940 #[tokio::test]
1941 async fn update_issue_without_summary() {
1942 let server = wiremock::MockServer::start().await;
1943
1944 wiremock::Mock::given(wiremock::matchers::method("PUT"))
1945 .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-42"))
1946 .respond_with(wiremock::ResponseTemplate::new(204))
1947 .expect(1)
1948 .mount(&server)
1949 .await;
1950
1951 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1952 let adf = AdfDocument::new();
1953 let result = client.update_issue("PROJ-42", &adf, None).await;
1954 assert!(result.is_ok());
1955 }
1956
1957 #[tokio::test]
1958 async fn update_issue_api_error() {
1959 let server = wiremock::MockServer::start().await;
1960
1961 wiremock::Mock::given(wiremock::matchers::method("PUT"))
1962 .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-42"))
1963 .respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
1964 .expect(1)
1965 .mount(&server)
1966 .await;
1967
1968 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1969 let adf = AdfDocument::new();
1970 let err = client
1971 .update_issue("PROJ-42", &adf, None)
1972 .await
1973 .unwrap_err();
1974 assert!(err.to_string().contains("403"));
1975 }
1976
1977 #[tokio::test]
1978 async fn search_issues_success() {
1979 let server = wiremock::MockServer::start().await;
1980
1981 let search_json = serde_json::json!({
1982 "issues": [
1983 {
1984 "key": "PROJ-1",
1985 "fields": {
1986 "summary": "First issue",
1987 "description": null,
1988 "status": {"name": "Open"},
1989 "issuetype": {"name": "Bug"},
1990 "assignee": {"displayName": "Alice"},
1991 "priority": {"name": "High"},
1992 "labels": []
1993 }
1994 },
1995 {
1996 "key": "PROJ-2",
1997 "fields": {
1998 "summary": "Second issue",
1999 "description": null,
2000 "status": {"name": "Done"},
2001 "issuetype": {"name": "Task"},
2002 "assignee": null,
2003 "priority": null,
2004 "labels": ["backend"]
2005 }
2006 }
2007 ],
2008 "total": 2
2009 });
2010
2011 wiremock::Mock::given(wiremock::matchers::method("POST"))
2012 .and(wiremock::matchers::path("/rest/api/3/search/jql"))
2013 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&search_json))
2014 .expect(1)
2015 .mount(&server)
2016 .await;
2017
2018 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2019 let result = client.search_issues("project = PROJ", 50).await.unwrap();
2020
2021 assert_eq!(result.total, 2);
2022 assert_eq!(result.issues.len(), 2);
2023 assert_eq!(result.issues[0].key, "PROJ-1");
2024 assert_eq!(result.issues[0].summary, "First issue");
2025 assert_eq!(result.issues[0].status.as_deref(), Some("Open"));
2026 assert_eq!(result.issues[1].key, "PROJ-2");
2027 assert!(result.issues[1].assignee.is_none());
2028 }
2029
2030 #[tokio::test]
2031 async fn search_issues_without_total() {
2032 let server = wiremock::MockServer::start().await;
2033
2034 wiremock::Mock::given(wiremock::matchers::method("POST"))
2035 .and(wiremock::matchers::path("/rest/api/3/search/jql"))
2036 .respond_with(
2037 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2038 "issues": [{
2039 "key": "PROJ-1",
2040 "fields": {
2041 "summary": "Test",
2042 "description": null,
2043 "status": null,
2044 "issuetype": null,
2045 "assignee": null,
2046 "priority": null,
2047 "labels": []
2048 }
2049 }]
2050 })),
2051 )
2052 .expect(1)
2053 .mount(&server)
2054 .await;
2055
2056 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2057 let result = client.search_issues("project = PROJ", 50).await.unwrap();
2058
2059 assert_eq!(result.issues.len(), 1);
2060 assert_eq!(result.total, 1);
2062 }
2063
2064 #[tokio::test]
2065 async fn search_issues_empty_results() {
2066 let server = wiremock::MockServer::start().await;
2067
2068 wiremock::Mock::given(wiremock::matchers::method("POST"))
2069 .and(wiremock::matchers::path("/rest/api/3/search/jql"))
2070 .respond_with(
2071 wiremock::ResponseTemplate::new(200)
2072 .set_body_json(serde_json::json!({"issues": [], "total": 0})),
2073 )
2074 .expect(1)
2075 .mount(&server)
2076 .await;
2077
2078 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2079 let result = client.search_issues("project = NOPE", 50).await.unwrap();
2080
2081 assert_eq!(result.total, 0);
2082 assert!(result.issues.is_empty());
2083 }
2084
2085 #[tokio::test]
2086 async fn search_issues_api_error() {
2087 let server = wiremock::MockServer::start().await;
2088
2089 wiremock::Mock::given(wiremock::matchers::method("POST"))
2090 .and(wiremock::matchers::path("/rest/api/3/search/jql"))
2091 .respond_with(wiremock::ResponseTemplate::new(400).set_body_string("Invalid JQL query"))
2092 .expect(1)
2093 .mount(&server)
2094 .await;
2095
2096 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2097 let err = client
2098 .search_issues("invalid jql !!!", 50)
2099 .await
2100 .unwrap_err();
2101 assert!(err.to_string().contains("400"));
2102 }
2103
2104 #[tokio::test]
2105 async fn create_issue_success() {
2106 let server = wiremock::MockServer::start().await;
2107
2108 wiremock::Mock::given(wiremock::matchers::method("POST"))
2109 .and(wiremock::matchers::path("/rest/api/3/issue"))
2110 .respond_with(wiremock::ResponseTemplate::new(201).set_body_json(
2111 serde_json::json!({"key": "PROJ-124", "id": "10042", "self": "https://org.atlassian.net/rest/api/3/issue/10042"}),
2112 ))
2113 .expect(1)
2114 .mount(&server)
2115 .await;
2116
2117 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2118 let result = client
2119 .create_issue("PROJ", "Bug", "Fix login", None, &[])
2120 .await
2121 .unwrap();
2122
2123 assert_eq!(result.key, "PROJ-124");
2124 assert_eq!(result.id, "10042");
2125 assert!(result.self_url.contains("10042"));
2126 }
2127
2128 #[tokio::test]
2129 async fn create_issue_with_description_and_labels() {
2130 let server = wiremock::MockServer::start().await;
2131
2132 wiremock::Mock::given(wiremock::matchers::method("POST"))
2133 .and(wiremock::matchers::path("/rest/api/3/issue"))
2134 .respond_with(wiremock::ResponseTemplate::new(201).set_body_json(
2135 serde_json::json!({"key": "PROJ-125", "id": "10043", "self": "https://org.atlassian.net/rest/api/3/issue/10043"}),
2136 ))
2137 .expect(1)
2138 .mount(&server)
2139 .await;
2140
2141 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2142 let adf = AdfDocument::new();
2143 let labels = vec!["backend".to_string(), "urgent".to_string()];
2144 let result = client
2145 .create_issue("PROJ", "Task", "Add feature", Some(&adf), &labels)
2146 .await
2147 .unwrap();
2148
2149 assert_eq!(result.key, "PROJ-125");
2150 }
2151
2152 #[tokio::test]
2153 async fn create_issue_api_error() {
2154 let server = wiremock::MockServer::start().await;
2155
2156 wiremock::Mock::given(wiremock::matchers::method("POST"))
2157 .and(wiremock::matchers::path("/rest/api/3/issue"))
2158 .respond_with(wiremock::ResponseTemplate::new(400).set_body_string("Project not found"))
2159 .expect(1)
2160 .mount(&server)
2161 .await;
2162
2163 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2164 let err = client
2165 .create_issue("NOPE", "Bug", "Test", None, &[])
2166 .await
2167 .unwrap_err();
2168 assert!(err.to_string().contains("400"));
2169 }
2170
2171 #[tokio::test]
2172 async fn create_issue_with_custom_fields_merges_into_payload() {
2173 let server = wiremock::MockServer::start().await;
2174
2175 wiremock::Mock::given(wiremock::matchers::method("POST"))
2176 .and(wiremock::matchers::path("/rest/api/3/issue"))
2177 .and(wiremock::matchers::body_json(serde_json::json!({
2178 "fields": {
2179 "project": {"key": "PROJ"},
2180 "issuetype": {"name": "Task"},
2181 "summary": "Test",
2182 "customfield_10001": {"value": "Unplanned"}
2183 }
2184 })))
2185 .respond_with(
2186 wiremock::ResponseTemplate::new(201).set_body_json(serde_json::json!({
2187 "id": "100",
2188 "key": "PROJ-100",
2189 "self": "https://org.atlassian.net/rest/api/3/issue/100"
2190 })),
2191 )
2192 .expect(1)
2193 .mount(&server)
2194 .await;
2195
2196 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2197 let mut custom = std::collections::BTreeMap::new();
2198 custom.insert(
2199 "customfield_10001".to_string(),
2200 serde_json::json!({"value": "Unplanned"}),
2201 );
2202 let result = client
2203 .create_issue_with_custom_fields("PROJ", "Task", "Test", None, &[], &custom)
2204 .await
2205 .unwrap();
2206 assert_eq!(result.key, "PROJ-100");
2207 }
2208
2209 #[tokio::test]
2210 async fn create_issue_shim_sends_no_custom_fields() {
2211 let server = wiremock::MockServer::start().await;
2212
2213 wiremock::Mock::given(wiremock::matchers::method("POST"))
2214 .and(wiremock::matchers::path("/rest/api/3/issue"))
2215 .and(wiremock::matchers::body_json(serde_json::json!({
2216 "fields": {
2217 "project": {"key": "PROJ"},
2218 "issuetype": {"name": "Task"},
2219 "summary": "Test"
2220 }
2221 })))
2222 .respond_with(
2223 wiremock::ResponseTemplate::new(201).set_body_json(serde_json::json!({
2224 "id": "100",
2225 "key": "PROJ-100",
2226 "self": "https://org.atlassian.net/rest/api/3/issue/100"
2227 })),
2228 )
2229 .expect(1)
2230 .mount(&server)
2231 .await;
2232
2233 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2234 client
2235 .create_issue("PROJ", "Task", "Test", None, &[])
2236 .await
2237 .unwrap();
2238 }
2239
2240 #[tokio::test]
2241 async fn get_createmeta_parses_nested_fields() {
2242 let server = wiremock::MockServer::start().await;
2243
2244 wiremock::Mock::given(wiremock::matchers::method("GET"))
2245 .and(wiremock::matchers::path("/rest/api/3/issue/createmeta"))
2246 .and(wiremock::matchers::query_param("projectKeys", "PROJ"))
2247 .and(wiremock::matchers::query_param("issuetypeNames", "Task"))
2248 .and(wiremock::matchers::query_param(
2249 "expand",
2250 "projects.issuetypes.fields",
2251 ))
2252 .respond_with(
2253 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2254 "projects": [{
2255 "key": "PROJ",
2256 "issuetypes": [{
2257 "name": "Task",
2258 "fields": {
2259 "customfield_10001": {
2260 "name": "Planned / Unplanned Work",
2261 "schema": {
2262 "type": "option",
2263 "custom": "com.atlassian.jira.plugin.system.customfieldtypes:select",
2264 "customId": 10001
2265 }
2266 }
2267 }
2268 }]
2269 }]
2270 })),
2271 )
2272 .expect(1)
2273 .mount(&server)
2274 .await;
2275
2276 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2277 let meta = client.get_createmeta("PROJ", "Task").await.unwrap();
2278 assert_eq!(meta.fields.len(), 1);
2279 let field = meta.fields.get("customfield_10001").unwrap();
2280 assert_eq!(field.name, "Planned / Unplanned Work");
2281 assert_eq!(field.schema.kind, "option");
2282 }
2283
2284 #[tokio::test]
2285 async fn get_createmeta_empty_projects_returns_empty_meta() {
2286 let server = wiremock::MockServer::start().await;
2287
2288 wiremock::Mock::given(wiremock::matchers::method("GET"))
2289 .and(wiremock::matchers::path("/rest/api/3/issue/createmeta"))
2290 .respond_with(
2291 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2292 "projects": []
2293 })),
2294 )
2295 .mount(&server)
2296 .await;
2297
2298 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2299 let meta = client.get_createmeta("PROJ", "Task").await.unwrap();
2300 assert!(meta.fields.is_empty());
2301 }
2302
2303 #[tokio::test]
2304 async fn get_createmeta_api_error_surfaces_status() {
2305 let server = wiremock::MockServer::start().await;
2306
2307 wiremock::Mock::given(wiremock::matchers::method("GET"))
2308 .and(wiremock::matchers::path("/rest/api/3/issue/createmeta"))
2309 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not found"))
2310 .mount(&server)
2311 .await;
2312
2313 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2314 let err = client.get_createmeta("NOPE", "Task").await.unwrap_err();
2315 assert!(err.to_string().contains("404"));
2316 }
2317
2318 #[tokio::test]
2319 async fn get_comments_success() {
2320 let server = wiremock::MockServer::start().await;
2321
2322 wiremock::Mock::given(wiremock::matchers::method("GET"))
2323 .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1/comment"))
2324 .respond_with(
2325 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2326 "startAt": 0,
2327 "maxResults": 100,
2328 "total": 2,
2329 "comments": [
2330 {
2331 "id": "100",
2332 "author": {"displayName": "Alice"},
2333 "body": {"version": 1, "type": "doc", "content": []},
2334 "created": "2026-04-01T10:00:00.000+0000"
2335 },
2336 {
2337 "id": "101",
2338 "author": {"displayName": "Bob"},
2339 "body": null,
2340 "created": "2026-04-02T14:00:00.000+0000"
2341 }
2342 ]
2343 })),
2344 )
2345 .expect(1)
2346 .mount(&server)
2347 .await;
2348
2349 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2350 let comments = client.get_comments("PROJ-1", 0).await.unwrap();
2351
2352 assert_eq!(comments.len(), 2);
2353 assert_eq!(comments[0].id, "100");
2354 assert_eq!(comments[0].author, "Alice");
2355 assert!(comments[0].body_adf.is_some());
2356 assert!(comments[0].created.contains("2026-04-01"));
2357 assert_eq!(comments[1].id, "101");
2358 assert_eq!(comments[1].author, "Bob");
2359 assert!(comments[1].body_adf.is_none());
2360 }
2361
2362 #[tokio::test]
2363 async fn get_comments_empty() {
2364 let server = wiremock::MockServer::start().await;
2365
2366 wiremock::Mock::given(wiremock::matchers::method("GET"))
2367 .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1/comment"))
2368 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
2369 serde_json::json!({"startAt": 0, "maxResults": 100, "total": 0, "comments": []}),
2370 ))
2371 .expect(1)
2372 .mount(&server)
2373 .await;
2374
2375 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2376 let comments = client.get_comments("PROJ-1", 0).await.unwrap();
2377 assert!(comments.is_empty());
2378 }
2379
2380 #[tokio::test]
2381 async fn get_comments_api_error() {
2382 let server = wiremock::MockServer::start().await;
2383
2384 wiremock::Mock::given(wiremock::matchers::method("GET"))
2385 .and(wiremock::matchers::path("/rest/api/3/issue/NOPE-1/comment"))
2386 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
2387 .expect(1)
2388 .mount(&server)
2389 .await;
2390
2391 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2392 let err = client.get_comments("NOPE-1", 0).await.unwrap_err();
2393 assert!(err.to_string().contains("404"));
2394 }
2395
2396 #[tokio::test]
2397 async fn get_comments_paginates_with_offset() {
2398 let server = wiremock::MockServer::start().await;
2399
2400 wiremock::Mock::given(wiremock::matchers::method("GET"))
2401 .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1/comment"))
2402 .and(wiremock::matchers::query_param("startAt", "0"))
2403 .respond_with(
2404 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2405 "startAt": 0,
2406 "maxResults": 2,
2407 "total": 3,
2408 "comments": [
2409 {"id": "1", "author": {"displayName": "A"}, "body": null, "created": "2026-04-01T10:00:00.000+0000"},
2410 {"id": "2", "author": {"displayName": "B"}, "body": null, "created": "2026-04-02T10:00:00.000+0000"}
2411 ]
2412 })),
2413 )
2414 .up_to_n_times(1)
2415 .mount(&server)
2416 .await;
2417
2418 wiremock::Mock::given(wiremock::matchers::method("GET"))
2419 .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1/comment"))
2420 .and(wiremock::matchers::query_param("startAt", "2"))
2421 .respond_with(
2422 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2423 "startAt": 2,
2424 "maxResults": 2,
2425 "total": 3,
2426 "comments": [
2427 {"id": "3", "author": {"displayName": "C"}, "body": null, "created": "2026-04-03T10:00:00.000+0000"}
2428 ]
2429 })),
2430 )
2431 .up_to_n_times(1)
2432 .mount(&server)
2433 .await;
2434
2435 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2436 let comments = client.get_comments("PROJ-1", 0).await.unwrap();
2437
2438 assert_eq!(comments.len(), 3);
2439 assert_eq!(comments[0].id, "1");
2440 assert_eq!(comments[1].id, "2");
2441 assert_eq!(comments[2].id, "3");
2442 }
2443
2444 #[tokio::test]
2445 async fn get_comments_respects_limit_single_page() {
2446 let server = wiremock::MockServer::start().await;
2447
2448 wiremock::Mock::given(wiremock::matchers::method("GET"))
2450 .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1/comment"))
2451 .and(wiremock::matchers::query_param("maxResults", "2"))
2452 .and(wiremock::matchers::query_param("startAt", "0"))
2453 .respond_with(
2454 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2455 "startAt": 0,
2456 "maxResults": 2,
2457 "total": 5,
2458 "comments": [
2459 {"id": "1", "author": {"displayName": "A"}, "body": null, "created": "2026-04-01T10:00:00.000+0000"},
2460 {"id": "2", "author": {"displayName": "B"}, "body": null, "created": "2026-04-02T10:00:00.000+0000"}
2461 ]
2462 })),
2463 )
2464 .expect(1)
2465 .mount(&server)
2466 .await;
2467
2468 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2469 let comments = client.get_comments("PROJ-1", 2).await.unwrap();
2470
2471 assert_eq!(comments.len(), 2);
2472 }
2473
2474 #[tokio::test]
2475 async fn add_comment_success() {
2476 let server = wiremock::MockServer::start().await;
2477
2478 wiremock::Mock::given(wiremock::matchers::method("POST"))
2479 .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1/comment"))
2480 .respond_with(
2481 wiremock::ResponseTemplate::new(201).set_body_json(
2482 serde_json::json!({"id": "200", "author": {"displayName": "Me"}}),
2483 ),
2484 )
2485 .expect(1)
2486 .mount(&server)
2487 .await;
2488
2489 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2490 let adf = AdfDocument::new();
2491 let result = client.add_comment("PROJ-1", &adf).await;
2492 assert!(result.is_ok());
2493 }
2494
2495 #[tokio::test]
2496 async fn add_comment_api_error() {
2497 let server = wiremock::MockServer::start().await;
2498
2499 wiremock::Mock::given(wiremock::matchers::method("POST"))
2500 .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1/comment"))
2501 .respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
2502 .expect(1)
2503 .mount(&server)
2504 .await;
2505
2506 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2507 let adf = AdfDocument::new();
2508 let err = client.add_comment("PROJ-1", &adf).await.unwrap_err();
2509 assert!(err.to_string().contains("403"));
2510 }
2511
2512 #[tokio::test]
2513 async fn get_transitions_success() {
2514 let server = wiremock::MockServer::start().await;
2515
2516 wiremock::Mock::given(wiremock::matchers::method("GET"))
2517 .and(wiremock::matchers::path(
2518 "/rest/api/3/issue/PROJ-1/transitions",
2519 ))
2520 .respond_with(
2521 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2522 "transitions": [
2523 {"id": "11", "name": "In Progress"},
2524 {"id": "21", "name": "Done"},
2525 {"id": "31", "name": "Won't Do"}
2526 ]
2527 })),
2528 )
2529 .expect(1)
2530 .mount(&server)
2531 .await;
2532
2533 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2534 let transitions = client.get_transitions("PROJ-1").await.unwrap();
2535
2536 assert_eq!(transitions.len(), 3);
2537 assert_eq!(transitions[0].id, "11");
2538 assert_eq!(transitions[0].name, "In Progress");
2539 assert_eq!(transitions[1].id, "21");
2540 assert_eq!(transitions[2].name, "Won't Do");
2541 }
2542
2543 #[tokio::test]
2544 async fn get_transitions_empty() {
2545 let server = wiremock::MockServer::start().await;
2546
2547 wiremock::Mock::given(wiremock::matchers::method("GET"))
2548 .and(wiremock::matchers::path(
2549 "/rest/api/3/issue/PROJ-1/transitions",
2550 ))
2551 .respond_with(
2552 wiremock::ResponseTemplate::new(200)
2553 .set_body_json(serde_json::json!({"transitions": []})),
2554 )
2555 .expect(1)
2556 .mount(&server)
2557 .await;
2558
2559 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2560 let transitions = client.get_transitions("PROJ-1").await.unwrap();
2561 assert!(transitions.is_empty());
2562 }
2563
2564 #[tokio::test]
2565 async fn get_transitions_api_error() {
2566 let server = wiremock::MockServer::start().await;
2567
2568 wiremock::Mock::given(wiremock::matchers::method("GET"))
2569 .and(wiremock::matchers::path(
2570 "/rest/api/3/issue/NOPE-1/transitions",
2571 ))
2572 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
2573 .expect(1)
2574 .mount(&server)
2575 .await;
2576
2577 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2578 let err = client.get_transitions("NOPE-1").await.unwrap_err();
2579 assert!(err.to_string().contains("404"));
2580 }
2581
2582 #[tokio::test]
2583 async fn do_transition_success() {
2584 let server = wiremock::MockServer::start().await;
2585
2586 wiremock::Mock::given(wiremock::matchers::method("POST"))
2587 .and(wiremock::matchers::path(
2588 "/rest/api/3/issue/PROJ-1/transitions",
2589 ))
2590 .respond_with(wiremock::ResponseTemplate::new(204))
2591 .expect(1)
2592 .mount(&server)
2593 .await;
2594
2595 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2596 let result = client.do_transition("PROJ-1", "21").await;
2597 assert!(result.is_ok());
2598 }
2599
2600 #[tokio::test]
2601 async fn do_transition_api_error() {
2602 let server = wiremock::MockServer::start().await;
2603
2604 wiremock::Mock::given(wiremock::matchers::method("POST"))
2605 .and(wiremock::matchers::path(
2606 "/rest/api/3/issue/PROJ-1/transitions",
2607 ))
2608 .respond_with(
2609 wiremock::ResponseTemplate::new(400).set_body_string("Invalid transition"),
2610 )
2611 .expect(1)
2612 .mount(&server)
2613 .await;
2614
2615 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2616 let err = client.do_transition("PROJ-1", "999").await.unwrap_err();
2617 assert!(err.to_string().contains("400"));
2618 }
2619
2620 #[tokio::test]
2621 async fn search_confluence_success() {
2622 let server = wiremock::MockServer::start().await;
2623
2624 wiremock::Mock::given(wiremock::matchers::method("GET"))
2625 .and(wiremock::matchers::path("/wiki/rest/api/content/search"))
2626 .respond_with(
2627 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2628 "results": [
2629 {
2630 "id": "12345",
2631 "title": "Architecture Overview",
2632 "_expandable": {"space": "/wiki/rest/api/space/ENG"}
2633 },
2634 {
2635 "id": "67890",
2636 "title": "Getting Started",
2637 "_expandable": {"space": "/wiki/rest/api/space/DOC"}
2638 }
2639 ],
2640 "size": 2
2641 })),
2642 )
2643 .expect(1)
2644 .mount(&server)
2645 .await;
2646
2647 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2648 let result = client.search_confluence("type = page", 25).await.unwrap();
2649
2650 assert_eq!(result.total, 2);
2651 assert_eq!(result.results.len(), 2);
2652 assert_eq!(result.results[0].id, "12345");
2653 assert_eq!(result.results[0].title, "Architecture Overview");
2654 assert_eq!(result.results[0].space_key, "ENG");
2655 assert_eq!(result.results[1].space_key, "DOC");
2656 }
2657
2658 #[tokio::test]
2659 async fn search_confluence_empty() {
2660 let server = wiremock::MockServer::start().await;
2661
2662 wiremock::Mock::given(wiremock::matchers::method("GET"))
2663 .and(wiremock::matchers::path("/wiki/rest/api/content/search"))
2664 .respond_with(
2665 wiremock::ResponseTemplate::new(200)
2666 .set_body_json(serde_json::json!({"results": [], "size": 0})),
2667 )
2668 .expect(1)
2669 .mount(&server)
2670 .await;
2671
2672 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2673 let result = client
2674 .search_confluence("title = \"Nonexistent\"", 25)
2675 .await
2676 .unwrap();
2677 assert_eq!(result.total, 0);
2678 assert!(result.results.is_empty());
2679 }
2680
2681 #[tokio::test]
2682 async fn search_confluence_api_error() {
2683 let server = wiremock::MockServer::start().await;
2684
2685 wiremock::Mock::given(wiremock::matchers::method("GET"))
2686 .and(wiremock::matchers::path("/wiki/rest/api/content/search"))
2687 .respond_with(wiremock::ResponseTemplate::new(400).set_body_string("Invalid CQL"))
2688 .expect(1)
2689 .mount(&server)
2690 .await;
2691
2692 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2693 let err = client
2694 .search_confluence("bad cql !!!", 25)
2695 .await
2696 .unwrap_err();
2697 assert!(err.to_string().contains("400"));
2698 }
2699
2700 #[tokio::test]
2701 async fn search_confluence_missing_space() {
2702 let server = wiremock::MockServer::start().await;
2703
2704 wiremock::Mock::given(wiremock::matchers::method("GET"))
2705 .and(wiremock::matchers::path("/wiki/rest/api/content/search"))
2706 .respond_with(
2707 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2708 "results": [{"id": "111", "title": "No Space"}],
2709 "size": 1
2710 })),
2711 )
2712 .expect(1)
2713 .mount(&server)
2714 .await;
2715
2716 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2717 let result = client.search_confluence("type = page", 10).await.unwrap();
2718 assert_eq!(result.results[0].space_key, "");
2719 }
2720
2721 #[tokio::test]
2724 async fn search_confluence_users_success() {
2725 let server = wiremock::MockServer::start().await;
2726
2727 wiremock::Mock::given(wiremock::matchers::method("GET"))
2728 .and(wiremock::matchers::path("/wiki/rest/api/search/user"))
2729 .respond_with(
2730 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2731 "results": [
2732 {
2733 "user": {
2734 "accountId": "abc123",
2735 "displayName": "Alice Smith",
2736 "email": "alice@example.com"
2737 },
2738 "entityType": "user"
2739 },
2740 {
2741 "user": {
2742 "accountId": "def456",
2743 "displayName": "Bob Jones",
2744 "email": "bob@example.com"
2745 },
2746 "entityType": "user"
2747 }
2748 ]
2749 })),
2750 )
2751 .expect(1)
2752 .mount(&server)
2753 .await;
2754
2755 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2756 let result = client.search_confluence_users("alice", 25).await.unwrap();
2757
2758 assert_eq!(result.total, 2);
2759 assert_eq!(result.users.len(), 2);
2760 assert_eq!(result.users[0].account_id.as_deref(), Some("abc123"));
2761 assert_eq!(result.users[0].display_name, "Alice Smith");
2762 assert_eq!(result.users[0].email.as_deref(), Some("alice@example.com"));
2763 assert_eq!(result.users[1].account_id.as_deref(), Some("def456"));
2764 assert_eq!(result.users[1].display_name, "Bob Jones");
2765 }
2766
2767 #[tokio::test]
2768 async fn search_confluence_users_empty() {
2769 let server = wiremock::MockServer::start().await;
2770
2771 wiremock::Mock::given(wiremock::matchers::method("GET"))
2772 .and(wiremock::matchers::path("/wiki/rest/api/search/user"))
2773 .respond_with(
2774 wiremock::ResponseTemplate::new(200)
2775 .set_body_json(serde_json::json!({"results": []})),
2776 )
2777 .expect(1)
2778 .mount(&server)
2779 .await;
2780
2781 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2782 let result = client
2783 .search_confluence_users("nonexistent", 25)
2784 .await
2785 .unwrap();
2786 assert_eq!(result.total, 0);
2787 assert!(result.users.is_empty());
2788 }
2789
2790 #[tokio::test]
2791 async fn search_confluence_users_api_error() {
2792 let server = wiremock::MockServer::start().await;
2793
2794 wiremock::Mock::given(wiremock::matchers::method("GET"))
2795 .and(wiremock::matchers::path("/wiki/rest/api/search/user"))
2796 .respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
2797 .expect(1)
2798 .mount(&server)
2799 .await;
2800
2801 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2802 let err = client
2803 .search_confluence_users("alice", 25)
2804 .await
2805 .unwrap_err();
2806 assert!(err.to_string().contains("403"));
2807 }
2808
2809 #[tokio::test]
2810 async fn search_confluence_users_missing_email() {
2811 let server = wiremock::MockServer::start().await;
2812
2813 wiremock::Mock::given(wiremock::matchers::method("GET"))
2814 .and(wiremock::matchers::path("/wiki/rest/api/search/user"))
2815 .respond_with(
2816 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2817 "results": [
2818 {
2819 "user": {
2820 "accountId": "xyz789",
2821 "displayName": "No Email User"
2822 }
2823 }
2824 ]
2825 })),
2826 )
2827 .expect(1)
2828 .mount(&server)
2829 .await;
2830
2831 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2832 let result = client
2833 .search_confluence_users("no email", 25)
2834 .await
2835 .unwrap();
2836 assert_eq!(result.users.len(), 1);
2837 assert_eq!(result.users[0].display_name, "No Email User");
2838 assert!(result.users[0].email.is_none());
2839 }
2840
2841 #[tokio::test]
2842 async fn search_confluence_users_missing_account_id() {
2843 let server = wiremock::MockServer::start().await;
2847
2848 wiremock::Mock::given(wiremock::matchers::method("GET"))
2849 .and(wiremock::matchers::path("/wiki/rest/api/search/user"))
2850 .respond_with(
2851 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2852 "results": [
2853 {
2854 "user": {
2855 "accountId": "abc123",
2856 "displayName": "Alice Smith",
2857 "email": "alice@example.com"
2858 }
2859 },
2860 {
2861 "user": {
2862 "displayName": "App Bot",
2863 "accountType": "app"
2864 }
2865 }
2866 ]
2867 })),
2868 )
2869 .expect(1)
2870 .mount(&server)
2871 .await;
2872
2873 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2874 let result = client.search_confluence_users("any", 25).await.unwrap();
2875 assert_eq!(result.users.len(), 2);
2876 assert_eq!(result.users[0].account_id.as_deref(), Some("abc123"));
2877 assert!(result.users[1].account_id.is_none());
2878 assert_eq!(result.users[1].display_name, "App Bot");
2879 }
2880
2881 #[tokio::test]
2882 async fn search_confluence_users_uses_public_name_when_no_display_name() {
2883 let server = wiremock::MockServer::start().await;
2884
2885 wiremock::Mock::given(wiremock::matchers::method("GET"))
2886 .and(wiremock::matchers::path("/wiki/rest/api/search/user"))
2887 .respond_with(
2888 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2889 "results": [
2890 {
2891 "user": {
2892 "accountId": "abc123",
2893 "publicName": "alice.smith"
2894 }
2895 }
2896 ]
2897 })),
2898 )
2899 .expect(1)
2900 .mount(&server)
2901 .await;
2902
2903 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2904 let result = client.search_confluence_users("alice", 25).await.unwrap();
2905 assert_eq!(result.users.len(), 1);
2906 assert_eq!(result.users[0].display_name, "alice.smith");
2907 }
2908
2909 #[tokio::test]
2910 async fn search_confluence_users_skips_entries_without_user() {
2911 let server = wiremock::MockServer::start().await;
2914
2915 wiremock::Mock::given(wiremock::matchers::method("GET"))
2916 .and(wiremock::matchers::path("/wiki/rest/api/search/user"))
2917 .respond_with(
2918 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2919 "results": [
2920 {"title": "Not a user", "entityType": "content"},
2921 {
2922 "user": {
2923 "accountId": "abc123",
2924 "displayName": "Alice Smith"
2925 }
2926 }
2927 ]
2928 })),
2929 )
2930 .expect(1)
2931 .mount(&server)
2932 .await;
2933
2934 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2935 let result = client.search_confluence_users("alice", 25).await.unwrap();
2936 assert_eq!(result.users.len(), 1);
2937 assert_eq!(result.users[0].account_id.as_deref(), Some("abc123"));
2938 }
2939
2940 #[tokio::test]
2941 async fn search_confluence_users_pagination() {
2942 let server = wiremock::MockServer::start().await;
2943
2944 wiremock::Mock::given(wiremock::matchers::method("GET"))
2946 .and(wiremock::matchers::path("/wiki/rest/api/search/user"))
2947 .and(wiremock::matchers::query_param("start", "0"))
2948 .respond_with(
2949 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2950 "results": [
2951 {
2952 "user": {
2953 "accountId": "page1",
2954 "displayName": "User One"
2955 }
2956 }
2957 ],
2958 "_links": {"next": "/wiki/rest/api/search/user?start=1"}
2959 })),
2960 )
2961 .expect(1)
2962 .mount(&server)
2963 .await;
2964
2965 wiremock::Mock::given(wiremock::matchers::method("GET"))
2967 .and(wiremock::matchers::path("/wiki/rest/api/search/user"))
2968 .and(wiremock::matchers::query_param("start", "1"))
2969 .respond_with(
2970 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2971 "results": [
2972 {
2973 "user": {
2974 "accountId": "page2",
2975 "displayName": "User Two"
2976 }
2977 }
2978 ]
2979 })),
2980 )
2981 .expect(1)
2982 .mount(&server)
2983 .await;
2984
2985 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2986 let result = client.search_confluence_users("user", 0).await.unwrap();
2987
2988 assert_eq!(result.total, 2);
2989 assert_eq!(result.users[0].account_id.as_deref(), Some("page1"));
2990 assert_eq!(result.users[1].account_id.as_deref(), Some("page2"));
2991 }
2992
2993 #[tokio::test]
2994 async fn get_boards_success() {
2995 let server = wiremock::MockServer::start().await;
2996
2997 wiremock::Mock::given(wiremock::matchers::method("GET"))
2998 .and(wiremock::matchers::path("/rest/agile/1.0/board"))
2999 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
3000 serde_json::json!({
3001 "values": [
3002 {"id": 1, "name": "PROJ Board", "type": "scrum", "location": {"projectKey": "PROJ"}},
3003 {"id": 2, "name": "Kanban", "type": "kanban"}
3004 ],
3005 "total": 2, "isLast": true
3006 }),
3007 ))
3008 .expect(1)
3009 .mount(&server)
3010 .await;
3011
3012 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3013 let result = client.get_boards(None, None, 50).await.unwrap();
3014
3015 assert_eq!(result.total, 2);
3016 assert_eq!(result.boards.len(), 2);
3017 assert_eq!(result.boards[0].id, 1);
3018 assert_eq!(result.boards[0].name, "PROJ Board");
3019 assert_eq!(result.boards[0].board_type, "scrum");
3020 assert_eq!(result.boards[0].project_key.as_deref(), Some("PROJ"));
3021 assert!(result.boards[1].project_key.is_none());
3022 }
3023
3024 #[tokio::test]
3025 async fn get_boards_with_filters() {
3026 let server = wiremock::MockServer::start().await;
3027
3028 wiremock::Mock::given(wiremock::matchers::method("GET"))
3029 .and(wiremock::matchers::path("/rest/agile/1.0/board"))
3030 .and(wiremock::matchers::query_param("projectKeyOrId", "PROJ"))
3031 .and(wiremock::matchers::query_param("type", "scrum"))
3032 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
3033 serde_json::json!({
3034 "values": [{"id": 1, "name": "PROJ Board", "type": "scrum", "location": {"projectKey": "PROJ"}}],
3035 "total": 1, "isLast": true
3036 }),
3037 ))
3038 .expect(1)
3039 .mount(&server)
3040 .await;
3041
3042 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3043 let result = client
3044 .get_boards(Some("PROJ"), Some("scrum"), 50)
3045 .await
3046 .unwrap();
3047
3048 assert_eq!(result.boards.len(), 1);
3049 assert_eq!(result.boards[0].project_key.as_deref(), Some("PROJ"));
3050 }
3051
3052 #[tokio::test]
3053 async fn search_issues_paginates_with_token() {
3054 let server = wiremock::MockServer::start().await;
3055
3056 wiremock::Mock::given(wiremock::matchers::method("POST"))
3058 .and(wiremock::matchers::path("/rest/api/3/search/jql"))
3059 .and(wiremock::matchers::body_partial_json(serde_json::json!({"jql": "project = PROJ"})))
3060 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
3061 serde_json::json!({
3062 "issues": [{"key": "PROJ-1", "fields": {"summary": "First", "description": null, "status": null, "issuetype": null, "assignee": null, "priority": null, "labels": []}}],
3063 "nextPageToken": "token123"
3064 }),
3065 ))
3066 .up_to_n_times(1)
3067 .mount(&server)
3068 .await;
3069
3070 wiremock::Mock::given(wiremock::matchers::method("POST"))
3072 .and(wiremock::matchers::path("/rest/api/3/search/jql"))
3073 .and(wiremock::matchers::body_partial_json(serde_json::json!({"nextPageToken": "token123"})))
3074 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
3075 serde_json::json!({
3076 "issues": [{"key": "PROJ-2", "fields": {"summary": "Second", "description": null, "status": null, "issuetype": null, "assignee": null, "priority": null, "labels": []}}]
3077 }),
3078 ))
3079 .up_to_n_times(1)
3080 .mount(&server)
3081 .await;
3082
3083 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3084 let result = client.search_issues("project = PROJ", 0).await.unwrap();
3085
3086 assert_eq!(result.issues.len(), 2);
3087 assert_eq!(result.issues[0].key, "PROJ-1");
3088 assert_eq!(result.issues[1].key, "PROJ-2");
3089 }
3090
3091 #[tokio::test]
3092 async fn search_issues_respects_limit() {
3093 let server = wiremock::MockServer::start().await;
3094
3095 wiremock::Mock::given(wiremock::matchers::method("POST"))
3096 .and(wiremock::matchers::path("/rest/api/3/search/jql"))
3097 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
3098 serde_json::json!({
3099 "issues": [
3100 {"key": "PROJ-1", "fields": {"summary": "A", "description": null, "status": null, "issuetype": null, "assignee": null, "priority": null, "labels": []}},
3101 {"key": "PROJ-2", "fields": {"summary": "B", "description": null, "status": null, "issuetype": null, "assignee": null, "priority": null, "labels": []}}
3102 ],
3103 "nextPageToken": "more"
3104 }),
3105 ))
3106 .up_to_n_times(1)
3107 .mount(&server)
3108 .await;
3109
3110 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3111 let result = client.search_issues("project = PROJ", 2).await.unwrap();
3113 assert_eq!(result.issues.len(), 2);
3114 }
3115
3116 #[tokio::test]
3117 async fn get_boards_paginates_with_offset() {
3118 let server = wiremock::MockServer::start().await;
3119
3120 wiremock::Mock::given(wiremock::matchers::method("GET"))
3122 .and(wiremock::matchers::path("/rest/agile/1.0/board"))
3123 .and(wiremock::matchers::query_param("startAt", "0"))
3124 .respond_with(
3125 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
3126 "values": [{"id": 1, "name": "Board 1", "type": "scrum"}],
3127 "total": 2, "isLast": false
3128 })),
3129 )
3130 .up_to_n_times(1)
3131 .mount(&server)
3132 .await;
3133
3134 wiremock::Mock::given(wiremock::matchers::method("GET"))
3136 .and(wiremock::matchers::path("/rest/agile/1.0/board"))
3137 .and(wiremock::matchers::query_param("startAt", "1"))
3138 .respond_with(
3139 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
3140 "values": [{"id": 2, "name": "Board 2", "type": "kanban"}],
3141 "total": 2, "isLast": true
3142 })),
3143 )
3144 .up_to_n_times(1)
3145 .mount(&server)
3146 .await;
3147
3148 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3149 let result = client.get_boards(None, None, 0).await.unwrap();
3150
3151 assert_eq!(result.boards.len(), 2);
3152 assert_eq!(result.boards[0].name, "Board 1");
3153 assert_eq!(result.boards[1].name, "Board 2");
3154 }
3155
3156 #[tokio::test]
3157 async fn get_boards_empty() {
3158 let server = wiremock::MockServer::start().await;
3159
3160 wiremock::Mock::given(wiremock::matchers::method("GET"))
3161 .and(wiremock::matchers::path("/rest/agile/1.0/board"))
3162 .respond_with(
3163 wiremock::ResponseTemplate::new(200)
3164 .set_body_json(serde_json::json!({"values": [], "total": 0})),
3165 )
3166 .expect(1)
3167 .mount(&server)
3168 .await;
3169
3170 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3171 let result = client.get_boards(None, None, 50).await.unwrap();
3172 assert!(result.boards.is_empty());
3173 }
3174
3175 #[tokio::test]
3176 async fn get_boards_api_error() {
3177 let server = wiremock::MockServer::start().await;
3178
3179 wiremock::Mock::given(wiremock::matchers::method("GET"))
3180 .and(wiremock::matchers::path("/rest/agile/1.0/board"))
3181 .respond_with(wiremock::ResponseTemplate::new(401).set_body_string("Unauthorized"))
3182 .expect(1)
3183 .mount(&server)
3184 .await;
3185
3186 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3187 let err = client.get_boards(None, None, 50).await.unwrap_err();
3188 assert!(err.to_string().contains("401"));
3189 }
3190
3191 #[tokio::test]
3192 async fn get_board_issues_success() {
3193 let server = wiremock::MockServer::start().await;
3194
3195 wiremock::Mock::given(wiremock::matchers::method("GET"))
3196 .and(wiremock::matchers::path("/rest/agile/1.0/board/1/issue"))
3197 .respond_with(
3198 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
3199 "issues": [{
3200 "key": "PROJ-1",
3201 "fields": {
3202 "summary": "Board issue",
3203 "description": null,
3204 "status": {"name": "Open"},
3205 "issuetype": {"name": "Task"},
3206 "assignee": null,
3207 "priority": null,
3208 "labels": []
3209 }
3210 }],
3211 "total": 1, "isLast": true
3212 })),
3213 )
3214 .expect(1)
3215 .mount(&server)
3216 .await;
3217
3218 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3219 let result = client.get_board_issues(1, None, 50).await.unwrap();
3220
3221 assert_eq!(result.total, 1);
3222 assert_eq!(result.issues[0].key, "PROJ-1");
3223 assert_eq!(result.issues[0].summary, "Board issue");
3224 }
3225
3226 #[tokio::test]
3227 async fn get_board_issues_api_error() {
3228 let server = wiremock::MockServer::start().await;
3229
3230 wiremock::Mock::given(wiremock::matchers::method("GET"))
3231 .and(wiremock::matchers::path("/rest/agile/1.0/board/999/issue"))
3232 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
3233 .expect(1)
3234 .mount(&server)
3235 .await;
3236
3237 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3238 let err = client.get_board_issues(999, None, 50).await.unwrap_err();
3239 assert!(err.to_string().contains("404"));
3240 }
3241
3242 #[tokio::test]
3243 async fn get_sprints_success() {
3244 let server = wiremock::MockServer::start().await;
3245
3246 wiremock::Mock::given(wiremock::matchers::method("GET"))
3247 .and(wiremock::matchers::path("/rest/agile/1.0/board/1/sprint"))
3248 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
3249 serde_json::json!({
3250 "values": [
3251 {"id": 10, "name": "Sprint 1", "state": "closed", "startDate": "2026-03-01", "endDate": "2026-03-14", "goal": "MVP"},
3252 {"id": 11, "name": "Sprint 2", "state": "active", "startDate": "2026-03-15", "endDate": "2026-03-28"}
3253 ],
3254 "total": 2, "isLast": true
3255 }),
3256 ))
3257 .expect(1)
3258 .mount(&server)
3259 .await;
3260
3261 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3262 let result = client.get_sprints(1, None, 50).await.unwrap();
3263
3264 assert_eq!(result.total, 2);
3265 assert_eq!(result.sprints.len(), 2);
3266 assert_eq!(result.sprints[0].id, 10);
3267 assert_eq!(result.sprints[0].name, "Sprint 1");
3268 assert_eq!(result.sprints[0].state, "closed");
3269 assert_eq!(result.sprints[0].goal.as_deref(), Some("MVP"));
3270 assert!(result.sprints[1].goal.is_none());
3271 }
3272
3273 #[tokio::test]
3274 async fn get_sprints_with_state_filter() {
3275 let server = wiremock::MockServer::start().await;
3276
3277 wiremock::Mock::given(wiremock::matchers::method("GET"))
3278 .and(wiremock::matchers::path("/rest/agile/1.0/board/1/sprint"))
3279 .and(wiremock::matchers::query_param("state", "active"))
3280 .respond_with(
3281 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
3282 "values": [{"id": 11, "name": "Sprint 2", "state": "active"}],
3283 "total": 1, "isLast": true
3284 })),
3285 )
3286 .expect(1)
3287 .mount(&server)
3288 .await;
3289
3290 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3291 let result = client.get_sprints(1, Some("active"), 50).await.unwrap();
3292 assert_eq!(result.sprints.len(), 1);
3293 assert_eq!(result.sprints[0].state, "active");
3294 }
3295
3296 #[tokio::test]
3297 async fn get_sprints_api_error() {
3298 let server = wiremock::MockServer::start().await;
3299
3300 wiremock::Mock::given(wiremock::matchers::method("GET"))
3301 .and(wiremock::matchers::path("/rest/agile/1.0/board/999/sprint"))
3302 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
3303 .expect(1)
3304 .mount(&server)
3305 .await;
3306
3307 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3308 let err = client.get_sprints(999, None, 50).await.unwrap_err();
3309 assert!(err.to_string().contains("404"));
3310 }
3311
3312 #[tokio::test]
3313 async fn get_sprint_issues_success() {
3314 let server = wiremock::MockServer::start().await;
3315
3316 wiremock::Mock::given(wiremock::matchers::method("GET"))
3317 .and(wiremock::matchers::path("/rest/agile/1.0/sprint/10/issue"))
3318 .respond_with(
3319 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
3320 "issues": [{
3321 "key": "PROJ-1",
3322 "fields": {
3323 "summary": "Sprint issue",
3324 "description": null,
3325 "status": {"name": "In Progress"},
3326 "issuetype": {"name": "Story"},
3327 "assignee": {"displayName": "Alice"},
3328 "priority": null,
3329 "labels": []
3330 }
3331 }],
3332 "total": 1, "isLast": true
3333 })),
3334 )
3335 .expect(1)
3336 .mount(&server)
3337 .await;
3338
3339 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3340 let result = client.get_sprint_issues(10, None, 50).await.unwrap();
3341
3342 assert_eq!(result.total, 1);
3343 assert_eq!(result.issues[0].key, "PROJ-1");
3344 assert_eq!(result.issues[0].assignee.as_deref(), Some("Alice"));
3345 }
3346
3347 #[tokio::test]
3348 async fn get_sprint_issues_api_error() {
3349 let server = wiremock::MockServer::start().await;
3350
3351 wiremock::Mock::given(wiremock::matchers::method("GET"))
3352 .and(wiremock::matchers::path("/rest/agile/1.0/sprint/999/issue"))
3353 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
3354 .expect(1)
3355 .mount(&server)
3356 .await;
3357
3358 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3359 let err = client.get_sprint_issues(999, None, 50).await.unwrap_err();
3360 assert!(err.to_string().contains("404"));
3361 }
3362
3363 #[tokio::test]
3364 async fn add_issues_to_sprint_success() {
3365 let server = wiremock::MockServer::start().await;
3366
3367 wiremock::Mock::given(wiremock::matchers::method("POST"))
3368 .and(wiremock::matchers::path("/rest/agile/1.0/sprint/10/issue"))
3369 .respond_with(wiremock::ResponseTemplate::new(204))
3370 .expect(1)
3371 .mount(&server)
3372 .await;
3373
3374 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3375 let result = client.add_issues_to_sprint(10, &["PROJ-1", "PROJ-2"]).await;
3376 assert!(result.is_ok());
3377 }
3378
3379 #[tokio::test]
3380 async fn add_issues_to_sprint_api_error() {
3381 let server = wiremock::MockServer::start().await;
3382
3383 wiremock::Mock::given(wiremock::matchers::method("POST"))
3384 .and(wiremock::matchers::path("/rest/agile/1.0/sprint/999/issue"))
3385 .respond_with(wiremock::ResponseTemplate::new(400).set_body_string("Bad Request"))
3386 .expect(1)
3387 .mount(&server)
3388 .await;
3389
3390 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3391 let err = client
3392 .add_issues_to_sprint(999, &["NOPE-1"])
3393 .await
3394 .unwrap_err();
3395 assert!(err.to_string().contains("400"));
3396 }
3397
3398 #[tokio::test]
3399 async fn create_sprint_success() {
3400 let server = wiremock::MockServer::start().await;
3401
3402 wiremock::Mock::given(wiremock::matchers::method("POST"))
3403 .and(wiremock::matchers::path("/rest/agile/1.0/sprint"))
3404 .respond_with(
3405 wiremock::ResponseTemplate::new(201).set_body_json(serde_json::json!({
3406 "id": 42,
3407 "name": "Sprint 5",
3408 "state": "future",
3409 "startDate": "2026-05-01",
3410 "endDate": "2026-05-14",
3411 "goal": "Ship v2"
3412 })),
3413 )
3414 .expect(1)
3415 .mount(&server)
3416 .await;
3417
3418 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3419 let sprint = client
3420 .create_sprint(
3421 1,
3422 "Sprint 5",
3423 Some("2026-05-01"),
3424 Some("2026-05-14"),
3425 Some("Ship v2"),
3426 )
3427 .await
3428 .unwrap();
3429
3430 assert_eq!(sprint.id, 42);
3431 assert_eq!(sprint.name, "Sprint 5");
3432 assert_eq!(sprint.state, "future");
3433 assert_eq!(sprint.goal.as_deref(), Some("Ship v2"));
3434 }
3435
3436 #[tokio::test]
3437 async fn create_sprint_minimal() {
3438 let server = wiremock::MockServer::start().await;
3439
3440 wiremock::Mock::given(wiremock::matchers::method("POST"))
3441 .and(wiremock::matchers::path("/rest/agile/1.0/sprint"))
3442 .respond_with(wiremock::ResponseTemplate::new(201).set_body_json(
3443 serde_json::json!({"id": 43, "name": "Sprint 6", "state": "future"}),
3444 ))
3445 .expect(1)
3446 .mount(&server)
3447 .await;
3448
3449 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3450 let sprint = client
3451 .create_sprint(1, "Sprint 6", None, None, None)
3452 .await
3453 .unwrap();
3454
3455 assert_eq!(sprint.id, 43);
3456 assert!(sprint.start_date.is_none());
3457 }
3458
3459 #[tokio::test]
3460 async fn create_sprint_api_error() {
3461 let server = wiremock::MockServer::start().await;
3462
3463 wiremock::Mock::given(wiremock::matchers::method("POST"))
3464 .and(wiremock::matchers::path("/rest/agile/1.0/sprint"))
3465 .respond_with(wiremock::ResponseTemplate::new(400).set_body_string("Bad Request"))
3466 .expect(1)
3467 .mount(&server)
3468 .await;
3469
3470 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3471 let err = client
3472 .create_sprint(999, "Bad", None, None, None)
3473 .await
3474 .unwrap_err();
3475 assert!(err.to_string().contains("400"));
3476 }
3477
3478 #[tokio::test]
3479 async fn update_sprint_success() {
3480 let server = wiremock::MockServer::start().await;
3481
3482 wiremock::Mock::given(wiremock::matchers::method("PUT"))
3483 .and(wiremock::matchers::path("/rest/agile/1.0/sprint/42"))
3484 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
3485 serde_json::json!({"id": 42, "name": "Sprint 5 Updated", "state": "active"}),
3486 ))
3487 .expect(1)
3488 .mount(&server)
3489 .await;
3490
3491 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3492 let result = client
3493 .update_sprint(
3494 42,
3495 Some("Sprint 5 Updated"),
3496 Some("active"),
3497 None,
3498 None,
3499 None,
3500 )
3501 .await;
3502 assert!(result.is_ok());
3503 }
3504
3505 #[tokio::test]
3506 async fn update_sprint_all_fields() {
3507 let server = wiremock::MockServer::start().await;
3508
3509 wiremock::Mock::given(wiremock::matchers::method("PUT"))
3510 .and(wiremock::matchers::path("/rest/agile/1.0/sprint/42"))
3511 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
3512 serde_json::json!({"id": 42, "name": "Sprint 5", "state": "active"}),
3513 ))
3514 .expect(1)
3515 .mount(&server)
3516 .await;
3517
3518 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3519 let result = client
3520 .update_sprint(
3521 42,
3522 Some("Sprint 5"),
3523 Some("active"),
3524 Some("2026-05-01"),
3525 Some("2026-05-14"),
3526 Some("Ship v2"),
3527 )
3528 .await;
3529 assert!(result.is_ok());
3530 }
3531
3532 #[tokio::test]
3533 async fn update_sprint_api_error() {
3534 let server = wiremock::MockServer::start().await;
3535
3536 wiremock::Mock::given(wiremock::matchers::method("PUT"))
3537 .and(wiremock::matchers::path("/rest/agile/1.0/sprint/999"))
3538 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
3539 .expect(1)
3540 .mount(&server)
3541 .await;
3542
3543 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3544 let err = client
3545 .update_sprint(999, Some("Nope"), None, None, None, None)
3546 .await
3547 .unwrap_err();
3548 assert!(err.to_string().contains("404"));
3549 }
3550
3551 #[tokio::test]
3552 async fn get_issue_links_success() {
3553 let server = wiremock::MockServer::start().await;
3554
3555 wiremock::Mock::given(wiremock::matchers::method("GET"))
3556 .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1"))
3557 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
3558 serde_json::json!({
3559 "fields": {
3560 "issuelinks": [
3561 {
3562 "id": "100",
3563 "type": {"name": "Blocks"},
3564 "outwardIssue": {"key": "PROJ-2", "fields": {"summary": "Blocked issue"}}
3565 },
3566 {
3567 "id": "101",
3568 "type": {"name": "Relates"},
3569 "inwardIssue": {"key": "PROJ-3", "fields": {"summary": "Related issue"}}
3570 }
3571 ]
3572 }
3573 }),
3574 ))
3575 .expect(1)
3576 .mount(&server)
3577 .await;
3578
3579 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3580 let links = client.get_issue_links("PROJ-1").await.unwrap();
3581
3582 assert_eq!(links.len(), 2);
3583 assert_eq!(links[0].id, "100");
3584 assert_eq!(links[0].link_type, "Blocks");
3585 assert_eq!(links[0].direction, "outward");
3586 assert_eq!(links[0].linked_issue_key, "PROJ-2");
3587 assert_eq!(links[0].linked_issue_summary, "Blocked issue");
3588 assert_eq!(links[1].id, "101");
3589 assert_eq!(links[1].direction, "inward");
3590 assert_eq!(links[1].linked_issue_key, "PROJ-3");
3591 }
3592
3593 #[tokio::test]
3594 async fn get_issue_links_empty() {
3595 let server = wiremock::MockServer::start().await;
3596
3597 wiremock::Mock::given(wiremock::matchers::method("GET"))
3598 .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1"))
3599 .respond_with(
3600 wiremock::ResponseTemplate::new(200)
3601 .set_body_json(serde_json::json!({"fields": {"issuelinks": []}})),
3602 )
3603 .expect(1)
3604 .mount(&server)
3605 .await;
3606
3607 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3608 let links = client.get_issue_links("PROJ-1").await.unwrap();
3609 assert!(links.is_empty());
3610 }
3611
3612 #[tokio::test]
3613 async fn get_issue_links_api_error() {
3614 let server = wiremock::MockServer::start().await;
3615
3616 wiremock::Mock::given(wiremock::matchers::method("GET"))
3617 .and(wiremock::matchers::path("/rest/api/3/issue/NOPE-1"))
3618 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
3619 .expect(1)
3620 .mount(&server)
3621 .await;
3622
3623 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3624 let err = client.get_issue_links("NOPE-1").await.unwrap_err();
3625 assert!(err.to_string().contains("404"));
3626 }
3627
3628 #[tokio::test]
3629 async fn get_link_types_success() {
3630 let server = wiremock::MockServer::start().await;
3631 wiremock::Mock::given(wiremock::matchers::method("GET"))
3632 .and(wiremock::matchers::path("/rest/api/3/issueLinkType"))
3633 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({"issueLinkTypes": [{"id": "1", "name": "Blocks", "inward": "is blocked by", "outward": "blocks"}, {"id": "2", "name": "Clones", "inward": "is cloned by", "outward": "clones"}]})))
3634 .expect(1).mount(&server).await;
3635 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3636 let types = client.get_link_types().await.unwrap();
3637 assert_eq!(types.len(), 2);
3638 assert_eq!(types[0].name, "Blocks");
3639 assert_eq!(types[0].inward, "is blocked by");
3640 }
3641
3642 #[tokio::test]
3643 async fn get_link_types_api_error() {
3644 let server = wiremock::MockServer::start().await;
3645 wiremock::Mock::given(wiremock::matchers::method("GET"))
3646 .and(wiremock::matchers::path("/rest/api/3/issueLinkType"))
3647 .respond_with(wiremock::ResponseTemplate::new(401).set_body_string("Unauthorized"))
3648 .expect(1)
3649 .mount(&server)
3650 .await;
3651 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3652 let err = client.get_link_types().await.unwrap_err();
3653 assert!(err.to_string().contains("401"));
3654 }
3655
3656 #[tokio::test]
3657 async fn create_issue_link_success() {
3658 let server = wiremock::MockServer::start().await;
3659 wiremock::Mock::given(wiremock::matchers::method("POST"))
3660 .and(wiremock::matchers::path("/rest/api/3/issueLink"))
3661 .respond_with(wiremock::ResponseTemplate::new(201))
3662 .expect(1)
3663 .mount(&server)
3664 .await;
3665 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3666 assert!(client
3667 .create_issue_link("Blocks", "PROJ-1", "PROJ-2")
3668 .await
3669 .is_ok());
3670 }
3671
3672 #[tokio::test]
3673 async fn create_issue_link_api_error() {
3674 let server = wiremock::MockServer::start().await;
3675 wiremock::Mock::given(wiremock::matchers::method("POST"))
3676 .and(wiremock::matchers::path("/rest/api/3/issueLink"))
3677 .respond_with(wiremock::ResponseTemplate::new(400).set_body_string("Bad Request"))
3678 .expect(1)
3679 .mount(&server)
3680 .await;
3681 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3682 let err = client
3683 .create_issue_link("Invalid", "NOPE-1", "NOPE-2")
3684 .await
3685 .unwrap_err();
3686 assert!(err.to_string().contains("400"));
3687 }
3688
3689 #[tokio::test]
3690 async fn remove_issue_link_success() {
3691 let server = wiremock::MockServer::start().await;
3692 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
3693 .and(wiremock::matchers::path("/rest/api/3/issueLink/12345"))
3694 .respond_with(wiremock::ResponseTemplate::new(204))
3695 .expect(1)
3696 .mount(&server)
3697 .await;
3698 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3699 assert!(client.remove_issue_link("12345").await.is_ok());
3700 }
3701
3702 #[tokio::test]
3703 async fn remove_issue_link_api_error() {
3704 let server = wiremock::MockServer::start().await;
3705 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
3706 .and(wiremock::matchers::path("/rest/api/3/issueLink/99999"))
3707 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
3708 .expect(1)
3709 .mount(&server)
3710 .await;
3711 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3712 let err = client.remove_issue_link("99999").await.unwrap_err();
3713 assert!(err.to_string().contains("404"));
3714 }
3715
3716 #[tokio::test]
3717 async fn link_to_epic_success() {
3718 let server = wiremock::MockServer::start().await;
3719 wiremock::Mock::given(wiremock::matchers::method("PUT"))
3720 .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-2"))
3721 .respond_with(wiremock::ResponseTemplate::new(204))
3722 .expect(1)
3723 .mount(&server)
3724 .await;
3725 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3726 assert!(client.link_to_epic("EPIC-1", "PROJ-2").await.is_ok());
3727 }
3728
3729 #[tokio::test]
3730 async fn link_to_epic_api_error() {
3731 let server = wiremock::MockServer::start().await;
3732 wiremock::Mock::given(wiremock::matchers::method("PUT"))
3733 .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-2"))
3734 .respond_with(wiremock::ResponseTemplate::new(400).set_body_string("Not an epic"))
3735 .expect(1)
3736 .mount(&server)
3737 .await;
3738 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3739 let err = client.link_to_epic("NOPE-1", "PROJ-2").await.unwrap_err();
3740 assert!(err.to_string().contains("400"));
3741 }
3742
3743 #[tokio::test]
3744 async fn get_bytes_success() {
3745 let server = wiremock::MockServer::start().await;
3746 wiremock::Mock::given(wiremock::matchers::method("GET"))
3747 .and(wiremock::matchers::path("/file.bin"))
3748 .and(wiremock::matchers::header("Accept", "*/*"))
3749 .respond_with(wiremock::ResponseTemplate::new(200).set_body_bytes(b"binary content"))
3750 .expect(1)
3751 .mount(&server)
3752 .await;
3753
3754 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3755 let data = client
3756 .get_bytes(&format!("{}/file.bin", server.uri()))
3757 .await
3758 .unwrap();
3759 assert_eq!(&data[..], b"binary content");
3760 }
3761
3762 #[tokio::test]
3763 async fn get_bytes_api_error() {
3764 let server = wiremock::MockServer::start().await;
3765 wiremock::Mock::given(wiremock::matchers::method("GET"))
3766 .and(wiremock::matchers::path("/missing.bin"))
3767 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
3768 .expect(1)
3769 .mount(&server)
3770 .await;
3771
3772 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3773 let err = client
3774 .get_bytes(&format!("{}/missing.bin", server.uri()))
3775 .await
3776 .unwrap_err();
3777 assert!(err.to_string().contains("404"));
3778 }
3779
3780 #[tokio::test]
3781 async fn get_attachments_success() {
3782 let server = wiremock::MockServer::start().await;
3783 wiremock::Mock::given(wiremock::matchers::method("GET"))
3784 .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1"))
3785 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
3786 serde_json::json!({
3787 "fields": {
3788 "attachment": [
3789 {"id": "1", "filename": "screenshot.png", "mimeType": "image/png", "size": 12345, "content": "https://org.atlassian.net/attachment/1"},
3790 {"id": "2", "filename": "report.pdf", "mimeType": "application/pdf", "size": 99999, "content": "https://org.atlassian.net/attachment/2"}
3791 ]
3792 }
3793 }),
3794 ))
3795 .expect(1)
3796 .mount(&server)
3797 .await;
3798
3799 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3800 let attachments = client.get_attachments("PROJ-1").await.unwrap();
3801
3802 assert_eq!(attachments.len(), 2);
3803 assert_eq!(attachments[0].filename, "screenshot.png");
3804 assert_eq!(attachments[0].mime_type, "image/png");
3805 assert_eq!(attachments[0].size, 12345);
3806 assert_eq!(attachments[1].filename, "report.pdf");
3807 }
3808
3809 #[tokio::test]
3810 async fn get_attachments_empty() {
3811 let server = wiremock::MockServer::start().await;
3812 wiremock::Mock::given(wiremock::matchers::method("GET"))
3813 .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1"))
3814 .respond_with(
3815 wiremock::ResponseTemplate::new(200)
3816 .set_body_json(serde_json::json!({"fields": {"attachment": []}})),
3817 )
3818 .expect(1)
3819 .mount(&server)
3820 .await;
3821
3822 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3823 let attachments = client.get_attachments("PROJ-1").await.unwrap();
3824 assert!(attachments.is_empty());
3825 }
3826
3827 #[tokio::test]
3828 async fn get_attachments_api_error() {
3829 let server = wiremock::MockServer::start().await;
3830 wiremock::Mock::given(wiremock::matchers::method("GET"))
3831 .and(wiremock::matchers::path("/rest/api/3/issue/NOPE-1"))
3832 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
3833 .expect(1)
3834 .mount(&server)
3835 .await;
3836
3837 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3838 let err = client.get_attachments("NOPE-1").await.unwrap_err();
3839 assert!(err.to_string().contains("404"));
3840 }
3841
3842 #[tokio::test]
3843 async fn get_changelog_success() {
3844 let server = wiremock::MockServer::start().await;
3845
3846 wiremock::Mock::given(wiremock::matchers::method("GET"))
3847 .and(wiremock::matchers::path(
3848 "/rest/api/3/issue/PROJ-1/changelog",
3849 ))
3850 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
3851 serde_json::json!({
3852 "values": [
3853 {
3854 "id": "100",
3855 "author": {"displayName": "Alice"},
3856 "created": "2026-04-01T10:00:00.000+0000",
3857 "items": [
3858 {"field": "status", "fromString": "Open", "toString": "In Progress"},
3859 {"field": "assignee", "fromString": null, "toString": "Bob"}
3860 ]
3861 },
3862 {
3863 "id": "101",
3864 "author": null,
3865 "created": "2026-04-02T14:00:00.000+0000",
3866 "items": [{"field": "priority", "fromString": "Medium", "toString": "High"}]
3867 }
3868 ],
3869 "isLast": true
3870 }),
3871 ))
3872 .expect(1)
3873 .mount(&server)
3874 .await;
3875
3876 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3877 let entries = client.get_changelog("PROJ-1", 50).await.unwrap();
3878
3879 assert_eq!(entries.len(), 2);
3880 assert_eq!(entries[0].id, "100");
3881 assert_eq!(entries[0].author, "Alice");
3882 assert_eq!(entries[0].items.len(), 2);
3883 assert_eq!(entries[0].items[0].field, "status");
3884 assert_eq!(entries[0].items[0].from_string.as_deref(), Some("Open"));
3885 assert_eq!(
3886 entries[0].items[0].to_string.as_deref(),
3887 Some("In Progress")
3888 );
3889 assert_eq!(entries[0].items[1].from_string, None);
3890 assert_eq!(entries[1].author, "");
3891 }
3892
3893 #[tokio::test]
3894 async fn get_changelog_empty() {
3895 let server = wiremock::MockServer::start().await;
3896
3897 wiremock::Mock::given(wiremock::matchers::method("GET"))
3898 .and(wiremock::matchers::path(
3899 "/rest/api/3/issue/PROJ-1/changelog",
3900 ))
3901 .respond_with(
3902 wiremock::ResponseTemplate::new(200)
3903 .set_body_json(serde_json::json!({"values": []})),
3904 )
3905 .expect(1)
3906 .mount(&server)
3907 .await;
3908
3909 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3910 let entries = client.get_changelog("PROJ-1", 50).await.unwrap();
3911 assert!(entries.is_empty());
3912 }
3913
3914 #[tokio::test]
3915 async fn get_changelog_api_error() {
3916 let server = wiremock::MockServer::start().await;
3917
3918 wiremock::Mock::given(wiremock::matchers::method("GET"))
3919 .and(wiremock::matchers::path(
3920 "/rest/api/3/issue/NOPE-1/changelog",
3921 ))
3922 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
3923 .expect(1)
3924 .mount(&server)
3925 .await;
3926
3927 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3928 let err = client.get_changelog("NOPE-1", 50).await.unwrap_err();
3929 assert!(err.to_string().contains("404"));
3930 }
3931
3932 #[tokio::test]
3933 async fn get_fields_success() {
3934 let server = wiremock::MockServer::start().await;
3935
3936 wiremock::Mock::given(wiremock::matchers::method("GET"))
3937 .and(wiremock::matchers::path("/rest/api/3/field"))
3938 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
3939 serde_json::json!([
3940 {"id": "summary", "name": "Summary", "custom": false, "schema": {"type": "string"}},
3941 {"id": "customfield_10001", "name": "Story Points", "custom": true, "schema": {"type": "number"}},
3942 {"id": "labels", "name": "Labels", "custom": false}
3943 ]),
3944 ))
3945 .expect(1)
3946 .mount(&server)
3947 .await;
3948
3949 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3950 let fields = client.get_fields().await.unwrap();
3951
3952 assert_eq!(fields.len(), 3);
3953 assert_eq!(fields[0].id, "summary");
3954 assert_eq!(fields[0].name, "Summary");
3955 assert!(!fields[0].custom);
3956 assert_eq!(fields[0].schema_type.as_deref(), Some("string"));
3957 assert_eq!(fields[1].id, "customfield_10001");
3958 assert!(fields[1].custom);
3959 assert!(fields[2].schema_type.is_none());
3960 }
3961
3962 #[tokio::test]
3963 async fn get_fields_api_error() {
3964 let server = wiremock::MockServer::start().await;
3965
3966 wiremock::Mock::given(wiremock::matchers::method("GET"))
3967 .and(wiremock::matchers::path("/rest/api/3/field"))
3968 .respond_with(wiremock::ResponseTemplate::new(401).set_body_string("Unauthorized"))
3969 .expect(1)
3970 .mount(&server)
3971 .await;
3972
3973 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3974 let err = client.get_fields().await.unwrap_err();
3975 assert!(err.to_string().contains("401"));
3976 }
3977
3978 #[tokio::test]
3979 async fn get_field_contexts_success() {
3980 let server = wiremock::MockServer::start().await;
3981
3982 wiremock::Mock::given(wiremock::matchers::method("GET"))
3983 .and(wiremock::matchers::path(
3984 "/rest/api/3/field/customfield_10001/context",
3985 ))
3986 .respond_with(
3987 wiremock::ResponseTemplate::new(200).set_body_json(
3988 serde_json::json!({"values": [{"id": "12345"}, {"id": "67890"}]}),
3989 ),
3990 )
3991 .expect(1)
3992 .mount(&server)
3993 .await;
3994
3995 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3996 let contexts = client
3997 .get_field_contexts("customfield_10001")
3998 .await
3999 .unwrap();
4000
4001 assert_eq!(contexts.len(), 2);
4002 assert_eq!(contexts[0], "12345");
4003 }
4004
4005 #[tokio::test]
4006 async fn get_field_contexts_api_error() {
4007 let server = wiremock::MockServer::start().await;
4008
4009 wiremock::Mock::given(wiremock::matchers::method("GET"))
4010 .and(wiremock::matchers::path(
4011 "/rest/api/3/field/nonexistent/context",
4012 ))
4013 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
4014 .expect(1)
4015 .mount(&server)
4016 .await;
4017
4018 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4019 let err = client.get_field_contexts("nonexistent").await.unwrap_err();
4020 assert!(err.to_string().contains("404"));
4021 }
4022
4023 #[tokio::test]
4024 async fn get_field_contexts_empty() {
4025 let server = wiremock::MockServer::start().await;
4026
4027 wiremock::Mock::given(wiremock::matchers::method("GET"))
4028 .and(wiremock::matchers::path(
4029 "/rest/api/3/field/customfield_99999/context",
4030 ))
4031 .respond_with(
4032 wiremock::ResponseTemplate::new(200)
4033 .set_body_json(serde_json::json!({"values": []})),
4034 )
4035 .expect(1)
4036 .mount(&server)
4037 .await;
4038
4039 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4040 let contexts = client
4041 .get_field_contexts("customfield_99999")
4042 .await
4043 .unwrap();
4044 assert!(contexts.is_empty());
4045 }
4046
4047 #[tokio::test]
4048 async fn get_field_options_auto_discovers_context() {
4049 let server = wiremock::MockServer::start().await;
4050
4051 wiremock::Mock::given(wiremock::matchers::method("GET"))
4053 .and(wiremock::matchers::path(
4054 "/rest/api/3/field/customfield_10001/context",
4055 ))
4056 .respond_with(
4057 wiremock::ResponseTemplate::new(200)
4058 .set_body_json(serde_json::json!({"values": [{"id": "12345"}]})),
4059 )
4060 .expect(1)
4061 .mount(&server)
4062 .await;
4063
4064 wiremock::Mock::given(wiremock::matchers::method("GET"))
4066 .and(wiremock::matchers::path(
4067 "/rest/api/3/field/customfield_10001/context/12345/option",
4068 ))
4069 .respond_with(
4070 wiremock::ResponseTemplate::new(200)
4071 .set_body_json(serde_json::json!({"values": [{"id": "1", "value": "High"}]})),
4072 )
4073 .expect(1)
4074 .mount(&server)
4075 .await;
4076
4077 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4078 let options = client
4079 .get_field_options("customfield_10001", None)
4080 .await
4081 .unwrap();
4082
4083 assert_eq!(options.len(), 1);
4084 assert_eq!(options[0].value, "High");
4085 }
4086
4087 #[tokio::test]
4088 async fn get_field_options_no_context_errors() {
4089 let server = wiremock::MockServer::start().await;
4090
4091 wiremock::Mock::given(wiremock::matchers::method("GET"))
4092 .and(wiremock::matchers::path(
4093 "/rest/api/3/field/customfield_99999/context",
4094 ))
4095 .respond_with(
4096 wiremock::ResponseTemplate::new(200)
4097 .set_body_json(serde_json::json!({"values": []})),
4098 )
4099 .expect(1)
4100 .mount(&server)
4101 .await;
4102
4103 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4104 let err = client
4105 .get_field_options("customfield_99999", None)
4106 .await
4107 .unwrap_err();
4108 assert!(err.to_string().contains("No contexts found"));
4109 }
4110
4111 #[tokio::test]
4112 async fn get_field_options_with_explicit_context() {
4113 let server = wiremock::MockServer::start().await;
4114
4115 wiremock::Mock::given(wiremock::matchers::method("GET"))
4116 .and(wiremock::matchers::path(
4117 "/rest/api/3/field/customfield_10001/context/12345/option",
4118 ))
4119 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
4120 serde_json::json!({"values": [
4121 {"id": "1", "value": "High"},
4122 {"id": "2", "value": "Medium"},
4123 {"id": "3", "value": "Low"}
4124 ]}),
4125 ))
4126 .expect(1)
4127 .mount(&server)
4128 .await;
4129
4130 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4131 let options = client
4132 .get_field_options("customfield_10001", Some("12345"))
4133 .await
4134 .unwrap();
4135
4136 assert_eq!(options.len(), 3);
4137 assert_eq!(options[0].id, "1");
4138 assert_eq!(options[0].value, "High");
4139 }
4140
4141 #[tokio::test]
4142 async fn get_field_options_with_context() {
4143 let server = wiremock::MockServer::start().await;
4144
4145 wiremock::Mock::given(wiremock::matchers::method("GET"))
4146 .and(wiremock::matchers::path(
4147 "/rest/api/3/field/customfield_10001/context/12345/option",
4148 ))
4149 .respond_with(
4150 wiremock::ResponseTemplate::new(200).set_body_json(
4151 serde_json::json!({"values": [{"id": "1", "value": "Option A"}]}),
4152 ),
4153 )
4154 .expect(1)
4155 .mount(&server)
4156 .await;
4157
4158 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4159 let options = client
4160 .get_field_options("customfield_10001", Some("12345"))
4161 .await
4162 .unwrap();
4163
4164 assert_eq!(options.len(), 1);
4165 assert_eq!(options[0].value, "Option A");
4166 }
4167
4168 #[tokio::test]
4169 async fn get_field_options_api_error() {
4170 let server = wiremock::MockServer::start().await;
4171
4172 wiremock::Mock::given(wiremock::matchers::method("GET"))
4173 .and(wiremock::matchers::path(
4174 "/rest/api/3/field/nonexistent/context/99999/option",
4175 ))
4176 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
4177 .expect(1)
4178 .mount(&server)
4179 .await;
4180
4181 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4182 let err = client
4183 .get_field_options("nonexistent", Some("99999"))
4184 .await
4185 .unwrap_err();
4186 assert!(err.to_string().contains("404"));
4187 }
4188
4189 #[tokio::test]
4190 async fn get_projects_success() {
4191 let server = wiremock::MockServer::start().await;
4192
4193 wiremock::Mock::given(wiremock::matchers::method("GET"))
4194 .and(wiremock::matchers::path("/rest/api/3/project/search"))
4195 .respond_with(
4196 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
4197 "values": [
4198 {
4199 "id": "10001",
4200 "key": "PROJ",
4201 "name": "My Project",
4202 "projectTypeKey": "software",
4203 "lead": {"displayName": "Alice"}
4204 },
4205 {
4206 "id": "10002",
4207 "key": "OPS",
4208 "name": "Operations",
4209 "projectTypeKey": "business",
4210 "lead": null
4211 }
4212 ],
4213 "total": 2, "isLast": true
4214 })),
4215 )
4216 .expect(1)
4217 .mount(&server)
4218 .await;
4219
4220 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4221 let result = client.get_projects(50).await.unwrap();
4222
4223 assert_eq!(result.total, 2);
4224 assert_eq!(result.projects.len(), 2);
4225 assert_eq!(result.projects[0].key, "PROJ");
4226 assert_eq!(result.projects[0].name, "My Project");
4227 assert_eq!(result.projects[0].project_type.as_deref(), Some("software"));
4228 assert_eq!(result.projects[0].lead.as_deref(), Some("Alice"));
4229 assert_eq!(result.projects[1].key, "OPS");
4230 assert!(result.projects[1].lead.is_none());
4231 }
4232
4233 #[tokio::test]
4234 async fn get_projects_empty() {
4235 let server = wiremock::MockServer::start().await;
4236
4237 wiremock::Mock::given(wiremock::matchers::method("GET"))
4238 .and(wiremock::matchers::path("/rest/api/3/project/search"))
4239 .respond_with(
4240 wiremock::ResponseTemplate::new(200)
4241 .set_body_json(serde_json::json!({"values": [], "total": 0})),
4242 )
4243 .expect(1)
4244 .mount(&server)
4245 .await;
4246
4247 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4248 let result = client.get_projects(50).await.unwrap();
4249 assert_eq!(result.total, 0);
4250 assert!(result.projects.is_empty());
4251 }
4252
4253 #[tokio::test]
4254 async fn get_projects_api_error() {
4255 let server = wiremock::MockServer::start().await;
4256
4257 wiremock::Mock::given(wiremock::matchers::method("GET"))
4258 .and(wiremock::matchers::path("/rest/api/3/project/search"))
4259 .respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
4260 .expect(1)
4261 .mount(&server)
4262 .await;
4263
4264 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4265 let err = client.get_projects(50).await.unwrap_err();
4266 assert!(err.to_string().contains("403"));
4267 }
4268
4269 #[tokio::test]
4270 async fn delete_issue_success() {
4271 let server = wiremock::MockServer::start().await;
4272
4273 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
4274 .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-42"))
4275 .respond_with(wiremock::ResponseTemplate::new(204))
4276 .expect(1)
4277 .mount(&server)
4278 .await;
4279
4280 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4281 let result = client.delete_issue("PROJ-42").await;
4282 assert!(result.is_ok());
4283 }
4284
4285 #[tokio::test]
4286 async fn delete_issue_not_found() {
4287 let server = wiremock::MockServer::start().await;
4288
4289 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
4290 .and(wiremock::matchers::path("/rest/api/3/issue/NOPE-1"))
4291 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
4292 .expect(1)
4293 .mount(&server)
4294 .await;
4295
4296 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4297 let err = client.delete_issue("NOPE-1").await.unwrap_err();
4298 assert!(err.to_string().contains("404"));
4299 }
4300
4301 #[tokio::test]
4302 async fn delete_issue_forbidden() {
4303 let server = wiremock::MockServer::start().await;
4304
4305 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
4306 .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1"))
4307 .respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
4308 .expect(1)
4309 .mount(&server)
4310 .await;
4311
4312 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4313 let err = client.delete_issue("PROJ-1").await.unwrap_err();
4314 assert!(err.to_string().contains("403"));
4315 }
4316
4317 #[tokio::test]
4320 async fn get_watchers_success() {
4321 let server = wiremock::MockServer::start().await;
4322
4323 wiremock::Mock::given(wiremock::matchers::method("GET"))
4324 .and(wiremock::matchers::path(
4325 "/rest/api/3/issue/PROJ-1/watchers",
4326 ))
4327 .respond_with(
4328 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
4329 "watchCount": 2,
4330 "watchers": [
4331 {
4332 "accountId": "abc123",
4333 "displayName": "Alice",
4334 "emailAddress": "alice@example.com"
4335 },
4336 {
4337 "accountId": "def456",
4338 "displayName": "Bob"
4339 }
4340 ]
4341 })),
4342 )
4343 .expect(1)
4344 .mount(&server)
4345 .await;
4346
4347 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4348 let result = client.get_watchers("PROJ-1").await.unwrap();
4349
4350 assert_eq!(result.watch_count, 2);
4351 assert_eq!(result.watchers.len(), 2);
4352 assert_eq!(result.watchers[0].display_name, "Alice");
4353 assert_eq!(result.watchers[0].account_id, "abc123");
4354 assert_eq!(
4355 result.watchers[0].email_address.as_deref(),
4356 Some("alice@example.com")
4357 );
4358 assert_eq!(result.watchers[1].display_name, "Bob");
4359 assert!(result.watchers[1].email_address.is_none());
4360 }
4361
4362 #[tokio::test]
4363 async fn get_watchers_empty() {
4364 let server = wiremock::MockServer::start().await;
4365
4366 wiremock::Mock::given(wiremock::matchers::method("GET"))
4367 .and(wiremock::matchers::path(
4368 "/rest/api/3/issue/PROJ-1/watchers",
4369 ))
4370 .respond_with(
4371 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
4372 "watchCount": 0,
4373 "watchers": []
4374 })),
4375 )
4376 .expect(1)
4377 .mount(&server)
4378 .await;
4379
4380 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4381 let result = client.get_watchers("PROJ-1").await.unwrap();
4382
4383 assert_eq!(result.watch_count, 0);
4384 assert!(result.watchers.is_empty());
4385 }
4386
4387 #[tokio::test]
4388 async fn get_watchers_api_error() {
4389 let server = wiremock::MockServer::start().await;
4390
4391 wiremock::Mock::given(wiremock::matchers::method("GET"))
4392 .and(wiremock::matchers::path(
4393 "/rest/api/3/issue/NOPE-1/watchers",
4394 ))
4395 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
4396 .expect(1)
4397 .mount(&server)
4398 .await;
4399
4400 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4401 let err = client.get_watchers("NOPE-1").await.unwrap_err();
4402 assert!(err.to_string().contains("404"));
4403 }
4404
4405 #[tokio::test]
4408 async fn add_watcher_success() {
4409 let server = wiremock::MockServer::start().await;
4410
4411 wiremock::Mock::given(wiremock::matchers::method("POST"))
4412 .and(wiremock::matchers::path(
4413 "/rest/api/3/issue/PROJ-1/watchers",
4414 ))
4415 .and(wiremock::matchers::body_json(serde_json::json!("abc123")))
4416 .respond_with(wiremock::ResponseTemplate::new(204))
4417 .expect(1)
4418 .mount(&server)
4419 .await;
4420
4421 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4422 let result = client.add_watcher("PROJ-1", "abc123").await;
4423 assert!(result.is_ok());
4424 }
4425
4426 #[tokio::test]
4427 async fn add_watcher_api_error() {
4428 let server = wiremock::MockServer::start().await;
4429
4430 wiremock::Mock::given(wiremock::matchers::method("POST"))
4431 .and(wiremock::matchers::path(
4432 "/rest/api/3/issue/PROJ-1/watchers",
4433 ))
4434 .respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
4435 .expect(1)
4436 .mount(&server)
4437 .await;
4438
4439 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4440 let err = client.add_watcher("PROJ-1", "abc123").await.unwrap_err();
4441 assert!(err.to_string().contains("403"));
4442 }
4443
4444 #[tokio::test]
4447 async fn remove_watcher_success() {
4448 let server = wiremock::MockServer::start().await;
4449
4450 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
4451 .and(wiremock::matchers::path(
4452 "/rest/api/3/issue/PROJ-1/watchers",
4453 ))
4454 .and(wiremock::matchers::query_param("accountId", "abc123"))
4455 .respond_with(wiremock::ResponseTemplate::new(204))
4456 .expect(1)
4457 .mount(&server)
4458 .await;
4459
4460 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4461 let result = client.remove_watcher("PROJ-1", "abc123").await;
4462 assert!(result.is_ok());
4463 }
4464
4465 #[tokio::test]
4466 async fn remove_watcher_api_error() {
4467 let server = wiremock::MockServer::start().await;
4468
4469 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
4470 .and(wiremock::matchers::path(
4471 "/rest/api/3/issue/PROJ-1/watchers",
4472 ))
4473 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
4474 .expect(1)
4475 .mount(&server)
4476 .await;
4477
4478 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4479 let err = client.remove_watcher("PROJ-1", "abc123").await.unwrap_err();
4480 assert!(err.to_string().contains("404"));
4481 }
4482
4483 #[tokio::test]
4484 async fn get_myself_success() {
4485 let server = wiremock::MockServer::start().await;
4486
4487 wiremock::Mock::given(wiremock::matchers::method("GET"))
4488 .and(wiremock::matchers::path("/rest/api/3/myself"))
4489 .respond_with(
4490 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
4491 "displayName": "Alice Smith",
4492 "emailAddress": "alice@example.com",
4493 "accountId": "abc123"
4494 })),
4495 )
4496 .expect(1)
4497 .mount(&server)
4498 .await;
4499
4500 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4501 let user = client.get_myself().await.unwrap();
4502 assert_eq!(user.display_name, "Alice Smith");
4503 assert_eq!(user.email_address.as_deref(), Some("alice@example.com"));
4504 assert_eq!(user.account_id, "abc123");
4505 }
4506
4507 #[tokio::test]
4508 async fn get_myself_api_error() {
4509 let server = wiremock::MockServer::start().await;
4510
4511 wiremock::Mock::given(wiremock::matchers::method("GET"))
4512 .and(wiremock::matchers::path("/rest/api/3/myself"))
4513 .respond_with(wiremock::ResponseTemplate::new(401).set_body_string("Unauthorized"))
4514 .expect(1)
4515 .mount(&server)
4516 .await;
4517
4518 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4519 let err = client.get_myself().await.unwrap_err();
4520 assert!(err.to_string().contains("401"));
4521 }
4522
4523 #[tokio::test]
4526 async fn get_issue_id_success() {
4527 let server = wiremock::MockServer::start().await;
4528
4529 wiremock::Mock::given(wiremock::matchers::method("GET"))
4530 .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1"))
4531 .respond_with(
4532 wiremock::ResponseTemplate::new(200).set_body_json(
4533 serde_json::json!({"id": "12345", "key": "PROJ-1", "fields": {}}),
4534 ),
4535 )
4536 .expect(1)
4537 .mount(&server)
4538 .await;
4539
4540 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4541 let id = client.get_issue_id("PROJ-1").await.unwrap();
4542 assert_eq!(id, "12345");
4543 }
4544
4545 #[tokio::test]
4546 async fn get_issue_id_api_error() {
4547 let server = wiremock::MockServer::start().await;
4548
4549 wiremock::Mock::given(wiremock::matchers::method("GET"))
4550 .and(wiremock::matchers::path("/rest/api/3/issue/NOPE-1"))
4551 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
4552 .expect(1)
4553 .mount(&server)
4554 .await;
4555
4556 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4557 let err = client.get_issue_id("NOPE-1").await.unwrap_err();
4558 assert!(err.to_string().contains("404"));
4559 }
4560
4561 #[tokio::test]
4564 async fn get_dev_status_summary_success() {
4565 let server = wiremock::MockServer::start().await;
4566
4567 wiremock::Mock::given(wiremock::matchers::method("GET"))
4569 .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1"))
4570 .respond_with(
4571 wiremock::ResponseTemplate::new(200).set_body_json(
4572 serde_json::json!({"id": "10001", "key": "PROJ-1", "fields": {}}),
4573 ),
4574 )
4575 .mount(&server)
4576 .await;
4577
4578 wiremock::Mock::given(wiremock::matchers::method("GET"))
4580 .and(wiremock::matchers::path(
4581 "/rest/dev-status/1.0/issue/summary",
4582 ))
4583 .respond_with(
4584 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
4585 "summary": {
4586 "pullrequest": {
4587 "overall": {"count": 2},
4588 "byInstanceType": {"GitHub": {"count": 2, "name": "GitHub"}}
4589 },
4590 "branch": {
4591 "overall": {"count": 1},
4592 "byInstanceType": {"GitHub": {"count": 1, "name": "GitHub"}}
4593 },
4594 "repository": {
4595 "overall": {"count": 1},
4596 "byInstanceType": {}
4597 }
4598 }
4599 })),
4600 )
4601 .expect(1)
4602 .mount(&server)
4603 .await;
4604
4605 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4606 let summary = client.get_dev_status_summary("PROJ-1").await.unwrap();
4607 assert_eq!(summary.pullrequest.count, 2);
4608 assert_eq!(summary.pullrequest.providers, vec!["GitHub"]);
4609 assert_eq!(summary.branch.count, 1);
4610 assert_eq!(summary.repository.count, 1);
4611 assert!(summary.repository.providers.is_empty());
4612 }
4613
4614 #[tokio::test]
4615 async fn get_dev_status_summary_api_error() {
4616 let server = wiremock::MockServer::start().await;
4617
4618 wiremock::Mock::given(wiremock::matchers::method("GET"))
4619 .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1"))
4620 .respond_with(
4621 wiremock::ResponseTemplate::new(200).set_body_json(
4622 serde_json::json!({"id": "10001", "key": "PROJ-1", "fields": {}}),
4623 ),
4624 )
4625 .mount(&server)
4626 .await;
4627
4628 wiremock::Mock::given(wiremock::matchers::method("GET"))
4629 .and(wiremock::matchers::path(
4630 "/rest/dev-status/1.0/issue/summary",
4631 ))
4632 .respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
4633 .expect(1)
4634 .mount(&server)
4635 .await;
4636
4637 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4638 let err = client.get_dev_status_summary("PROJ-1").await.unwrap_err();
4639 assert!(err.to_string().contains("403"));
4640 }
4641
4642 async fn mount_issue_id_mock(server: &wiremock::MockServer) {
4646 wiremock::Mock::given(wiremock::matchers::method("GET"))
4647 .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1"))
4648 .respond_with(
4649 wiremock::ResponseTemplate::new(200).set_body_json(
4650 serde_json::json!({"id": "10001", "key": "PROJ-1", "fields": {}}),
4651 ),
4652 )
4653 .mount(server)
4654 .await;
4655 }
4656
4657 async fn mount_summary_mock(server: &wiremock::MockServer) {
4659 wiremock::Mock::given(wiremock::matchers::method("GET"))
4660 .and(wiremock::matchers::path(
4661 "/rest/dev-status/1.0/issue/summary",
4662 ))
4663 .respond_with(
4664 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
4665 "summary": {
4666 "pullrequest": {
4667 "overall": {"count": 1},
4668 "byInstanceType": {"GitHub": {"count": 1, "name": "GitHub"}}
4669 },
4670 "branch": {
4671 "overall": {"count": 0},
4672 "byInstanceType": {}
4673 },
4674 "repository": {
4675 "overall": {"count": 0},
4676 "byInstanceType": {}
4677 }
4678 }
4679 })),
4680 )
4681 .mount(server)
4682 .await;
4683 }
4684
4685 fn dev_status_detail_response() -> serde_json::Value {
4686 serde_json::json!({
4687 "detail": [{
4688 "pullRequests": [{
4689 "id": "#42",
4690 "name": "Fix login bug",
4691 "status": "MERGED",
4692 "url": "https://github.com/org/repo/pull/42",
4693 "repositoryName": "org/repo",
4694 "source": {"branch": "fix-login"},
4695 "destination": {"branch": "main"},
4696 "author": {"name": "Alice"},
4697 "reviewers": [{"name": "Bob"}],
4698 "commentCount": 3,
4699 "lastUpdate": "2024-01-15T10:30:00.000+0000"
4700 }],
4701 "branches": [{
4702 "name": "fix-login",
4703 "url": "https://github.com/org/repo/tree/fix-login",
4704 "repositoryName": "org/repo",
4705 "createPullRequestUrl": "https://github.com/org/repo/compare/fix-login",
4706 "lastCommit": {
4707 "id": "abc123def456",
4708 "displayId": "abc123d",
4709 "message": "Fix the login",
4710 "author": {"name": "Alice"},
4711 "authorTimestamp": "2024-01-14T08:00:00.000+0000",
4712 "url": "https://github.com/org/repo/commit/abc123d",
4713 "fileCount": 2,
4714 "merge": false
4715 }
4716 }],
4717 "repositories": [{
4718 "name": "org/repo",
4719 "url": "https://github.com/org/repo",
4720 "commits": [{
4721 "id": "abc123def456",
4722 "displayId": "abc123d",
4723 "message": "Fix the login",
4724 "author": {"name": "Alice"},
4725 "authorTimestamp": "2024-01-14T08:00:00.000+0000",
4726 "url": "https://github.com/org/repo/commit/abc123d",
4727 "fileCount": 2,
4728 "merge": false
4729 }]
4730 }],
4731 "_instance": {"name": "GitHub", "type": "GitHub"}
4732 }]
4733 })
4734 }
4735
4736 #[tokio::test]
4737 async fn get_dev_status_pullrequest_fields() {
4738 let server = wiremock::MockServer::start().await;
4739 mount_issue_id_mock(&server).await;
4740
4741 wiremock::Mock::given(wiremock::matchers::method("GET"))
4742 .and(wiremock::matchers::path(
4743 "/rest/dev-status/1.0/issue/detail",
4744 ))
4745 .and(wiremock::matchers::query_param("dataType", "pullrequest"))
4746 .respond_with(
4747 wiremock::ResponseTemplate::new(200).set_body_json(dev_status_detail_response()),
4748 )
4749 .mount(&server)
4750 .await;
4751
4752 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4753 let status = client
4754 .get_dev_status("PROJ-1", Some("pullrequest"), Some("GitHub"))
4755 .await
4756 .unwrap();
4757
4758 assert_eq!(status.pull_requests.len(), 1);
4759 let pr = &status.pull_requests[0];
4760 assert_eq!(pr.id, "#42");
4761 assert_eq!(pr.status, "MERGED");
4762 assert_eq!(pr.author.as_deref(), Some("Alice"));
4763 assert_eq!(pr.reviewers, vec!["Bob"]);
4764 assert_eq!(pr.comment_count, Some(3));
4765 assert!(pr.last_update.is_some());
4766 assert_eq!(pr.source_branch, "fix-login");
4767 assert_eq!(pr.destination_branch, "main");
4768 }
4769
4770 #[tokio::test]
4771 async fn get_dev_status_branch_fields() {
4772 let server = wiremock::MockServer::start().await;
4773 mount_issue_id_mock(&server).await;
4774
4775 wiremock::Mock::given(wiremock::matchers::method("GET"))
4776 .and(wiremock::matchers::path(
4777 "/rest/dev-status/1.0/issue/detail",
4778 ))
4779 .and(wiremock::matchers::query_param("dataType", "branch"))
4780 .respond_with(
4781 wiremock::ResponseTemplate::new(200).set_body_json(dev_status_detail_response()),
4782 )
4783 .mount(&server)
4784 .await;
4785
4786 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4787 let status = client
4788 .get_dev_status("PROJ-1", Some("branch"), Some("GitHub"))
4789 .await
4790 .unwrap();
4791
4792 assert_eq!(status.branches.len(), 1);
4793 let branch = &status.branches[0];
4794 assert_eq!(branch.name, "fix-login");
4795 assert!(branch.create_pr_url.is_some());
4796 let commit = branch.last_commit.as_ref().unwrap();
4797 assert_eq!(commit.display_id, "abc123d");
4798 assert_eq!(commit.file_count, 2);
4799 assert!(!commit.merge);
4800 }
4801
4802 #[tokio::test]
4803 async fn get_dev_status_repository_with_commits() {
4804 let server = wiremock::MockServer::start().await;
4805 mount_issue_id_mock(&server).await;
4806
4807 wiremock::Mock::given(wiremock::matchers::method("GET"))
4808 .and(wiremock::matchers::path(
4809 "/rest/dev-status/1.0/issue/detail",
4810 ))
4811 .and(wiremock::matchers::query_param("dataType", "repository"))
4812 .respond_with(
4813 wiremock::ResponseTemplate::new(200).set_body_json(dev_status_detail_response()),
4814 )
4815 .mount(&server)
4816 .await;
4817
4818 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4819 let status = client
4820 .get_dev_status("PROJ-1", Some("repository"), Some("GitHub"))
4821 .await
4822 .unwrap();
4823
4824 assert_eq!(status.repositories.len(), 1);
4825 assert_eq!(status.repositories[0].commits.len(), 1);
4826 assert_eq!(status.repositories[0].commits[0].display_id, "abc123d");
4827 assert_eq!(
4828 status.repositories[0].commits[0].author.as_deref(),
4829 Some("Alice")
4830 );
4831 }
4832
4833 #[tokio::test]
4834 async fn get_dev_status_auto_discovers_providers() {
4835 let server = wiremock::MockServer::start().await;
4836 mount_issue_id_mock(&server).await;
4837 mount_summary_mock(&server).await;
4838
4839 wiremock::Mock::given(wiremock::matchers::method("GET"))
4840 .and(wiremock::matchers::path(
4841 "/rest/dev-status/1.0/issue/detail",
4842 ))
4843 .respond_with(
4844 wiremock::ResponseTemplate::new(200).set_body_json(dev_status_detail_response()),
4845 )
4846 .mount(&server)
4847 .await;
4848
4849 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4850 let status = client
4851 .get_dev_status("PROJ-1", Some("pullrequest"), None)
4852 .await
4853 .unwrap();
4854
4855 assert_eq!(status.pull_requests.len(), 1);
4856 assert_eq!(status.pull_requests[0].name, "Fix login bug");
4857 }
4858
4859 #[tokio::test]
4860 async fn get_dev_status_empty_response() {
4861 let server = wiremock::MockServer::start().await;
4862 mount_issue_id_mock(&server).await;
4863
4864 wiremock::Mock::given(wiremock::matchers::method("GET"))
4865 .and(wiremock::matchers::path(
4866 "/rest/dev-status/1.0/issue/detail",
4867 ))
4868 .respond_with(
4869 wiremock::ResponseTemplate::new(200)
4870 .set_body_json(serde_json::json!({"detail": []})),
4871 )
4872 .mount(&server)
4873 .await;
4874
4875 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4876 let status = client
4877 .get_dev_status("PROJ-1", None, Some("GitHub"))
4878 .await
4879 .unwrap();
4880
4881 assert!(status.pull_requests.is_empty());
4882 assert!(status.branches.is_empty());
4883 assert!(status.repositories.is_empty());
4884 }
4885
4886 #[tokio::test]
4887 async fn get_dev_status_detail_api_error() {
4888 let server = wiremock::MockServer::start().await;
4889 mount_issue_id_mock(&server).await;
4890
4891 wiremock::Mock::given(wiremock::matchers::method("GET"))
4892 .and(wiremock::matchers::path(
4893 "/rest/dev-status/1.0/issue/detail",
4894 ))
4895 .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("Server Error"))
4896 .mount(&server)
4897 .await;
4898
4899 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4900 let err = client
4901 .get_dev_status("PROJ-1", Some("pullrequest"), Some("GitHub"))
4902 .await
4903 .unwrap_err();
4904 assert!(err.to_string().contains("500"));
4905 }
4906
4907 #[tokio::test]
4908 async fn get_dev_status_with_data_type_filter() {
4909 let server = wiremock::MockServer::start().await;
4910 mount_issue_id_mock(&server).await;
4911
4912 wiremock::Mock::given(wiremock::matchers::method("GET"))
4914 .and(wiremock::matchers::path(
4915 "/rest/dev-status/1.0/issue/detail",
4916 ))
4917 .and(wiremock::matchers::query_param("dataType", "branch"))
4918 .respond_with(
4919 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
4920 "detail": [{
4921 "pullRequests": [],
4922 "branches": [{
4923 "name": "feature-x",
4924 "url": "https://github.com/org/repo/tree/feature-x",
4925 "repositoryName": "org/repo"
4926 }],
4927 "repositories": []
4928 }]
4929 })),
4930 )
4931 .mount(&server)
4932 .await;
4933
4934 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4935 let status = client
4936 .get_dev_status("PROJ-1", Some("branch"), Some("GitHub"))
4937 .await
4938 .unwrap();
4939
4940 assert!(status.pull_requests.is_empty());
4941 assert_eq!(status.branches.len(), 1);
4942 assert_eq!(status.branches[0].name, "feature-x");
4943 assert!(status.branches[0].last_commit.is_none());
4944 assert!(status.branches[0].create_pr_url.is_none());
4945 assert!(status.repositories.is_empty());
4946 }
4947
4948 #[tokio::test]
4949 async fn get_dev_status_summary_empty() {
4950 let server = wiremock::MockServer::start().await;
4951 mount_issue_id_mock(&server).await;
4952
4953 wiremock::Mock::given(wiremock::matchers::method("GET"))
4954 .and(wiremock::matchers::path(
4955 "/rest/dev-status/1.0/issue/summary",
4956 ))
4957 .respond_with(
4958 wiremock::ResponseTemplate::new(200)
4959 .set_body_json(serde_json::json!({"summary": {}})),
4960 )
4961 .expect(1)
4962 .mount(&server)
4963 .await;
4964
4965 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4966 let summary = client.get_dev_status_summary("PROJ-1").await.unwrap();
4967 assert_eq!(summary.pullrequest.count, 0);
4968 assert_eq!(summary.branch.count, 0);
4969 assert_eq!(summary.repository.count, 0);
4970 }
4971
4972 #[tokio::test]
4973 async fn convert_commit_maps_all_fields() {
4974 let internal = DevStatusCommit {
4975 id: "abc123".to_string(),
4976 display_id: "abc".to_string(),
4977 message: "Test commit".to_string(),
4978 author: Some(DevStatusAuthor {
4979 name: "Alice".to_string(),
4980 }),
4981 author_timestamp: Some("2024-01-01T00:00:00.000+0000".to_string()),
4982 url: "https://example.com/commit/abc".to_string(),
4983 file_count: 5,
4984 merge: true,
4985 };
4986 let public = AtlassianClient::convert_commit(internal);
4987 assert_eq!(public.id, "abc123");
4988 assert_eq!(public.display_id, "abc");
4989 assert_eq!(public.message, "Test commit");
4990 assert_eq!(public.author.as_deref(), Some("Alice"));
4991 assert!(public.timestamp.is_some());
4992 assert_eq!(public.file_count, 5);
4993 assert!(public.merge);
4994 }
4995
4996 #[tokio::test]
4997 async fn convert_commit_no_author() {
4998 let internal = DevStatusCommit {
4999 id: "def456".to_string(),
5000 display_id: "def".to_string(),
5001 message: "Anonymous".to_string(),
5002 author: None,
5003 author_timestamp: None,
5004 url: "https://example.com/commit/def".to_string(),
5005 file_count: 0,
5006 merge: false,
5007 };
5008 let public = AtlassianClient::convert_commit(internal);
5009 assert!(public.author.is_none());
5010 assert!(public.timestamp.is_none());
5011 }
5012
5013 #[test]
5016 fn extract_worklog_comment_none() {
5017 assert_eq!(AtlassianClient::extract_worklog_comment(None), None);
5018 }
5019
5020 #[test]
5021 fn extract_worklog_comment_valid_adf() {
5022 let adf = serde_json::json!({
5023 "version": 1,
5024 "type": "doc",
5025 "content": [{
5026 "type": "paragraph",
5027 "content": [{"type": "text", "text": "Fixed the login bug"}]
5028 }]
5029 });
5030 let result = AtlassianClient::extract_worklog_comment(Some(&adf));
5031 assert_eq!(result.as_deref(), Some("Fixed the login bug"));
5032 }
5033
5034 #[test]
5035 fn extract_worklog_comment_empty_adf() {
5036 let adf = serde_json::json!({
5037 "version": 1,
5038 "type": "doc",
5039 "content": []
5040 });
5041 let result = AtlassianClient::extract_worklog_comment(Some(&adf));
5042 assert_eq!(result, None);
5043 }
5044
5045 #[test]
5046 fn extract_worklog_comment_invalid_json() {
5047 let invalid = serde_json::json!({"not": "adf"});
5048 let result = AtlassianClient::extract_worklog_comment(Some(&invalid));
5049 assert_eq!(result, None);
5050 }
5051
5052 #[test]
5055 fn worklog_response_deserializes() {
5056 let json = r#"{
5057 "worklogs": [
5058 {
5059 "id": "100",
5060 "author": {"displayName": "Alice"},
5061 "timeSpent": "2h",
5062 "timeSpentSeconds": 7200,
5063 "started": "2026-04-16T09:00:00.000+0000",
5064 "comment": {
5065 "version": 1,
5066 "type": "doc",
5067 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Debugging"}]}]
5068 }
5069 },
5070 {
5071 "id": "101",
5072 "author": {"displayName": "Bob"},
5073 "timeSpent": "1d",
5074 "timeSpentSeconds": 28800,
5075 "started": "2026-04-15T10:00:00.000+0000"
5076 }
5077 ],
5078 "total": 2
5079 }"#;
5080 let resp: JiraWorklogResponse = serde_json::from_str(json).unwrap();
5081 assert_eq!(resp.total, 2);
5082 assert_eq!(resp.worklogs.len(), 2);
5083 assert_eq!(resp.worklogs[0].id, "100");
5084 assert_eq!(resp.worklogs[0].time_spent.as_deref(), Some("2h"));
5085 assert_eq!(resp.worklogs[0].time_spent_seconds, 7200);
5086 assert!(resp.worklogs[0].comment.is_some());
5087 assert!(resp.worklogs[1].comment.is_none());
5088 }
5089
5090 #[test]
5091 fn worklog_response_empty() {
5092 let json = r#"{"worklogs": [], "total": 0}"#;
5093 let resp: JiraWorklogResponse = serde_json::from_str(json).unwrap();
5094 assert_eq!(resp.total, 0);
5095 assert!(resp.worklogs.is_empty());
5096 }
5097
5098 #[test]
5099 fn worklog_response_missing_optional_fields() {
5100 let json = r#"{
5101 "worklogs": [{
5102 "id": "200",
5103 "timeSpentSeconds": 3600
5104 }],
5105 "total": 1
5106 }"#;
5107 let resp: JiraWorklogResponse = serde_json::from_str(json).unwrap();
5108 assert!(resp.worklogs[0].author.is_none());
5109 assert!(resp.worklogs[0].time_spent.is_none());
5110 assert!(resp.worklogs[0].started.is_none());
5111 }
5112
5113 #[tokio::test]
5116 async fn get_worklogs_success() {
5117 let server = wiremock::MockServer::start().await;
5118
5119 let worklog_json = serde_json::json!({
5120 "worklogs": [
5121 {
5122 "id": "100",
5123 "author": {"displayName": "Alice"},
5124 "timeSpent": "2h",
5125 "timeSpentSeconds": 7200,
5126 "started": "2026-04-16T09:00:00.000+0000",
5127 "comment": {
5128 "version": 1,
5129 "type": "doc",
5130 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Debugging login"}]}]
5131 }
5132 },
5133 {
5134 "id": "101",
5135 "author": {"displayName": "Bob"},
5136 "timeSpent": "1d",
5137 "timeSpentSeconds": 28800,
5138 "started": "2026-04-15T10:00:00.000+0000"
5139 }
5140 ],
5141 "total": 2
5142 });
5143
5144 wiremock::Mock::given(wiremock::matchers::method("GET"))
5145 .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1/worklog"))
5146 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(worklog_json))
5147 .expect(1)
5148 .mount(&server)
5149 .await;
5150
5151 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
5152 let result = client.get_worklogs("PROJ-1", 50).await.unwrap();
5153
5154 assert_eq!(result.total, 2);
5155 assert_eq!(result.worklogs.len(), 2);
5156 assert_eq!(result.worklogs[0].author, "Alice");
5157 assert_eq!(result.worklogs[0].time_spent, "2h");
5158 assert_eq!(result.worklogs[0].time_spent_seconds, 7200);
5159 assert_eq!(
5160 result.worklogs[0].comment.as_deref(),
5161 Some("Debugging login")
5162 );
5163 assert_eq!(result.worklogs[1].author, "Bob");
5164 assert_eq!(result.worklogs[1].comment, None);
5165 }
5166
5167 #[tokio::test]
5168 async fn get_worklogs_empty() {
5169 let server = wiremock::MockServer::start().await;
5170
5171 wiremock::Mock::given(wiremock::matchers::method("GET"))
5172 .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1/worklog"))
5173 .respond_with(
5174 wiremock::ResponseTemplate::new(200)
5175 .set_body_json(serde_json::json!({"worklogs": [], "total": 0})),
5176 )
5177 .expect(1)
5178 .mount(&server)
5179 .await;
5180
5181 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
5182 let result = client.get_worklogs("PROJ-1", 50).await.unwrap();
5183
5184 assert_eq!(result.total, 0);
5185 assert!(result.worklogs.is_empty());
5186 }
5187
5188 #[tokio::test]
5189 async fn get_worklogs_api_error() {
5190 let server = wiremock::MockServer::start().await;
5191
5192 wiremock::Mock::given(wiremock::matchers::method("GET"))
5193 .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1/worklog"))
5194 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
5195 .expect(1)
5196 .mount(&server)
5197 .await;
5198
5199 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
5200 let result = client.get_worklogs("PROJ-1", 50).await;
5201 assert!(result.is_err());
5202 }
5203
5204 #[tokio::test]
5205 async fn add_worklog_success() {
5206 let server = wiremock::MockServer::start().await;
5207
5208 wiremock::Mock::given(wiremock::matchers::method("POST"))
5209 .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1/worklog"))
5210 .respond_with(wiremock::ResponseTemplate::new(201))
5211 .expect(1)
5212 .mount(&server)
5213 .await;
5214
5215 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
5216 let result = client.add_worklog("PROJ-1", "2h", None, None).await;
5217 assert!(result.is_ok());
5218 }
5219
5220 #[tokio::test]
5221 async fn add_worklog_with_all_fields() {
5222 let server = wiremock::MockServer::start().await;
5223
5224 wiremock::Mock::given(wiremock::matchers::method("POST"))
5225 .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1/worklog"))
5226 .respond_with(wiremock::ResponseTemplate::new(201))
5227 .expect(1)
5228 .mount(&server)
5229 .await;
5230
5231 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
5232 let result = client
5233 .add_worklog(
5234 "PROJ-1",
5235 "2h 30m",
5236 Some("2026-04-16T09:00:00.000+0000"),
5237 Some("Fixed the bug"),
5238 )
5239 .await;
5240 assert!(result.is_ok());
5241 }
5242
5243 #[tokio::test]
5244 async fn add_worklog_api_error() {
5245 let server = wiremock::MockServer::start().await;
5246
5247 wiremock::Mock::given(wiremock::matchers::method("POST"))
5248 .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1/worklog"))
5249 .respond_with(wiremock::ResponseTemplate::new(400).set_body_string("Bad Request"))
5250 .expect(1)
5251 .mount(&server)
5252 .await;
5253
5254 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
5255 let result = client.add_worklog("PROJ-1", "2h", None, None).await;
5256 assert!(result.is_err());
5257 }
5258
5259 #[tokio::test]
5260 async fn get_worklogs_respects_limit() {
5261 let server = wiremock::MockServer::start().await;
5262
5263 let worklog_json = serde_json::json!({
5264 "worklogs": [
5265 {"id": "1", "author": {"displayName": "A"}, "timeSpent": "1h", "timeSpentSeconds": 3600, "started": "2026-04-16T09:00:00.000+0000"},
5266 {"id": "2", "author": {"displayName": "B"}, "timeSpent": "2h", "timeSpentSeconds": 7200, "started": "2026-04-16T10:00:00.000+0000"},
5267 {"id": "3", "author": {"displayName": "C"}, "timeSpent": "3h", "timeSpentSeconds": 10800, "started": "2026-04-16T11:00:00.000+0000"}
5268 ],
5269 "total": 3
5270 });
5271
5272 wiremock::Mock::given(wiremock::matchers::method("GET"))
5273 .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1/worklog"))
5274 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(worklog_json))
5275 .expect(1)
5276 .mount(&server)
5277 .await;
5278
5279 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
5280 let result = client.get_worklogs("PROJ-1", 2).await.unwrap();
5281
5282 assert_eq!(result.worklogs.len(), 2);
5283 assert_eq!(result.total, 3);
5284 }
5285}
5286
5287impl AtlassianClient {
5288 pub fn new(instance_url: &str, email: &str, api_token: &str) -> Result<Self> {
5292 let client = Client::builder()
5293 .timeout(REQUEST_TIMEOUT)
5294 .build()
5295 .context("Failed to build HTTP client")?;
5296
5297 let credentials = format!("{email}:{api_token}");
5298 let encoded = base64::engine::general_purpose::STANDARD.encode(credentials);
5299 let auth_header = format!("Basic {encoded}");
5300
5301 Ok(Self {
5302 client,
5303 instance_url: instance_url.trim_end_matches('/').to_string(),
5304 auth_header,
5305 })
5306 }
5307
5308 pub fn from_credentials(creds: &crate::atlassian::auth::AtlassianCredentials) -> Result<Self> {
5310 Self::new(&creds.instance_url, &creds.email, &creds.api_token)
5311 }
5312
5313 #[must_use]
5315 pub fn instance_url(&self) -> &str {
5316 &self.instance_url
5317 }
5318
5319 pub async fn get_json(&self, url: &str) -> Result<reqwest::Response> {
5324 for attempt in 0..=MAX_RETRIES {
5325 let response = self
5326 .client
5327 .get(url)
5328 .header("Authorization", &self.auth_header)
5329 .header("Accept", "application/json")
5330 .send()
5331 .await
5332 .context("Failed to send GET request to Atlassian API")?;
5333
5334 if response.status().as_u16() != 429 || attempt == MAX_RETRIES {
5335 return Ok(response);
5336 }
5337 Self::wait_for_retry(&response, attempt).await;
5338 }
5339 unreachable!()
5340 }
5341
5342 pub async fn put_json<T: serde::Serialize + Sync + ?Sized>(
5347 &self,
5348 url: &str,
5349 body: &T,
5350 ) -> Result<reqwest::Response> {
5351 for attempt in 0..=MAX_RETRIES {
5352 let response = self
5353 .client
5354 .put(url)
5355 .header("Authorization", &self.auth_header)
5356 .header("Content-Type", "application/json")
5357 .json(body)
5358 .send()
5359 .await
5360 .context("Failed to send PUT request to Atlassian API")?;
5361
5362 if response.status().as_u16() != 429 || attempt == MAX_RETRIES {
5363 return Ok(response);
5364 }
5365 Self::wait_for_retry(&response, attempt).await;
5366 }
5367 unreachable!()
5368 }
5369
5370 pub async fn post_json<T: serde::Serialize + Sync + ?Sized>(
5372 &self,
5373 url: &str,
5374 body: &T,
5375 ) -> Result<reqwest::Response> {
5376 for attempt in 0..=MAX_RETRIES {
5377 let response = self
5378 .client
5379 .post(url)
5380 .header("Authorization", &self.auth_header)
5381 .header("Content-Type", "application/json")
5382 .json(body)
5383 .send()
5384 .await
5385 .context("Failed to send POST request to Atlassian API")?;
5386
5387 if response.status().as_u16() != 429 || attempt == MAX_RETRIES {
5388 return Ok(response);
5389 }
5390 Self::wait_for_retry(&response, attempt).await;
5391 }
5392 unreachable!()
5393 }
5394
5395 pub async fn get_bytes(&self, url: &str) -> Result<Vec<u8>> {
5397 let response = self.get_json_raw_accept(url, "*/*").await?;
5398
5399 if !response.status().is_success() {
5400 let status = response.status().as_u16();
5401 let body = response.text().await.unwrap_or_default();
5402 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
5403 }
5404
5405 let bytes = response
5406 .bytes()
5407 .await
5408 .context("Failed to read response bytes")?;
5409 Ok(bytes.to_vec())
5410 }
5411
5412 pub async fn delete(&self, url: &str) -> Result<reqwest::Response> {
5414 for attempt in 0..=MAX_RETRIES {
5415 let response = self
5416 .client
5417 .delete(url)
5418 .header("Authorization", &self.auth_header)
5419 .send()
5420 .await
5421 .context("Failed to send DELETE request to Atlassian API")?;
5422
5423 if response.status().as_u16() != 429 || attempt == MAX_RETRIES {
5424 return Ok(response);
5425 }
5426 Self::wait_for_retry(&response, attempt).await;
5427 }
5428 unreachable!()
5429 }
5430
5431 async fn get_json_raw_accept(&self, url: &str, accept: &str) -> Result<reqwest::Response> {
5433 for attempt in 0..=MAX_RETRIES {
5434 let response = self
5435 .client
5436 .get(url)
5437 .header("Authorization", &self.auth_header)
5438 .header("Accept", accept)
5439 .send()
5440 .await
5441 .context("Failed to send GET request to Atlassian API")?;
5442
5443 if response.status().as_u16() != 429 || attempt == MAX_RETRIES {
5444 return Ok(response);
5445 }
5446 Self::wait_for_retry(&response, attempt).await;
5447 }
5448 unreachable!()
5449 }
5450
5451 async fn wait_for_retry(response: &reqwest::Response, attempt: u32) {
5454 let delay = response
5455 .headers()
5456 .get("Retry-After")
5457 .and_then(|v| v.to_str().ok())
5458 .and_then(|s| s.parse::<u64>().ok())
5459 .unwrap_or_else(|| DEFAULT_RETRY_DELAY_SECS.pow(attempt + 1));
5460
5461 eprintln!(
5462 "Rate limited (429). Retrying in {delay}s (attempt {})...",
5463 attempt + 1
5464 );
5465 tokio::time::sleep(Duration::from_secs(delay)).await;
5466 }
5467
5468 pub async fn get_issue(&self, key: &str) -> Result<JiraIssue> {
5474 self.get_issue_with_fields(key, FieldSelection::Standard)
5475 .await
5476 }
5477
5478 pub async fn get_issue_with_fields(
5485 &self,
5486 key: &str,
5487 selection: FieldSelection,
5488 ) -> Result<JiraIssue> {
5489 const STANDARD_FIELDS: &str =
5490 "summary,description,status,issuetype,assignee,priority,labels";
5491
5492 let fields_param = match &selection {
5493 FieldSelection::Standard => STANDARD_FIELDS.to_string(),
5494 FieldSelection::Named(names) => {
5495 let mut parts: Vec<&str> = STANDARD_FIELDS.split(',').collect();
5496 parts.extend(names.iter().map(String::as_str));
5497 parts.join(",")
5498 }
5499 FieldSelection::All => "*all".to_string(),
5500 };
5501
5502 let base = format!("{}/rest/api/3/issue/{}", self.instance_url, key);
5503 let url = reqwest::Url::parse_with_params(
5504 &base,
5505 &[
5506 ("fields", fields_param.as_str()),
5507 ("expand", "names,schema"),
5508 ],
5509 )
5510 .context("Failed to build JIRA issue URL")?;
5511
5512 let response = self
5513 .client
5514 .get(url)
5515 .header("Authorization", &self.auth_header)
5516 .header("Accept", "application/json")
5517 .send()
5518 .await
5519 .context("Failed to send request to JIRA API")?;
5520
5521 if !response.status().is_success() {
5522 let status = response.status().as_u16();
5523 let body = response.text().await.unwrap_or_default();
5524 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
5525 }
5526
5527 let envelope: JiraIssueEnvelope = response
5528 .json()
5529 .await
5530 .context("Failed to parse JIRA issue response")?;
5531
5532 Ok(envelope.into_issue(&selection))
5533 }
5534
5535 pub async fn update_issue(
5540 &self,
5541 key: &str,
5542 description_adf: &AdfDocument,
5543 summary: Option<&str>,
5544 ) -> Result<()> {
5545 self.update_issue_with_custom_fields(
5546 key,
5547 description_adf,
5548 summary,
5549 &std::collections::BTreeMap::new(),
5550 )
5551 .await
5552 }
5553
5554 pub async fn update_issue_with_custom_fields(
5557 &self,
5558 key: &str,
5559 description_adf: &AdfDocument,
5560 summary: Option<&str>,
5561 custom_fields: &std::collections::BTreeMap<String, serde_json::Value>,
5562 ) -> Result<()> {
5563 let url = format!("{}/rest/api/3/issue/{}", self.instance_url, key);
5564
5565 let mut fields = serde_json::Map::new();
5566 fields.insert(
5567 "description".to_string(),
5568 serde_json::to_value(description_adf).context("Failed to serialize ADF document")?,
5569 );
5570 if let Some(summary_text) = summary {
5571 fields.insert(
5572 "summary".to_string(),
5573 serde_json::Value::String(summary_text.to_string()),
5574 );
5575 }
5576 for (id, value) in custom_fields {
5577 fields.insert(id.clone(), value.clone());
5578 }
5579
5580 let body = serde_json::json!({ "fields": fields });
5581
5582 let response = self
5583 .client
5584 .put(&url)
5585 .header("Authorization", &self.auth_header)
5586 .header("Content-Type", "application/json")
5587 .json(&body)
5588 .send()
5589 .await
5590 .context("Failed to send update request to JIRA API")?;
5591
5592 if !response.status().is_success() {
5593 let status = response.status().as_u16();
5594 let body = response.text().await.unwrap_or_default();
5595 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
5596 }
5597
5598 Ok(())
5599 }
5600
5601 pub async fn get_editmeta(&self, key: &str) -> Result<EditMeta> {
5607 let url = format!("{}/rest/api/3/issue/{}/editmeta", self.instance_url, key);
5608
5609 let response = self
5610 .client
5611 .get(&url)
5612 .header("Authorization", &self.auth_header)
5613 .header("Accept", "application/json")
5614 .send()
5615 .await
5616 .context("Failed to send editmeta request to JIRA API")?;
5617
5618 if !response.status().is_success() {
5619 let status = response.status().as_u16();
5620 let body = response.text().await.unwrap_or_default();
5621 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
5622 }
5623
5624 let raw: JiraEditMetaResponse = response
5625 .json()
5626 .await
5627 .context("Failed to parse JIRA editmeta response")?;
5628
5629 let fields = raw
5630 .fields
5631 .into_iter()
5632 .map(|(id, field)| {
5633 let schema = field.schema.map_or_else(
5634 || EditMetaSchema {
5635 kind: String::new(),
5636 custom: None,
5637 },
5638 |s| EditMetaSchema {
5639 kind: s.kind.unwrap_or_default(),
5640 custom: s.custom,
5641 },
5642 );
5643 (
5644 id,
5645 EditMetaField {
5646 name: field.name.unwrap_or_default(),
5647 schema,
5648 },
5649 )
5650 })
5651 .collect();
5652 Ok(EditMeta { fields })
5653 }
5654
5655 pub async fn create_issue(
5660 &self,
5661 project_key: &str,
5662 issue_type: &str,
5663 summary: &str,
5664 description_adf: Option<&AdfDocument>,
5665 labels: &[String],
5666 ) -> Result<JiraCreatedIssue> {
5667 self.create_issue_with_custom_fields(
5668 project_key,
5669 issue_type,
5670 summary,
5671 description_adf,
5672 labels,
5673 &std::collections::BTreeMap::new(),
5674 )
5675 .await
5676 }
5677
5678 pub async fn create_issue_with_custom_fields(
5681 &self,
5682 project_key: &str,
5683 issue_type: &str,
5684 summary: &str,
5685 description_adf: Option<&AdfDocument>,
5686 labels: &[String],
5687 custom_fields: &std::collections::BTreeMap<String, serde_json::Value>,
5688 ) -> Result<JiraCreatedIssue> {
5689 let url = format!("{}/rest/api/3/issue", self.instance_url);
5690
5691 let mut fields = serde_json::Map::new();
5692 fields.insert(
5693 "project".to_string(),
5694 serde_json::json!({ "key": project_key }),
5695 );
5696 fields.insert(
5697 "issuetype".to_string(),
5698 serde_json::json!({ "name": issue_type }),
5699 );
5700 fields.insert(
5701 "summary".to_string(),
5702 serde_json::Value::String(summary.to_string()),
5703 );
5704 if let Some(adf) = description_adf {
5705 fields.insert(
5706 "description".to_string(),
5707 serde_json::to_value(adf).context("Failed to serialize ADF document")?,
5708 );
5709 }
5710 if !labels.is_empty() {
5711 fields.insert("labels".to_string(), serde_json::to_value(labels)?);
5712 }
5713 for (id, value) in custom_fields {
5714 fields.insert(id.clone(), value.clone());
5715 }
5716
5717 let body = serde_json::json!({ "fields": fields });
5718
5719 let response = self
5720 .post_json(&url, &body)
5721 .await
5722 .context("Failed to send create request to JIRA API")?;
5723
5724 if !response.status().is_success() {
5725 let status = response.status().as_u16();
5726 let body = response.text().await.unwrap_or_default();
5727 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
5728 }
5729
5730 let create_response: JiraCreateResponse = response
5731 .json()
5732 .await
5733 .context("Failed to parse JIRA create response")?;
5734
5735 Ok(JiraCreatedIssue {
5736 key: create_response.key,
5737 id: create_response.id,
5738 self_url: create_response.self_url,
5739 })
5740 }
5741
5742 pub async fn get_createmeta(&self, project_key: &str, issue_type: &str) -> Result<EditMeta> {
5749 let base = format!("{}/rest/api/3/issue/createmeta", self.instance_url);
5750 let url = reqwest::Url::parse_with_params(
5751 &base,
5752 &[
5753 ("projectKeys", project_key),
5754 ("issuetypeNames", issue_type),
5755 ("expand", "projects.issuetypes.fields"),
5756 ],
5757 )
5758 .context("Failed to build JIRA createmeta URL")?;
5759
5760 let response = self
5761 .client
5762 .get(url)
5763 .header("Authorization", &self.auth_header)
5764 .header("Accept", "application/json")
5765 .send()
5766 .await
5767 .context("Failed to send createmeta request to JIRA API")?;
5768
5769 if !response.status().is_success() {
5770 let status = response.status().as_u16();
5771 let body = response.text().await.unwrap_or_default();
5772 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
5773 }
5774
5775 let raw: JiraCreateMetaResponse = response
5776 .json()
5777 .await
5778 .context("Failed to parse JIRA createmeta response")?;
5779
5780 let Some(project) = raw.projects.into_iter().next() else {
5781 return Ok(EditMeta::default());
5782 };
5783 let Some(issuetype) = project.issuetypes.into_iter().next() else {
5784 return Ok(EditMeta::default());
5785 };
5786
5787 let fields = issuetype
5788 .fields
5789 .into_iter()
5790 .map(|(id, field)| {
5791 let schema = field.schema.map_or_else(
5792 || EditMetaSchema {
5793 kind: String::new(),
5794 custom: None,
5795 },
5796 |s| EditMetaSchema {
5797 kind: s.kind.unwrap_or_default(),
5798 custom: s.custom,
5799 },
5800 );
5801 (
5802 id,
5803 EditMetaField {
5804 name: field.name.unwrap_or_default(),
5805 schema,
5806 },
5807 )
5808 })
5809 .collect();
5810 Ok(EditMeta { fields })
5811 }
5812
5813 pub async fn get_comments(&self, key: &str, limit: u32) -> Result<Vec<JiraComment>> {
5817 let effective_limit = if limit == 0 { u32::MAX } else { limit };
5818 let mut all_comments = Vec::new();
5819 let mut start_at: u32 = 0;
5820
5821 loop {
5822 let remaining = effective_limit.saturating_sub(all_comments.len() as u32);
5823 if remaining == 0 {
5824 break;
5825 }
5826 let page_size = remaining.min(PAGE_SIZE);
5827
5828 let url = format!(
5829 "{}/rest/api/3/issue/{}/comment?orderBy=created&maxResults={}&startAt={}",
5830 self.instance_url, key, page_size, start_at
5831 );
5832
5833 let response = self.get_json(&url).await?;
5834
5835 if !response.status().is_success() {
5836 let status = response.status().as_u16();
5837 let body = response.text().await.unwrap_or_default();
5838 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
5839 }
5840
5841 let resp: JiraCommentsResponse = response
5842 .json()
5843 .await
5844 .context("Failed to parse comments response")?;
5845
5846 let page_count = resp.comments.len() as u32;
5847 for c in resp.comments {
5848 all_comments.push(JiraComment {
5849 id: c.id,
5850 author: c.author.and_then(|a| a.display_name).unwrap_or_default(),
5851 body_adf: c.body,
5852 created: c.created.unwrap_or_default(),
5853 });
5854 }
5855
5856 if page_count == 0 {
5857 break;
5858 }
5859
5860 let fetched = resp.start_at.saturating_add(page_count);
5861 if fetched >= resp.total {
5862 break;
5863 }
5864
5865 start_at += page_count;
5866 }
5867
5868 Ok(all_comments)
5869 }
5870
5871 pub async fn add_comment(&self, key: &str, body_adf: &AdfDocument) -> Result<()> {
5873 let url = format!("{}/rest/api/3/issue/{}/comment", self.instance_url, key);
5874
5875 let body = serde_json::json!({
5876 "body": body_adf
5877 });
5878
5879 let response = self.post_json(&url, &body).await?;
5880
5881 if !response.status().is_success() {
5882 let status = response.status().as_u16();
5883 let body = response.text().await.unwrap_or_default();
5884 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
5885 }
5886
5887 Ok(())
5888 }
5889
5890 pub async fn get_worklogs(&self, key: &str, limit: u32) -> Result<JiraWorklogList> {
5892 let effective_limit = if limit == 0 { u32::MAX } else { limit };
5893 let url = format!(
5894 "{}/rest/api/3/issue/{}/worklog?maxResults={}",
5895 self.instance_url,
5896 key,
5897 effective_limit.min(5000)
5898 );
5899
5900 let response = self.get_json(&url).await?;
5901
5902 if !response.status().is_success() {
5903 let status = response.status().as_u16();
5904 let body = response.text().await.unwrap_or_default();
5905 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
5906 }
5907
5908 let resp: JiraWorklogResponse = response
5909 .json()
5910 .await
5911 .context("Failed to parse worklog response")?;
5912
5913 let worklogs: Vec<JiraWorklog> = resp
5914 .worklogs
5915 .into_iter()
5916 .take(effective_limit as usize)
5917 .map(|w| JiraWorklog {
5918 id: w.id,
5919 author: w.author.and_then(|a| a.display_name).unwrap_or_default(),
5920 time_spent: w.time_spent.unwrap_or_default(),
5921 time_spent_seconds: w.time_spent_seconds,
5922 started: w.started.unwrap_or_default(),
5923 comment: Self::extract_worklog_comment(w.comment.as_ref()),
5924 })
5925 .collect();
5926
5927 Ok(JiraWorklogList {
5928 total: resp.total,
5929 worklogs,
5930 })
5931 }
5932
5933 pub async fn add_worklog(
5935 &self,
5936 key: &str,
5937 time_spent: &str,
5938 started: Option<&str>,
5939 comment: Option<&str>,
5940 ) -> Result<()> {
5941 let url = format!("{}/rest/api/3/issue/{}/worklog", self.instance_url, key);
5942
5943 let mut body = serde_json::json!({
5944 "timeSpent": time_spent,
5945 });
5946
5947 if let Some(started) = started {
5948 body["started"] = serde_json::Value::String(started.to_string());
5949 }
5950
5951 if let Some(comment_text) = comment {
5952 body["comment"] = serde_json::json!({
5953 "type": "doc",
5954 "version": 1,
5955 "content": [{
5956 "type": "paragraph",
5957 "content": [{
5958 "type": "text",
5959 "text": comment_text
5960 }]
5961 }]
5962 });
5963 }
5964
5965 let response = self.post_json(&url, &body).await?;
5966
5967 if !response.status().is_success() {
5968 let status = response.status().as_u16();
5969 let body = response.text().await.unwrap_or_default();
5970 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
5971 }
5972
5973 Ok(())
5974 }
5975
5976 fn extract_worklog_comment(adf_value: Option<&serde_json::Value>) -> Option<String> {
5978 let adf_value = adf_value?;
5979 let adf: AdfDocument = serde_json::from_value(adf_value.clone()).ok()?;
5980 let md = adf_to_markdown(&adf).ok()?;
5981 let trimmed = md.trim();
5982 if trimmed.is_empty() {
5983 None
5984 } else {
5985 Some(trimmed.to_string())
5986 }
5987 }
5988
5989 pub async fn get_transitions(&self, key: &str) -> Result<Vec<JiraTransition>> {
5991 let url = format!("{}/rest/api/3/issue/{}/transitions", self.instance_url, key);
5992
5993 let response = self.get_json(&url).await?;
5994
5995 if !response.status().is_success() {
5996 let status = response.status().as_u16();
5997 let body = response.text().await.unwrap_or_default();
5998 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
5999 }
6000
6001 let resp: JiraTransitionsResponse = response
6002 .json()
6003 .await
6004 .context("Failed to parse transitions response")?;
6005
6006 Ok(resp
6007 .transitions
6008 .into_iter()
6009 .map(|t| JiraTransition {
6010 id: t.id,
6011 name: t.name,
6012 })
6013 .collect())
6014 }
6015
6016 pub async fn do_transition(&self, key: &str, transition_id: &str) -> Result<()> {
6018 let url = format!("{}/rest/api/3/issue/{}/transitions", self.instance_url, key);
6019
6020 let body = serde_json::json!({
6021 "transition": { "id": transition_id }
6022 });
6023
6024 let response = self.post_json(&url, &body).await?;
6025
6026 if !response.status().is_success() {
6027 let status = response.status().as_u16();
6028 let body = response.text().await.unwrap_or_default();
6029 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
6030 }
6031
6032 Ok(())
6033 }
6034
6035 pub async fn search_issues(&self, jql: &str, limit: u32) -> Result<JiraSearchResult> {
6039 let url = format!("{}/rest/api/3/search/jql", self.instance_url);
6040 let effective_limit = if limit == 0 { u32::MAX } else { limit };
6041 let mut all_issues = Vec::new();
6042 let mut next_token: Option<String> = None;
6043
6044 loop {
6045 let remaining = effective_limit.saturating_sub(all_issues.len() as u32);
6046 if remaining == 0 {
6047 break;
6048 }
6049 let page_size = remaining.min(PAGE_SIZE);
6050
6051 let mut body = serde_json::json!({
6052 "jql": jql,
6053 "maxResults": page_size,
6054 "fields": ["summary", "status", "issuetype", "assignee", "priority"]
6055 });
6056 if let Some(ref token) = next_token {
6057 body["nextPageToken"] = serde_json::Value::String(token.clone());
6058 }
6059
6060 let response = self
6061 .post_json(&url, &body)
6062 .await
6063 .context("Failed to send search request to JIRA API")?;
6064
6065 if !response.status().is_success() {
6066 let status = response.status().as_u16();
6067 let body = response.text().await.unwrap_or_default();
6068 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
6069 }
6070
6071 let page: JiraSearchResponse = response
6072 .json()
6073 .await
6074 .context("Failed to parse JIRA search response")?;
6075
6076 let page_count = page.issues.len();
6077 for r in page.issues {
6078 all_issues.push(JiraIssue {
6079 key: r.key,
6080 summary: r.fields.summary.unwrap_or_default(),
6081 description_adf: r.fields.description,
6082 status: r.fields.status.and_then(|s| s.name),
6083 issue_type: r.fields.issuetype.and_then(|t| t.name),
6084 assignee: r.fields.assignee.and_then(|a| a.display_name),
6085 priority: r.fields.priority.and_then(|p| p.name),
6086 labels: r.fields.labels,
6087 custom_fields: Vec::new(),
6088 });
6089 }
6090
6091 match page.next_page_token {
6092 Some(token) if page_count > 0 => next_token = Some(token),
6093 _ => break,
6094 }
6095 }
6096
6097 let total = all_issues.len() as u32;
6098 Ok(JiraSearchResult {
6099 issues: all_issues,
6100 total,
6101 })
6102 }
6103
6104 pub async fn search_confluence(
6106 &self,
6107 cql: &str,
6108 limit: u32,
6109 ) -> Result<ConfluenceSearchResults> {
6110 let effective_limit = if limit == 0 { u32::MAX } else { limit };
6111 let mut all_results = Vec::new();
6112 let mut start: u32 = 0;
6113
6114 loop {
6115 let remaining = effective_limit.saturating_sub(all_results.len() as u32);
6116 if remaining == 0 {
6117 break;
6118 }
6119 let page_size = remaining.min(PAGE_SIZE);
6120
6121 let base = format!("{}/wiki/rest/api/content/search", self.instance_url);
6122 let url = reqwest::Url::parse_with_params(
6123 &base,
6124 &[
6125 ("cql", cql),
6126 ("limit", &page_size.to_string()),
6127 ("start", &start.to_string()),
6128 ("expand", "space"),
6129 ],
6130 )
6131 .context("Failed to build Confluence search URL")?;
6132
6133 let response = self.get_json(url.as_str()).await?;
6134
6135 if !response.status().is_success() {
6136 let status = response.status().as_u16();
6137 let body = response.text().await.unwrap_or_default();
6138 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
6139 }
6140
6141 let resp: ConfluenceContentSearchResponse = response
6142 .json()
6143 .await
6144 .context("Failed to parse Confluence search response")?;
6145
6146 let page_count = resp.results.len() as u32;
6147 for r in resp.results {
6148 let space_key = r
6149 .expandable
6150 .and_then(|e| e.space)
6151 .and_then(|s| s.rsplit('/').next().map(String::from))
6152 .unwrap_or_default();
6153 all_results.push(ConfluenceSearchResult {
6154 id: r.id,
6155 title: r.title,
6156 space_key,
6157 });
6158 }
6159
6160 let has_next = resp.links.and_then(|l| l.next).is_some();
6161 if !has_next || page_count == 0 {
6162 break;
6163 }
6164 start += page_count;
6165 }
6166
6167 let total = all_results.len() as u32;
6168 Ok(ConfluenceSearchResults {
6169 results: all_results,
6170 total,
6171 })
6172 }
6173
6174 pub async fn search_confluence_users(
6176 &self,
6177 query: &str,
6178 limit: u32,
6179 ) -> Result<ConfluenceUserSearchResults> {
6180 let effective_limit = if limit == 0 { u32::MAX } else { limit };
6181 let mut all_results = Vec::new();
6182 let mut start: u32 = 0;
6183
6184 let cql = format!("user.fullname~\"{query}\"");
6185
6186 loop {
6187 let remaining = effective_limit.saturating_sub(all_results.len() as u32);
6188 if remaining == 0 {
6189 break;
6190 }
6191 let page_size = remaining.min(PAGE_SIZE);
6192
6193 let base = format!("{}/wiki/rest/api/search/user", self.instance_url);
6194 let url = reqwest::Url::parse_with_params(
6195 &base,
6196 &[
6197 ("cql", cql.as_str()),
6198 ("limit", &page_size.to_string()),
6199 ("start", &start.to_string()),
6200 ],
6201 )
6202 .context("Failed to build Confluence user search URL")?;
6203
6204 let response = self.get_json(url.as_str()).await?;
6205
6206 if !response.status().is_success() {
6207 let status = response.status().as_u16();
6208 let body = response.text().await.unwrap_or_default();
6209 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
6210 }
6211
6212 let resp: ConfluenceUserSearchResponse = response
6213 .json()
6214 .await
6215 .context("Failed to parse Confluence user search response")?;
6216
6217 let page_count = resp.results.len() as u32;
6218 for r in resp.results {
6219 let Some(user) = r.user else {
6220 continue;
6221 };
6222 let display_name = user.display_name.or(user.public_name).unwrap_or_default();
6223 all_results.push(ConfluenceUserSearchResult {
6224 account_id: user.account_id,
6225 display_name,
6226 email: user.email,
6227 });
6228 }
6229
6230 let has_next = resp.links.and_then(|l| l.next).is_some();
6231 if !has_next || page_count == 0 {
6232 break;
6233 }
6234 start += page_count;
6235 }
6236
6237 let total = all_results.len() as u32;
6238 Ok(ConfluenceUserSearchResults {
6239 users: all_results,
6240 total,
6241 })
6242 }
6243
6244 pub async fn get_boards(
6246 &self,
6247 project: Option<&str>,
6248 board_type: Option<&str>,
6249 limit: u32,
6250 ) -> Result<AgileBoardList> {
6251 let effective_limit = if limit == 0 { u32::MAX } else { limit };
6252 let mut all_boards = Vec::new();
6253 let mut start_at: u32 = 0;
6254
6255 loop {
6256 let remaining = effective_limit.saturating_sub(all_boards.len() as u32);
6257 if remaining == 0 {
6258 break;
6259 }
6260 let page_size = remaining.min(PAGE_SIZE);
6261
6262 let mut url = format!(
6263 "{}/rest/agile/1.0/board?maxResults={}&startAt={}",
6264 self.instance_url, page_size, start_at
6265 );
6266 if let Some(proj) = project {
6267 url.push_str(&format!("&projectKeyOrId={proj}"));
6268 }
6269 if let Some(bt) = board_type {
6270 url.push_str(&format!("&type={bt}"));
6271 }
6272
6273 let response = self.get_json(&url).await?;
6274
6275 if !response.status().is_success() {
6276 let status = response.status().as_u16();
6277 let body = response.text().await.unwrap_or_default();
6278 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
6279 }
6280
6281 let resp: AgileBoardListResponse = response
6282 .json()
6283 .await
6284 .context("Failed to parse board list response")?;
6285
6286 let page_count = resp.values.len() as u32;
6287 for b in resp.values {
6288 all_boards.push(AgileBoard {
6289 id: b.id,
6290 name: b.name,
6291 board_type: b.board_type,
6292 project_key: b.location.and_then(|l| l.project_key),
6293 });
6294 }
6295
6296 if resp.is_last || page_count == 0 {
6297 break;
6298 }
6299 start_at += page_count;
6300 }
6301
6302 let total = all_boards.len() as u32;
6303 Ok(AgileBoardList {
6304 boards: all_boards,
6305 total,
6306 })
6307 }
6308
6309 pub async fn get_board_issues(
6311 &self,
6312 board_id: u64,
6313 jql: Option<&str>,
6314 limit: u32,
6315 ) -> Result<JiraSearchResult> {
6316 let effective_limit = if limit == 0 { u32::MAX } else { limit };
6317 let mut all_issues = Vec::new();
6318 let mut start_at: u32 = 0;
6319
6320 loop {
6321 let remaining = effective_limit.saturating_sub(all_issues.len() as u32);
6322 if remaining == 0 {
6323 break;
6324 }
6325 let page_size = remaining.min(PAGE_SIZE);
6326
6327 let base = format!(
6328 "{}/rest/agile/1.0/board/{}/issue",
6329 self.instance_url, board_id
6330 );
6331 let mut params: Vec<(&str, String)> = vec![
6332 ("maxResults", page_size.to_string()),
6333 ("startAt", start_at.to_string()),
6334 ];
6335 if let Some(jql_str) = jql {
6336 params.push(("jql", jql_str.to_string()));
6337 }
6338 let url = reqwest::Url::parse_with_params(
6339 &base,
6340 params.iter().map(|(k, v)| (*k, v.as_str())),
6341 )
6342 .context("Failed to build board issues URL")?;
6343
6344 let response = self.get_json(url.as_str()).await?;
6345
6346 if !response.status().is_success() {
6347 let status = response.status().as_u16();
6348 let body = response.text().await.unwrap_or_default();
6349 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
6350 }
6351
6352 let resp: AgileIssueListResponse = response
6353 .json()
6354 .await
6355 .context("Failed to parse board issues response")?;
6356
6357 let page_count = resp.issues.len() as u32;
6358 for r in resp.issues {
6359 all_issues.push(JiraIssue {
6360 key: r.key,
6361 summary: r.fields.summary.unwrap_or_default(),
6362 description_adf: r.fields.description,
6363 status: r.fields.status.and_then(|s| s.name),
6364 issue_type: r.fields.issuetype.and_then(|t| t.name),
6365 assignee: r.fields.assignee.and_then(|a| a.display_name),
6366 priority: r.fields.priority.and_then(|p| p.name),
6367 labels: r.fields.labels,
6368 custom_fields: Vec::new(),
6369 });
6370 }
6371
6372 if resp.is_last || page_count == 0 {
6373 break;
6374 }
6375 start_at += page_count;
6376 }
6377
6378 let total = all_issues.len() as u32;
6379 Ok(JiraSearchResult {
6380 issues: all_issues,
6381 total,
6382 })
6383 }
6384
6385 pub async fn get_sprints(
6387 &self,
6388 board_id: u64,
6389 state: Option<&str>,
6390 limit: u32,
6391 ) -> Result<AgileSprintList> {
6392 let effective_limit = if limit == 0 { u32::MAX } else { limit };
6393 let mut all_sprints = Vec::new();
6394 let mut start_at: u32 = 0;
6395
6396 loop {
6397 let remaining = effective_limit.saturating_sub(all_sprints.len() as u32);
6398 if remaining == 0 {
6399 break;
6400 }
6401 let page_size = remaining.min(PAGE_SIZE);
6402
6403 let mut url = format!(
6404 "{}/rest/agile/1.0/board/{}/sprint?maxResults={}&startAt={}",
6405 self.instance_url, board_id, page_size, start_at
6406 );
6407 if let Some(s) = state {
6408 url.push_str(&format!("&state={s}"));
6409 }
6410
6411 let response = self.get_json(&url).await?;
6412
6413 if !response.status().is_success() {
6414 let status = response.status().as_u16();
6415 let body = response.text().await.unwrap_or_default();
6416 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
6417 }
6418
6419 let resp: AgileSprintListResponse = response
6420 .json()
6421 .await
6422 .context("Failed to parse sprint list response")?;
6423
6424 let page_count = resp.values.len() as u32;
6425 for s in resp.values {
6426 all_sprints.push(AgileSprint {
6427 id: s.id,
6428 name: s.name,
6429 state: s.state,
6430 start_date: s.start_date,
6431 end_date: s.end_date,
6432 goal: s.goal,
6433 });
6434 }
6435
6436 if resp.is_last || page_count == 0 {
6437 break;
6438 }
6439 start_at += page_count;
6440 }
6441
6442 let total = all_sprints.len() as u32;
6443 Ok(AgileSprintList {
6444 sprints: all_sprints,
6445 total,
6446 })
6447 }
6448
6449 pub async fn get_sprint_issues(
6451 &self,
6452 sprint_id: u64,
6453 jql: Option<&str>,
6454 limit: u32,
6455 ) -> Result<JiraSearchResult> {
6456 let effective_limit = if limit == 0 { u32::MAX } else { limit };
6457 let mut all_issues = Vec::new();
6458 let mut start_at: u32 = 0;
6459
6460 loop {
6461 let remaining = effective_limit.saturating_sub(all_issues.len() as u32);
6462 if remaining == 0 {
6463 break;
6464 }
6465 let page_size = remaining.min(PAGE_SIZE);
6466
6467 let base = format!(
6468 "{}/rest/agile/1.0/sprint/{}/issue",
6469 self.instance_url, sprint_id
6470 );
6471 let mut params: Vec<(&str, String)> = vec![
6472 ("maxResults", page_size.to_string()),
6473 ("startAt", start_at.to_string()),
6474 ];
6475 if let Some(jql_str) = jql {
6476 params.push(("jql", jql_str.to_string()));
6477 }
6478 let url = reqwest::Url::parse_with_params(
6479 &base,
6480 params.iter().map(|(k, v)| (*k, v.as_str())),
6481 )
6482 .context("Failed to build sprint issues URL")?;
6483
6484 let response = self.get_json(url.as_str()).await?;
6485
6486 if !response.status().is_success() {
6487 let status = response.status().as_u16();
6488 let body = response.text().await.unwrap_or_default();
6489 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
6490 }
6491
6492 let resp: AgileIssueListResponse = response
6493 .json()
6494 .await
6495 .context("Failed to parse sprint issues response")?;
6496
6497 let page_count = resp.issues.len() as u32;
6498 for r in resp.issues {
6499 all_issues.push(JiraIssue {
6500 key: r.key,
6501 summary: r.fields.summary.unwrap_or_default(),
6502 description_adf: r.fields.description,
6503 status: r.fields.status.and_then(|s| s.name),
6504 issue_type: r.fields.issuetype.and_then(|t| t.name),
6505 assignee: r.fields.assignee.and_then(|a| a.display_name),
6506 priority: r.fields.priority.and_then(|p| p.name),
6507 labels: r.fields.labels,
6508 custom_fields: Vec::new(),
6509 });
6510 }
6511
6512 if resp.is_last || page_count == 0 {
6513 break;
6514 }
6515 start_at += page_count;
6516 }
6517
6518 let total = all_issues.len() as u32;
6519 Ok(JiraSearchResult {
6520 issues: all_issues,
6521 total,
6522 })
6523 }
6524
6525 pub async fn add_issues_to_sprint(&self, sprint_id: u64, issue_keys: &[&str]) -> Result<()> {
6527 let url = format!(
6528 "{}/rest/agile/1.0/sprint/{}/issue",
6529 self.instance_url, sprint_id
6530 );
6531
6532 let body = serde_json::json!({ "issues": issue_keys });
6533
6534 let response = self.post_json(&url, &body).await?;
6535
6536 if !response.status().is_success() {
6537 let status = response.status().as_u16();
6538 let body = response.text().await.unwrap_or_default();
6539 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
6540 }
6541
6542 Ok(())
6543 }
6544
6545 pub async fn create_sprint(
6547 &self,
6548 board_id: u64,
6549 name: &str,
6550 start_date: Option<&str>,
6551 end_date: Option<&str>,
6552 goal: Option<&str>,
6553 ) -> Result<AgileSprint> {
6554 let url = format!("{}/rest/agile/1.0/sprint", self.instance_url);
6555
6556 let mut body = serde_json::json!({
6557 "originBoardId": board_id,
6558 "name": name
6559 });
6560 if let Some(sd) = start_date {
6561 body["startDate"] = serde_json::Value::String(sd.to_string());
6562 }
6563 if let Some(ed) = end_date {
6564 body["endDate"] = serde_json::Value::String(ed.to_string());
6565 }
6566 if let Some(g) = goal {
6567 body["goal"] = serde_json::Value::String(g.to_string());
6568 }
6569
6570 let response = self.post_json(&url, &body).await?;
6571
6572 if !response.status().is_success() {
6573 let status = response.status().as_u16();
6574 let body = response.text().await.unwrap_or_default();
6575 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
6576 }
6577
6578 let entry: AgileSprintEntry = response
6579 .json()
6580 .await
6581 .context("Failed to parse sprint create response")?;
6582
6583 Ok(AgileSprint {
6584 id: entry.id,
6585 name: entry.name,
6586 state: entry.state,
6587 start_date: entry.start_date,
6588 end_date: entry.end_date,
6589 goal: entry.goal,
6590 })
6591 }
6592
6593 pub async fn update_sprint(
6595 &self,
6596 sprint_id: u64,
6597 name: Option<&str>,
6598 state: Option<&str>,
6599 start_date: Option<&str>,
6600 end_date: Option<&str>,
6601 goal: Option<&str>,
6602 ) -> Result<()> {
6603 let url = format!("{}/rest/agile/1.0/sprint/{}", self.instance_url, sprint_id);
6604
6605 let mut body = serde_json::Map::new();
6606 if let Some(n) = name {
6607 body.insert("name".to_string(), serde_json::Value::String(n.to_string()));
6608 }
6609 if let Some(s) = state {
6610 body.insert(
6611 "state".to_string(),
6612 serde_json::Value::String(s.to_string()),
6613 );
6614 }
6615 if let Some(sd) = start_date {
6616 body.insert(
6617 "startDate".to_string(),
6618 serde_json::Value::String(sd.to_string()),
6619 );
6620 }
6621 if let Some(ed) = end_date {
6622 body.insert(
6623 "endDate".to_string(),
6624 serde_json::Value::String(ed.to_string()),
6625 );
6626 }
6627 if let Some(g) = goal {
6628 body.insert("goal".to_string(), serde_json::Value::String(g.to_string()));
6629 }
6630
6631 let response = self
6632 .put_json(&url, &serde_json::Value::Object(body))
6633 .await?;
6634
6635 if !response.status().is_success() {
6636 let status = response.status().as_u16();
6637 let body = response.text().await.unwrap_or_default();
6638 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
6639 }
6640
6641 Ok(())
6642 }
6643
6644 pub async fn get_issue_links(&self, key: &str) -> Result<Vec<JiraIssueLink>> {
6646 let url = format!(
6647 "{}/rest/api/3/issue/{}?fields=issuelinks",
6648 self.instance_url, key
6649 );
6650
6651 let response = self.get_json(&url).await?;
6652
6653 if !response.status().is_success() {
6654 let status = response.status().as_u16();
6655 let body = response.text().await.unwrap_or_default();
6656 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
6657 }
6658
6659 let resp: JiraIssueLinksResponse = response
6660 .json()
6661 .await
6662 .context("Failed to parse issue links response")?;
6663
6664 let mut links = Vec::new();
6665 for entry in resp.fields.issuelinks {
6666 if let Some(inward) = entry.inward_issue {
6667 links.push(JiraIssueLink {
6668 id: entry.id.clone(),
6669 link_type: entry.link_type.name.clone(),
6670 direction: "inward".to_string(),
6671 linked_issue_key: inward.key,
6672 linked_issue_summary: inward.fields.and_then(|f| f.summary).unwrap_or_default(),
6673 });
6674 }
6675 if let Some(outward) = entry.outward_issue {
6676 links.push(JiraIssueLink {
6677 id: entry.id,
6678 link_type: entry.link_type.name,
6679 direction: "outward".to_string(),
6680 linked_issue_key: outward.key,
6681 linked_issue_summary: outward
6682 .fields
6683 .and_then(|f| f.summary)
6684 .unwrap_or_default(),
6685 });
6686 }
6687 }
6688
6689 Ok(links)
6690 }
6691
6692 pub async fn get_link_types(&self) -> Result<Vec<JiraLinkType>> {
6694 let url = format!("{}/rest/api/3/issueLinkType", self.instance_url);
6695 let response = self.get_json(&url).await?;
6696 if !response.status().is_success() {
6697 let status = response.status().as_u16();
6698 let body = response.text().await.unwrap_or_default();
6699 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
6700 }
6701 let resp: JiraLinkTypesResponse = response
6702 .json()
6703 .await
6704 .context("Failed to parse link types response")?;
6705 Ok(resp
6706 .issue_link_types
6707 .into_iter()
6708 .map(|t| JiraLinkType {
6709 id: t.id,
6710 name: t.name,
6711 inward: t.inward,
6712 outward: t.outward,
6713 })
6714 .collect())
6715 }
6716
6717 pub async fn create_issue_link(
6719 &self,
6720 type_name: &str,
6721 inward_key: &str,
6722 outward_key: &str,
6723 ) -> Result<()> {
6724 let url = format!("{}/rest/api/3/issueLink", self.instance_url);
6725 let body = serde_json::json!({"type": {"name": type_name}, "inwardIssue": {"key": inward_key}, "outwardIssue": {"key": outward_key}});
6726 let response = self.post_json(&url, &body).await?;
6727 if !response.status().is_success() {
6728 let status = response.status().as_u16();
6729 let body = response.text().await.unwrap_or_default();
6730 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
6731 }
6732 Ok(())
6733 }
6734
6735 pub async fn remove_issue_link(&self, link_id: &str) -> Result<()> {
6737 let url = format!("{}/rest/api/3/issueLink/{}", self.instance_url, link_id);
6738 let response = self.delete(&url).await?;
6739 if !response.status().is_success() {
6740 let status = response.status().as_u16();
6741 let body = response.text().await.unwrap_or_default();
6742 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
6743 }
6744 Ok(())
6745 }
6746
6747 pub async fn link_to_epic(&self, epic_key: &str, issue_key: &str) -> Result<()> {
6749 let url = format!("{}/rest/api/3/issue/{}", self.instance_url, issue_key);
6750 let body = serde_json::json!({"fields": {"parent": {"key": epic_key}}});
6751 let response = self.put_json(&url, &body).await?;
6752 if !response.status().is_success() {
6753 let status = response.status().as_u16();
6754 let body = response.text().await.unwrap_or_default();
6755 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
6756 }
6757 Ok(())
6758 }
6759
6760 pub async fn get_issue_id(&self, key: &str) -> Result<String> {
6762 let url = format!("{}/rest/api/3/issue/{}?fields=", self.instance_url, key);
6763 let response = self.get_json(&url).await?;
6764 if !response.status().is_success() {
6765 let status = response.status().as_u16();
6766 let body = response.text().await.unwrap_or_default();
6767 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
6768 }
6769 let resp: JiraIssueIdResponse = response
6770 .json()
6771 .await
6772 .context("Failed to parse issue ID response")?;
6773 Ok(resp.id)
6774 }
6775
6776 pub async fn get_dev_status_summary(&self, key: &str) -> Result<JiraDevStatusSummary> {
6781 let issue_id = self.get_issue_id(key).await?;
6782 let url = format!(
6783 "{}/rest/dev-status/1.0/issue/summary?issueId={}",
6784 self.instance_url, issue_id
6785 );
6786 let response = self.get_json(&url).await?;
6787 if !response.status().is_success() {
6788 let status = response.status().as_u16();
6789 let body = response.text().await.unwrap_or_default();
6790 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
6791 }
6792 let resp: DevStatusSummaryResponse = response
6793 .json()
6794 .await
6795 .context("Failed to parse DevStatus summary response")?;
6796
6797 fn extract_count(cat: Option<DevStatusSummaryCategory>) -> JiraDevStatusCount {
6798 match cat {
6799 Some(c) => JiraDevStatusCount {
6800 count: c.overall.map_or(0, |o| o.count),
6801 providers: c
6802 .by_instance_type
6803 .into_values()
6804 .map(|i| i.name)
6805 .filter(|n| !n.is_empty())
6806 .collect(),
6807 },
6808 None => JiraDevStatusCount {
6809 count: 0,
6810 providers: Vec::new(),
6811 },
6812 }
6813 }
6814
6815 Ok(JiraDevStatusSummary {
6816 pullrequest: extract_count(resp.summary.pullrequest),
6817 branch: extract_count(resp.summary.branch),
6818 repository: extract_count(resp.summary.repository),
6819 })
6820 }
6821
6822 pub async fn get_dev_status(
6831 &self,
6832 key: &str,
6833 data_type: Option<&str>,
6834 application_type: Option<&str>,
6835 ) -> Result<JiraDevStatus> {
6836 let issue_id = self.get_issue_id(key).await?;
6837
6838 let app_types: Vec<String> = if let Some(app) = application_type {
6839 vec![app.to_string()]
6840 } else {
6841 let summary = self.get_dev_status_summary(key).await?;
6843 let mut providers: Vec<String> = Vec::new();
6844 for p in summary
6845 .pullrequest
6846 .providers
6847 .into_iter()
6848 .chain(summary.branch.providers)
6849 .chain(summary.repository.providers)
6850 {
6851 if !providers.contains(&p) {
6852 providers.push(p);
6853 }
6854 }
6855 if providers.is_empty() {
6856 providers.push("GitHub".to_string());
6857 }
6858 providers
6859 };
6860
6861 let data_types: Vec<&str> = match data_type {
6862 Some(dt) => vec![dt],
6863 None => vec!["pullrequest", "branch", "repository"],
6864 };
6865
6866 let mut status = JiraDevStatus {
6867 pull_requests: Vec::new(),
6868 branches: Vec::new(),
6869 repositories: Vec::new(),
6870 };
6871
6872 for app in &app_types {
6873 for dt in &data_types {
6874 let url = format!(
6875 "{}/rest/dev-status/1.0/issue/detail?issueId={}&applicationType={}&dataType={}",
6876 self.instance_url, issue_id, app, dt
6877 );
6878 let response = self.get_json(&url).await?;
6879 if !response.status().is_success() {
6880 let http_status = response.status().as_u16();
6881 let body = response.text().await.unwrap_or_default();
6882 return Err(AtlassianError::ApiRequestFailed {
6883 status: http_status,
6884 body,
6885 }
6886 .into());
6887 }
6888
6889 let resp: DevStatusResponse = response
6890 .json()
6891 .await
6892 .context("Failed to parse DevStatus response")?;
6893
6894 for detail in resp.detail {
6895 for pr in detail.pull_requests {
6896 status.pull_requests.push(JiraDevPullRequest {
6897 id: pr.id,
6898 name: pr.name,
6899 status: pr.status,
6900 url: pr.url,
6901 repository_name: pr.repository_name,
6902 source_branch: pr.source.map(|s| s.branch).unwrap_or_default(),
6903 destination_branch: pr
6904 .destination
6905 .map(|d| d.branch)
6906 .unwrap_or_default(),
6907 author: pr.author.map(|a| a.name),
6908 reviewers: pr.reviewers.into_iter().map(|r| r.name).collect(),
6909 comment_count: pr.comment_count,
6910 last_update: pr.last_update,
6911 });
6912 }
6913 for branch in detail.branches {
6914 status.branches.push(JiraDevBranch {
6915 name: branch.name,
6916 url: branch.url,
6917 repository_name: branch.repository_name,
6918 create_pr_url: branch.create_pr_url,
6919 last_commit: branch.last_commit.map(Self::convert_commit),
6920 });
6921 }
6922 for repo in detail.repositories {
6923 status.repositories.push(JiraDevRepository {
6924 name: repo.name,
6925 url: repo.url,
6926 commits: repo.commits.into_iter().map(Self::convert_commit).collect(),
6927 });
6928 }
6929 }
6930 }
6931 }
6932
6933 Ok(status)
6934 }
6935
6936 fn convert_commit(c: DevStatusCommit) -> JiraDevCommit {
6938 JiraDevCommit {
6939 id: c.id,
6940 display_id: c.display_id,
6941 message: c.message,
6942 author: c.author.map(|a| a.name),
6943 timestamp: c.author_timestamp,
6944 url: c.url,
6945 file_count: c.file_count,
6946 merge: c.merge,
6947 }
6948 }
6949
6950 pub async fn get_attachments(&self, key: &str) -> Result<Vec<JiraAttachment>> {
6952 let url = format!(
6953 "{}/rest/api/3/issue/{}?fields=attachment",
6954 self.instance_url, key
6955 );
6956
6957 let response = self.get_json(&url).await?;
6958
6959 if !response.status().is_success() {
6960 let status = response.status().as_u16();
6961 let body = response.text().await.unwrap_or_default();
6962 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
6963 }
6964
6965 let resp: JiraAttachmentIssueResponse = response
6966 .json()
6967 .await
6968 .context("Failed to parse attachment response")?;
6969
6970 Ok(resp
6971 .fields
6972 .attachment
6973 .into_iter()
6974 .map(|a| JiraAttachment {
6975 id: a.id,
6976 filename: a.filename,
6977 mime_type: a.mime_type,
6978 size: a.size,
6979 content_url: a.content,
6980 })
6981 .collect())
6982 }
6983
6984 pub async fn get_changelog(&self, key: &str, limit: u32) -> Result<Vec<JiraChangelogEntry>> {
6986 let effective_limit = if limit == 0 { u32::MAX } else { limit };
6987 let mut all_entries = Vec::new();
6988 let mut start_at: u32 = 0;
6989
6990 loop {
6991 let remaining = effective_limit.saturating_sub(all_entries.len() as u32);
6992 if remaining == 0 {
6993 break;
6994 }
6995 let page_size = remaining.min(PAGE_SIZE);
6996
6997 let url = format!(
6998 "{}/rest/api/3/issue/{}/changelog?maxResults={}&startAt={}",
6999 self.instance_url, key, page_size, start_at
7000 );
7001
7002 let response = self.get_json(&url).await?;
7003
7004 if !response.status().is_success() {
7005 let status = response.status().as_u16();
7006 let body = response.text().await.unwrap_or_default();
7007 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
7008 }
7009
7010 let resp: JiraChangelogResponse = response
7011 .json()
7012 .await
7013 .context("Failed to parse changelog response")?;
7014
7015 let page_count = resp.values.len() as u32;
7016 for e in resp.values {
7017 all_entries.push(JiraChangelogEntry {
7018 id: e.id,
7019 author: e.author.and_then(|a| a.display_name).unwrap_or_default(),
7020 created: e.created.unwrap_or_default(),
7021 items: e
7022 .items
7023 .into_iter()
7024 .map(|i| JiraChangelogItem {
7025 field: i.field,
7026 from_string: i.from_string,
7027 to_string: i.to_string,
7028 })
7029 .collect(),
7030 });
7031 }
7032
7033 if resp.is_last || page_count == 0 {
7034 break;
7035 }
7036 start_at += page_count;
7037 }
7038
7039 Ok(all_entries)
7040 }
7041
7042 pub async fn get_fields(&self) -> Result<Vec<JiraField>> {
7044 let url = format!("{}/rest/api/3/field", self.instance_url);
7045
7046 let response = self.get_json(&url).await?;
7047
7048 if !response.status().is_success() {
7049 let status = response.status().as_u16();
7050 let body = response.text().await.unwrap_or_default();
7051 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
7052 }
7053
7054 let entries: Vec<JiraFieldEntry> = response
7055 .json()
7056 .await
7057 .context("Failed to parse field list response")?;
7058
7059 Ok(entries
7060 .into_iter()
7061 .map(|f| JiraField {
7062 id: f.id,
7063 name: f.name,
7064 custom: f.custom,
7065 schema_type: f.schema.and_then(|s| s.schema_type),
7066 })
7067 .collect())
7068 }
7069
7070 pub async fn get_field_contexts(&self, field_id: &str) -> Result<Vec<String>> {
7073 let url = format!(
7074 "{}/rest/api/3/field/{}/context",
7075 self.instance_url, field_id
7076 );
7077
7078 let response = self.get_json(&url).await?;
7079
7080 if !response.status().is_success() {
7081 let status = response.status().as_u16();
7082 let body = response.text().await.unwrap_or_default();
7083 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
7084 }
7085
7086 let resp: JiraFieldContextsResponse = response
7087 .json()
7088 .await
7089 .context("Failed to parse field contexts response")?;
7090
7091 Ok(resp.values.into_iter().map(|c| c.id).collect())
7092 }
7093
7094 pub async fn get_field_options(
7098 &self,
7099 field_id: &str,
7100 context_id: Option<&str>,
7101 ) -> Result<Vec<JiraFieldOption>> {
7102 let ctx = if let Some(id) = context_id {
7103 id.to_string()
7104 } else {
7105 let contexts = self.get_field_contexts(field_id).await?;
7106 contexts.into_iter().next().ok_or_else(|| {
7107 anyhow::anyhow!(
7108 "No contexts found for field \"{field_id}\". \
7109 Use --context-id to specify one explicitly."
7110 )
7111 })?
7112 };
7113
7114 let url = format!(
7115 "{}/rest/api/3/field/{}/context/{}/option",
7116 self.instance_url, field_id, ctx
7117 );
7118
7119 let response = self.get_json(&url).await?;
7120
7121 if !response.status().is_success() {
7122 let status = response.status().as_u16();
7123 let body = response.text().await.unwrap_or_default();
7124 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
7125 }
7126
7127 let resp: JiraFieldOptionsResponse = response
7128 .json()
7129 .await
7130 .context("Failed to parse field options response")?;
7131
7132 Ok(resp
7133 .values
7134 .into_iter()
7135 .map(|o| JiraFieldOption {
7136 id: o.id,
7137 value: o.value,
7138 })
7139 .collect())
7140 }
7141
7142 pub async fn get_projects(&self, limit: u32) -> Result<JiraProjectList> {
7144 let effective_limit = if limit == 0 { u32::MAX } else { limit };
7145 let mut all_projects = Vec::new();
7146 let mut start_at: u32 = 0;
7147
7148 loop {
7149 let remaining = effective_limit.saturating_sub(all_projects.len() as u32);
7150 if remaining == 0 {
7151 break;
7152 }
7153 let page_size = remaining.min(PAGE_SIZE);
7154
7155 let url = format!(
7156 "{}/rest/api/3/project/search?maxResults={}&startAt={}",
7157 self.instance_url, page_size, start_at
7158 );
7159
7160 let response = self.get_json(&url).await?;
7161
7162 if !response.status().is_success() {
7163 let status = response.status().as_u16();
7164 let body = response.text().await.unwrap_or_default();
7165 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
7166 }
7167
7168 let resp: JiraProjectSearchResponse = response
7169 .json()
7170 .await
7171 .context("Failed to parse project search response")?;
7172
7173 let page_count = resp.values.len() as u32;
7174 for p in resp.values {
7175 all_projects.push(JiraProject {
7176 id: p.id,
7177 key: p.key,
7178 name: p.name,
7179 project_type: p.project_type_key,
7180 lead: p.lead.and_then(|l| l.display_name),
7181 });
7182 }
7183
7184 if resp.is_last || page_count == 0 {
7185 break;
7186 }
7187 start_at += page_count;
7188 }
7189
7190 let total = all_projects.len() as u32;
7191 Ok(JiraProjectList {
7192 projects: all_projects,
7193 total,
7194 })
7195 }
7196
7197 pub async fn delete_issue(&self, key: &str) -> Result<()> {
7199 let url = format!("{}/rest/api/3/issue/{}", self.instance_url, key);
7200
7201 let response = self.delete(&url).await?;
7202
7203 if !response.status().is_success() {
7204 let status = response.status().as_u16();
7205 let body = response.text().await.unwrap_or_default();
7206 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
7207 }
7208
7209 Ok(())
7210 }
7211
7212 pub async fn get_watchers(&self, key: &str) -> Result<JiraWatcherList> {
7214 let url = format!("{}/rest/api/3/issue/{}/watchers", self.instance_url, key);
7215
7216 let response = self.get_json(&url).await?;
7217
7218 if !response.status().is_success() {
7219 let status = response.status().as_u16();
7220 let body = response.text().await.unwrap_or_default();
7221 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
7222 }
7223
7224 let json: serde_json::Value = response
7225 .json()
7226 .await
7227 .context("Failed to parse watchers response")?;
7228
7229 let watch_count = json["watchCount"].as_u64().unwrap_or(0) as u32;
7230
7231 let watchers = json["watchers"]
7232 .as_array()
7233 .map(|arr| {
7234 arr.iter()
7235 .filter_map(|v| serde_json::from_value::<JiraUser>(v.clone()).ok())
7236 .collect()
7237 })
7238 .unwrap_or_default();
7239
7240 Ok(JiraWatcherList {
7241 watchers,
7242 watch_count,
7243 })
7244 }
7245
7246 pub async fn add_watcher(&self, key: &str, account_id: &str) -> Result<()> {
7248 let url = format!("{}/rest/api/3/issue/{}/watchers", self.instance_url, key);
7249
7250 let body = serde_json::json!(account_id);
7251
7252 let response = self.post_json(&url, &body).await?;
7253
7254 if !response.status().is_success() {
7255 let status = response.status().as_u16();
7256 let body = response.text().await.unwrap_or_default();
7257 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
7258 }
7259
7260 Ok(())
7261 }
7262
7263 pub async fn remove_watcher(&self, key: &str, account_id: &str) -> Result<()> {
7265 let url = format!(
7266 "{}/rest/api/3/issue/{}/watchers?accountId={}",
7267 self.instance_url, key, account_id
7268 );
7269
7270 let response = self.delete(&url).await?;
7271
7272 if !response.status().is_success() {
7273 let status = response.status().as_u16();
7274 let body = response.text().await.unwrap_or_default();
7275 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
7276 }
7277
7278 Ok(())
7279 }
7280
7281 pub async fn get_myself(&self) -> Result<JiraUser> {
7283 let url = format!("{}/rest/api/3/myself", self.instance_url);
7284
7285 let response = self
7286 .client
7287 .get(&url)
7288 .header("Authorization", &self.auth_header)
7289 .header("Accept", "application/json")
7290 .send()
7291 .await
7292 .context("Failed to send request to JIRA API")?;
7293
7294 if !response.status().is_success() {
7295 let status = response.status().as_u16();
7296 let body = response.text().await.unwrap_or_default();
7297 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
7298 }
7299
7300 response
7301 .json()
7302 .await
7303 .context("Failed to parse user response")
7304 }
7305}