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