Skip to main content

omni_dev/atlassian/
client.rs

1//! Atlassian Cloud REST API client.
2//!
3//! Provides HTTP access to JIRA Cloud REST API v3 for reading and
4//! writing issues. Uses Basic Auth (email + API token).
5
6use 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
16/// HTTP request timeout for Atlassian API calls.
17const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
18
19/// Internal page size for auto-pagination. Individual API calls request
20/// this many items per page; the `limit` parameter controls the total.
21const PAGE_SIZE: u32 = 100;
22
23/// HTTP client for Atlassian Cloud REST APIs.
24pub struct AtlassianClient {
25    client: Client,
26    instance_url: String,
27    auth_header: String,
28}
29
30/// JIRA issue data returned from the REST API.
31#[derive(Debug, Clone)]
32pub struct JiraIssue {
33    /// Issue key (e.g., "PROJ-123").
34    pub key: String,
35
36    /// Issue summary (title).
37    pub summary: String,
38
39    /// Issue description as raw ADF JSON (may be null).
40    pub description_adf: Option<serde_json::Value>,
41
42    /// Issue status name.
43    pub status: Option<String>,
44
45    /// Issue type name.
46    pub issue_type: Option<String>,
47
48    /// Assignee display name.
49    pub assignee: Option<String>,
50
51    /// Priority name.
52    pub priority: Option<String>,
53
54    /// Labels.
55    pub labels: Vec<String>,
56}
57
58/// Response from the JIRA `/myself` endpoint.
59#[derive(Debug, Deserialize)]
60pub struct JiraUser {
61    /// User display name.
62    #[serde(rename = "displayName")]
63    pub display_name: String,
64
65    /// User email address.
66    #[serde(rename = "emailAddress")]
67    pub email_address: Option<String>,
68
69    /// Account ID.
70    #[serde(rename = "accountId")]
71    pub account_id: String,
72}
73
74/// Result from creating a JIRA issue via the REST API.
75#[derive(Debug, Clone)]
76pub struct JiraCreatedIssue {
77    /// Issue key (e.g., "PROJ-124").
78    pub key: String,
79    /// Issue numeric ID.
80    pub id: String,
81    /// API self URL.
82    pub self_url: String,
83}
84
85/// Result from a JIRA JQL search.
86#[derive(Debug, Clone)]
87pub struct JiraSearchResult {
88    /// Matching issues.
89    pub issues: Vec<JiraIssue>,
90
91    /// Total number of matching issues (may exceed `issues.len()` if paginated).
92    pub total: u32,
93}
94
95/// A Confluence search result.
96#[derive(Debug, Clone)]
97pub struct ConfluenceSearchResult {
98    /// Page ID.
99    pub id: String,
100    /// Page title.
101    pub title: String,
102    /// Space key (e.g., "ENG").
103    pub space_key: String,
104}
105
106/// Result from a Confluence CQL search.
107#[derive(Debug, Clone)]
108pub struct ConfluenceSearchResults {
109    /// Matching pages.
110    pub results: Vec<ConfluenceSearchResult>,
111    /// Total number of matching results.
112    pub total: u32,
113}
114
115/// A JIRA issue comment.
116#[derive(Debug, Clone)]
117pub struct JiraComment {
118    /// Comment ID.
119    pub id: String,
120    /// Author display name.
121    pub author: String,
122    /// Comment body as raw ADF JSON.
123    pub body_adf: Option<serde_json::Value>,
124    /// ISO 8601 creation timestamp.
125    pub created: String,
126}
127
128/// A JIRA project.
129#[derive(Debug, Clone)]
130pub struct JiraProject {
131    /// Project ID.
132    pub id: String,
133    /// Project key (e.g., "PROJ").
134    pub key: String,
135    /// Project name.
136    pub name: String,
137    /// Project type key (e.g., "software", "business").
138    pub project_type: Option<String>,
139    /// Project lead display name.
140    pub lead: Option<String>,
141}
142
143/// Result from listing JIRA projects.
144#[derive(Debug, Clone)]
145pub struct JiraProjectList {
146    /// Projects returned.
147    pub projects: Vec<JiraProject>,
148    /// Total number of projects.
149    pub total: u32,
150}
151
152/// A JIRA field definition.
153#[derive(Debug, Clone)]
154pub struct JiraField {
155    /// Field ID (e.g., "summary", "customfield_10001").
156    pub id: String,
157    /// Human-readable field name.
158    pub name: String,
159    /// Whether this is a custom field.
160    pub custom: bool,
161    /// Schema type (e.g., "string", "array", "option").
162    pub schema_type: Option<String>,
163}
164
165/// An option value for a JIRA custom field.
166#[derive(Debug, Clone)]
167pub struct JiraFieldOption {
168    /// Option ID.
169    pub id: String,
170    /// Option display value.
171    pub value: String,
172}
173
174/// A JIRA agile board.
175#[derive(Debug, Clone)]
176pub struct AgileBoard {
177    /// Board ID.
178    pub id: u64,
179    /// Board name.
180    pub name: String,
181    /// Board type (e.g., "scrum", "kanban").
182    pub board_type: String,
183    /// Project key associated with the board, if available.
184    pub project_key: Option<String>,
185}
186
187/// Result from listing agile boards.
188#[derive(Debug, Clone)]
189pub struct AgileBoardList {
190    /// Boards returned.
191    pub boards: Vec<AgileBoard>,
192    /// Total number of boards.
193    pub total: u32,
194}
195
196/// A JIRA agile sprint.
197#[derive(Debug, Clone)]
198pub struct AgileSprint {
199    /// Sprint ID.
200    pub id: u64,
201    /// Sprint name.
202    pub name: String,
203    /// Sprint state (e.g., "active", "future", "closed").
204    pub state: String,
205    /// Sprint start date (ISO 8601).
206    pub start_date: Option<String>,
207    /// Sprint end date (ISO 8601).
208    pub end_date: Option<String>,
209    /// Sprint goal.
210    pub goal: Option<String>,
211}
212
213/// Result from listing agile sprints.
214#[derive(Debug, Clone)]
215pub struct AgileSprintList {
216    /// Sprints returned.
217    pub sprints: Vec<AgileSprint>,
218    /// Total number of sprints.
219    pub total: u32,
220}
221
222/// A JIRA issue changelog entry.
223#[derive(Debug, Clone)]
224pub struct JiraChangelogEntry {
225    /// Entry ID.
226    pub id: String,
227    /// Author display name.
228    pub author: String,
229    /// ISO 8601 timestamp.
230    pub created: String,
231    /// Changed items.
232    pub items: Vec<JiraChangelogItem>,
233}
234
235/// A single field change in a changelog entry.
236#[derive(Debug, Clone)]
237pub struct JiraChangelogItem {
238    /// Field name that changed.
239    pub field: String,
240    /// Previous value (display string).
241    pub from_string: Option<String>,
242    /// New value (display string).
243    pub to_string: Option<String>,
244}
245
246/// A JIRA issue link type.
247#[derive(Debug, Clone)]
248pub struct JiraLinkType {
249    /// Link type ID.
250    pub id: String,
251    /// Link type name (e.g., "Blocks", "Clones").
252    pub name: String,
253    /// Inward description (e.g., "is blocked by").
254    pub inward: String,
255    /// Outward description (e.g., "blocks").
256    pub outward: String,
257}
258
259/// A link on a JIRA issue.
260#[derive(Debug, Clone)]
261pub struct JiraIssueLink {
262    /// Link ID (used for removal).
263    pub id: String,
264    /// Link type name.
265    pub link_type: String,
266    /// Direction: "inward" or "outward".
267    pub direction: String,
268    /// The linked issue key.
269    pub linked_issue_key: String,
270    /// The linked issue summary.
271    pub linked_issue_summary: String,
272}
273
274/// A JIRA issue attachment.
275#[derive(Debug, Clone)]
276pub struct JiraAttachment {
277    /// Attachment ID.
278    pub id: String,
279    /// File name.
280    pub filename: String,
281    /// MIME type (e.g., "image/png", "application/pdf").
282    pub mime_type: String,
283    /// File size in bytes.
284    pub size: u64,
285    /// Download URL.
286    pub content_url: String,
287}
288
289/// A JIRA workflow transition.
290#[derive(Debug, Clone)]
291pub struct JiraTransition {
292    /// Transition ID.
293    pub id: String,
294    /// Transition name (e.g., "In Progress", "Done").
295    pub name: String,
296}
297
298// ── Internal API response structs ───────────────────────────────────
299
300#[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// ── Agile API response structs ─────────────────────────────────────
398
399#[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// ── Tests ──────────────────────────────────────────────────────────
627
628#[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        // total falls back to issues count when not in response
1083        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        // First page returns a nextPageToken
1580        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        // Second page has no nextPageToken (last page)
1594        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        // Limit to 2 — should not fetch second page
1635        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        // First page
1644        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        // Second page
1658        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        // Context discovery
2422        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        // Options for discovered context
2435        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    /// Creates a new Atlassian API client.
2730    ///
2731    /// Constructs the Basic Auth header from the email and API token.
2732    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    /// Creates a client from stored credentials.
2750    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    /// Returns the instance URL.
2755    #[must_use]
2756    pub fn instance_url(&self) -> &str {
2757        &self.instance_url
2758    }
2759
2760    /// Sends an authenticated GET request and returns the raw response.
2761    ///
2762    /// Shared transport method used by both JIRA and Confluence API
2763    /// implementations.
2764    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    /// Sends an authenticated PUT request with a JSON body and returns the raw response.
2775    ///
2776    /// Shared transport method used by both JIRA and Confluence API
2777    /// implementations.
2778    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    /// Sends an authenticated POST request with a JSON body and returns the raw response.
2794    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    /// Sends an authenticated GET request and returns raw bytes.
2810    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    /// Sends an authenticated DELETE request and returns the raw response.
2834    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    /// Fetches a JIRA issue by key.
2844    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    /// Updates a JIRA issue's description and optionally its summary.
2883    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    /// Creates a new JIRA issue.
2925    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    /// Lists comments on a JIRA issue.
2984    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    /// Adds a comment to a JIRA issue.
3016    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    /// Lists available transitions for a JIRA issue.
3035    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    /// Executes a transition on a JIRA issue.
3062    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    /// Searches JIRA issues using JQL with auto-pagination.
3081    ///
3082    /// `limit` controls total results: 0 means unlimited.
3083    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    /// Searches Confluence pages using CQL with auto-pagination.
3149    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    /// Lists agile boards with auto-pagination.
3219    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    /// Lists issues on an agile board with auto-pagination.
3284    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    /// Lists sprints for an agile board with auto-pagination.
3359    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    /// Lists issues in an agile sprint with auto-pagination.
3423    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    /// Adds issues to an agile sprint.
3498    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    /// Lists links on a JIRA issue.
3518    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    /// Lists available issue link types.
3566    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    /// Creates a link between two JIRA issues.
3591    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    /// Removes an issue link by ID.
3609    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    /// Links an issue to an epic by setting the parent field.
3621    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    /// Gets attachment metadata for a JIRA issue.
3634    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    /// Gets the changelog for a JIRA issue with auto-pagination.
3668    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    /// Lists all JIRA field definitions.
3726    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    /// Lists options for a JIRA custom field.
3754    /// Lists contexts for a JIRA custom field.
3755    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    /// Lists options for a JIRA custom field.
3778    ///
3779    /// When `context_id` is `None`, auto-discovers the first context for the field.
3780    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    /// Lists JIRA projects.
3826    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    /// Deletes a JIRA issue.
3881    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    /// Verifies authentication by fetching the current user.
3896    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}