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