1use std::time::Duration;
7
8use anyhow::{Context, Result};
9use base64::Engine;
10use reqwest::Client;
11use serde::{Deserialize, Serialize};
12
13use crate::atlassian::adf::AdfDocument;
14use crate::atlassian::error::AtlassianError;
15
16const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
18
19const PAGE_SIZE: u32 = 100;
22
23const MAX_RETRIES: u32 = 3;
25
26const DEFAULT_RETRY_DELAY_SECS: u64 = 2;
28
29pub struct AtlassianClient {
31 client: Client,
32 instance_url: String,
33 auth_header: String,
34}
35
36#[derive(Debug, Clone, Serialize)]
38pub struct JiraIssue {
39 pub key: String,
41
42 pub summary: String,
44
45 pub description_adf: Option<serde_json::Value>,
47
48 pub status: Option<String>,
50
51 pub issue_type: Option<String>,
53
54 pub assignee: Option<String>,
56
57 pub priority: Option<String>,
59
60 pub labels: Vec<String>,
62}
63
64#[derive(Debug, Deserialize)]
66pub struct JiraUser {
67 #[serde(rename = "displayName")]
69 pub display_name: String,
70
71 #[serde(rename = "emailAddress")]
73 pub email_address: Option<String>,
74
75 #[serde(rename = "accountId")]
77 pub account_id: String,
78}
79
80#[derive(Debug, Clone, Serialize)]
82pub struct JiraCreatedIssue {
83 pub key: String,
85 pub id: String,
87 pub self_url: String,
89}
90
91#[derive(Debug, Clone, Serialize)]
93pub struct JiraSearchResult {
94 pub issues: Vec<JiraIssue>,
96
97 pub total: u32,
99}
100
101#[derive(Debug, Clone, Serialize)]
103pub struct ConfluenceSearchResult {
104 pub id: String,
106 pub title: String,
108 pub space_key: String,
110}
111
112#[derive(Debug, Clone, Serialize)]
114pub struct ConfluenceSearchResults {
115 pub results: Vec<ConfluenceSearchResult>,
117 pub total: u32,
119}
120
121#[derive(Debug, Clone, Serialize)]
123pub struct JiraComment {
124 pub id: String,
126 pub author: String,
128 pub body_adf: Option<serde_json::Value>,
130 pub created: String,
132}
133
134#[derive(Debug, Clone, Serialize)]
136pub struct JiraProject {
137 pub id: String,
139 pub key: String,
141 pub name: String,
143 pub project_type: Option<String>,
145 pub lead: Option<String>,
147}
148
149#[derive(Debug, Clone, Serialize)]
151pub struct JiraProjectList {
152 pub projects: Vec<JiraProject>,
154 pub total: u32,
156}
157
158#[derive(Debug, Clone, Serialize)]
160pub struct JiraField {
161 pub id: String,
163 pub name: String,
165 pub custom: bool,
167 pub schema_type: Option<String>,
169}
170
171#[derive(Debug, Clone, Serialize)]
173pub struct JiraFieldOption {
174 pub id: String,
176 pub value: String,
178}
179
180#[derive(Debug, Clone, Serialize)]
182pub struct AgileBoard {
183 pub id: u64,
185 pub name: String,
187 pub board_type: String,
189 pub project_key: Option<String>,
191}
192
193#[derive(Debug, Clone, Serialize)]
195pub struct AgileBoardList {
196 pub boards: Vec<AgileBoard>,
198 pub total: u32,
200}
201
202#[derive(Debug, Clone, Serialize)]
204pub struct AgileSprint {
205 pub id: u64,
207 pub name: String,
209 pub state: String,
211 pub start_date: Option<String>,
213 pub end_date: Option<String>,
215 pub goal: Option<String>,
217}
218
219#[derive(Debug, Clone, Serialize)]
221pub struct AgileSprintList {
222 pub sprints: Vec<AgileSprint>,
224 pub total: u32,
226}
227
228#[derive(Debug, Clone, Serialize)]
230pub struct JiraChangelogEntry {
231 pub id: String,
233 pub author: String,
235 pub created: String,
237 pub items: Vec<JiraChangelogItem>,
239}
240
241#[derive(Debug, Clone, Serialize)]
243pub struct JiraChangelogItem {
244 pub field: String,
246 pub from_string: Option<String>,
248 pub to_string: Option<String>,
250}
251
252#[derive(Debug, Clone, Serialize)]
254pub struct JiraLinkType {
255 pub id: String,
257 pub name: String,
259 pub inward: String,
261 pub outward: String,
263}
264
265#[derive(Debug, Clone, Serialize)]
267pub struct JiraIssueLink {
268 pub id: String,
270 pub link_type: String,
272 pub direction: String,
274 pub linked_issue_key: String,
276 pub linked_issue_summary: String,
278}
279
280#[derive(Debug, Clone, Serialize)]
282pub struct JiraAttachment {
283 pub id: String,
285 pub filename: String,
287 pub mime_type: String,
289 pub size: u64,
291 pub content_url: String,
293}
294
295#[derive(Debug, Clone, Serialize)]
297pub struct JiraTransition {
298 pub id: String,
300 pub name: String,
302}
303
304#[derive(Deserialize)]
307struct JiraIssueResponse {
308 key: String,
309 fields: JiraIssueFields,
310}
311
312#[derive(Deserialize)]
313struct JiraIssueFields {
314 summary: Option<String>,
315 description: Option<serde_json::Value>,
316 status: Option<JiraNameField>,
317 issuetype: Option<JiraNameField>,
318 assignee: Option<JiraAssigneeField>,
319 priority: Option<JiraNameField>,
320 #[serde(default)]
321 labels: Vec<String>,
322}
323
324#[derive(Deserialize)]
325struct JiraNameField {
326 name: Option<String>,
327}
328
329#[derive(Deserialize)]
330struct JiraAssigneeField {
331 #[serde(rename = "displayName")]
332 display_name: Option<String>,
333}
334
335#[derive(Deserialize)]
336#[allow(dead_code)]
337struct JiraSearchResponse {
338 issues: Vec<JiraIssueResponse>,
339 #[serde(default)]
340 total: u32,
341 #[serde(rename = "nextPageToken", default)]
342 next_page_token: Option<String>,
343}
344
345#[derive(Deserialize)]
346struct JiraTransitionsResponse {
347 transitions: Vec<JiraTransitionEntry>,
348}
349
350#[derive(Deserialize)]
351struct JiraTransitionEntry {
352 id: String,
353 name: String,
354}
355
356#[derive(Deserialize)]
357struct JiraCommentsResponse {
358 comments: Vec<JiraCommentEntry>,
359}
360
361#[derive(Deserialize)]
362struct JiraCommentEntry {
363 id: String,
364 author: Option<JiraCommentAuthor>,
365 body: Option<serde_json::Value>,
366 created: Option<String>,
367}
368
369#[derive(Deserialize)]
370struct JiraCommentAuthor {
371 #[serde(rename = "displayName")]
372 display_name: Option<String>,
373}
374
375#[derive(Deserialize)]
376#[allow(dead_code)]
377struct ConfluenceContentSearchResponse {
378 results: Vec<ConfluenceContentSearchEntry>,
379 #[serde(default)]
380 size: u32,
381 #[serde(rename = "_links", default)]
382 links: Option<ConfluenceSearchLinks>,
383}
384
385#[derive(Deserialize, Default)]
386struct ConfluenceSearchLinks {
387 next: Option<String>,
388}
389
390#[derive(Deserialize)]
391struct ConfluenceContentSearchEntry {
392 id: String,
393 title: String,
394 #[serde(rename = "_expandable")]
395 expandable: Option<ConfluenceExpandable>,
396}
397
398#[derive(Deserialize)]
399struct ConfluenceExpandable {
400 space: Option<String>,
401}
402
403#[derive(Deserialize)]
406#[allow(dead_code)]
407struct AgileBoardListResponse {
408 values: Vec<AgileBoardEntry>,
409 #[serde(default)]
410 total: u32,
411 #[serde(rename = "isLast", default)]
412 is_last: bool,
413}
414
415#[derive(Deserialize)]
416struct AgileBoardEntry {
417 id: u64,
418 name: String,
419 #[serde(rename = "type")]
420 board_type: String,
421 location: Option<AgileBoardLocation>,
422}
423
424#[derive(Deserialize)]
425struct AgileBoardLocation {
426 #[serde(rename = "projectKey")]
427 project_key: Option<String>,
428}
429
430#[derive(Deserialize)]
431#[allow(dead_code)]
432struct AgileIssueListResponse {
433 issues: Vec<JiraIssueResponse>,
434 #[serde(default)]
435 total: u32,
436 #[serde(rename = "isLast", default)]
437 is_last: bool,
438}
439
440#[derive(Deserialize)]
441#[allow(dead_code)]
442struct AgileSprintListResponse {
443 values: Vec<AgileSprintEntry>,
444 #[serde(default)]
445 total: u32,
446 #[serde(rename = "isLast", default)]
447 is_last: bool,
448}
449
450#[derive(Deserialize)]
451struct AgileSprintEntry {
452 id: u64,
453 name: String,
454 state: String,
455 #[serde(rename = "startDate")]
456 start_date: Option<String>,
457 #[serde(rename = "endDate")]
458 end_date: Option<String>,
459 goal: Option<String>,
460}
461
462#[derive(Deserialize)]
463struct JiraIssueLinksResponse {
464 fields: JiraIssueLinksFields,
465}
466
467#[derive(Deserialize)]
468struct JiraIssueLinksFields {
469 #[serde(default)]
470 issuelinks: Vec<JiraIssueLinkEntry>,
471}
472
473#[derive(Deserialize)]
474struct JiraIssueLinkEntry {
475 id: String,
476 #[serde(rename = "type")]
477 link_type: JiraIssueLinkType,
478 #[serde(rename = "inwardIssue")]
479 inward_issue: Option<JiraIssueLinkIssue>,
480 #[serde(rename = "outwardIssue")]
481 outward_issue: Option<JiraIssueLinkIssue>,
482}
483
484#[derive(Deserialize)]
485struct JiraIssueLinkType {
486 name: String,
487}
488
489#[derive(Deserialize)]
490struct JiraIssueLinkIssue {
491 key: String,
492 fields: Option<JiraIssueLinkIssueFields>,
493}
494
495#[derive(Deserialize)]
496struct JiraIssueLinkIssueFields {
497 summary: Option<String>,
498}
499
500#[derive(Deserialize)]
501struct JiraLinkTypesResponse {
502 #[serde(rename = "issueLinkTypes")]
503 issue_link_types: Vec<JiraLinkTypeEntry>,
504}
505
506#[derive(Deserialize)]
507struct JiraLinkTypeEntry {
508 id: String,
509 name: String,
510 inward: String,
511 outward: String,
512}
513
514#[derive(Deserialize)]
515struct JiraAttachmentIssueResponse {
516 fields: JiraAttachmentFields,
517}
518
519#[derive(Deserialize)]
520struct JiraAttachmentFields {
521 #[serde(default)]
522 attachment: Vec<JiraAttachmentEntry>,
523}
524
525#[derive(Deserialize)]
526struct JiraAttachmentEntry {
527 id: String,
528 filename: String,
529 #[serde(rename = "mimeType")]
530 mime_type: String,
531 size: u64,
532 content: String,
533}
534
535#[derive(Deserialize)]
536#[allow(dead_code)]
537struct JiraChangelogResponse {
538 values: Vec<JiraChangelogEntryResponse>,
539 #[serde(default)]
540 total: u32,
541 #[serde(rename = "isLast", default)]
542 is_last: bool,
543}
544
545#[derive(Deserialize)]
546struct JiraChangelogEntryResponse {
547 id: String,
548 author: Option<JiraCommentAuthor>,
549 created: Option<String>,
550 #[serde(default)]
551 items: Vec<JiraChangelogItemResponse>,
552}
553
554#[derive(Deserialize)]
555struct JiraChangelogItemResponse {
556 field: String,
557 #[serde(rename = "fromString")]
558 from_string: Option<String>,
559 #[serde(rename = "toString")]
560 to_string: Option<String>,
561}
562
563#[derive(Deserialize)]
564struct JiraFieldEntry {
565 id: String,
566 name: String,
567 #[serde(default)]
568 custom: bool,
569 schema: Option<JiraFieldSchema>,
570}
571
572#[derive(Deserialize)]
573struct JiraFieldSchema {
574 #[serde(rename = "type")]
575 schema_type: Option<String>,
576}
577
578#[derive(Deserialize)]
579struct JiraFieldContextsResponse {
580 values: Vec<JiraFieldContextEntry>,
581}
582
583#[derive(Deserialize)]
584struct JiraFieldContextEntry {
585 id: String,
586}
587
588#[derive(Deserialize)]
589struct JiraFieldOptionsResponse {
590 values: Vec<JiraFieldOptionEntry>,
591}
592
593#[derive(Deserialize)]
594struct JiraFieldOptionEntry {
595 id: String,
596 value: String,
597}
598
599#[derive(Deserialize)]
600#[allow(dead_code)]
601struct JiraProjectSearchResponse {
602 values: Vec<JiraProjectEntry>,
603 total: u32,
604 #[serde(rename = "isLast", default)]
605 is_last: bool,
606}
607
608#[derive(Deserialize)]
609struct JiraProjectEntry {
610 id: String,
611 key: String,
612 name: String,
613 #[serde(rename = "projectTypeKey")]
614 project_type_key: Option<String>,
615 lead: Option<JiraProjectLead>,
616}
617
618#[derive(Deserialize)]
619struct JiraProjectLead {
620 #[serde(rename = "displayName")]
621 display_name: Option<String>,
622}
623
624#[derive(Deserialize)]
625struct JiraCreateResponse {
626 key: String,
627 id: String,
628 #[serde(rename = "self")]
629 self_url: String,
630}
631
632#[cfg(test)]
635#[allow(clippy::unwrap_used, clippy::expect_used)]
636mod tests {
637 use super::*;
638
639 #[test]
640 fn new_client_strips_trailing_slash() {
641 let client =
642 AtlassianClient::new("https://org.atlassian.net/", "user@test.com", "token").unwrap();
643 assert_eq!(client.instance_url(), "https://org.atlassian.net");
644 }
645
646 #[test]
647 fn new_client_preserves_clean_url() {
648 let client =
649 AtlassianClient::new("https://org.atlassian.net", "user@test.com", "token").unwrap();
650 assert_eq!(client.instance_url(), "https://org.atlassian.net");
651 }
652
653 #[test]
654 fn new_client_sets_basic_auth() {
655 let client =
656 AtlassianClient::new("https://org.atlassian.net", "user@test.com", "token").unwrap();
657 let expected_credentials = "user@test.com:token";
658 let expected_encoded =
659 base64::engine::general_purpose::STANDARD.encode(expected_credentials);
660 assert_eq!(client.auth_header, format!("Basic {expected_encoded}"));
661 }
662
663 #[test]
664 fn from_credentials() {
665 let creds = crate::atlassian::auth::AtlassianCredentials {
666 instance_url: "https://org.atlassian.net".to_string(),
667 email: "user@test.com".to_string(),
668 api_token: "token123".to_string(),
669 };
670 let client = AtlassianClient::from_credentials(&creds).unwrap();
671 assert_eq!(client.instance_url(), "https://org.atlassian.net");
672 }
673
674 #[test]
675 fn jira_issue_struct_fields() {
676 let issue = JiraIssue {
677 key: "TEST-1".to_string(),
678 summary: "Test issue".to_string(),
679 description_adf: None,
680 status: Some("Open".to_string()),
681 issue_type: Some("Bug".to_string()),
682 assignee: Some("Alice".to_string()),
683 priority: Some("High".to_string()),
684 labels: vec!["backend".to_string()],
685 };
686 assert_eq!(issue.key, "TEST-1");
687 assert_eq!(issue.labels.len(), 1);
688 }
689
690 #[test]
691 fn jira_user_deserialization() {
692 let json = r#"{
693 "displayName": "Alice Smith",
694 "emailAddress": "alice@example.com",
695 "accountId": "abc123"
696 }"#;
697 let user: JiraUser = serde_json::from_str(json).unwrap();
698 assert_eq!(user.display_name, "Alice Smith");
699 assert_eq!(user.email_address.as_deref(), Some("alice@example.com"));
700 assert_eq!(user.account_id, "abc123");
701 }
702
703 #[test]
704 fn jira_user_optional_email() {
705 let json = r#"{
706 "displayName": "Bot",
707 "accountId": "bot123"
708 }"#;
709 let user: JiraUser = serde_json::from_str(json).unwrap();
710 assert!(user.email_address.is_none());
711 }
712
713 #[test]
714 fn jira_issue_response_deserialization() {
715 let json = r#"{
716 "key": "PROJ-42",
717 "fields": {
718 "summary": "Test",
719 "description": null,
720 "status": {"name": "Open"},
721 "issuetype": {"name": "Bug"},
722 "assignee": {"displayName": "Bob"},
723 "priority": {"name": "Medium"},
724 "labels": ["frontend"]
725 }
726 }"#;
727 let response: JiraIssueResponse = serde_json::from_str(json).unwrap();
728 assert_eq!(response.key, "PROJ-42");
729 assert_eq!(response.fields.summary.as_deref(), Some("Test"));
730 assert_eq!(response.fields.labels, vec!["frontend"]);
731 }
732
733 #[test]
734 fn jira_issue_response_minimal_fields() {
735 let json = r#"{
736 "key": "PROJ-1",
737 "fields": {
738 "summary": null,
739 "description": null,
740 "status": null,
741 "issuetype": null,
742 "assignee": null,
743 "priority": null,
744 "labels": []
745 }
746 }"#;
747 let response: JiraIssueResponse = serde_json::from_str(json).unwrap();
748 assert_eq!(response.key, "PROJ-1");
749 assert!(response.fields.summary.is_none());
750 }
751
752 #[tokio::test]
753 async fn get_json_retries_on_429() {
754 let server = wiremock::MockServer::start().await;
755
756 wiremock::Mock::given(wiremock::matchers::method("GET"))
758 .and(wiremock::matchers::path("/test"))
759 .respond_with(wiremock::ResponseTemplate::new(429).append_header("Retry-After", "0"))
760 .up_to_n_times(1)
761 .mount(&server)
762 .await;
763
764 wiremock::Mock::given(wiremock::matchers::method("GET"))
766 .and(wiremock::matchers::path("/test"))
767 .respond_with(
768 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({"ok": true})),
769 )
770 .up_to_n_times(1)
771 .mount(&server)
772 .await;
773
774 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
775 let resp = client
776 .get_json(&format!("{}/test", server.uri()))
777 .await
778 .unwrap();
779 assert!(resp.status().is_success());
780 }
781
782 #[tokio::test]
783 async fn get_json_returns_429_after_max_retries() {
784 let server = wiremock::MockServer::start().await;
785
786 wiremock::Mock::given(wiremock::matchers::method("GET"))
788 .and(wiremock::matchers::path("/test"))
789 .respond_with(wiremock::ResponseTemplate::new(429).append_header("Retry-After", "0"))
790 .mount(&server)
791 .await;
792
793 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
794 let resp = client
795 .get_json(&format!("{}/test", server.uri()))
796 .await
797 .unwrap();
798 assert_eq!(resp.status().as_u16(), 429);
800 }
801
802 #[tokio::test]
803 async fn post_json_retries_on_429() {
804 let server = wiremock::MockServer::start().await;
805
806 wiremock::Mock::given(wiremock::matchers::method("POST"))
807 .and(wiremock::matchers::path("/test"))
808 .respond_with(wiremock::ResponseTemplate::new(429).append_header("Retry-After", "0"))
809 .up_to_n_times(1)
810 .mount(&server)
811 .await;
812
813 wiremock::Mock::given(wiremock::matchers::method("POST"))
814 .and(wiremock::matchers::path("/test"))
815 .respond_with(wiremock::ResponseTemplate::new(201))
816 .up_to_n_times(1)
817 .mount(&server)
818 .await;
819
820 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
821 let body = serde_json::json!({"key": "value"});
822 let resp = client
823 .post_json(&format!("{}/test", server.uri()), &body)
824 .await
825 .unwrap();
826 assert_eq!(resp.status().as_u16(), 201);
827 }
828
829 #[tokio::test]
830 async fn delete_retries_on_429() {
831 let server = wiremock::MockServer::start().await;
832
833 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
834 .and(wiremock::matchers::path("/test"))
835 .respond_with(wiremock::ResponseTemplate::new(429).append_header("Retry-After", "0"))
836 .up_to_n_times(1)
837 .mount(&server)
838 .await;
839
840 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
841 .and(wiremock::matchers::path("/test"))
842 .respond_with(wiremock::ResponseTemplate::new(204))
843 .up_to_n_times(1)
844 .mount(&server)
845 .await;
846
847 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
848 let resp = client
849 .delete(&format!("{}/test", server.uri()))
850 .await
851 .unwrap();
852 assert_eq!(resp.status().as_u16(), 204);
853 }
854
855 #[tokio::test]
856 async fn get_json_sends_auth_header() {
857 let server = wiremock::MockServer::start().await;
858
859 wiremock::Mock::given(wiremock::matchers::method("GET"))
860 .and(wiremock::matchers::header(
861 "Authorization",
862 "Basic dXNlckB0ZXN0LmNvbTp0b2tlbg==",
863 ))
864 .and(wiremock::matchers::header("Accept", "application/json"))
865 .respond_with(
866 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({"ok": true})),
867 )
868 .expect(1)
869 .mount(&server)
870 .await;
871
872 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
873 let resp = client
874 .get_json(&format!("{}/test", server.uri()))
875 .await
876 .unwrap();
877 assert!(resp.status().is_success());
878 }
879
880 #[tokio::test]
881 async fn put_json_sends_body_and_auth() {
882 let server = wiremock::MockServer::start().await;
883
884 wiremock::Mock::given(wiremock::matchers::method("PUT"))
885 .and(wiremock::matchers::header(
886 "Authorization",
887 "Basic dXNlckB0ZXN0LmNvbTp0b2tlbg==",
888 ))
889 .and(wiremock::matchers::header(
890 "Content-Type",
891 "application/json",
892 ))
893 .respond_with(wiremock::ResponseTemplate::new(200))
894 .expect(1)
895 .mount(&server)
896 .await;
897
898 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
899 let body = serde_json::json!({"key": "value"});
900 let resp = client
901 .put_json(&format!("{}/test", server.uri()), &body)
902 .await
903 .unwrap();
904 assert!(resp.status().is_success());
905 }
906
907 #[tokio::test]
908 async fn post_json_sends_body_and_auth() {
909 let server = wiremock::MockServer::start().await;
910
911 wiremock::Mock::given(wiremock::matchers::method("POST"))
912 .and(wiremock::matchers::header(
913 "Authorization",
914 "Basic dXNlckB0ZXN0LmNvbTp0b2tlbg==",
915 ))
916 .and(wiremock::matchers::header(
917 "Content-Type",
918 "application/json",
919 ))
920 .respond_with(
921 wiremock::ResponseTemplate::new(201).set_body_json(serde_json::json!({"id": "1"})),
922 )
923 .expect(1)
924 .mount(&server)
925 .await;
926
927 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
928 let body = serde_json::json!({"name": "test"});
929 let resp = client
930 .post_json(&format!("{}/test", server.uri()), &body)
931 .await
932 .unwrap();
933 assert_eq!(resp.status().as_u16(), 201);
934 }
935
936 #[tokio::test]
937 async fn post_json_error_response() {
938 let server = wiremock::MockServer::start().await;
939
940 wiremock::Mock::given(wiremock::matchers::method("POST"))
941 .respond_with(wiremock::ResponseTemplate::new(400).set_body_string("Bad Request"))
942 .expect(1)
943 .mount(&server)
944 .await;
945
946 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
947 let body = serde_json::json!({});
948 let resp = client
949 .post_json(&format!("{}/test", server.uri()), &body)
950 .await
951 .unwrap();
952 assert_eq!(resp.status().as_u16(), 400);
953 }
954
955 #[tokio::test]
956 async fn delete_sends_auth_header() {
957 let server = wiremock::MockServer::start().await;
958
959 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
960 .and(wiremock::matchers::header(
961 "Authorization",
962 "Basic dXNlckB0ZXN0LmNvbTp0b2tlbg==",
963 ))
964 .respond_with(wiremock::ResponseTemplate::new(204))
965 .expect(1)
966 .mount(&server)
967 .await;
968
969 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
970 let resp = client
971 .delete(&format!("{}/test", server.uri()))
972 .await
973 .unwrap();
974 assert_eq!(resp.status().as_u16(), 204);
975 }
976
977 #[tokio::test]
978 async fn delete_error_response() {
979 let server = wiremock::MockServer::start().await;
980
981 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
982 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
983 .expect(1)
984 .mount(&server)
985 .await;
986
987 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
988 let resp = client
989 .delete(&format!("{}/test", server.uri()))
990 .await
991 .unwrap();
992 assert_eq!(resp.status().as_u16(), 404);
993 }
994
995 #[tokio::test]
996 async fn get_issue_success() {
997 let server = wiremock::MockServer::start().await;
998
999 let issue_json = serde_json::json!({
1000 "key": "PROJ-42",
1001 "fields": {
1002 "summary": "Fix the bug",
1003 "description": {
1004 "version": 1,
1005 "type": "doc",
1006 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Details"}]}]
1007 },
1008 "status": {"name": "Open"},
1009 "issuetype": {"name": "Bug"},
1010 "assignee": {"displayName": "Alice"},
1011 "priority": {"name": "High"},
1012 "labels": ["backend", "urgent"]
1013 }
1014 });
1015
1016 wiremock::Mock::given(wiremock::matchers::method("GET"))
1017 .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-42"))
1018 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&issue_json))
1019 .expect(1)
1020 .mount(&server)
1021 .await;
1022
1023 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1024 let issue = client.get_issue("PROJ-42").await.unwrap();
1025
1026 assert_eq!(issue.key, "PROJ-42");
1027 assert_eq!(issue.summary, "Fix the bug");
1028 assert_eq!(issue.status.as_deref(), Some("Open"));
1029 assert_eq!(issue.issue_type.as_deref(), Some("Bug"));
1030 assert_eq!(issue.assignee.as_deref(), Some("Alice"));
1031 assert_eq!(issue.priority.as_deref(), Some("High"));
1032 assert_eq!(issue.labels, vec!["backend", "urgent"]);
1033 assert!(issue.description_adf.is_some());
1034 }
1035
1036 #[tokio::test]
1037 async fn get_issue_api_error() {
1038 let server = wiremock::MockServer::start().await;
1039
1040 wiremock::Mock::given(wiremock::matchers::method("GET"))
1041 .and(wiremock::matchers::path("/rest/api/3/issue/NOPE-1"))
1042 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
1043 .expect(1)
1044 .mount(&server)
1045 .await;
1046
1047 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1048 let err = client.get_issue("NOPE-1").await.unwrap_err();
1049 assert!(err.to_string().contains("404"));
1050 }
1051
1052 #[tokio::test]
1053 async fn update_issue_success() {
1054 let server = wiremock::MockServer::start().await;
1055
1056 wiremock::Mock::given(wiremock::matchers::method("PUT"))
1057 .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-42"))
1058 .respond_with(wiremock::ResponseTemplate::new(204))
1059 .expect(1)
1060 .mount(&server)
1061 .await;
1062
1063 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1064 let adf = AdfDocument::new();
1065 let result = client
1066 .update_issue("PROJ-42", &adf, Some("New title"))
1067 .await;
1068 assert!(result.is_ok());
1069 }
1070
1071 #[tokio::test]
1072 async fn update_issue_without_summary() {
1073 let server = wiremock::MockServer::start().await;
1074
1075 wiremock::Mock::given(wiremock::matchers::method("PUT"))
1076 .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-42"))
1077 .respond_with(wiremock::ResponseTemplate::new(204))
1078 .expect(1)
1079 .mount(&server)
1080 .await;
1081
1082 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1083 let adf = AdfDocument::new();
1084 let result = client.update_issue("PROJ-42", &adf, None).await;
1085 assert!(result.is_ok());
1086 }
1087
1088 #[tokio::test]
1089 async fn update_issue_api_error() {
1090 let server = wiremock::MockServer::start().await;
1091
1092 wiremock::Mock::given(wiremock::matchers::method("PUT"))
1093 .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-42"))
1094 .respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
1095 .expect(1)
1096 .mount(&server)
1097 .await;
1098
1099 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1100 let adf = AdfDocument::new();
1101 let err = client
1102 .update_issue("PROJ-42", &adf, None)
1103 .await
1104 .unwrap_err();
1105 assert!(err.to_string().contains("403"));
1106 }
1107
1108 #[tokio::test]
1109 async fn search_issues_success() {
1110 let server = wiremock::MockServer::start().await;
1111
1112 let search_json = serde_json::json!({
1113 "issues": [
1114 {
1115 "key": "PROJ-1",
1116 "fields": {
1117 "summary": "First issue",
1118 "description": null,
1119 "status": {"name": "Open"},
1120 "issuetype": {"name": "Bug"},
1121 "assignee": {"displayName": "Alice"},
1122 "priority": {"name": "High"},
1123 "labels": []
1124 }
1125 },
1126 {
1127 "key": "PROJ-2",
1128 "fields": {
1129 "summary": "Second issue",
1130 "description": null,
1131 "status": {"name": "Done"},
1132 "issuetype": {"name": "Task"},
1133 "assignee": null,
1134 "priority": null,
1135 "labels": ["backend"]
1136 }
1137 }
1138 ],
1139 "total": 2
1140 });
1141
1142 wiremock::Mock::given(wiremock::matchers::method("POST"))
1143 .and(wiremock::matchers::path("/rest/api/3/search/jql"))
1144 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&search_json))
1145 .expect(1)
1146 .mount(&server)
1147 .await;
1148
1149 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1150 let result = client.search_issues("project = PROJ", 50).await.unwrap();
1151
1152 assert_eq!(result.total, 2);
1153 assert_eq!(result.issues.len(), 2);
1154 assert_eq!(result.issues[0].key, "PROJ-1");
1155 assert_eq!(result.issues[0].summary, "First issue");
1156 assert_eq!(result.issues[0].status.as_deref(), Some("Open"));
1157 assert_eq!(result.issues[1].key, "PROJ-2");
1158 assert!(result.issues[1].assignee.is_none());
1159 }
1160
1161 #[tokio::test]
1162 async fn search_issues_without_total() {
1163 let server = wiremock::MockServer::start().await;
1164
1165 wiremock::Mock::given(wiremock::matchers::method("POST"))
1166 .and(wiremock::matchers::path("/rest/api/3/search/jql"))
1167 .respond_with(
1168 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1169 "issues": [{
1170 "key": "PROJ-1",
1171 "fields": {
1172 "summary": "Test",
1173 "description": null,
1174 "status": null,
1175 "issuetype": null,
1176 "assignee": null,
1177 "priority": null,
1178 "labels": []
1179 }
1180 }]
1181 })),
1182 )
1183 .expect(1)
1184 .mount(&server)
1185 .await;
1186
1187 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1188 let result = client.search_issues("project = PROJ", 50).await.unwrap();
1189
1190 assert_eq!(result.issues.len(), 1);
1191 assert_eq!(result.total, 1);
1193 }
1194
1195 #[tokio::test]
1196 async fn search_issues_empty_results() {
1197 let server = wiremock::MockServer::start().await;
1198
1199 wiremock::Mock::given(wiremock::matchers::method("POST"))
1200 .and(wiremock::matchers::path("/rest/api/3/search/jql"))
1201 .respond_with(
1202 wiremock::ResponseTemplate::new(200)
1203 .set_body_json(serde_json::json!({"issues": [], "total": 0})),
1204 )
1205 .expect(1)
1206 .mount(&server)
1207 .await;
1208
1209 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1210 let result = client.search_issues("project = NOPE", 50).await.unwrap();
1211
1212 assert_eq!(result.total, 0);
1213 assert!(result.issues.is_empty());
1214 }
1215
1216 #[tokio::test]
1217 async fn search_issues_api_error() {
1218 let server = wiremock::MockServer::start().await;
1219
1220 wiremock::Mock::given(wiremock::matchers::method("POST"))
1221 .and(wiremock::matchers::path("/rest/api/3/search/jql"))
1222 .respond_with(wiremock::ResponseTemplate::new(400).set_body_string("Invalid JQL query"))
1223 .expect(1)
1224 .mount(&server)
1225 .await;
1226
1227 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1228 let err = client
1229 .search_issues("invalid jql !!!", 50)
1230 .await
1231 .unwrap_err();
1232 assert!(err.to_string().contains("400"));
1233 }
1234
1235 #[tokio::test]
1236 async fn create_issue_success() {
1237 let server = wiremock::MockServer::start().await;
1238
1239 wiremock::Mock::given(wiremock::matchers::method("POST"))
1240 .and(wiremock::matchers::path("/rest/api/3/issue"))
1241 .respond_with(wiremock::ResponseTemplate::new(201).set_body_json(
1242 serde_json::json!({"key": "PROJ-124", "id": "10042", "self": "https://org.atlassian.net/rest/api/3/issue/10042"}),
1243 ))
1244 .expect(1)
1245 .mount(&server)
1246 .await;
1247
1248 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1249 let result = client
1250 .create_issue("PROJ", "Bug", "Fix login", None, &[])
1251 .await
1252 .unwrap();
1253
1254 assert_eq!(result.key, "PROJ-124");
1255 assert_eq!(result.id, "10042");
1256 assert!(result.self_url.contains("10042"));
1257 }
1258
1259 #[tokio::test]
1260 async fn create_issue_with_description_and_labels() {
1261 let server = wiremock::MockServer::start().await;
1262
1263 wiremock::Mock::given(wiremock::matchers::method("POST"))
1264 .and(wiremock::matchers::path("/rest/api/3/issue"))
1265 .respond_with(wiremock::ResponseTemplate::new(201).set_body_json(
1266 serde_json::json!({"key": "PROJ-125", "id": "10043", "self": "https://org.atlassian.net/rest/api/3/issue/10043"}),
1267 ))
1268 .expect(1)
1269 .mount(&server)
1270 .await;
1271
1272 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1273 let adf = AdfDocument::new();
1274 let labels = vec!["backend".to_string(), "urgent".to_string()];
1275 let result = client
1276 .create_issue("PROJ", "Task", "Add feature", Some(&adf), &labels)
1277 .await
1278 .unwrap();
1279
1280 assert_eq!(result.key, "PROJ-125");
1281 }
1282
1283 #[tokio::test]
1284 async fn create_issue_api_error() {
1285 let server = wiremock::MockServer::start().await;
1286
1287 wiremock::Mock::given(wiremock::matchers::method("POST"))
1288 .and(wiremock::matchers::path("/rest/api/3/issue"))
1289 .respond_with(wiremock::ResponseTemplate::new(400).set_body_string("Project not found"))
1290 .expect(1)
1291 .mount(&server)
1292 .await;
1293
1294 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1295 let err = client
1296 .create_issue("NOPE", "Bug", "Test", None, &[])
1297 .await
1298 .unwrap_err();
1299 assert!(err.to_string().contains("400"));
1300 }
1301
1302 #[tokio::test]
1303 async fn get_comments_success() {
1304 let server = wiremock::MockServer::start().await;
1305
1306 wiremock::Mock::given(wiremock::matchers::method("GET"))
1307 .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1/comment"))
1308 .respond_with(
1309 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1310 "comments": [
1311 {
1312 "id": "100",
1313 "author": {"displayName": "Alice"},
1314 "body": {"version": 1, "type": "doc", "content": []},
1315 "created": "2026-04-01T10:00:00.000+0000"
1316 },
1317 {
1318 "id": "101",
1319 "author": {"displayName": "Bob"},
1320 "body": null,
1321 "created": "2026-04-02T14:00:00.000+0000"
1322 }
1323 ]
1324 })),
1325 )
1326 .expect(1)
1327 .mount(&server)
1328 .await;
1329
1330 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1331 let comments = client.get_comments("PROJ-1").await.unwrap();
1332
1333 assert_eq!(comments.len(), 2);
1334 assert_eq!(comments[0].id, "100");
1335 assert_eq!(comments[0].author, "Alice");
1336 assert!(comments[0].body_adf.is_some());
1337 assert!(comments[0].created.contains("2026-04-01"));
1338 assert_eq!(comments[1].id, "101");
1339 assert_eq!(comments[1].author, "Bob");
1340 assert!(comments[1].body_adf.is_none());
1341 }
1342
1343 #[tokio::test]
1344 async fn get_comments_empty() {
1345 let server = wiremock::MockServer::start().await;
1346
1347 wiremock::Mock::given(wiremock::matchers::method("GET"))
1348 .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1/comment"))
1349 .respond_with(
1350 wiremock::ResponseTemplate::new(200)
1351 .set_body_json(serde_json::json!({"comments": []})),
1352 )
1353 .expect(1)
1354 .mount(&server)
1355 .await;
1356
1357 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1358 let comments = client.get_comments("PROJ-1").await.unwrap();
1359 assert!(comments.is_empty());
1360 }
1361
1362 #[tokio::test]
1363 async fn get_comments_api_error() {
1364 let server = wiremock::MockServer::start().await;
1365
1366 wiremock::Mock::given(wiremock::matchers::method("GET"))
1367 .and(wiremock::matchers::path("/rest/api/3/issue/NOPE-1/comment"))
1368 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
1369 .expect(1)
1370 .mount(&server)
1371 .await;
1372
1373 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1374 let err = client.get_comments("NOPE-1").await.unwrap_err();
1375 assert!(err.to_string().contains("404"));
1376 }
1377
1378 #[tokio::test]
1379 async fn add_comment_success() {
1380 let server = wiremock::MockServer::start().await;
1381
1382 wiremock::Mock::given(wiremock::matchers::method("POST"))
1383 .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1/comment"))
1384 .respond_with(
1385 wiremock::ResponseTemplate::new(201).set_body_json(
1386 serde_json::json!({"id": "200", "author": {"displayName": "Me"}}),
1387 ),
1388 )
1389 .expect(1)
1390 .mount(&server)
1391 .await;
1392
1393 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1394 let adf = AdfDocument::new();
1395 let result = client.add_comment("PROJ-1", &adf).await;
1396 assert!(result.is_ok());
1397 }
1398
1399 #[tokio::test]
1400 async fn add_comment_api_error() {
1401 let server = wiremock::MockServer::start().await;
1402
1403 wiremock::Mock::given(wiremock::matchers::method("POST"))
1404 .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1/comment"))
1405 .respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
1406 .expect(1)
1407 .mount(&server)
1408 .await;
1409
1410 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1411 let adf = AdfDocument::new();
1412 let err = client.add_comment("PROJ-1", &adf).await.unwrap_err();
1413 assert!(err.to_string().contains("403"));
1414 }
1415
1416 #[tokio::test]
1417 async fn get_transitions_success() {
1418 let server = wiremock::MockServer::start().await;
1419
1420 wiremock::Mock::given(wiremock::matchers::method("GET"))
1421 .and(wiremock::matchers::path(
1422 "/rest/api/3/issue/PROJ-1/transitions",
1423 ))
1424 .respond_with(
1425 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1426 "transitions": [
1427 {"id": "11", "name": "In Progress"},
1428 {"id": "21", "name": "Done"},
1429 {"id": "31", "name": "Won't Do"}
1430 ]
1431 })),
1432 )
1433 .expect(1)
1434 .mount(&server)
1435 .await;
1436
1437 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1438 let transitions = client.get_transitions("PROJ-1").await.unwrap();
1439
1440 assert_eq!(transitions.len(), 3);
1441 assert_eq!(transitions[0].id, "11");
1442 assert_eq!(transitions[0].name, "In Progress");
1443 assert_eq!(transitions[1].id, "21");
1444 assert_eq!(transitions[2].name, "Won't Do");
1445 }
1446
1447 #[tokio::test]
1448 async fn get_transitions_empty() {
1449 let server = wiremock::MockServer::start().await;
1450
1451 wiremock::Mock::given(wiremock::matchers::method("GET"))
1452 .and(wiremock::matchers::path(
1453 "/rest/api/3/issue/PROJ-1/transitions",
1454 ))
1455 .respond_with(
1456 wiremock::ResponseTemplate::new(200)
1457 .set_body_json(serde_json::json!({"transitions": []})),
1458 )
1459 .expect(1)
1460 .mount(&server)
1461 .await;
1462
1463 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1464 let transitions = client.get_transitions("PROJ-1").await.unwrap();
1465 assert!(transitions.is_empty());
1466 }
1467
1468 #[tokio::test]
1469 async fn get_transitions_api_error() {
1470 let server = wiremock::MockServer::start().await;
1471
1472 wiremock::Mock::given(wiremock::matchers::method("GET"))
1473 .and(wiremock::matchers::path(
1474 "/rest/api/3/issue/NOPE-1/transitions",
1475 ))
1476 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
1477 .expect(1)
1478 .mount(&server)
1479 .await;
1480
1481 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1482 let err = client.get_transitions("NOPE-1").await.unwrap_err();
1483 assert!(err.to_string().contains("404"));
1484 }
1485
1486 #[tokio::test]
1487 async fn do_transition_success() {
1488 let server = wiremock::MockServer::start().await;
1489
1490 wiremock::Mock::given(wiremock::matchers::method("POST"))
1491 .and(wiremock::matchers::path(
1492 "/rest/api/3/issue/PROJ-1/transitions",
1493 ))
1494 .respond_with(wiremock::ResponseTemplate::new(204))
1495 .expect(1)
1496 .mount(&server)
1497 .await;
1498
1499 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1500 let result = client.do_transition("PROJ-1", "21").await;
1501 assert!(result.is_ok());
1502 }
1503
1504 #[tokio::test]
1505 async fn do_transition_api_error() {
1506 let server = wiremock::MockServer::start().await;
1507
1508 wiremock::Mock::given(wiremock::matchers::method("POST"))
1509 .and(wiremock::matchers::path(
1510 "/rest/api/3/issue/PROJ-1/transitions",
1511 ))
1512 .respond_with(
1513 wiremock::ResponseTemplate::new(400).set_body_string("Invalid transition"),
1514 )
1515 .expect(1)
1516 .mount(&server)
1517 .await;
1518
1519 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1520 let err = client.do_transition("PROJ-1", "999").await.unwrap_err();
1521 assert!(err.to_string().contains("400"));
1522 }
1523
1524 #[tokio::test]
1525 async fn search_confluence_success() {
1526 let server = wiremock::MockServer::start().await;
1527
1528 wiremock::Mock::given(wiremock::matchers::method("GET"))
1529 .and(wiremock::matchers::path("/wiki/rest/api/content/search"))
1530 .respond_with(
1531 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1532 "results": [
1533 {
1534 "id": "12345",
1535 "title": "Architecture Overview",
1536 "_expandable": {"space": "/wiki/rest/api/space/ENG"}
1537 },
1538 {
1539 "id": "67890",
1540 "title": "Getting Started",
1541 "_expandable": {"space": "/wiki/rest/api/space/DOC"}
1542 }
1543 ],
1544 "size": 2
1545 })),
1546 )
1547 .expect(1)
1548 .mount(&server)
1549 .await;
1550
1551 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1552 let result = client.search_confluence("type = page", 25).await.unwrap();
1553
1554 assert_eq!(result.total, 2);
1555 assert_eq!(result.results.len(), 2);
1556 assert_eq!(result.results[0].id, "12345");
1557 assert_eq!(result.results[0].title, "Architecture Overview");
1558 assert_eq!(result.results[0].space_key, "ENG");
1559 assert_eq!(result.results[1].space_key, "DOC");
1560 }
1561
1562 #[tokio::test]
1563 async fn search_confluence_empty() {
1564 let server = wiremock::MockServer::start().await;
1565
1566 wiremock::Mock::given(wiremock::matchers::method("GET"))
1567 .and(wiremock::matchers::path("/wiki/rest/api/content/search"))
1568 .respond_with(
1569 wiremock::ResponseTemplate::new(200)
1570 .set_body_json(serde_json::json!({"results": [], "size": 0})),
1571 )
1572 .expect(1)
1573 .mount(&server)
1574 .await;
1575
1576 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1577 let result = client
1578 .search_confluence("title = \"Nonexistent\"", 25)
1579 .await
1580 .unwrap();
1581 assert_eq!(result.total, 0);
1582 assert!(result.results.is_empty());
1583 }
1584
1585 #[tokio::test]
1586 async fn search_confluence_api_error() {
1587 let server = wiremock::MockServer::start().await;
1588
1589 wiremock::Mock::given(wiremock::matchers::method("GET"))
1590 .and(wiremock::matchers::path("/wiki/rest/api/content/search"))
1591 .respond_with(wiremock::ResponseTemplate::new(400).set_body_string("Invalid CQL"))
1592 .expect(1)
1593 .mount(&server)
1594 .await;
1595
1596 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1597 let err = client
1598 .search_confluence("bad cql !!!", 25)
1599 .await
1600 .unwrap_err();
1601 assert!(err.to_string().contains("400"));
1602 }
1603
1604 #[tokio::test]
1605 async fn search_confluence_missing_space() {
1606 let server = wiremock::MockServer::start().await;
1607
1608 wiremock::Mock::given(wiremock::matchers::method("GET"))
1609 .and(wiremock::matchers::path("/wiki/rest/api/content/search"))
1610 .respond_with(
1611 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1612 "results": [{"id": "111", "title": "No Space"}],
1613 "size": 1
1614 })),
1615 )
1616 .expect(1)
1617 .mount(&server)
1618 .await;
1619
1620 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1621 let result = client.search_confluence("type = page", 10).await.unwrap();
1622 assert_eq!(result.results[0].space_key, "");
1623 }
1624
1625 #[tokio::test]
1626 async fn get_boards_success() {
1627 let server = wiremock::MockServer::start().await;
1628
1629 wiremock::Mock::given(wiremock::matchers::method("GET"))
1630 .and(wiremock::matchers::path("/rest/agile/1.0/board"))
1631 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
1632 serde_json::json!({
1633 "values": [
1634 {"id": 1, "name": "PROJ Board", "type": "scrum", "location": {"projectKey": "PROJ"}},
1635 {"id": 2, "name": "Kanban", "type": "kanban"}
1636 ],
1637 "total": 2, "isLast": true
1638 }),
1639 ))
1640 .expect(1)
1641 .mount(&server)
1642 .await;
1643
1644 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1645 let result = client.get_boards(None, None, 50).await.unwrap();
1646
1647 assert_eq!(result.total, 2);
1648 assert_eq!(result.boards.len(), 2);
1649 assert_eq!(result.boards[0].id, 1);
1650 assert_eq!(result.boards[0].name, "PROJ Board");
1651 assert_eq!(result.boards[0].board_type, "scrum");
1652 assert_eq!(result.boards[0].project_key.as_deref(), Some("PROJ"));
1653 assert!(result.boards[1].project_key.is_none());
1654 }
1655
1656 #[tokio::test]
1657 async fn get_boards_with_filters() {
1658 let server = wiremock::MockServer::start().await;
1659
1660 wiremock::Mock::given(wiremock::matchers::method("GET"))
1661 .and(wiremock::matchers::path("/rest/agile/1.0/board"))
1662 .and(wiremock::matchers::query_param("projectKeyOrId", "PROJ"))
1663 .and(wiremock::matchers::query_param("type", "scrum"))
1664 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
1665 serde_json::json!({
1666 "values": [{"id": 1, "name": "PROJ Board", "type": "scrum", "location": {"projectKey": "PROJ"}}],
1667 "total": 1, "isLast": true
1668 }),
1669 ))
1670 .expect(1)
1671 .mount(&server)
1672 .await;
1673
1674 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1675 let result = client
1676 .get_boards(Some("PROJ"), Some("scrum"), 50)
1677 .await
1678 .unwrap();
1679
1680 assert_eq!(result.boards.len(), 1);
1681 assert_eq!(result.boards[0].project_key.as_deref(), Some("PROJ"));
1682 }
1683
1684 #[tokio::test]
1685 async fn search_issues_paginates_with_token() {
1686 let server = wiremock::MockServer::start().await;
1687
1688 wiremock::Mock::given(wiremock::matchers::method("POST"))
1690 .and(wiremock::matchers::path("/rest/api/3/search/jql"))
1691 .and(wiremock::matchers::body_partial_json(serde_json::json!({"jql": "project = PROJ"})))
1692 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
1693 serde_json::json!({
1694 "issues": [{"key": "PROJ-1", "fields": {"summary": "First", "description": null, "status": null, "issuetype": null, "assignee": null, "priority": null, "labels": []}}],
1695 "nextPageToken": "token123"
1696 }),
1697 ))
1698 .up_to_n_times(1)
1699 .mount(&server)
1700 .await;
1701
1702 wiremock::Mock::given(wiremock::matchers::method("POST"))
1704 .and(wiremock::matchers::path("/rest/api/3/search/jql"))
1705 .and(wiremock::matchers::body_partial_json(serde_json::json!({"nextPageToken": "token123"})))
1706 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
1707 serde_json::json!({
1708 "issues": [{"key": "PROJ-2", "fields": {"summary": "Second", "description": null, "status": null, "issuetype": null, "assignee": null, "priority": null, "labels": []}}]
1709 }),
1710 ))
1711 .up_to_n_times(1)
1712 .mount(&server)
1713 .await;
1714
1715 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1716 let result = client.search_issues("project = PROJ", 0).await.unwrap();
1717
1718 assert_eq!(result.issues.len(), 2);
1719 assert_eq!(result.issues[0].key, "PROJ-1");
1720 assert_eq!(result.issues[1].key, "PROJ-2");
1721 }
1722
1723 #[tokio::test]
1724 async fn search_issues_respects_limit() {
1725 let server = wiremock::MockServer::start().await;
1726
1727 wiremock::Mock::given(wiremock::matchers::method("POST"))
1728 .and(wiremock::matchers::path("/rest/api/3/search/jql"))
1729 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
1730 serde_json::json!({
1731 "issues": [
1732 {"key": "PROJ-1", "fields": {"summary": "A", "description": null, "status": null, "issuetype": null, "assignee": null, "priority": null, "labels": []}},
1733 {"key": "PROJ-2", "fields": {"summary": "B", "description": null, "status": null, "issuetype": null, "assignee": null, "priority": null, "labels": []}}
1734 ],
1735 "nextPageToken": "more"
1736 }),
1737 ))
1738 .up_to_n_times(1)
1739 .mount(&server)
1740 .await;
1741
1742 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1743 let result = client.search_issues("project = PROJ", 2).await.unwrap();
1745 assert_eq!(result.issues.len(), 2);
1746 }
1747
1748 #[tokio::test]
1749 async fn get_boards_paginates_with_offset() {
1750 let server = wiremock::MockServer::start().await;
1751
1752 wiremock::Mock::given(wiremock::matchers::method("GET"))
1754 .and(wiremock::matchers::path("/rest/agile/1.0/board"))
1755 .and(wiremock::matchers::query_param("startAt", "0"))
1756 .respond_with(
1757 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1758 "values": [{"id": 1, "name": "Board 1", "type": "scrum"}],
1759 "total": 2, "isLast": false
1760 })),
1761 )
1762 .up_to_n_times(1)
1763 .mount(&server)
1764 .await;
1765
1766 wiremock::Mock::given(wiremock::matchers::method("GET"))
1768 .and(wiremock::matchers::path("/rest/agile/1.0/board"))
1769 .and(wiremock::matchers::query_param("startAt", "1"))
1770 .respond_with(
1771 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1772 "values": [{"id": 2, "name": "Board 2", "type": "kanban"}],
1773 "total": 2, "isLast": true
1774 })),
1775 )
1776 .up_to_n_times(1)
1777 .mount(&server)
1778 .await;
1779
1780 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1781 let result = client.get_boards(None, None, 0).await.unwrap();
1782
1783 assert_eq!(result.boards.len(), 2);
1784 assert_eq!(result.boards[0].name, "Board 1");
1785 assert_eq!(result.boards[1].name, "Board 2");
1786 }
1787
1788 #[tokio::test]
1789 async fn get_boards_empty() {
1790 let server = wiremock::MockServer::start().await;
1791
1792 wiremock::Mock::given(wiremock::matchers::method("GET"))
1793 .and(wiremock::matchers::path("/rest/agile/1.0/board"))
1794 .respond_with(
1795 wiremock::ResponseTemplate::new(200)
1796 .set_body_json(serde_json::json!({"values": [], "total": 0})),
1797 )
1798 .expect(1)
1799 .mount(&server)
1800 .await;
1801
1802 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1803 let result = client.get_boards(None, None, 50).await.unwrap();
1804 assert!(result.boards.is_empty());
1805 }
1806
1807 #[tokio::test]
1808 async fn get_boards_api_error() {
1809 let server = wiremock::MockServer::start().await;
1810
1811 wiremock::Mock::given(wiremock::matchers::method("GET"))
1812 .and(wiremock::matchers::path("/rest/agile/1.0/board"))
1813 .respond_with(wiremock::ResponseTemplate::new(401).set_body_string("Unauthorized"))
1814 .expect(1)
1815 .mount(&server)
1816 .await;
1817
1818 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1819 let err = client.get_boards(None, None, 50).await.unwrap_err();
1820 assert!(err.to_string().contains("401"));
1821 }
1822
1823 #[tokio::test]
1824 async fn get_board_issues_success() {
1825 let server = wiremock::MockServer::start().await;
1826
1827 wiremock::Mock::given(wiremock::matchers::method("GET"))
1828 .and(wiremock::matchers::path("/rest/agile/1.0/board/1/issue"))
1829 .respond_with(
1830 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1831 "issues": [{
1832 "key": "PROJ-1",
1833 "fields": {
1834 "summary": "Board issue",
1835 "description": null,
1836 "status": {"name": "Open"},
1837 "issuetype": {"name": "Task"},
1838 "assignee": null,
1839 "priority": null,
1840 "labels": []
1841 }
1842 }],
1843 "total": 1, "isLast": true
1844 })),
1845 )
1846 .expect(1)
1847 .mount(&server)
1848 .await;
1849
1850 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1851 let result = client.get_board_issues(1, None, 50).await.unwrap();
1852
1853 assert_eq!(result.total, 1);
1854 assert_eq!(result.issues[0].key, "PROJ-1");
1855 assert_eq!(result.issues[0].summary, "Board issue");
1856 }
1857
1858 #[tokio::test]
1859 async fn get_board_issues_api_error() {
1860 let server = wiremock::MockServer::start().await;
1861
1862 wiremock::Mock::given(wiremock::matchers::method("GET"))
1863 .and(wiremock::matchers::path("/rest/agile/1.0/board/999/issue"))
1864 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
1865 .expect(1)
1866 .mount(&server)
1867 .await;
1868
1869 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1870 let err = client.get_board_issues(999, None, 50).await.unwrap_err();
1871 assert!(err.to_string().contains("404"));
1872 }
1873
1874 #[tokio::test]
1875 async fn get_sprints_success() {
1876 let server = wiremock::MockServer::start().await;
1877
1878 wiremock::Mock::given(wiremock::matchers::method("GET"))
1879 .and(wiremock::matchers::path("/rest/agile/1.0/board/1/sprint"))
1880 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
1881 serde_json::json!({
1882 "values": [
1883 {"id": 10, "name": "Sprint 1", "state": "closed", "startDate": "2026-03-01", "endDate": "2026-03-14", "goal": "MVP"},
1884 {"id": 11, "name": "Sprint 2", "state": "active", "startDate": "2026-03-15", "endDate": "2026-03-28"}
1885 ],
1886 "total": 2, "isLast": true
1887 }),
1888 ))
1889 .expect(1)
1890 .mount(&server)
1891 .await;
1892
1893 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1894 let result = client.get_sprints(1, None, 50).await.unwrap();
1895
1896 assert_eq!(result.total, 2);
1897 assert_eq!(result.sprints.len(), 2);
1898 assert_eq!(result.sprints[0].id, 10);
1899 assert_eq!(result.sprints[0].name, "Sprint 1");
1900 assert_eq!(result.sprints[0].state, "closed");
1901 assert_eq!(result.sprints[0].goal.as_deref(), Some("MVP"));
1902 assert!(result.sprints[1].goal.is_none());
1903 }
1904
1905 #[tokio::test]
1906 async fn get_sprints_with_state_filter() {
1907 let server = wiremock::MockServer::start().await;
1908
1909 wiremock::Mock::given(wiremock::matchers::method("GET"))
1910 .and(wiremock::matchers::path("/rest/agile/1.0/board/1/sprint"))
1911 .and(wiremock::matchers::query_param("state", "active"))
1912 .respond_with(
1913 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1914 "values": [{"id": 11, "name": "Sprint 2", "state": "active"}],
1915 "total": 1, "isLast": true
1916 })),
1917 )
1918 .expect(1)
1919 .mount(&server)
1920 .await;
1921
1922 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1923 let result = client.get_sprints(1, Some("active"), 50).await.unwrap();
1924 assert_eq!(result.sprints.len(), 1);
1925 assert_eq!(result.sprints[0].state, "active");
1926 }
1927
1928 #[tokio::test]
1929 async fn get_sprints_api_error() {
1930 let server = wiremock::MockServer::start().await;
1931
1932 wiremock::Mock::given(wiremock::matchers::method("GET"))
1933 .and(wiremock::matchers::path("/rest/agile/1.0/board/999/sprint"))
1934 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
1935 .expect(1)
1936 .mount(&server)
1937 .await;
1938
1939 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1940 let err = client.get_sprints(999, None, 50).await.unwrap_err();
1941 assert!(err.to_string().contains("404"));
1942 }
1943
1944 #[tokio::test]
1945 async fn get_sprint_issues_success() {
1946 let server = wiremock::MockServer::start().await;
1947
1948 wiremock::Mock::given(wiremock::matchers::method("GET"))
1949 .and(wiremock::matchers::path("/rest/agile/1.0/sprint/10/issue"))
1950 .respond_with(
1951 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1952 "issues": [{
1953 "key": "PROJ-1",
1954 "fields": {
1955 "summary": "Sprint issue",
1956 "description": null,
1957 "status": {"name": "In Progress"},
1958 "issuetype": {"name": "Story"},
1959 "assignee": {"displayName": "Alice"},
1960 "priority": null,
1961 "labels": []
1962 }
1963 }],
1964 "total": 1, "isLast": true
1965 })),
1966 )
1967 .expect(1)
1968 .mount(&server)
1969 .await;
1970
1971 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1972 let result = client.get_sprint_issues(10, None, 50).await.unwrap();
1973
1974 assert_eq!(result.total, 1);
1975 assert_eq!(result.issues[0].key, "PROJ-1");
1976 assert_eq!(result.issues[0].assignee.as_deref(), Some("Alice"));
1977 }
1978
1979 #[tokio::test]
1980 async fn get_sprint_issues_api_error() {
1981 let server = wiremock::MockServer::start().await;
1982
1983 wiremock::Mock::given(wiremock::matchers::method("GET"))
1984 .and(wiremock::matchers::path("/rest/agile/1.0/sprint/999/issue"))
1985 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
1986 .expect(1)
1987 .mount(&server)
1988 .await;
1989
1990 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1991 let err = client.get_sprint_issues(999, None, 50).await.unwrap_err();
1992 assert!(err.to_string().contains("404"));
1993 }
1994
1995 #[tokio::test]
1996 async fn add_issues_to_sprint_success() {
1997 let server = wiremock::MockServer::start().await;
1998
1999 wiremock::Mock::given(wiremock::matchers::method("POST"))
2000 .and(wiremock::matchers::path("/rest/agile/1.0/sprint/10/issue"))
2001 .respond_with(wiremock::ResponseTemplate::new(204))
2002 .expect(1)
2003 .mount(&server)
2004 .await;
2005
2006 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2007 let result = client.add_issues_to_sprint(10, &["PROJ-1", "PROJ-2"]).await;
2008 assert!(result.is_ok());
2009 }
2010
2011 #[tokio::test]
2012 async fn add_issues_to_sprint_api_error() {
2013 let server = wiremock::MockServer::start().await;
2014
2015 wiremock::Mock::given(wiremock::matchers::method("POST"))
2016 .and(wiremock::matchers::path("/rest/agile/1.0/sprint/999/issue"))
2017 .respond_with(wiremock::ResponseTemplate::new(400).set_body_string("Bad Request"))
2018 .expect(1)
2019 .mount(&server)
2020 .await;
2021
2022 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2023 let err = client
2024 .add_issues_to_sprint(999, &["NOPE-1"])
2025 .await
2026 .unwrap_err();
2027 assert!(err.to_string().contains("400"));
2028 }
2029
2030 #[tokio::test]
2031 async fn get_issue_links_success() {
2032 let server = wiremock::MockServer::start().await;
2033
2034 wiremock::Mock::given(wiremock::matchers::method("GET"))
2035 .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1"))
2036 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
2037 serde_json::json!({
2038 "fields": {
2039 "issuelinks": [
2040 {
2041 "id": "100",
2042 "type": {"name": "Blocks"},
2043 "outwardIssue": {"key": "PROJ-2", "fields": {"summary": "Blocked issue"}}
2044 },
2045 {
2046 "id": "101",
2047 "type": {"name": "Relates"},
2048 "inwardIssue": {"key": "PROJ-3", "fields": {"summary": "Related issue"}}
2049 }
2050 ]
2051 }
2052 }),
2053 ))
2054 .expect(1)
2055 .mount(&server)
2056 .await;
2057
2058 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2059 let links = client.get_issue_links("PROJ-1").await.unwrap();
2060
2061 assert_eq!(links.len(), 2);
2062 assert_eq!(links[0].id, "100");
2063 assert_eq!(links[0].link_type, "Blocks");
2064 assert_eq!(links[0].direction, "outward");
2065 assert_eq!(links[0].linked_issue_key, "PROJ-2");
2066 assert_eq!(links[0].linked_issue_summary, "Blocked issue");
2067 assert_eq!(links[1].id, "101");
2068 assert_eq!(links[1].direction, "inward");
2069 assert_eq!(links[1].linked_issue_key, "PROJ-3");
2070 }
2071
2072 #[tokio::test]
2073 async fn get_issue_links_empty() {
2074 let server = wiremock::MockServer::start().await;
2075
2076 wiremock::Mock::given(wiremock::matchers::method("GET"))
2077 .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1"))
2078 .respond_with(
2079 wiremock::ResponseTemplate::new(200)
2080 .set_body_json(serde_json::json!({"fields": {"issuelinks": []}})),
2081 )
2082 .expect(1)
2083 .mount(&server)
2084 .await;
2085
2086 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2087 let links = client.get_issue_links("PROJ-1").await.unwrap();
2088 assert!(links.is_empty());
2089 }
2090
2091 #[tokio::test]
2092 async fn get_issue_links_api_error() {
2093 let server = wiremock::MockServer::start().await;
2094
2095 wiremock::Mock::given(wiremock::matchers::method("GET"))
2096 .and(wiremock::matchers::path("/rest/api/3/issue/NOPE-1"))
2097 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
2098 .expect(1)
2099 .mount(&server)
2100 .await;
2101
2102 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2103 let err = client.get_issue_links("NOPE-1").await.unwrap_err();
2104 assert!(err.to_string().contains("404"));
2105 }
2106
2107 #[tokio::test]
2108 async fn get_link_types_success() {
2109 let server = wiremock::MockServer::start().await;
2110 wiremock::Mock::given(wiremock::matchers::method("GET"))
2111 .and(wiremock::matchers::path("/rest/api/3/issueLinkType"))
2112 .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"}]})))
2113 .expect(1).mount(&server).await;
2114 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2115 let types = client.get_link_types().await.unwrap();
2116 assert_eq!(types.len(), 2);
2117 assert_eq!(types[0].name, "Blocks");
2118 assert_eq!(types[0].inward, "is blocked by");
2119 }
2120
2121 #[tokio::test]
2122 async fn get_link_types_api_error() {
2123 let server = wiremock::MockServer::start().await;
2124 wiremock::Mock::given(wiremock::matchers::method("GET"))
2125 .and(wiremock::matchers::path("/rest/api/3/issueLinkType"))
2126 .respond_with(wiremock::ResponseTemplate::new(401).set_body_string("Unauthorized"))
2127 .expect(1)
2128 .mount(&server)
2129 .await;
2130 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2131 let err = client.get_link_types().await.unwrap_err();
2132 assert!(err.to_string().contains("401"));
2133 }
2134
2135 #[tokio::test]
2136 async fn create_issue_link_success() {
2137 let server = wiremock::MockServer::start().await;
2138 wiremock::Mock::given(wiremock::matchers::method("POST"))
2139 .and(wiremock::matchers::path("/rest/api/3/issueLink"))
2140 .respond_with(wiremock::ResponseTemplate::new(201))
2141 .expect(1)
2142 .mount(&server)
2143 .await;
2144 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2145 assert!(client
2146 .create_issue_link("Blocks", "PROJ-1", "PROJ-2")
2147 .await
2148 .is_ok());
2149 }
2150
2151 #[tokio::test]
2152 async fn create_issue_link_api_error() {
2153 let server = wiremock::MockServer::start().await;
2154 wiremock::Mock::given(wiremock::matchers::method("POST"))
2155 .and(wiremock::matchers::path("/rest/api/3/issueLink"))
2156 .respond_with(wiremock::ResponseTemplate::new(400).set_body_string("Bad Request"))
2157 .expect(1)
2158 .mount(&server)
2159 .await;
2160 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2161 let err = client
2162 .create_issue_link("Invalid", "NOPE-1", "NOPE-2")
2163 .await
2164 .unwrap_err();
2165 assert!(err.to_string().contains("400"));
2166 }
2167
2168 #[tokio::test]
2169 async fn remove_issue_link_success() {
2170 let server = wiremock::MockServer::start().await;
2171 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
2172 .and(wiremock::matchers::path("/rest/api/3/issueLink/12345"))
2173 .respond_with(wiremock::ResponseTemplate::new(204))
2174 .expect(1)
2175 .mount(&server)
2176 .await;
2177 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2178 assert!(client.remove_issue_link("12345").await.is_ok());
2179 }
2180
2181 #[tokio::test]
2182 async fn remove_issue_link_api_error() {
2183 let server = wiremock::MockServer::start().await;
2184 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
2185 .and(wiremock::matchers::path("/rest/api/3/issueLink/99999"))
2186 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
2187 .expect(1)
2188 .mount(&server)
2189 .await;
2190 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2191 let err = client.remove_issue_link("99999").await.unwrap_err();
2192 assert!(err.to_string().contains("404"));
2193 }
2194
2195 #[tokio::test]
2196 async fn link_to_epic_success() {
2197 let server = wiremock::MockServer::start().await;
2198 wiremock::Mock::given(wiremock::matchers::method("PUT"))
2199 .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-2"))
2200 .respond_with(wiremock::ResponseTemplate::new(204))
2201 .expect(1)
2202 .mount(&server)
2203 .await;
2204 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2205 assert!(client.link_to_epic("EPIC-1", "PROJ-2").await.is_ok());
2206 }
2207
2208 #[tokio::test]
2209 async fn link_to_epic_api_error() {
2210 let server = wiremock::MockServer::start().await;
2211 wiremock::Mock::given(wiremock::matchers::method("PUT"))
2212 .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-2"))
2213 .respond_with(wiremock::ResponseTemplate::new(400).set_body_string("Not an epic"))
2214 .expect(1)
2215 .mount(&server)
2216 .await;
2217 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2218 let err = client.link_to_epic("NOPE-1", "PROJ-2").await.unwrap_err();
2219 assert!(err.to_string().contains("400"));
2220 }
2221
2222 #[tokio::test]
2223 async fn get_bytes_success() {
2224 let server = wiremock::MockServer::start().await;
2225 wiremock::Mock::given(wiremock::matchers::method("GET"))
2226 .and(wiremock::matchers::path("/file.bin"))
2227 .and(wiremock::matchers::header("Accept", "*/*"))
2228 .respond_with(wiremock::ResponseTemplate::new(200).set_body_bytes(b"binary content"))
2229 .expect(1)
2230 .mount(&server)
2231 .await;
2232
2233 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2234 let data = client
2235 .get_bytes(&format!("{}/file.bin", server.uri()))
2236 .await
2237 .unwrap();
2238 assert_eq!(&data[..], b"binary content");
2239 }
2240
2241 #[tokio::test]
2242 async fn get_bytes_api_error() {
2243 let server = wiremock::MockServer::start().await;
2244 wiremock::Mock::given(wiremock::matchers::method("GET"))
2245 .and(wiremock::matchers::path("/missing.bin"))
2246 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
2247 .expect(1)
2248 .mount(&server)
2249 .await;
2250
2251 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2252 let err = client
2253 .get_bytes(&format!("{}/missing.bin", server.uri()))
2254 .await
2255 .unwrap_err();
2256 assert!(err.to_string().contains("404"));
2257 }
2258
2259 #[tokio::test]
2260 async fn get_attachments_success() {
2261 let server = wiremock::MockServer::start().await;
2262 wiremock::Mock::given(wiremock::matchers::method("GET"))
2263 .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1"))
2264 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
2265 serde_json::json!({
2266 "fields": {
2267 "attachment": [
2268 {"id": "1", "filename": "screenshot.png", "mimeType": "image/png", "size": 12345, "content": "https://org.atlassian.net/attachment/1"},
2269 {"id": "2", "filename": "report.pdf", "mimeType": "application/pdf", "size": 99999, "content": "https://org.atlassian.net/attachment/2"}
2270 ]
2271 }
2272 }),
2273 ))
2274 .expect(1)
2275 .mount(&server)
2276 .await;
2277
2278 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2279 let attachments = client.get_attachments("PROJ-1").await.unwrap();
2280
2281 assert_eq!(attachments.len(), 2);
2282 assert_eq!(attachments[0].filename, "screenshot.png");
2283 assert_eq!(attachments[0].mime_type, "image/png");
2284 assert_eq!(attachments[0].size, 12345);
2285 assert_eq!(attachments[1].filename, "report.pdf");
2286 }
2287
2288 #[tokio::test]
2289 async fn get_attachments_empty() {
2290 let server = wiremock::MockServer::start().await;
2291 wiremock::Mock::given(wiremock::matchers::method("GET"))
2292 .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1"))
2293 .respond_with(
2294 wiremock::ResponseTemplate::new(200)
2295 .set_body_json(serde_json::json!({"fields": {"attachment": []}})),
2296 )
2297 .expect(1)
2298 .mount(&server)
2299 .await;
2300
2301 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2302 let attachments = client.get_attachments("PROJ-1").await.unwrap();
2303 assert!(attachments.is_empty());
2304 }
2305
2306 #[tokio::test]
2307 async fn get_attachments_api_error() {
2308 let server = wiremock::MockServer::start().await;
2309 wiremock::Mock::given(wiremock::matchers::method("GET"))
2310 .and(wiremock::matchers::path("/rest/api/3/issue/NOPE-1"))
2311 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
2312 .expect(1)
2313 .mount(&server)
2314 .await;
2315
2316 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2317 let err = client.get_attachments("NOPE-1").await.unwrap_err();
2318 assert!(err.to_string().contains("404"));
2319 }
2320
2321 #[tokio::test]
2322 async fn get_changelog_success() {
2323 let server = wiremock::MockServer::start().await;
2324
2325 wiremock::Mock::given(wiremock::matchers::method("GET"))
2326 .and(wiremock::matchers::path(
2327 "/rest/api/3/issue/PROJ-1/changelog",
2328 ))
2329 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
2330 serde_json::json!({
2331 "values": [
2332 {
2333 "id": "100",
2334 "author": {"displayName": "Alice"},
2335 "created": "2026-04-01T10:00:00.000+0000",
2336 "items": [
2337 {"field": "status", "fromString": "Open", "toString": "In Progress"},
2338 {"field": "assignee", "fromString": null, "toString": "Bob"}
2339 ]
2340 },
2341 {
2342 "id": "101",
2343 "author": null,
2344 "created": "2026-04-02T14:00:00.000+0000",
2345 "items": [{"field": "priority", "fromString": "Medium", "toString": "High"}]
2346 }
2347 ],
2348 "isLast": true
2349 }),
2350 ))
2351 .expect(1)
2352 .mount(&server)
2353 .await;
2354
2355 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2356 let entries = client.get_changelog("PROJ-1", 50).await.unwrap();
2357
2358 assert_eq!(entries.len(), 2);
2359 assert_eq!(entries[0].id, "100");
2360 assert_eq!(entries[0].author, "Alice");
2361 assert_eq!(entries[0].items.len(), 2);
2362 assert_eq!(entries[0].items[0].field, "status");
2363 assert_eq!(entries[0].items[0].from_string.as_deref(), Some("Open"));
2364 assert_eq!(
2365 entries[0].items[0].to_string.as_deref(),
2366 Some("In Progress")
2367 );
2368 assert_eq!(entries[0].items[1].from_string, None);
2369 assert_eq!(entries[1].author, "");
2370 }
2371
2372 #[tokio::test]
2373 async fn get_changelog_empty() {
2374 let server = wiremock::MockServer::start().await;
2375
2376 wiremock::Mock::given(wiremock::matchers::method("GET"))
2377 .and(wiremock::matchers::path(
2378 "/rest/api/3/issue/PROJ-1/changelog",
2379 ))
2380 .respond_with(
2381 wiremock::ResponseTemplate::new(200)
2382 .set_body_json(serde_json::json!({"values": []})),
2383 )
2384 .expect(1)
2385 .mount(&server)
2386 .await;
2387
2388 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2389 let entries = client.get_changelog("PROJ-1", 50).await.unwrap();
2390 assert!(entries.is_empty());
2391 }
2392
2393 #[tokio::test]
2394 async fn get_changelog_api_error() {
2395 let server = wiremock::MockServer::start().await;
2396
2397 wiremock::Mock::given(wiremock::matchers::method("GET"))
2398 .and(wiremock::matchers::path(
2399 "/rest/api/3/issue/NOPE-1/changelog",
2400 ))
2401 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
2402 .expect(1)
2403 .mount(&server)
2404 .await;
2405
2406 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2407 let err = client.get_changelog("NOPE-1", 50).await.unwrap_err();
2408 assert!(err.to_string().contains("404"));
2409 }
2410
2411 #[tokio::test]
2412 async fn get_fields_success() {
2413 let server = wiremock::MockServer::start().await;
2414
2415 wiremock::Mock::given(wiremock::matchers::method("GET"))
2416 .and(wiremock::matchers::path("/rest/api/3/field"))
2417 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
2418 serde_json::json!([
2419 {"id": "summary", "name": "Summary", "custom": false, "schema": {"type": "string"}},
2420 {"id": "customfield_10001", "name": "Story Points", "custom": true, "schema": {"type": "number"}},
2421 {"id": "labels", "name": "Labels", "custom": false}
2422 ]),
2423 ))
2424 .expect(1)
2425 .mount(&server)
2426 .await;
2427
2428 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2429 let fields = client.get_fields().await.unwrap();
2430
2431 assert_eq!(fields.len(), 3);
2432 assert_eq!(fields[0].id, "summary");
2433 assert_eq!(fields[0].name, "Summary");
2434 assert!(!fields[0].custom);
2435 assert_eq!(fields[0].schema_type.as_deref(), Some("string"));
2436 assert_eq!(fields[1].id, "customfield_10001");
2437 assert!(fields[1].custom);
2438 assert!(fields[2].schema_type.is_none());
2439 }
2440
2441 #[tokio::test]
2442 async fn get_fields_api_error() {
2443 let server = wiremock::MockServer::start().await;
2444
2445 wiremock::Mock::given(wiremock::matchers::method("GET"))
2446 .and(wiremock::matchers::path("/rest/api/3/field"))
2447 .respond_with(wiremock::ResponseTemplate::new(401).set_body_string("Unauthorized"))
2448 .expect(1)
2449 .mount(&server)
2450 .await;
2451
2452 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2453 let err = client.get_fields().await.unwrap_err();
2454 assert!(err.to_string().contains("401"));
2455 }
2456
2457 #[tokio::test]
2458 async fn get_field_contexts_success() {
2459 let server = wiremock::MockServer::start().await;
2460
2461 wiremock::Mock::given(wiremock::matchers::method("GET"))
2462 .and(wiremock::matchers::path(
2463 "/rest/api/3/field/customfield_10001/context",
2464 ))
2465 .respond_with(
2466 wiremock::ResponseTemplate::new(200).set_body_json(
2467 serde_json::json!({"values": [{"id": "12345"}, {"id": "67890"}]}),
2468 ),
2469 )
2470 .expect(1)
2471 .mount(&server)
2472 .await;
2473
2474 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2475 let contexts = client
2476 .get_field_contexts("customfield_10001")
2477 .await
2478 .unwrap();
2479
2480 assert_eq!(contexts.len(), 2);
2481 assert_eq!(contexts[0], "12345");
2482 }
2483
2484 #[tokio::test]
2485 async fn get_field_contexts_api_error() {
2486 let server = wiremock::MockServer::start().await;
2487
2488 wiremock::Mock::given(wiremock::matchers::method("GET"))
2489 .and(wiremock::matchers::path(
2490 "/rest/api/3/field/nonexistent/context",
2491 ))
2492 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
2493 .expect(1)
2494 .mount(&server)
2495 .await;
2496
2497 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2498 let err = client.get_field_contexts("nonexistent").await.unwrap_err();
2499 assert!(err.to_string().contains("404"));
2500 }
2501
2502 #[tokio::test]
2503 async fn get_field_contexts_empty() {
2504 let server = wiremock::MockServer::start().await;
2505
2506 wiremock::Mock::given(wiremock::matchers::method("GET"))
2507 .and(wiremock::matchers::path(
2508 "/rest/api/3/field/customfield_99999/context",
2509 ))
2510 .respond_with(
2511 wiremock::ResponseTemplate::new(200)
2512 .set_body_json(serde_json::json!({"values": []})),
2513 )
2514 .expect(1)
2515 .mount(&server)
2516 .await;
2517
2518 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2519 let contexts = client
2520 .get_field_contexts("customfield_99999")
2521 .await
2522 .unwrap();
2523 assert!(contexts.is_empty());
2524 }
2525
2526 #[tokio::test]
2527 async fn get_field_options_auto_discovers_context() {
2528 let server = wiremock::MockServer::start().await;
2529
2530 wiremock::Mock::given(wiremock::matchers::method("GET"))
2532 .and(wiremock::matchers::path(
2533 "/rest/api/3/field/customfield_10001/context",
2534 ))
2535 .respond_with(
2536 wiremock::ResponseTemplate::new(200)
2537 .set_body_json(serde_json::json!({"values": [{"id": "12345"}]})),
2538 )
2539 .expect(1)
2540 .mount(&server)
2541 .await;
2542
2543 wiremock::Mock::given(wiremock::matchers::method("GET"))
2545 .and(wiremock::matchers::path(
2546 "/rest/api/3/field/customfield_10001/context/12345/option",
2547 ))
2548 .respond_with(
2549 wiremock::ResponseTemplate::new(200)
2550 .set_body_json(serde_json::json!({"values": [{"id": "1", "value": "High"}]})),
2551 )
2552 .expect(1)
2553 .mount(&server)
2554 .await;
2555
2556 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2557 let options = client
2558 .get_field_options("customfield_10001", None)
2559 .await
2560 .unwrap();
2561
2562 assert_eq!(options.len(), 1);
2563 assert_eq!(options[0].value, "High");
2564 }
2565
2566 #[tokio::test]
2567 async fn get_field_options_no_context_errors() {
2568 let server = wiremock::MockServer::start().await;
2569
2570 wiremock::Mock::given(wiremock::matchers::method("GET"))
2571 .and(wiremock::matchers::path(
2572 "/rest/api/3/field/customfield_99999/context",
2573 ))
2574 .respond_with(
2575 wiremock::ResponseTemplate::new(200)
2576 .set_body_json(serde_json::json!({"values": []})),
2577 )
2578 .expect(1)
2579 .mount(&server)
2580 .await;
2581
2582 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2583 let err = client
2584 .get_field_options("customfield_99999", None)
2585 .await
2586 .unwrap_err();
2587 assert!(err.to_string().contains("No contexts found"));
2588 }
2589
2590 #[tokio::test]
2591 async fn get_field_options_with_explicit_context() {
2592 let server = wiremock::MockServer::start().await;
2593
2594 wiremock::Mock::given(wiremock::matchers::method("GET"))
2595 .and(wiremock::matchers::path(
2596 "/rest/api/3/field/customfield_10001/context/12345/option",
2597 ))
2598 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
2599 serde_json::json!({"values": [
2600 {"id": "1", "value": "High"},
2601 {"id": "2", "value": "Medium"},
2602 {"id": "3", "value": "Low"}
2603 ]}),
2604 ))
2605 .expect(1)
2606 .mount(&server)
2607 .await;
2608
2609 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2610 let options = client
2611 .get_field_options("customfield_10001", Some("12345"))
2612 .await
2613 .unwrap();
2614
2615 assert_eq!(options.len(), 3);
2616 assert_eq!(options[0].id, "1");
2617 assert_eq!(options[0].value, "High");
2618 }
2619
2620 #[tokio::test]
2621 async fn get_field_options_with_context() {
2622 let server = wiremock::MockServer::start().await;
2623
2624 wiremock::Mock::given(wiremock::matchers::method("GET"))
2625 .and(wiremock::matchers::path(
2626 "/rest/api/3/field/customfield_10001/context/12345/option",
2627 ))
2628 .respond_with(
2629 wiremock::ResponseTemplate::new(200).set_body_json(
2630 serde_json::json!({"values": [{"id": "1", "value": "Option A"}]}),
2631 ),
2632 )
2633 .expect(1)
2634 .mount(&server)
2635 .await;
2636
2637 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2638 let options = client
2639 .get_field_options("customfield_10001", Some("12345"))
2640 .await
2641 .unwrap();
2642
2643 assert_eq!(options.len(), 1);
2644 assert_eq!(options[0].value, "Option A");
2645 }
2646
2647 #[tokio::test]
2648 async fn get_field_options_api_error() {
2649 let server = wiremock::MockServer::start().await;
2650
2651 wiremock::Mock::given(wiremock::matchers::method("GET"))
2652 .and(wiremock::matchers::path(
2653 "/rest/api/3/field/nonexistent/context/99999/option",
2654 ))
2655 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
2656 .expect(1)
2657 .mount(&server)
2658 .await;
2659
2660 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2661 let err = client
2662 .get_field_options("nonexistent", Some("99999"))
2663 .await
2664 .unwrap_err();
2665 assert!(err.to_string().contains("404"));
2666 }
2667
2668 #[tokio::test]
2669 async fn get_projects_success() {
2670 let server = wiremock::MockServer::start().await;
2671
2672 wiremock::Mock::given(wiremock::matchers::method("GET"))
2673 .and(wiremock::matchers::path("/rest/api/3/project/search"))
2674 .respond_with(
2675 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2676 "values": [
2677 {
2678 "id": "10001",
2679 "key": "PROJ",
2680 "name": "My Project",
2681 "projectTypeKey": "software",
2682 "lead": {"displayName": "Alice"}
2683 },
2684 {
2685 "id": "10002",
2686 "key": "OPS",
2687 "name": "Operations",
2688 "projectTypeKey": "business",
2689 "lead": null
2690 }
2691 ],
2692 "total": 2, "isLast": true
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 result = client.get_projects(50).await.unwrap();
2701
2702 assert_eq!(result.total, 2);
2703 assert_eq!(result.projects.len(), 2);
2704 assert_eq!(result.projects[0].key, "PROJ");
2705 assert_eq!(result.projects[0].name, "My Project");
2706 assert_eq!(result.projects[0].project_type.as_deref(), Some("software"));
2707 assert_eq!(result.projects[0].lead.as_deref(), Some("Alice"));
2708 assert_eq!(result.projects[1].key, "OPS");
2709 assert!(result.projects[1].lead.is_none());
2710 }
2711
2712 #[tokio::test]
2713 async fn get_projects_empty() {
2714 let server = wiremock::MockServer::start().await;
2715
2716 wiremock::Mock::given(wiremock::matchers::method("GET"))
2717 .and(wiremock::matchers::path("/rest/api/3/project/search"))
2718 .respond_with(
2719 wiremock::ResponseTemplate::new(200)
2720 .set_body_json(serde_json::json!({"values": [], "total": 0})),
2721 )
2722 .expect(1)
2723 .mount(&server)
2724 .await;
2725
2726 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2727 let result = client.get_projects(50).await.unwrap();
2728 assert_eq!(result.total, 0);
2729 assert!(result.projects.is_empty());
2730 }
2731
2732 #[tokio::test]
2733 async fn get_projects_api_error() {
2734 let server = wiremock::MockServer::start().await;
2735
2736 wiremock::Mock::given(wiremock::matchers::method("GET"))
2737 .and(wiremock::matchers::path("/rest/api/3/project/search"))
2738 .respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
2739 .expect(1)
2740 .mount(&server)
2741 .await;
2742
2743 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2744 let err = client.get_projects(50).await.unwrap_err();
2745 assert!(err.to_string().contains("403"));
2746 }
2747
2748 #[tokio::test]
2749 async fn delete_issue_success() {
2750 let server = wiremock::MockServer::start().await;
2751
2752 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
2753 .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-42"))
2754 .respond_with(wiremock::ResponseTemplate::new(204))
2755 .expect(1)
2756 .mount(&server)
2757 .await;
2758
2759 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2760 let result = client.delete_issue("PROJ-42").await;
2761 assert!(result.is_ok());
2762 }
2763
2764 #[tokio::test]
2765 async fn delete_issue_not_found() {
2766 let server = wiremock::MockServer::start().await;
2767
2768 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
2769 .and(wiremock::matchers::path("/rest/api/3/issue/NOPE-1"))
2770 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
2771 .expect(1)
2772 .mount(&server)
2773 .await;
2774
2775 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2776 let err = client.delete_issue("NOPE-1").await.unwrap_err();
2777 assert!(err.to_string().contains("404"));
2778 }
2779
2780 #[tokio::test]
2781 async fn delete_issue_forbidden() {
2782 let server = wiremock::MockServer::start().await;
2783
2784 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
2785 .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1"))
2786 .respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
2787 .expect(1)
2788 .mount(&server)
2789 .await;
2790
2791 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2792 let err = client.delete_issue("PROJ-1").await.unwrap_err();
2793 assert!(err.to_string().contains("403"));
2794 }
2795
2796 #[tokio::test]
2797 async fn get_myself_success() {
2798 let server = wiremock::MockServer::start().await;
2799
2800 wiremock::Mock::given(wiremock::matchers::method("GET"))
2801 .and(wiremock::matchers::path("/rest/api/3/myself"))
2802 .respond_with(
2803 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2804 "displayName": "Alice Smith",
2805 "emailAddress": "alice@example.com",
2806 "accountId": "abc123"
2807 })),
2808 )
2809 .expect(1)
2810 .mount(&server)
2811 .await;
2812
2813 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2814 let user = client.get_myself().await.unwrap();
2815 assert_eq!(user.display_name, "Alice Smith");
2816 assert_eq!(user.email_address.as_deref(), Some("alice@example.com"));
2817 assert_eq!(user.account_id, "abc123");
2818 }
2819
2820 #[tokio::test]
2821 async fn get_myself_api_error() {
2822 let server = wiremock::MockServer::start().await;
2823
2824 wiremock::Mock::given(wiremock::matchers::method("GET"))
2825 .and(wiremock::matchers::path("/rest/api/3/myself"))
2826 .respond_with(wiremock::ResponseTemplate::new(401).set_body_string("Unauthorized"))
2827 .expect(1)
2828 .mount(&server)
2829 .await;
2830
2831 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2832 let err = client.get_myself().await.unwrap_err();
2833 assert!(err.to_string().contains("401"));
2834 }
2835}
2836
2837impl AtlassianClient {
2838 pub fn new(instance_url: &str, email: &str, api_token: &str) -> Result<Self> {
2842 let client = Client::builder()
2843 .timeout(REQUEST_TIMEOUT)
2844 .build()
2845 .context("Failed to build HTTP client")?;
2846
2847 let credentials = format!("{email}:{api_token}");
2848 let encoded = base64::engine::general_purpose::STANDARD.encode(credentials);
2849 let auth_header = format!("Basic {encoded}");
2850
2851 Ok(Self {
2852 client,
2853 instance_url: instance_url.trim_end_matches('/').to_string(),
2854 auth_header,
2855 })
2856 }
2857
2858 pub fn from_credentials(creds: &crate::atlassian::auth::AtlassianCredentials) -> Result<Self> {
2860 Self::new(&creds.instance_url, &creds.email, &creds.api_token)
2861 }
2862
2863 #[must_use]
2865 pub fn instance_url(&self) -> &str {
2866 &self.instance_url
2867 }
2868
2869 pub async fn get_json(&self, url: &str) -> Result<reqwest::Response> {
2874 for attempt in 0..=MAX_RETRIES {
2875 let response = self
2876 .client
2877 .get(url)
2878 .header("Authorization", &self.auth_header)
2879 .header("Accept", "application/json")
2880 .send()
2881 .await
2882 .context("Failed to send GET request to Atlassian API")?;
2883
2884 if response.status().as_u16() != 429 || attempt == MAX_RETRIES {
2885 return Ok(response);
2886 }
2887 Self::wait_for_retry(&response, attempt).await;
2888 }
2889 unreachable!()
2890 }
2891
2892 pub async fn put_json<T: serde::Serialize + Sync + ?Sized>(
2897 &self,
2898 url: &str,
2899 body: &T,
2900 ) -> Result<reqwest::Response> {
2901 for attempt in 0..=MAX_RETRIES {
2902 let response = self
2903 .client
2904 .put(url)
2905 .header("Authorization", &self.auth_header)
2906 .header("Content-Type", "application/json")
2907 .json(body)
2908 .send()
2909 .await
2910 .context("Failed to send PUT request to Atlassian API")?;
2911
2912 if response.status().as_u16() != 429 || attempt == MAX_RETRIES {
2913 return Ok(response);
2914 }
2915 Self::wait_for_retry(&response, attempt).await;
2916 }
2917 unreachable!()
2918 }
2919
2920 pub async fn post_json<T: serde::Serialize + Sync + ?Sized>(
2922 &self,
2923 url: &str,
2924 body: &T,
2925 ) -> Result<reqwest::Response> {
2926 for attempt in 0..=MAX_RETRIES {
2927 let response = self
2928 .client
2929 .post(url)
2930 .header("Authorization", &self.auth_header)
2931 .header("Content-Type", "application/json")
2932 .json(body)
2933 .send()
2934 .await
2935 .context("Failed to send POST request to Atlassian API")?;
2936
2937 if response.status().as_u16() != 429 || attempt == MAX_RETRIES {
2938 return Ok(response);
2939 }
2940 Self::wait_for_retry(&response, attempt).await;
2941 }
2942 unreachable!()
2943 }
2944
2945 pub async fn get_bytes(&self, url: &str) -> Result<Vec<u8>> {
2947 let response = self.get_json_raw_accept(url, "*/*").await?;
2948
2949 if !response.status().is_success() {
2950 let status = response.status().as_u16();
2951 let body = response.text().await.unwrap_or_default();
2952 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
2953 }
2954
2955 let bytes = response
2956 .bytes()
2957 .await
2958 .context("Failed to read response bytes")?;
2959 Ok(bytes.to_vec())
2960 }
2961
2962 pub async fn delete(&self, url: &str) -> Result<reqwest::Response> {
2964 for attempt in 0..=MAX_RETRIES {
2965 let response = self
2966 .client
2967 .delete(url)
2968 .header("Authorization", &self.auth_header)
2969 .send()
2970 .await
2971 .context("Failed to send DELETE request to Atlassian API")?;
2972
2973 if response.status().as_u16() != 429 || attempt == MAX_RETRIES {
2974 return Ok(response);
2975 }
2976 Self::wait_for_retry(&response, attempt).await;
2977 }
2978 unreachable!()
2979 }
2980
2981 async fn get_json_raw_accept(&self, url: &str, accept: &str) -> Result<reqwest::Response> {
2983 for attempt in 0..=MAX_RETRIES {
2984 let response = self
2985 .client
2986 .get(url)
2987 .header("Authorization", &self.auth_header)
2988 .header("Accept", accept)
2989 .send()
2990 .await
2991 .context("Failed to send GET request to Atlassian API")?;
2992
2993 if response.status().as_u16() != 429 || attempt == MAX_RETRIES {
2994 return Ok(response);
2995 }
2996 Self::wait_for_retry(&response, attempt).await;
2997 }
2998 unreachable!()
2999 }
3000
3001 async fn wait_for_retry(response: &reqwest::Response, attempt: u32) {
3004 let delay = response
3005 .headers()
3006 .get("Retry-After")
3007 .and_then(|v| v.to_str().ok())
3008 .and_then(|s| s.parse::<u64>().ok())
3009 .unwrap_or_else(|| DEFAULT_RETRY_DELAY_SECS.pow(attempt + 1));
3010
3011 eprintln!(
3012 "Rate limited (429). Retrying in {delay}s (attempt {})...",
3013 attempt + 1
3014 );
3015 tokio::time::sleep(Duration::from_secs(delay)).await;
3016 }
3017
3018 pub async fn get_issue(&self, key: &str) -> Result<JiraIssue> {
3020 let url = format!(
3021 "{}/rest/api/3/issue/{}?fields=summary,description,status,issuetype,assignee,priority,labels",
3022 self.instance_url, key
3023 );
3024
3025 let response = self
3026 .client
3027 .get(&url)
3028 .header("Authorization", &self.auth_header)
3029 .header("Accept", "application/json")
3030 .send()
3031 .await
3032 .context("Failed to send request to JIRA API")?;
3033
3034 if !response.status().is_success() {
3035 let status = response.status().as_u16();
3036 let body = response.text().await.unwrap_or_default();
3037 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
3038 }
3039
3040 let issue_response: JiraIssueResponse = response
3041 .json()
3042 .await
3043 .context("Failed to parse JIRA issue response")?;
3044
3045 Ok(JiraIssue {
3046 key: issue_response.key,
3047 summary: issue_response.fields.summary.unwrap_or_default(),
3048 description_adf: issue_response.fields.description,
3049 status: issue_response.fields.status.and_then(|s| s.name),
3050 issue_type: issue_response.fields.issuetype.and_then(|t| t.name),
3051 assignee: issue_response.fields.assignee.and_then(|a| a.display_name),
3052 priority: issue_response.fields.priority.and_then(|p| p.name),
3053 labels: issue_response.fields.labels,
3054 })
3055 }
3056
3057 pub async fn update_issue(
3059 &self,
3060 key: &str,
3061 description_adf: &AdfDocument,
3062 summary: Option<&str>,
3063 ) -> Result<()> {
3064 let url = format!("{}/rest/api/3/issue/{}", self.instance_url, key);
3065
3066 let mut fields = serde_json::Map::new();
3067 fields.insert(
3068 "description".to_string(),
3069 serde_json::to_value(description_adf).context("Failed to serialize ADF document")?,
3070 );
3071 if let Some(summary_text) = summary {
3072 fields.insert(
3073 "summary".to_string(),
3074 serde_json::Value::String(summary_text.to_string()),
3075 );
3076 }
3077
3078 let body = serde_json::json!({ "fields": fields });
3079
3080 let response = self
3081 .client
3082 .put(&url)
3083 .header("Authorization", &self.auth_header)
3084 .header("Content-Type", "application/json")
3085 .json(&body)
3086 .send()
3087 .await
3088 .context("Failed to send update request to JIRA API")?;
3089
3090 if !response.status().is_success() {
3091 let status = response.status().as_u16();
3092 let body = response.text().await.unwrap_or_default();
3093 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
3094 }
3095
3096 Ok(())
3097 }
3098
3099 pub async fn create_issue(
3101 &self,
3102 project_key: &str,
3103 issue_type: &str,
3104 summary: &str,
3105 description_adf: Option<&AdfDocument>,
3106 labels: &[String],
3107 ) -> Result<JiraCreatedIssue> {
3108 let url = format!("{}/rest/api/3/issue", self.instance_url);
3109
3110 let mut fields = serde_json::Map::new();
3111 fields.insert(
3112 "project".to_string(),
3113 serde_json::json!({ "key": project_key }),
3114 );
3115 fields.insert(
3116 "issuetype".to_string(),
3117 serde_json::json!({ "name": issue_type }),
3118 );
3119 fields.insert(
3120 "summary".to_string(),
3121 serde_json::Value::String(summary.to_string()),
3122 );
3123 if let Some(adf) = description_adf {
3124 fields.insert(
3125 "description".to_string(),
3126 serde_json::to_value(adf).context("Failed to serialize ADF document")?,
3127 );
3128 }
3129 if !labels.is_empty() {
3130 fields.insert("labels".to_string(), serde_json::to_value(labels)?);
3131 }
3132
3133 let body = serde_json::json!({ "fields": fields });
3134
3135 let response = self
3136 .post_json(&url, &body)
3137 .await
3138 .context("Failed to send create request to JIRA API")?;
3139
3140 if !response.status().is_success() {
3141 let status = response.status().as_u16();
3142 let body = response.text().await.unwrap_or_default();
3143 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
3144 }
3145
3146 let create_response: JiraCreateResponse = response
3147 .json()
3148 .await
3149 .context("Failed to parse JIRA create response")?;
3150
3151 Ok(JiraCreatedIssue {
3152 key: create_response.key,
3153 id: create_response.id,
3154 self_url: create_response.self_url,
3155 })
3156 }
3157
3158 pub async fn get_comments(&self, key: &str) -> Result<Vec<JiraComment>> {
3160 let url = format!(
3161 "{}/rest/api/3/issue/{}/comment?orderBy=created",
3162 self.instance_url, key
3163 );
3164
3165 let response = self.get_json(&url).await?;
3166
3167 if !response.status().is_success() {
3168 let status = response.status().as_u16();
3169 let body = response.text().await.unwrap_or_default();
3170 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
3171 }
3172
3173 let resp: JiraCommentsResponse = response
3174 .json()
3175 .await
3176 .context("Failed to parse comments response")?;
3177
3178 Ok(resp
3179 .comments
3180 .into_iter()
3181 .map(|c| JiraComment {
3182 id: c.id,
3183 author: c.author.and_then(|a| a.display_name).unwrap_or_default(),
3184 body_adf: c.body,
3185 created: c.created.unwrap_or_default(),
3186 })
3187 .collect())
3188 }
3189
3190 pub async fn add_comment(&self, key: &str, body_adf: &AdfDocument) -> Result<()> {
3192 let url = format!("{}/rest/api/3/issue/{}/comment", self.instance_url, key);
3193
3194 let body = serde_json::json!({
3195 "body": body_adf
3196 });
3197
3198 let response = self.post_json(&url, &body).await?;
3199
3200 if !response.status().is_success() {
3201 let status = response.status().as_u16();
3202 let body = response.text().await.unwrap_or_default();
3203 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
3204 }
3205
3206 Ok(())
3207 }
3208
3209 pub async fn get_transitions(&self, key: &str) -> Result<Vec<JiraTransition>> {
3211 let url = format!("{}/rest/api/3/issue/{}/transitions", self.instance_url, key);
3212
3213 let response = self.get_json(&url).await?;
3214
3215 if !response.status().is_success() {
3216 let status = response.status().as_u16();
3217 let body = response.text().await.unwrap_or_default();
3218 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
3219 }
3220
3221 let resp: JiraTransitionsResponse = response
3222 .json()
3223 .await
3224 .context("Failed to parse transitions response")?;
3225
3226 Ok(resp
3227 .transitions
3228 .into_iter()
3229 .map(|t| JiraTransition {
3230 id: t.id,
3231 name: t.name,
3232 })
3233 .collect())
3234 }
3235
3236 pub async fn do_transition(&self, key: &str, transition_id: &str) -> Result<()> {
3238 let url = format!("{}/rest/api/3/issue/{}/transitions", self.instance_url, key);
3239
3240 let body = serde_json::json!({
3241 "transition": { "id": transition_id }
3242 });
3243
3244 let response = self.post_json(&url, &body).await?;
3245
3246 if !response.status().is_success() {
3247 let status = response.status().as_u16();
3248 let body = response.text().await.unwrap_or_default();
3249 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
3250 }
3251
3252 Ok(())
3253 }
3254
3255 pub async fn search_issues(&self, jql: &str, limit: u32) -> Result<JiraSearchResult> {
3259 let url = format!("{}/rest/api/3/search/jql", self.instance_url);
3260 let effective_limit = if limit == 0 { u32::MAX } else { limit };
3261 let mut all_issues = Vec::new();
3262 let mut next_token: Option<String> = None;
3263
3264 loop {
3265 let remaining = effective_limit.saturating_sub(all_issues.len() as u32);
3266 if remaining == 0 {
3267 break;
3268 }
3269 let page_size = remaining.min(PAGE_SIZE);
3270
3271 let mut body = serde_json::json!({
3272 "jql": jql,
3273 "maxResults": page_size,
3274 "fields": ["summary", "status", "issuetype", "assignee", "priority"]
3275 });
3276 if let Some(ref token) = next_token {
3277 body["nextPageToken"] = serde_json::Value::String(token.clone());
3278 }
3279
3280 let response = self
3281 .post_json(&url, &body)
3282 .await
3283 .context("Failed to send search request to JIRA API")?;
3284
3285 if !response.status().is_success() {
3286 let status = response.status().as_u16();
3287 let body = response.text().await.unwrap_or_default();
3288 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
3289 }
3290
3291 let page: JiraSearchResponse = response
3292 .json()
3293 .await
3294 .context("Failed to parse JIRA search response")?;
3295
3296 let page_count = page.issues.len();
3297 for r in page.issues {
3298 all_issues.push(JiraIssue {
3299 key: r.key,
3300 summary: r.fields.summary.unwrap_or_default(),
3301 description_adf: r.fields.description,
3302 status: r.fields.status.and_then(|s| s.name),
3303 issue_type: r.fields.issuetype.and_then(|t| t.name),
3304 assignee: r.fields.assignee.and_then(|a| a.display_name),
3305 priority: r.fields.priority.and_then(|p| p.name),
3306 labels: r.fields.labels,
3307 });
3308 }
3309
3310 match page.next_page_token {
3311 Some(token) if page_count > 0 => next_token = Some(token),
3312 _ => break,
3313 }
3314 }
3315
3316 let total = all_issues.len() as u32;
3317 Ok(JiraSearchResult {
3318 issues: all_issues,
3319 total,
3320 })
3321 }
3322
3323 pub async fn search_confluence(
3325 &self,
3326 cql: &str,
3327 limit: u32,
3328 ) -> Result<ConfluenceSearchResults> {
3329 let effective_limit = if limit == 0 { u32::MAX } else { limit };
3330 let mut all_results = Vec::new();
3331 let mut start: u32 = 0;
3332
3333 loop {
3334 let remaining = effective_limit.saturating_sub(all_results.len() as u32);
3335 if remaining == 0 {
3336 break;
3337 }
3338 let page_size = remaining.min(PAGE_SIZE);
3339
3340 let base = format!("{}/wiki/rest/api/content/search", self.instance_url);
3341 let url = reqwest::Url::parse_with_params(
3342 &base,
3343 &[
3344 ("cql", cql),
3345 ("limit", &page_size.to_string()),
3346 ("start", &start.to_string()),
3347 ("expand", "space"),
3348 ],
3349 )
3350 .context("Failed to build Confluence search URL")?;
3351
3352 let response = self.get_json(url.as_str()).await?;
3353
3354 if !response.status().is_success() {
3355 let status = response.status().as_u16();
3356 let body = response.text().await.unwrap_or_default();
3357 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
3358 }
3359
3360 let resp: ConfluenceContentSearchResponse = response
3361 .json()
3362 .await
3363 .context("Failed to parse Confluence search response")?;
3364
3365 let page_count = resp.results.len() as u32;
3366 for r in resp.results {
3367 let space_key = r
3368 .expandable
3369 .and_then(|e| e.space)
3370 .and_then(|s| s.rsplit('/').next().map(String::from))
3371 .unwrap_or_default();
3372 all_results.push(ConfluenceSearchResult {
3373 id: r.id,
3374 title: r.title,
3375 space_key,
3376 });
3377 }
3378
3379 let has_next = resp.links.and_then(|l| l.next).is_some();
3380 if !has_next || page_count == 0 {
3381 break;
3382 }
3383 start += page_count;
3384 }
3385
3386 let total = all_results.len() as u32;
3387 Ok(ConfluenceSearchResults {
3388 results: all_results,
3389 total,
3390 })
3391 }
3392
3393 pub async fn get_boards(
3395 &self,
3396 project: Option<&str>,
3397 board_type: Option<&str>,
3398 limit: u32,
3399 ) -> Result<AgileBoardList> {
3400 let effective_limit = if limit == 0 { u32::MAX } else { limit };
3401 let mut all_boards = Vec::new();
3402 let mut start_at: u32 = 0;
3403
3404 loop {
3405 let remaining = effective_limit.saturating_sub(all_boards.len() as u32);
3406 if remaining == 0 {
3407 break;
3408 }
3409 let page_size = remaining.min(PAGE_SIZE);
3410
3411 let mut url = format!(
3412 "{}/rest/agile/1.0/board?maxResults={}&startAt={}",
3413 self.instance_url, page_size, start_at
3414 );
3415 if let Some(proj) = project {
3416 url.push_str(&format!("&projectKeyOrId={proj}"));
3417 }
3418 if let Some(bt) = board_type {
3419 url.push_str(&format!("&type={bt}"));
3420 }
3421
3422 let response = self.get_json(&url).await?;
3423
3424 if !response.status().is_success() {
3425 let status = response.status().as_u16();
3426 let body = response.text().await.unwrap_or_default();
3427 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
3428 }
3429
3430 let resp: AgileBoardListResponse = response
3431 .json()
3432 .await
3433 .context("Failed to parse board list response")?;
3434
3435 let page_count = resp.values.len() as u32;
3436 for b in resp.values {
3437 all_boards.push(AgileBoard {
3438 id: b.id,
3439 name: b.name,
3440 board_type: b.board_type,
3441 project_key: b.location.and_then(|l| l.project_key),
3442 });
3443 }
3444
3445 if resp.is_last || page_count == 0 {
3446 break;
3447 }
3448 start_at += page_count;
3449 }
3450
3451 let total = all_boards.len() as u32;
3452 Ok(AgileBoardList {
3453 boards: all_boards,
3454 total,
3455 })
3456 }
3457
3458 pub async fn get_board_issues(
3460 &self,
3461 board_id: u64,
3462 jql: Option<&str>,
3463 limit: u32,
3464 ) -> Result<JiraSearchResult> {
3465 let effective_limit = if limit == 0 { u32::MAX } else { limit };
3466 let mut all_issues = Vec::new();
3467 let mut start_at: u32 = 0;
3468
3469 loop {
3470 let remaining = effective_limit.saturating_sub(all_issues.len() as u32);
3471 if remaining == 0 {
3472 break;
3473 }
3474 let page_size = remaining.min(PAGE_SIZE);
3475
3476 let base = format!(
3477 "{}/rest/agile/1.0/board/{}/issue",
3478 self.instance_url, board_id
3479 );
3480 let mut params: Vec<(&str, String)> = vec![
3481 ("maxResults", page_size.to_string()),
3482 ("startAt", start_at.to_string()),
3483 ];
3484 if let Some(jql_str) = jql {
3485 params.push(("jql", jql_str.to_string()));
3486 }
3487 let url = reqwest::Url::parse_with_params(
3488 &base,
3489 params.iter().map(|(k, v)| (*k, v.as_str())),
3490 )
3491 .context("Failed to build board issues URL")?;
3492
3493 let response = self.get_json(url.as_str()).await?;
3494
3495 if !response.status().is_success() {
3496 let status = response.status().as_u16();
3497 let body = response.text().await.unwrap_or_default();
3498 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
3499 }
3500
3501 let resp: AgileIssueListResponse = response
3502 .json()
3503 .await
3504 .context("Failed to parse board issues response")?;
3505
3506 let page_count = resp.issues.len() as u32;
3507 for r in resp.issues {
3508 all_issues.push(JiraIssue {
3509 key: r.key,
3510 summary: r.fields.summary.unwrap_or_default(),
3511 description_adf: r.fields.description,
3512 status: r.fields.status.and_then(|s| s.name),
3513 issue_type: r.fields.issuetype.and_then(|t| t.name),
3514 assignee: r.fields.assignee.and_then(|a| a.display_name),
3515 priority: r.fields.priority.and_then(|p| p.name),
3516 labels: r.fields.labels,
3517 });
3518 }
3519
3520 if resp.is_last || page_count == 0 {
3521 break;
3522 }
3523 start_at += page_count;
3524 }
3525
3526 let total = all_issues.len() as u32;
3527 Ok(JiraSearchResult {
3528 issues: all_issues,
3529 total,
3530 })
3531 }
3532
3533 pub async fn get_sprints(
3535 &self,
3536 board_id: u64,
3537 state: Option<&str>,
3538 limit: u32,
3539 ) -> Result<AgileSprintList> {
3540 let effective_limit = if limit == 0 { u32::MAX } else { limit };
3541 let mut all_sprints = Vec::new();
3542 let mut start_at: u32 = 0;
3543
3544 loop {
3545 let remaining = effective_limit.saturating_sub(all_sprints.len() as u32);
3546 if remaining == 0 {
3547 break;
3548 }
3549 let page_size = remaining.min(PAGE_SIZE);
3550
3551 let mut url = format!(
3552 "{}/rest/agile/1.0/board/{}/sprint?maxResults={}&startAt={}",
3553 self.instance_url, board_id, page_size, start_at
3554 );
3555 if let Some(s) = state {
3556 url.push_str(&format!("&state={s}"));
3557 }
3558
3559 let response = self.get_json(&url).await?;
3560
3561 if !response.status().is_success() {
3562 let status = response.status().as_u16();
3563 let body = response.text().await.unwrap_or_default();
3564 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
3565 }
3566
3567 let resp: AgileSprintListResponse = response
3568 .json()
3569 .await
3570 .context("Failed to parse sprint list response")?;
3571
3572 let page_count = resp.values.len() as u32;
3573 for s in resp.values {
3574 all_sprints.push(AgileSprint {
3575 id: s.id,
3576 name: s.name,
3577 state: s.state,
3578 start_date: s.start_date,
3579 end_date: s.end_date,
3580 goal: s.goal,
3581 });
3582 }
3583
3584 if resp.is_last || page_count == 0 {
3585 break;
3586 }
3587 start_at += page_count;
3588 }
3589
3590 let total = all_sprints.len() as u32;
3591 Ok(AgileSprintList {
3592 sprints: all_sprints,
3593 total,
3594 })
3595 }
3596
3597 pub async fn get_sprint_issues(
3599 &self,
3600 sprint_id: u64,
3601 jql: Option<&str>,
3602 limit: u32,
3603 ) -> Result<JiraSearchResult> {
3604 let effective_limit = if limit == 0 { u32::MAX } else { limit };
3605 let mut all_issues = Vec::new();
3606 let mut start_at: u32 = 0;
3607
3608 loop {
3609 let remaining = effective_limit.saturating_sub(all_issues.len() as u32);
3610 if remaining == 0 {
3611 break;
3612 }
3613 let page_size = remaining.min(PAGE_SIZE);
3614
3615 let base = format!(
3616 "{}/rest/agile/1.0/sprint/{}/issue",
3617 self.instance_url, sprint_id
3618 );
3619 let mut params: Vec<(&str, String)> = vec![
3620 ("maxResults", page_size.to_string()),
3621 ("startAt", start_at.to_string()),
3622 ];
3623 if let Some(jql_str) = jql {
3624 params.push(("jql", jql_str.to_string()));
3625 }
3626 let url = reqwest::Url::parse_with_params(
3627 &base,
3628 params.iter().map(|(k, v)| (*k, v.as_str())),
3629 )
3630 .context("Failed to build sprint issues URL")?;
3631
3632 let response = self.get_json(url.as_str()).await?;
3633
3634 if !response.status().is_success() {
3635 let status = response.status().as_u16();
3636 let body = response.text().await.unwrap_or_default();
3637 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
3638 }
3639
3640 let resp: AgileIssueListResponse = response
3641 .json()
3642 .await
3643 .context("Failed to parse sprint issues response")?;
3644
3645 let page_count = resp.issues.len() as u32;
3646 for r in resp.issues {
3647 all_issues.push(JiraIssue {
3648 key: r.key,
3649 summary: r.fields.summary.unwrap_or_default(),
3650 description_adf: r.fields.description,
3651 status: r.fields.status.and_then(|s| s.name),
3652 issue_type: r.fields.issuetype.and_then(|t| t.name),
3653 assignee: r.fields.assignee.and_then(|a| a.display_name),
3654 priority: r.fields.priority.and_then(|p| p.name),
3655 labels: r.fields.labels,
3656 });
3657 }
3658
3659 if resp.is_last || page_count == 0 {
3660 break;
3661 }
3662 start_at += page_count;
3663 }
3664
3665 let total = all_issues.len() as u32;
3666 Ok(JiraSearchResult {
3667 issues: all_issues,
3668 total,
3669 })
3670 }
3671
3672 pub async fn add_issues_to_sprint(&self, sprint_id: u64, issue_keys: &[&str]) -> Result<()> {
3674 let url = format!(
3675 "{}/rest/agile/1.0/sprint/{}/issue",
3676 self.instance_url, sprint_id
3677 );
3678
3679 let body = serde_json::json!({ "issues": issue_keys });
3680
3681 let response = self.post_json(&url, &body).await?;
3682
3683 if !response.status().is_success() {
3684 let status = response.status().as_u16();
3685 let body = response.text().await.unwrap_or_default();
3686 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
3687 }
3688
3689 Ok(())
3690 }
3691
3692 pub async fn get_issue_links(&self, key: &str) -> Result<Vec<JiraIssueLink>> {
3694 let url = format!(
3695 "{}/rest/api/3/issue/{}?fields=issuelinks",
3696 self.instance_url, key
3697 );
3698
3699 let response = self.get_json(&url).await?;
3700
3701 if !response.status().is_success() {
3702 let status = response.status().as_u16();
3703 let body = response.text().await.unwrap_or_default();
3704 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
3705 }
3706
3707 let resp: JiraIssueLinksResponse = response
3708 .json()
3709 .await
3710 .context("Failed to parse issue links response")?;
3711
3712 let mut links = Vec::new();
3713 for entry in resp.fields.issuelinks {
3714 if let Some(inward) = entry.inward_issue {
3715 links.push(JiraIssueLink {
3716 id: entry.id.clone(),
3717 link_type: entry.link_type.name.clone(),
3718 direction: "inward".to_string(),
3719 linked_issue_key: inward.key,
3720 linked_issue_summary: inward.fields.and_then(|f| f.summary).unwrap_or_default(),
3721 });
3722 }
3723 if let Some(outward) = entry.outward_issue {
3724 links.push(JiraIssueLink {
3725 id: entry.id,
3726 link_type: entry.link_type.name,
3727 direction: "outward".to_string(),
3728 linked_issue_key: outward.key,
3729 linked_issue_summary: outward
3730 .fields
3731 .and_then(|f| f.summary)
3732 .unwrap_or_default(),
3733 });
3734 }
3735 }
3736
3737 Ok(links)
3738 }
3739
3740 pub async fn get_link_types(&self) -> Result<Vec<JiraLinkType>> {
3742 let url = format!("{}/rest/api/3/issueLinkType", self.instance_url);
3743 let response = self.get_json(&url).await?;
3744 if !response.status().is_success() {
3745 let status = response.status().as_u16();
3746 let body = response.text().await.unwrap_or_default();
3747 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
3748 }
3749 let resp: JiraLinkTypesResponse = response
3750 .json()
3751 .await
3752 .context("Failed to parse link types response")?;
3753 Ok(resp
3754 .issue_link_types
3755 .into_iter()
3756 .map(|t| JiraLinkType {
3757 id: t.id,
3758 name: t.name,
3759 inward: t.inward,
3760 outward: t.outward,
3761 })
3762 .collect())
3763 }
3764
3765 pub async fn create_issue_link(
3767 &self,
3768 type_name: &str,
3769 inward_key: &str,
3770 outward_key: &str,
3771 ) -> Result<()> {
3772 let url = format!("{}/rest/api/3/issueLink", self.instance_url);
3773 let body = serde_json::json!({"type": {"name": type_name}, "inwardIssue": {"key": inward_key}, "outwardIssue": {"key": outward_key}});
3774 let response = self.post_json(&url, &body).await?;
3775 if !response.status().is_success() {
3776 let status = response.status().as_u16();
3777 let body = response.text().await.unwrap_or_default();
3778 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
3779 }
3780 Ok(())
3781 }
3782
3783 pub async fn remove_issue_link(&self, link_id: &str) -> Result<()> {
3785 let url = format!("{}/rest/api/3/issueLink/{}", self.instance_url, link_id);
3786 let response = self.delete(&url).await?;
3787 if !response.status().is_success() {
3788 let status = response.status().as_u16();
3789 let body = response.text().await.unwrap_or_default();
3790 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
3791 }
3792 Ok(())
3793 }
3794
3795 pub async fn link_to_epic(&self, epic_key: &str, issue_key: &str) -> Result<()> {
3797 let url = format!("{}/rest/api/3/issue/{}", self.instance_url, issue_key);
3798 let body = serde_json::json!({"fields": {"parent": {"key": epic_key}}});
3799 let response = self.put_json(&url, &body).await?;
3800 if !response.status().is_success() {
3801 let status = response.status().as_u16();
3802 let body = response.text().await.unwrap_or_default();
3803 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
3804 }
3805 Ok(())
3806 }
3807
3808 pub async fn get_attachments(&self, key: &str) -> Result<Vec<JiraAttachment>> {
3810 let url = format!(
3811 "{}/rest/api/3/issue/{}?fields=attachment",
3812 self.instance_url, key
3813 );
3814
3815 let response = self.get_json(&url).await?;
3816
3817 if !response.status().is_success() {
3818 let status = response.status().as_u16();
3819 let body = response.text().await.unwrap_or_default();
3820 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
3821 }
3822
3823 let resp: JiraAttachmentIssueResponse = response
3824 .json()
3825 .await
3826 .context("Failed to parse attachment response")?;
3827
3828 Ok(resp
3829 .fields
3830 .attachment
3831 .into_iter()
3832 .map(|a| JiraAttachment {
3833 id: a.id,
3834 filename: a.filename,
3835 mime_type: a.mime_type,
3836 size: a.size,
3837 content_url: a.content,
3838 })
3839 .collect())
3840 }
3841
3842 pub async fn get_changelog(&self, key: &str, limit: u32) -> Result<Vec<JiraChangelogEntry>> {
3844 let effective_limit = if limit == 0 { u32::MAX } else { limit };
3845 let mut all_entries = Vec::new();
3846 let mut start_at: u32 = 0;
3847
3848 loop {
3849 let remaining = effective_limit.saturating_sub(all_entries.len() as u32);
3850 if remaining == 0 {
3851 break;
3852 }
3853 let page_size = remaining.min(PAGE_SIZE);
3854
3855 let url = format!(
3856 "{}/rest/api/3/issue/{}/changelog?maxResults={}&startAt={}",
3857 self.instance_url, key, page_size, start_at
3858 );
3859
3860 let response = self.get_json(&url).await?;
3861
3862 if !response.status().is_success() {
3863 let status = response.status().as_u16();
3864 let body = response.text().await.unwrap_or_default();
3865 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
3866 }
3867
3868 let resp: JiraChangelogResponse = response
3869 .json()
3870 .await
3871 .context("Failed to parse changelog response")?;
3872
3873 let page_count = resp.values.len() as u32;
3874 for e in resp.values {
3875 all_entries.push(JiraChangelogEntry {
3876 id: e.id,
3877 author: e.author.and_then(|a| a.display_name).unwrap_or_default(),
3878 created: e.created.unwrap_or_default(),
3879 items: e
3880 .items
3881 .into_iter()
3882 .map(|i| JiraChangelogItem {
3883 field: i.field,
3884 from_string: i.from_string,
3885 to_string: i.to_string,
3886 })
3887 .collect(),
3888 });
3889 }
3890
3891 if resp.is_last || page_count == 0 {
3892 break;
3893 }
3894 start_at += page_count;
3895 }
3896
3897 Ok(all_entries)
3898 }
3899
3900 pub async fn get_fields(&self) -> Result<Vec<JiraField>> {
3902 let url = format!("{}/rest/api/3/field", self.instance_url);
3903
3904 let response = self.get_json(&url).await?;
3905
3906 if !response.status().is_success() {
3907 let status = response.status().as_u16();
3908 let body = response.text().await.unwrap_or_default();
3909 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
3910 }
3911
3912 let entries: Vec<JiraFieldEntry> = response
3913 .json()
3914 .await
3915 .context("Failed to parse field list response")?;
3916
3917 Ok(entries
3918 .into_iter()
3919 .map(|f| JiraField {
3920 id: f.id,
3921 name: f.name,
3922 custom: f.custom,
3923 schema_type: f.schema.and_then(|s| s.schema_type),
3924 })
3925 .collect())
3926 }
3927
3928 pub async fn get_field_contexts(&self, field_id: &str) -> Result<Vec<String>> {
3931 let url = format!(
3932 "{}/rest/api/3/field/{}/context",
3933 self.instance_url, field_id
3934 );
3935
3936 let response = self.get_json(&url).await?;
3937
3938 if !response.status().is_success() {
3939 let status = response.status().as_u16();
3940 let body = response.text().await.unwrap_or_default();
3941 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
3942 }
3943
3944 let resp: JiraFieldContextsResponse = response
3945 .json()
3946 .await
3947 .context("Failed to parse field contexts response")?;
3948
3949 Ok(resp.values.into_iter().map(|c| c.id).collect())
3950 }
3951
3952 pub async fn get_field_options(
3956 &self,
3957 field_id: &str,
3958 context_id: Option<&str>,
3959 ) -> Result<Vec<JiraFieldOption>> {
3960 let ctx = if let Some(id) = context_id {
3961 id.to_string()
3962 } else {
3963 let contexts = self.get_field_contexts(field_id).await?;
3964 contexts.into_iter().next().ok_or_else(|| {
3965 anyhow::anyhow!(
3966 "No contexts found for field \"{field_id}\". \
3967 Use --context-id to specify one explicitly."
3968 )
3969 })?
3970 };
3971
3972 let url = format!(
3973 "{}/rest/api/3/field/{}/context/{}/option",
3974 self.instance_url, field_id, ctx
3975 );
3976
3977 let response = self.get_json(&url).await?;
3978
3979 if !response.status().is_success() {
3980 let status = response.status().as_u16();
3981 let body = response.text().await.unwrap_or_default();
3982 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
3983 }
3984
3985 let resp: JiraFieldOptionsResponse = response
3986 .json()
3987 .await
3988 .context("Failed to parse field options response")?;
3989
3990 Ok(resp
3991 .values
3992 .into_iter()
3993 .map(|o| JiraFieldOption {
3994 id: o.id,
3995 value: o.value,
3996 })
3997 .collect())
3998 }
3999
4000 pub async fn get_projects(&self, limit: u32) -> Result<JiraProjectList> {
4002 let effective_limit = if limit == 0 { u32::MAX } else { limit };
4003 let mut all_projects = Vec::new();
4004 let mut start_at: u32 = 0;
4005
4006 loop {
4007 let remaining = effective_limit.saturating_sub(all_projects.len() as u32);
4008 if remaining == 0 {
4009 break;
4010 }
4011 let page_size = remaining.min(PAGE_SIZE);
4012
4013 let url = format!(
4014 "{}/rest/api/3/project/search?maxResults={}&startAt={}",
4015 self.instance_url, page_size, start_at
4016 );
4017
4018 let response = self.get_json(&url).await?;
4019
4020 if !response.status().is_success() {
4021 let status = response.status().as_u16();
4022 let body = response.text().await.unwrap_or_default();
4023 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
4024 }
4025
4026 let resp: JiraProjectSearchResponse = response
4027 .json()
4028 .await
4029 .context("Failed to parse project search response")?;
4030
4031 let page_count = resp.values.len() as u32;
4032 for p in resp.values {
4033 all_projects.push(JiraProject {
4034 id: p.id,
4035 key: p.key,
4036 name: p.name,
4037 project_type: p.project_type_key,
4038 lead: p.lead.and_then(|l| l.display_name),
4039 });
4040 }
4041
4042 if resp.is_last || page_count == 0 {
4043 break;
4044 }
4045 start_at += page_count;
4046 }
4047
4048 let total = all_projects.len() as u32;
4049 Ok(JiraProjectList {
4050 projects: all_projects,
4051 total,
4052 })
4053 }
4054
4055 pub async fn delete_issue(&self, key: &str) -> Result<()> {
4057 let url = format!("{}/rest/api/3/issue/{}", self.instance_url, key);
4058
4059 let response = self.delete(&url).await?;
4060
4061 if !response.status().is_success() {
4062 let status = response.status().as_u16();
4063 let body = response.text().await.unwrap_or_default();
4064 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
4065 }
4066
4067 Ok(())
4068 }
4069
4070 pub async fn get_myself(&self) -> Result<JiraUser> {
4072 let url = format!("{}/rest/api/3/myself", self.instance_url);
4073
4074 let response = self
4075 .client
4076 .get(&url)
4077 .header("Authorization", &self.auth_header)
4078 .header("Accept", "application/json")
4079 .send()
4080 .await
4081 .context("Failed to send request to JIRA API")?;
4082
4083 if !response.status().is_success() {
4084 let status = response.status().as_u16();
4085 let body = response.text().await.unwrap_or_default();
4086 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
4087 }
4088
4089 response
4090 .json()
4091 .await
4092 .context("Failed to parse user response")
4093 }
4094}