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