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