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