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