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