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