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