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::Instant;
7
8use anyhow::{Context, Result};
9use base64::Engine;
10use reqwest::Client;
11
12use crate::atlassian::adf::AdfDocument;
13use crate::atlassian::adf_validated::ValidatedAdfDocument;
14use crate::atlassian::confluence_types::{
15    ConfluenceContentSearchResponse, ConfluenceSearchResult, ConfluenceSearchResults,
16    ConfluenceUserGetEntry, ConfluenceUserGetResults, ConfluenceUserRecord,
17    ConfluenceUserSearchResponse, ConfluenceUserSearchResult, ConfluenceUserSearchResults,
18};
19use crate::atlassian::convert::adf_to_markdown;
20use crate::atlassian::error::AtlassianError;
21use crate::atlassian::jira_types::{
22    AgileBoard, AgileBoardList, AgileBoardListResponse, AgileIssueListResponse, AgileSprint,
23    AgileSprintEntry, AgileSprintList, AgileSprintListResponse, CreateMeta, CreateMetaField,
24    DevStatusCommit, DevStatusResponse, DevStatusSummaryCategory, DevStatusSummaryResponse,
25    EditMeta, EditMetaField, FieldSelection, JiraAllowedValueRaw, JiraAttachment,
26    JiraAttachmentIssueResponse, JiraChangelogEntry, JiraChangelogItem, JiraChangelogResponse,
27    JiraComment, JiraCommentEntry, JiraCommentsResponse, JiraCreateMetaFullResponse,
28    JiraCreateMetaResponse, JiraCreateMetaSchemaRaw, JiraCreateResponse, JiraCreatedIssue,
29    JiraDevBranch, JiraDevCommit, JiraDevProvider, JiraDevPullRequest, JiraDevRepository,
30    JiraDevStatus, JiraDevStatusCount, JiraDevStatusSummary, JiraEditMetaResponse, JiraField,
31    JiraFieldContextsResponse, JiraFieldEntry, JiraFieldOption, JiraFieldOptionsResponse,
32    JiraIssue, JiraIssueEnvelope, JiraIssueIdResponse, JiraIssueLink, JiraIssueLinksResponse,
33    JiraLinkType, JiraLinkTypesResponse, JiraProject, JiraProjectList, JiraProjectSearchResponse,
34    JiraProjectVersion, JiraProjectVersionEntry, JiraProjectVersionList, JiraRemoteIssueLink,
35    JiraRemoteIssueLinkEntry, JiraRemoteIssueLinkIcon, JiraRemoteIssueLinkObject,
36    JiraSearchResponse, JiraSearchResult, JiraTransition, JiraTransitionToStatus,
37    JiraTransitionsResponse, JiraUser, JiraUserGetResults, JiraUserRecord, JiraUserSearchEntry,
38    JiraUserSearchResult, JiraUserSearchResults, JiraVisibility, JiraWatcherList, JiraWorklog,
39    JiraWorklogList, JiraWorklogResponse, TEXTAREA_CUSTOM_TYPE,
40};
41use crate::request_log;
42use crate::utils::http::{retry_429, REQUEST_TIMEOUT};
43
44/// Internal page size for auto-pagination. Individual API calls request
45/// this many items per page; the `limit` parameter controls the total.
46const PAGE_SIZE: u32 = 100;
47
48/// JIRA's standard error envelope returned by REST API v3 on validation
49/// failures: `{ "errorMessages": [...], "errors": { "<field_id>": "<msg>" } }`.
50#[derive(serde::Deserialize)]
51struct JiraErrorEnvelope {
52    #[serde(default, rename = "errorMessages")]
53    _error_messages: Vec<String>,
54    #[serde(default)]
55    errors: std::collections::BTreeMap<String, String>,
56}
57
58/// Builds an `anyhow::Error` for a non-success JIRA write response.
59///
60/// On HTTP 400, parses `body` as JIRA's standard
61/// `{ "errorMessages": [...], "errors": {...} }` envelope and looks for
62/// per-field errors whose message indicates the field requires an ADF
63/// document (substring `"atlassian document"`, case-insensitive). When at
64/// least one such field is found, returns
65/// [`AtlassianError::JiraAdfFieldRequired`] naming the offending field
66/// IDs. All other status codes (and 400 responses with no detected
67/// ADF-required message) fall back to [`AtlassianError::ApiRequestFailed`].
68fn jira_write_error(status: u16, body: String) -> anyhow::Error {
69    if status == 400 {
70        if let Ok(parsed) = serde_json::from_str::<JiraErrorEnvelope>(&body) {
71            let needle = "atlassian document";
72            let matching: Vec<(&String, &String)> = parsed
73                .errors
74                .iter()
75                .filter(|(_, msg)| msg.to_ascii_lowercase().contains(needle))
76                .collect();
77            if !matching.is_empty() {
78                let fields: Vec<String> = matching.iter().map(|(k, _)| (*k).clone()).collect();
79                let original_message = matching[0].1.clone();
80                return AtlassianError::JiraAdfFieldRequired {
81                    fields,
82                    original_message,
83                    body,
84                }
85                .into();
86            }
87        }
88    }
89    AtlassianError::ApiRequestFailed { status, body }.into()
90}
91
92/// Shared HTTP client for Atlassian Cloud REST APIs.
93///
94/// Backs every JIRA, Confluence, and Agile helper exposed by this crate.
95/// Construct directly via [`AtlassianClient::new`] (instance URL + email + API
96/// token) or, more commonly, via [`AtlassianClient::from_credentials`] which
97/// accepts an [`AtlassianCredentials`](crate::atlassian::auth::AtlassianCredentials)
98/// resolved from the `ATLASSIAN_INSTANCE_URL`, `ATLASSIAN_EMAIL`, and
99/// `ATLASSIAN_API_TOKEN` environment variables (falling back to
100/// `~/.omni-dev/settings.json`) by
101/// [`load_credentials`](crate::atlassian::auth::load_credentials).
102///
103/// Authenticates every request with HTTP Basic auth: a precomputed
104/// `Authorization: Basic <base64(email:api_token)>` header is attached to all
105/// outbound calls. Requests time out after 30s and automatically retry up to
106/// three times on HTTP 429, honoring any `Retry-After` header.
107pub struct AtlassianClient {
108    client: Client,
109    instance_url: String,
110    auth_header: String,
111}
112
113/// Maps a raw `(schema.type, schema.custom)` pair from the JIRA field API into
114/// the value omni-dev surfaces as `schema_type`. Rich-text custom fields are
115/// reported as `"richtext"` so callers can detect ADF-required fields without
116/// inspecting the plugin URI; all other fields pass through unchanged.
117fn map_schema_type(raw_type: Option<String>, raw_custom: Option<&str>) -> Option<String> {
118    if raw_custom == Some(TEXTAREA_CUSTOM_TYPE) {
119        return Some("richtext".to_string());
120    }
121    raw_type
122}
123
124/// Validates that a date string is `YYYY-MM-DD`.
125///
126/// Surfaces a clear error before the request is sent, so callers don't
127/// have to interpret JIRA's opaque 400s on malformed dates.
128fn validate_iso_date(date: Option<&str>, field: &str) -> Result<()> {
129    let Some(d) = date else { return Ok(()) };
130    chrono::NaiveDate::parse_from_str(d, "%Y-%m-%d")
131        .with_context(|| format!("{field} must be YYYY-MM-DD, got {d:?}"))?;
132    Ok(())
133}
134
135/// Builds the `error` string stored on a stub user record when a single
136/// account-ID lookup fails. Includes a short body snippet when the API
137/// returned one so callers can distinguish "not found" from "no permission".
138fn user_lookup_error(status: u16, body: &str) -> String {
139    let snippet = body.trim();
140    if snippet.is_empty() {
141        format!("HTTP {status}")
142    } else {
143        let snippet: String = snippet.chars().take(200).collect();
144        format!("HTTP {status}: {snippet}")
145    }
146}
147
148// ── Tests ──────────────────────────────────────────────────────────
149
150#[cfg(test)]
151#[allow(
152    clippy::unwrap_used,
153    clippy::expect_used,
154    clippy::items_after_test_module
155)]
156mod tests {
157    use super::*;
158    use crate::atlassian::jira_types::{DevStatusAuthor, JiraIssueResponse, JiraVisibilityType};
159
160    #[test]
161    fn new_client_strips_trailing_slash() {
162        let client =
163            AtlassianClient::new("https://org.atlassian.net/", "user@test.com", "token").unwrap();
164        assert_eq!(client.instance_url(), "https://org.atlassian.net");
165    }
166
167    #[test]
168    fn new_client_preserves_clean_url() {
169        let client =
170            AtlassianClient::new("https://org.atlassian.net", "user@test.com", "token").unwrap();
171        assert_eq!(client.instance_url(), "https://org.atlassian.net");
172    }
173
174    #[test]
175    fn new_client_sets_basic_auth() {
176        let client =
177            AtlassianClient::new("https://org.atlassian.net", "user@test.com", "token").unwrap();
178        let expected_credentials = "user@test.com:token";
179        let expected_encoded =
180            base64::engine::general_purpose::STANDARD.encode(expected_credentials);
181        assert_eq!(client.auth_header, format!("Basic {expected_encoded}"));
182    }
183
184    #[test]
185    fn from_credentials() {
186        let creds = crate::atlassian::auth::AtlassianCredentials {
187            instance_url: "https://org.atlassian.net".to_string(),
188            email: "user@test.com".to_string(),
189            api_token: "token123".into(),
190        };
191        let client = AtlassianClient::from_credentials(&creds).unwrap();
192        assert_eq!(client.instance_url(), "https://org.atlassian.net");
193    }
194
195    #[test]
196    fn jira_issue_struct_fields() {
197        let issue = JiraIssue {
198            key: "TEST-1".to_string(),
199            summary: "Test issue".to_string(),
200            description_adf: None,
201            status: Some("Open".to_string()),
202            issue_type: Some("Bug".to_string()),
203            assignee: Some("Alice".to_string()),
204            priority: Some("High".to_string()),
205            labels: vec!["backend".to_string()],
206            custom_fields: Vec::new(),
207        };
208        assert_eq!(issue.key, "TEST-1");
209        assert_eq!(issue.labels.len(), 1);
210    }
211
212    #[test]
213    fn jira_user_deserialization() {
214        let json = r#"{
215            "displayName": "Alice Smith",
216            "emailAddress": "alice@example.com",
217            "accountId": "abc123"
218        }"#;
219        let user: JiraUser = serde_json::from_str(json).unwrap();
220        assert_eq!(user.display_name, "Alice Smith");
221        assert_eq!(user.email_address.as_deref(), Some("alice@example.com"));
222        assert_eq!(user.account_id, "abc123");
223    }
224
225    #[test]
226    fn jira_user_optional_email() {
227        let json = r#"{
228            "displayName": "Bot",
229            "accountId": "bot123"
230        }"#;
231        let user: JiraUser = serde_json::from_str(json).unwrap();
232        assert!(user.email_address.is_none());
233    }
234
235    #[test]
236    fn jira_issue_response_deserialization() {
237        let json = r#"{
238            "key": "PROJ-42",
239            "fields": {
240                "summary": "Test",
241                "description": null,
242                "status": {"name": "Open"},
243                "issuetype": {"name": "Bug"},
244                "assignee": {"displayName": "Bob"},
245                "priority": {"name": "Medium"},
246                "labels": ["frontend"]
247            }
248        }"#;
249        let response: JiraIssueResponse = serde_json::from_str(json).unwrap();
250        assert_eq!(response.key, "PROJ-42");
251        assert_eq!(response.fields.summary.as_deref(), Some("Test"));
252        assert_eq!(response.fields.labels, vec!["frontend"]);
253    }
254
255    #[test]
256    fn jira_issue_response_minimal_fields() {
257        let json = r#"{
258            "key": "PROJ-1",
259            "fields": {
260                "summary": null,
261                "description": null,
262                "status": null,
263                "issuetype": null,
264                "assignee": null,
265                "priority": null,
266                "labels": []
267            }
268        }"#;
269        let response: JiraIssueResponse = serde_json::from_str(json).unwrap();
270        assert_eq!(response.key, "PROJ-1");
271        assert!(response.fields.summary.is_none());
272    }
273
274    #[tokio::test]
275    async fn get_json_retries_on_429() {
276        let server = wiremock::MockServer::start().await;
277
278        // First request returns 429 with Retry-After: 0
279        wiremock::Mock::given(wiremock::matchers::method("GET"))
280            .and(wiremock::matchers::path("/test"))
281            .respond_with(wiremock::ResponseTemplate::new(429).append_header("Retry-After", "0"))
282            .up_to_n_times(1)
283            .mount(&server)
284            .await;
285
286        // Second request succeeds
287        wiremock::Mock::given(wiremock::matchers::method("GET"))
288            .and(wiremock::matchers::path("/test"))
289            .respond_with(
290                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({"ok": true})),
291            )
292            .up_to_n_times(1)
293            .mount(&server)
294            .await;
295
296        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
297        let resp = client
298            .get_json(&format!("{}/test", server.uri()))
299            .await
300            .unwrap();
301        assert!(resp.status().is_success());
302    }
303
304    #[tokio::test]
305    async fn get_json_returns_429_after_max_retries() {
306        let server = wiremock::MockServer::start().await;
307
308        // All requests return 429
309        wiremock::Mock::given(wiremock::matchers::method("GET"))
310            .and(wiremock::matchers::path("/test"))
311            .respond_with(wiremock::ResponseTemplate::new(429).append_header("Retry-After", "0"))
312            .mount(&server)
313            .await;
314
315        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
316        let resp = client
317            .get_json(&format!("{}/test", server.uri()))
318            .await
319            .unwrap();
320        // After max retries, returns the 429 response to the caller
321        assert_eq!(resp.status().as_u16(), 429);
322    }
323
324    // ── user-get (account ID → record) ────────────────────────────
325
326    #[test]
327    fn user_lookup_error_formats() {
328        assert_eq!(user_lookup_error(404, ""), "HTTP 404");
329        assert_eq!(user_lookup_error(404, "   "), "HTTP 404");
330        assert_eq!(
331            user_lookup_error(403, "no permission"),
332            "HTTP 403: no permission"
333        );
334    }
335
336    #[tokio::test]
337    async fn get_jira_user_success() {
338        let server = wiremock::MockServer::start().await;
339        wiremock::Mock::given(wiremock::matchers::method("GET"))
340            .and(wiremock::matchers::path("/rest/api/3/user"))
341            .and(wiremock::matchers::query_param("accountId", "abc123"))
342            .respond_with(
343                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
344                    "accountId": "abc123",
345                    "displayName": "Alice Smith",
346                    "emailAddress": "alice@example.com",
347                    "active": true,
348                    "accountType": "atlassian"
349                })),
350            )
351            .mount(&server)
352            .await;
353
354        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
355        let record = client.get_jira_user("abc123").await.unwrap();
356        assert_eq!(record.account_id, "abc123");
357        assert_eq!(record.display_name.as_deref(), Some("Alice Smith"));
358        assert_eq!(record.email_address.as_deref(), Some("alice@example.com"));
359        assert_eq!(record.active, Some(true));
360        assert_eq!(record.account_type.as_deref(), Some("atlassian"));
361        assert!(record.error.is_none());
362    }
363
364    #[tokio::test]
365    async fn get_jira_user_deactivated_is_a_record_not_an_error() {
366        let server = wiremock::MockServer::start().await;
367        wiremock::Mock::given(wiremock::matchers::method("GET"))
368            .and(wiremock::matchers::path("/rest/api/3/user"))
369            .respond_with(
370                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
371                    "accountId": "gone1",
372                    "active": false,
373                    "accountType": "atlassian"
374                })),
375            )
376            .mount(&server)
377            .await;
378
379        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
380        let record = client.get_jira_user("gone1").await.unwrap();
381        assert_eq!(record.account_id, "gone1");
382        assert_eq!(record.active, Some(false));
383        assert!(record.display_name.is_none());
384        assert!(record.error.is_none());
385    }
386
387    #[tokio::test]
388    async fn get_jira_user_not_found_yields_stub() {
389        let server = wiremock::MockServer::start().await;
390        wiremock::Mock::given(wiremock::matchers::method("GET"))
391            .and(wiremock::matchers::path("/rest/api/3/user"))
392            .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not found"))
393            .mount(&server)
394            .await;
395
396        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
397        let record = client.get_jira_user("missing").await.unwrap();
398        assert_eq!(record.account_id, "missing");
399        assert!(record.display_name.is_none());
400        assert!(record.error.as_deref().unwrap().starts_with("HTTP 404"));
401    }
402
403    #[tokio::test]
404    async fn get_jira_user_unauthorized_is_hard_error() {
405        let server = wiremock::MockServer::start().await;
406        wiremock::Mock::given(wiremock::matchers::method("GET"))
407            .and(wiremock::matchers::path("/rest/api/3/user"))
408            .respond_with(wiremock::ResponseTemplate::new(401).set_body_string("Unauthorized"))
409            .mount(&server)
410            .await;
411
412        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
413        assert!(client.get_jira_user("whoever").await.is_err());
414    }
415
416    #[tokio::test]
417    async fn get_jira_users_batch_survives_one_bad_id() {
418        let server = wiremock::MockServer::start().await;
419        wiremock::Mock::given(wiremock::matchers::method("GET"))
420            .and(wiremock::matchers::path("/rest/api/3/user"))
421            .and(wiremock::matchers::query_param("accountId", "good"))
422            .respond_with(
423                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
424                    "accountId": "good",
425                    "displayName": "Good User",
426                    "active": true
427                })),
428            )
429            .mount(&server)
430            .await;
431        wiremock::Mock::given(wiremock::matchers::method("GET"))
432            .and(wiremock::matchers::path("/rest/api/3/user"))
433            .and(wiremock::matchers::query_param("accountId", "bad"))
434            .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not found"))
435            .mount(&server)
436            .await;
437
438        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
439        let ids = vec!["good".to_string(), "bad".to_string()];
440        let results = client.get_jira_users(&ids).await.unwrap();
441        assert_eq!(results.users.len(), 2);
442        assert_eq!(results.users[0].account_id, "good");
443        assert_eq!(results.users[0].display_name.as_deref(), Some("Good User"));
444        assert!(results.users[0].error.is_none());
445        assert_eq!(results.users[1].account_id, "bad");
446        assert!(results.users[1].error.is_some());
447    }
448
449    #[tokio::test]
450    async fn get_jira_users_empty_input_makes_no_requests() {
451        let client = AtlassianClient::new("https://org.atlassian.net", "u@t.com", "tok").unwrap();
452        let results = client.get_jira_users(&[]).await.unwrap();
453        assert!(results.users.is_empty());
454    }
455
456    #[tokio::test]
457    async fn get_confluence_user_success() {
458        let server = wiremock::MockServer::start().await;
459        wiremock::Mock::given(wiremock::matchers::method("GET"))
460            .and(wiremock::matchers::path("/wiki/rest/api/user"))
461            .and(wiremock::matchers::query_param("accountId", "abc123"))
462            .respond_with(
463                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
464                    "accountId": "abc123",
465                    "accountType": "atlassian",
466                    "displayName": "Alice Smith",
467                    "email": "alice@example.com"
468                })),
469            )
470            .mount(&server)
471            .await;
472
473        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
474        let record = client.get_confluence_user("abc123").await.unwrap();
475        assert_eq!(record.account_id, "abc123");
476        assert_eq!(record.display_name.as_deref(), Some("Alice Smith"));
477        assert_eq!(record.email.as_deref(), Some("alice@example.com"));
478        assert_eq!(record.account_type.as_deref(), Some("atlassian"));
479        assert!(record.active.is_none());
480        assert!(record.error.is_none());
481    }
482
483    #[tokio::test]
484    async fn get_confluence_user_falls_back_to_public_name() {
485        let server = wiremock::MockServer::start().await;
486        wiremock::Mock::given(wiremock::matchers::method("GET"))
487            .and(wiremock::matchers::path("/wiki/rest/api/user"))
488            .respond_with(
489                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
490                    "accountId": "app1",
491                    "accountType": "app",
492                    "publicName": "Automation App"
493                })),
494            )
495            .mount(&server)
496            .await;
497
498        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
499        let record = client.get_confluence_user("app1").await.unwrap();
500        assert_eq!(record.display_name.as_deref(), Some("Automation App"));
501    }
502
503    #[tokio::test]
504    async fn get_confluence_user_not_found_yields_stub() {
505        let server = wiremock::MockServer::start().await;
506        wiremock::Mock::given(wiremock::matchers::method("GET"))
507            .and(wiremock::matchers::path("/wiki/rest/api/user"))
508            .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not found"))
509            .mount(&server)
510            .await;
511
512        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
513        let record = client.get_confluence_user("missing").await.unwrap();
514        assert_eq!(record.account_id, "missing");
515        assert!(record.error.as_deref().unwrap().starts_with("HTTP 404"));
516    }
517
518    #[tokio::test]
519    async fn get_confluence_user_unauthorized_is_hard_error() {
520        let server = wiremock::MockServer::start().await;
521        wiremock::Mock::given(wiremock::matchers::method("GET"))
522            .and(wiremock::matchers::path("/wiki/rest/api/user"))
523            .respond_with(wiremock::ResponseTemplate::new(401).set_body_string("Unauthorized"))
524            .mount(&server)
525            .await;
526
527        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
528        assert!(client.get_confluence_user("whoever").await.is_err());
529    }
530
531    #[tokio::test]
532    async fn get_confluence_users_batch_survives_one_bad_id() {
533        let server = wiremock::MockServer::start().await;
534        wiremock::Mock::given(wiremock::matchers::method("GET"))
535            .and(wiremock::matchers::path("/wiki/rest/api/user"))
536            .and(wiremock::matchers::query_param("accountId", "good"))
537            .respond_with(
538                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
539                    "accountId": "good",
540                    "displayName": "Good User"
541                })),
542            )
543            .mount(&server)
544            .await;
545        wiremock::Mock::given(wiremock::matchers::method("GET"))
546            .and(wiremock::matchers::path("/wiki/rest/api/user"))
547            .and(wiremock::matchers::query_param("accountId", "bad"))
548            .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not found"))
549            .mount(&server)
550            .await;
551
552        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
553        let ids = vec!["good".to_string(), "bad".to_string()];
554        let results = client.get_confluence_users(&ids).await.unwrap();
555        assert_eq!(results.users.len(), 2);
556        assert_eq!(results.users[0].display_name.as_deref(), Some("Good User"));
557        assert!(results.users[0].error.is_none());
558        assert!(results.users[1].error.is_some());
559    }
560
561    #[tokio::test]
562    async fn post_json_retries_on_429() {
563        let server = wiremock::MockServer::start().await;
564
565        wiremock::Mock::given(wiremock::matchers::method("POST"))
566            .and(wiremock::matchers::path("/test"))
567            .respond_with(wiremock::ResponseTemplate::new(429).append_header("Retry-After", "0"))
568            .up_to_n_times(1)
569            .mount(&server)
570            .await;
571
572        wiremock::Mock::given(wiremock::matchers::method("POST"))
573            .and(wiremock::matchers::path("/test"))
574            .respond_with(wiremock::ResponseTemplate::new(201))
575            .up_to_n_times(1)
576            .mount(&server)
577            .await;
578
579        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
580        let body = serde_json::json!({"key": "value"});
581        let resp = client
582            .post_json(&format!("{}/test", server.uri()), &body)
583            .await
584            .unwrap();
585        assert_eq!(resp.status().as_u16(), 201);
586    }
587
588    #[tokio::test]
589    async fn delete_retries_on_429() {
590        let server = wiremock::MockServer::start().await;
591
592        wiremock::Mock::given(wiremock::matchers::method("DELETE"))
593            .and(wiremock::matchers::path("/test"))
594            .respond_with(wiremock::ResponseTemplate::new(429).append_header("Retry-After", "0"))
595            .up_to_n_times(1)
596            .mount(&server)
597            .await;
598
599        wiremock::Mock::given(wiremock::matchers::method("DELETE"))
600            .and(wiremock::matchers::path("/test"))
601            .respond_with(wiremock::ResponseTemplate::new(204))
602            .up_to_n_times(1)
603            .mount(&server)
604            .await;
605
606        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
607        let resp = client
608            .delete(&format!("{}/test", server.uri()))
609            .await
610            .unwrap();
611        assert_eq!(resp.status().as_u16(), 204);
612    }
613
614    #[tokio::test]
615    async fn get_json_sends_auth_header() {
616        let server = wiremock::MockServer::start().await;
617
618        wiremock::Mock::given(wiremock::matchers::method("GET"))
619            .and(wiremock::matchers::header(
620                "Authorization",
621                "Basic dXNlckB0ZXN0LmNvbTp0b2tlbg==",
622            ))
623            .and(wiremock::matchers::header("Accept", "application/json"))
624            .respond_with(
625                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({"ok": true})),
626            )
627            .expect(1)
628            .mount(&server)
629            .await;
630
631        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
632        let resp = client
633            .get_json(&format!("{}/test", server.uri()))
634            .await
635            .unwrap();
636        assert!(resp.status().is_success());
637    }
638
639    #[tokio::test]
640    async fn put_json_sends_body_and_auth() {
641        let server = wiremock::MockServer::start().await;
642
643        wiremock::Mock::given(wiremock::matchers::method("PUT"))
644            .and(wiremock::matchers::header(
645                "Authorization",
646                "Basic dXNlckB0ZXN0LmNvbTp0b2tlbg==",
647            ))
648            .and(wiremock::matchers::header(
649                "Content-Type",
650                "application/json",
651            ))
652            .respond_with(wiremock::ResponseTemplate::new(200))
653            .expect(1)
654            .mount(&server)
655            .await;
656
657        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
658        let body = serde_json::json!({"key": "value"});
659        let resp = client
660            .put_json(&format!("{}/test", server.uri()), &body)
661            .await
662            .unwrap();
663        assert!(resp.status().is_success());
664    }
665
666    #[tokio::test]
667    async fn post_json_sends_body_and_auth() {
668        let server = wiremock::MockServer::start().await;
669
670        wiremock::Mock::given(wiremock::matchers::method("POST"))
671            .and(wiremock::matchers::header(
672                "Authorization",
673                "Basic dXNlckB0ZXN0LmNvbTp0b2tlbg==",
674            ))
675            .and(wiremock::matchers::header(
676                "Content-Type",
677                "application/json",
678            ))
679            .respond_with(
680                wiremock::ResponseTemplate::new(201).set_body_json(serde_json::json!({"id": "1"})),
681            )
682            .expect(1)
683            .mount(&server)
684            .await;
685
686        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
687        let body = serde_json::json!({"name": "test"});
688        let resp = client
689            .post_json(&format!("{}/test", server.uri()), &body)
690            .await
691            .unwrap();
692        assert_eq!(resp.status().as_u16(), 201);
693    }
694
695    #[tokio::test]
696    async fn post_json_error_response() {
697        let server = wiremock::MockServer::start().await;
698
699        wiremock::Mock::given(wiremock::matchers::method("POST"))
700            .respond_with(wiremock::ResponseTemplate::new(400).set_body_string("Bad Request"))
701            .expect(1)
702            .mount(&server)
703            .await;
704
705        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
706        let body = serde_json::json!({});
707        let resp = client
708            .post_json(&format!("{}/test", server.uri()), &body)
709            .await
710            .unwrap();
711        assert_eq!(resp.status().as_u16(), 400);
712    }
713
714    #[tokio::test]
715    async fn delete_sends_auth_header() {
716        let server = wiremock::MockServer::start().await;
717
718        wiremock::Mock::given(wiremock::matchers::method("DELETE"))
719            .and(wiremock::matchers::header(
720                "Authorization",
721                "Basic dXNlckB0ZXN0LmNvbTp0b2tlbg==",
722            ))
723            .respond_with(wiremock::ResponseTemplate::new(204))
724            .expect(1)
725            .mount(&server)
726            .await;
727
728        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
729        let resp = client
730            .delete(&format!("{}/test", server.uri()))
731            .await
732            .unwrap();
733        assert_eq!(resp.status().as_u16(), 204);
734    }
735
736    #[tokio::test]
737    async fn delete_error_response() {
738        let server = wiremock::MockServer::start().await;
739
740        wiremock::Mock::given(wiremock::matchers::method("DELETE"))
741            .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
742            .expect(1)
743            .mount(&server)
744            .await;
745
746        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
747        let resp = client
748            .delete(&format!("{}/test", server.uri()))
749            .await
750            .unwrap();
751        assert_eq!(resp.status().as_u16(), 404);
752    }
753
754    #[tokio::test]
755    async fn get_issue_success() {
756        let server = wiremock::MockServer::start().await;
757
758        let issue_json = serde_json::json!({
759            "key": "PROJ-42",
760            "fields": {
761                "summary": "Fix the bug",
762                "description": {
763                    "version": 1,
764                    "type": "doc",
765                    "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Details"}]}]
766                },
767                "status": {"name": "Open"},
768                "issuetype": {"name": "Bug"},
769                "assignee": {"displayName": "Alice"},
770                "priority": {"name": "High"},
771                "labels": ["backend", "urgent"]
772            }
773        });
774
775        wiremock::Mock::given(wiremock::matchers::method("GET"))
776            .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-42"))
777            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&issue_json))
778            .expect(1)
779            .mount(&server)
780            .await;
781
782        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
783        let issue = client.get_issue("PROJ-42").await.unwrap();
784
785        assert_eq!(issue.key, "PROJ-42");
786        assert_eq!(issue.summary, "Fix the bug");
787        assert_eq!(issue.status.as_deref(), Some("Open"));
788        assert_eq!(issue.issue_type.as_deref(), Some("Bug"));
789        assert_eq!(issue.assignee.as_deref(), Some("Alice"));
790        assert_eq!(issue.priority.as_deref(), Some("High"));
791        assert_eq!(issue.labels, vec!["backend", "urgent"]);
792        assert!(issue.description_adf.is_some());
793    }
794
795    #[tokio::test]
796    async fn get_issue_api_error() {
797        let server = wiremock::MockServer::start().await;
798
799        wiremock::Mock::given(wiremock::matchers::method("GET"))
800            .and(wiremock::matchers::path("/rest/api/3/issue/NOPE-1"))
801            .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
802            .expect(1)
803            .mount(&server)
804            .await;
805
806        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
807        let err = client.get_issue("NOPE-1").await.unwrap_err();
808        assert!(err.to_string().contains("404"));
809    }
810
811    #[tokio::test]
812    async fn get_issue_with_fields_named_populates_custom_fields() {
813        let server = wiremock::MockServer::start().await;
814
815        let issue_json = serde_json::json!({
816            "key": "ACCS-1",
817            "fields": {
818                "summary": "S",
819                "description": null,
820                "status": {"name": "Open"},
821                "issuetype": {"name": "Bug"},
822                "assignee": null,
823                "priority": null,
824                "labels": [],
825                "customfield_19300": {
826                    "type": "doc",
827                    "version": 1,
828                    "content": [{"type": "paragraph", "content": [{"type": "text", "text": "AC"}]}]
829                }
830            },
831            "names": {
832                "customfield_19300": "Acceptance Criteria"
833            }
834        });
835
836        wiremock::Mock::given(wiremock::matchers::method("GET"))
837            .and(wiremock::matchers::path("/rest/api/3/issue/ACCS-1"))
838            .and(wiremock::matchers::query_param("expand", "names,schema"))
839            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&issue_json))
840            .expect(1)
841            .mount(&server)
842            .await;
843
844        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
845        let issue = client
846            .get_issue_with_fields(
847                "ACCS-1",
848                FieldSelection::Named(vec!["customfield_19300".to_string()]),
849            )
850            .await
851            .unwrap();
852
853        assert_eq!(issue.key, "ACCS-1");
854        assert_eq!(issue.custom_fields.len(), 1);
855        let cf = &issue.custom_fields[0];
856        assert_eq!(cf.id, "customfield_19300");
857        assert_eq!(cf.name, "Acceptance Criteria");
858        assert_eq!(cf.value["type"], "doc");
859    }
860
861    #[tokio::test]
862    async fn get_issue_with_fields_standard_omits_custom_fields() {
863        let server = wiremock::MockServer::start().await;
864
865        let issue_json = serde_json::json!({
866            "key": "ACCS-1",
867            "fields": {
868                "summary": "S",
869                "description": null,
870                "status": null,
871                "issuetype": null,
872                "assignee": null,
873                "priority": null,
874                "labels": [],
875                "customfield_19300": {"value": "Unplanned"}
876            },
877            "names": {
878                "customfield_19300": "Planned / Unplanned Work"
879            }
880        });
881
882        wiremock::Mock::given(wiremock::matchers::method("GET"))
883            .and(wiremock::matchers::path("/rest/api/3/issue/ACCS-1"))
884            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&issue_json))
885            .expect(1)
886            .mount(&server)
887            .await;
888
889        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
890        let issue = client
891            .get_issue_with_fields("ACCS-1", FieldSelection::Standard)
892            .await
893            .unwrap();
894
895        assert!(issue.custom_fields.is_empty());
896    }
897
898    #[tokio::test]
899    async fn get_issue_with_fields_all_uses_star_param() {
900        let server = wiremock::MockServer::start().await;
901
902        let issue_json = serde_json::json!({
903            "key": "ACCS-1",
904            "fields": {
905                "summary": "S",
906                "description": null,
907                "status": null,
908                "issuetype": null,
909                "assignee": null,
910                "priority": null,
911                "labels": [],
912                "customfield_10001": {"value": "Unplanned"},
913                "customfield_10002": 42
914            },
915            "names": {
916                "customfield_10001": "Planned / Unplanned Work",
917                "customfield_10002": "Story points"
918            }
919        });
920
921        wiremock::Mock::given(wiremock::matchers::method("GET"))
922            .and(wiremock::matchers::path("/rest/api/3/issue/ACCS-1"))
923            .and(wiremock::matchers::query_param("fields", "*all"))
924            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&issue_json))
925            .expect(1)
926            .mount(&server)
927            .await;
928
929        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
930        let issue = client
931            .get_issue_with_fields("ACCS-1", FieldSelection::All)
932            .await
933            .unwrap();
934
935        assert_eq!(issue.custom_fields.len(), 2);
936        let names: Vec<&str> = issue
937            .custom_fields
938            .iter()
939            .map(|c| c.name.as_str())
940            .collect();
941        assert!(names.contains(&"Planned / Unplanned Work"));
942        assert!(names.contains(&"Story points"));
943    }
944
945    #[tokio::test]
946    async fn get_editmeta_parses_field_schema() {
947        let server = wiremock::MockServer::start().await;
948
949        let editmeta_json = serde_json::json!({
950            "fields": {
951                "customfield_19300": {
952                    "name": "Acceptance Criteria",
953                    "schema": {
954                        "type": "string",
955                        "custom": "com.atlassian.jira.plugin.system.customfieldtypes:textarea",
956                        "customId": 19300
957                    }
958                },
959                "customfield_10001": {
960                    "name": "Planned / Unplanned Work",
961                    "schema": {
962                        "type": "option",
963                        "custom": "com.atlassian.jira.plugin.system.customfieldtypes:select",
964                        "customId": 10001
965                    }
966                },
967                "labels": {
968                    "name": "Labels",
969                    "schema": {
970                        "type": "array",
971                        "items": "string",
972                        "system": "labels"
973                    }
974                },
975                "description": {
976                    "name": "Description",
977                    "schema": {
978                        "type": "string",
979                        "system": "description"
980                    }
981                }
982            }
983        });
984
985        wiremock::Mock::given(wiremock::matchers::method("GET"))
986            .and(wiremock::matchers::path(
987                "/rest/api/3/issue/ACCS-1/editmeta",
988            ))
989            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&editmeta_json))
990            .expect(1)
991            .mount(&server)
992            .await;
993
994        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
995        let meta = client.get_editmeta("ACCS-1").await.unwrap();
996
997        assert_eq!(meta.fields.len(), 4);
998        let ac = meta.fields.get("customfield_19300").unwrap();
999        assert_eq!(ac.name, "Acceptance Criteria");
1000        assert!(ac.is_adf_rich_text());
1001        let opt = meta.fields.get("customfield_10001").unwrap();
1002        assert_eq!(opt.schema.kind, "option");
1003        assert!(!opt.is_adf_rich_text());
1004        let labels = meta.fields.get("labels").unwrap();
1005        assert_eq!(labels.schema.kind, "array");
1006        assert_eq!(labels.schema.items.as_deref(), Some("string"));
1007        assert_eq!(labels.schema.system.as_deref(), Some("labels"));
1008        assert!(!labels.is_adf_rich_text());
1009        let description = meta.fields.get("description").unwrap();
1010        assert_eq!(description.schema.system.as_deref(), Some("description"));
1011        assert!(description.is_adf_rich_text());
1012    }
1013
1014    #[tokio::test]
1015    async fn get_editmeta_api_error_surfaces_status() {
1016        let server = wiremock::MockServer::start().await;
1017
1018        wiremock::Mock::given(wiremock::matchers::method("GET"))
1019            .and(wiremock::matchers::path(
1020                "/rest/api/3/issue/NOPE-1/editmeta",
1021            ))
1022            .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("not found"))
1023            .mount(&server)
1024            .await;
1025
1026        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1027        let err = client.get_editmeta("NOPE-1").await.unwrap_err();
1028        assert!(err.to_string().contains("404"));
1029    }
1030
1031    #[tokio::test]
1032    async fn update_issue_with_custom_fields_merges_into_payload() {
1033        let server = wiremock::MockServer::start().await;
1034
1035        wiremock::Mock::given(wiremock::matchers::method("PUT"))
1036            .and(wiremock::matchers::path("/rest/api/3/issue/ACCS-1"))
1037            .and(wiremock::matchers::body_json(serde_json::json!({
1038                "fields": {
1039                    "description": {"version": 1, "type": "doc", "content": []},
1040                    "summary": "New title",
1041                    "customfield_10001": {"value": "Unplanned"},
1042                    "customfield_19300": {
1043                        "type": "doc",
1044                        "version": 1,
1045                        "content": [{"type": "paragraph"}]
1046                    }
1047                }
1048            })))
1049            .respond_with(wiremock::ResponseTemplate::new(204))
1050            .expect(1)
1051            .mount(&server)
1052            .await;
1053
1054        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1055        let adf = ValidatedAdfDocument::empty();
1056        let mut custom = std::collections::BTreeMap::new();
1057        custom.insert(
1058            "customfield_10001".to_string(),
1059            serde_json::json!({"value": "Unplanned"}),
1060        );
1061        custom.insert(
1062            "customfield_19300".to_string(),
1063            serde_json::json!({"type": "doc", "version": 1, "content": [{"type": "paragraph"}]}),
1064        );
1065        let result = client
1066            .update_issue_with_custom_fields("ACCS-1", Some(&adf), Some("New title"), &custom)
1067            .await;
1068        assert!(result.is_ok());
1069    }
1070
1071    #[tokio::test]
1072    async fn update_issue_with_no_fields_errors() {
1073        let client =
1074            AtlassianClient::new("https://example.atlassian.net", "user@test.com", "token")
1075                .unwrap();
1076        let err = client
1077            .update_issue_with_custom_fields(
1078                "ACCS-1",
1079                None,
1080                None,
1081                &std::collections::BTreeMap::new(),
1082            )
1083            .await
1084            .unwrap_err();
1085        assert!(err.to_string().contains("no fields to update"));
1086    }
1087
1088    #[tokio::test]
1089    async fn update_issue_shim_sends_no_custom_fields() {
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/ACCS-1"))
1094            .and(wiremock::matchers::body_json(serde_json::json!({
1095                "fields": {
1096                    "description": {"version": 1, "type": "doc", "content": []}
1097                }
1098            })))
1099            .respond_with(wiremock::ResponseTemplate::new(204))
1100            .expect(1)
1101            .mount(&server)
1102            .await;
1103
1104        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1105        let adf = ValidatedAdfDocument::empty();
1106        client.update_issue("ACCS-1", &adf, None).await.unwrap();
1107    }
1108
1109    #[tokio::test]
1110    async fn get_issue_with_fields_falls_back_to_id_when_names_missing() {
1111        let server = wiremock::MockServer::start().await;
1112
1113        let issue_json = serde_json::json!({
1114            "key": "ACCS-1",
1115            "fields": {
1116                "summary": "S",
1117                "description": null,
1118                "status": null,
1119                "issuetype": null,
1120                "assignee": null,
1121                "priority": null,
1122                "labels": [],
1123                "customfield_99999": "raw"
1124            }
1125        });
1126
1127        wiremock::Mock::given(wiremock::matchers::method("GET"))
1128            .and(wiremock::matchers::path("/rest/api/3/issue/ACCS-1"))
1129            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&issue_json))
1130            .expect(1)
1131            .mount(&server)
1132            .await;
1133
1134        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1135        let issue = client
1136            .get_issue_with_fields("ACCS-1", FieldSelection::All)
1137            .await
1138            .unwrap();
1139
1140        assert_eq!(issue.custom_fields.len(), 1);
1141        assert_eq!(issue.custom_fields[0].name, "customfield_99999");
1142    }
1143
1144    #[tokio::test]
1145    async fn update_issue_success() {
1146        let server = wiremock::MockServer::start().await;
1147
1148        wiremock::Mock::given(wiremock::matchers::method("PUT"))
1149            .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-42"))
1150            .respond_with(wiremock::ResponseTemplate::new(204))
1151            .expect(1)
1152            .mount(&server)
1153            .await;
1154
1155        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1156        let adf = ValidatedAdfDocument::empty();
1157        let result = client
1158            .update_issue("PROJ-42", &adf, Some("New title"))
1159            .await;
1160        assert!(result.is_ok());
1161    }
1162
1163    #[tokio::test]
1164    async fn update_issue_without_summary() {
1165        let server = wiremock::MockServer::start().await;
1166
1167        wiremock::Mock::given(wiremock::matchers::method("PUT"))
1168            .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-42"))
1169            .respond_with(wiremock::ResponseTemplate::new(204))
1170            .expect(1)
1171            .mount(&server)
1172            .await;
1173
1174        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1175        let adf = ValidatedAdfDocument::empty();
1176        let result = client.update_issue("PROJ-42", &adf, None).await;
1177        assert!(result.is_ok());
1178    }
1179
1180    #[tokio::test]
1181    async fn update_issue_api_error() {
1182        let server = wiremock::MockServer::start().await;
1183
1184        wiremock::Mock::given(wiremock::matchers::method("PUT"))
1185            .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-42"))
1186            .respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
1187            .expect(1)
1188            .mount(&server)
1189            .await;
1190
1191        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1192        let adf = ValidatedAdfDocument::empty();
1193        let err = client
1194            .update_issue("PROJ-42", &adf, None)
1195            .await
1196            .unwrap_err();
1197        assert!(err.to_string().contains("403"));
1198    }
1199
1200    #[tokio::test]
1201    async fn search_issues_success() {
1202        let server = wiremock::MockServer::start().await;
1203
1204        let search_json = serde_json::json!({
1205            "issues": [
1206                {
1207                    "key": "PROJ-1",
1208                    "fields": {
1209                        "summary": "First issue",
1210                        "description": null,
1211                        "status": {"name": "Open"},
1212                        "issuetype": {"name": "Bug"},
1213                        "assignee": {"displayName": "Alice"},
1214                        "priority": {"name": "High"},
1215                        "labels": []
1216                    }
1217                },
1218                {
1219                    "key": "PROJ-2",
1220                    "fields": {
1221                        "summary": "Second issue",
1222                        "description": null,
1223                        "status": {"name": "Done"},
1224                        "issuetype": {"name": "Task"},
1225                        "assignee": null,
1226                        "priority": null,
1227                        "labels": ["backend"]
1228                    }
1229                }
1230            ],
1231            "total": 2
1232        });
1233
1234        wiremock::Mock::given(wiremock::matchers::method("POST"))
1235            .and(wiremock::matchers::path("/rest/api/3/search/jql"))
1236            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&search_json))
1237            .expect(1)
1238            .mount(&server)
1239            .await;
1240
1241        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1242        let result = client.search_issues("project = PROJ", 50).await.unwrap();
1243
1244        assert_eq!(result.total, 2);
1245        assert_eq!(result.issues.len(), 2);
1246        assert_eq!(result.issues[0].key, "PROJ-1");
1247        assert_eq!(result.issues[0].summary, "First issue");
1248        assert_eq!(result.issues[0].status.as_deref(), Some("Open"));
1249        assert_eq!(result.issues[1].key, "PROJ-2");
1250        assert!(result.issues[1].assignee.is_none());
1251    }
1252
1253    #[tokio::test]
1254    async fn search_issues_without_total() {
1255        let server = wiremock::MockServer::start().await;
1256
1257        wiremock::Mock::given(wiremock::matchers::method("POST"))
1258            .and(wiremock::matchers::path("/rest/api/3/search/jql"))
1259            .respond_with(
1260                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1261                    "issues": [{
1262                        "key": "PROJ-1",
1263                        "fields": {
1264                            "summary": "Test",
1265                            "description": null,
1266                            "status": null,
1267                            "issuetype": null,
1268                            "assignee": null,
1269                            "priority": null,
1270                            "labels": []
1271                        }
1272                    }]
1273                })),
1274            )
1275            .expect(1)
1276            .mount(&server)
1277            .await;
1278
1279        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1280        let result = client.search_issues("project = PROJ", 50).await.unwrap();
1281
1282        assert_eq!(result.issues.len(), 1);
1283        // total falls back to issues count when not in response
1284        assert_eq!(result.total, 1);
1285    }
1286
1287    #[tokio::test]
1288    async fn search_issues_empty_results() {
1289        let server = wiremock::MockServer::start().await;
1290
1291        wiremock::Mock::given(wiremock::matchers::method("POST"))
1292            .and(wiremock::matchers::path("/rest/api/3/search/jql"))
1293            .respond_with(
1294                wiremock::ResponseTemplate::new(200)
1295                    .set_body_json(serde_json::json!({"issues": [], "total": 0})),
1296            )
1297            .expect(1)
1298            .mount(&server)
1299            .await;
1300
1301        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1302        let result = client.search_issues("project = NOPE", 50).await.unwrap();
1303
1304        assert_eq!(result.total, 0);
1305        assert!(result.issues.is_empty());
1306    }
1307
1308    #[tokio::test]
1309    async fn search_issues_api_error() {
1310        let server = wiremock::MockServer::start().await;
1311
1312        wiremock::Mock::given(wiremock::matchers::method("POST"))
1313            .and(wiremock::matchers::path("/rest/api/3/search/jql"))
1314            .respond_with(wiremock::ResponseTemplate::new(400).set_body_string("Invalid JQL query"))
1315            .expect(1)
1316            .mount(&server)
1317            .await;
1318
1319        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1320        let err = client
1321            .search_issues("invalid jql !!!", 50)
1322            .await
1323            .unwrap_err();
1324        assert!(err.to_string().contains("400"));
1325    }
1326
1327    #[tokio::test]
1328    async fn create_issue_success() {
1329        let server = wiremock::MockServer::start().await;
1330
1331        wiremock::Mock::given(wiremock::matchers::method("POST"))
1332            .and(wiremock::matchers::path("/rest/api/3/issue"))
1333            .respond_with(wiremock::ResponseTemplate::new(201).set_body_json(
1334                serde_json::json!({"key": "PROJ-124", "id": "10042", "self": "https://org.atlassian.net/rest/api/3/issue/10042"}),
1335            ))
1336            .expect(1)
1337            .mount(&server)
1338            .await;
1339
1340        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1341        let result = client
1342            .create_issue("PROJ", "Bug", "Fix login", None, &[])
1343            .await
1344            .unwrap();
1345
1346        assert_eq!(result.key, "PROJ-124");
1347        assert_eq!(result.id, "10042");
1348        assert!(result.self_url.contains("10042"));
1349    }
1350
1351    #[tokio::test]
1352    async fn create_issue_with_description_and_labels() {
1353        let server = wiremock::MockServer::start().await;
1354
1355        wiremock::Mock::given(wiremock::matchers::method("POST"))
1356            .and(wiremock::matchers::path("/rest/api/3/issue"))
1357            .respond_with(wiremock::ResponseTemplate::new(201).set_body_json(
1358                serde_json::json!({"key": "PROJ-125", "id": "10043", "self": "https://org.atlassian.net/rest/api/3/issue/10043"}),
1359            ))
1360            .expect(1)
1361            .mount(&server)
1362            .await;
1363
1364        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1365        let adf = ValidatedAdfDocument::empty();
1366        let labels = vec!["backend".to_string(), "urgent".to_string()];
1367        let result = client
1368            .create_issue("PROJ", "Task", "Add feature", Some(&adf), &labels)
1369            .await
1370            .unwrap();
1371
1372        assert_eq!(result.key, "PROJ-125");
1373    }
1374
1375    #[tokio::test]
1376    async fn create_issue_api_error() {
1377        let server = wiremock::MockServer::start().await;
1378
1379        wiremock::Mock::given(wiremock::matchers::method("POST"))
1380            .and(wiremock::matchers::path("/rest/api/3/issue"))
1381            .respond_with(wiremock::ResponseTemplate::new(400).set_body_string("Project not found"))
1382            .expect(1)
1383            .mount(&server)
1384            .await;
1385
1386        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1387        let err = client
1388            .create_issue("NOPE", "Bug", "Test", None, &[])
1389            .await
1390            .unwrap_err();
1391        assert!(err.to_string().contains("400"));
1392    }
1393
1394    #[tokio::test]
1395    async fn create_issue_with_custom_fields_merges_into_payload() {
1396        let server = wiremock::MockServer::start().await;
1397
1398        wiremock::Mock::given(wiremock::matchers::method("POST"))
1399            .and(wiremock::matchers::path("/rest/api/3/issue"))
1400            .and(wiremock::matchers::body_json(serde_json::json!({
1401                "fields": {
1402                    "project": {"key": "PROJ"},
1403                    "issuetype": {"name": "Task"},
1404                    "summary": "Test",
1405                    "customfield_10001": {"value": "Unplanned"}
1406                }
1407            })))
1408            .respond_with(
1409                wiremock::ResponseTemplate::new(201).set_body_json(serde_json::json!({
1410                    "id": "100",
1411                    "key": "PROJ-100",
1412                    "self": "https://org.atlassian.net/rest/api/3/issue/100"
1413                })),
1414            )
1415            .expect(1)
1416            .mount(&server)
1417            .await;
1418
1419        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1420        let mut custom = std::collections::BTreeMap::new();
1421        custom.insert(
1422            "customfield_10001".to_string(),
1423            serde_json::json!({"value": "Unplanned"}),
1424        );
1425        let result = client
1426            .create_issue_with_custom_fields("PROJ", "Task", "Test", None, &[], &custom)
1427            .await
1428            .unwrap();
1429        assert_eq!(result.key, "PROJ-100");
1430    }
1431
1432    #[tokio::test]
1433    async fn create_issue_surfaces_adf_field_required_on_400() {
1434        // Issue #1047: a 400 whose error envelope reports a field needs an
1435        // "Atlassian document" must surface as the actionable
1436        // JiraAdfFieldRequired, matching the update path's behaviour.
1437        let server = wiremock::MockServer::start().await;
1438        wiremock::Mock::given(wiremock::matchers::method("POST"))
1439            .and(wiremock::matchers::path("/rest/api/3/issue"))
1440            .respond_with(
1441                wiremock::ResponseTemplate::new(400).set_body_json(serde_json::json!({
1442                    "errorMessages": [],
1443                    "errors": {
1444                        "description": "Operation value must be an Atlassian document."
1445                    }
1446                })),
1447            )
1448            .mount(&server)
1449            .await;
1450
1451        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1452        let err = client
1453            .create_issue_with_custom_fields(
1454                "PROJ",
1455                "Task",
1456                "Test",
1457                None,
1458                &[],
1459                &std::collections::BTreeMap::new(),
1460            )
1461            .await
1462            .unwrap_err();
1463        let msg = err.to_string();
1464        assert!(msg.contains("description"), "got: {msg}");
1465        assert!(msg.contains("ADF"), "got: {msg}");
1466    }
1467
1468    #[tokio::test]
1469    async fn create_issue_shim_sends_no_custom_fields() {
1470        let server = wiremock::MockServer::start().await;
1471
1472        wiremock::Mock::given(wiremock::matchers::method("POST"))
1473            .and(wiremock::matchers::path("/rest/api/3/issue"))
1474            .and(wiremock::matchers::body_json(serde_json::json!({
1475                "fields": {
1476                    "project": {"key": "PROJ"},
1477                    "issuetype": {"name": "Task"},
1478                    "summary": "Test"
1479                }
1480            })))
1481            .respond_with(
1482                wiremock::ResponseTemplate::new(201).set_body_json(serde_json::json!({
1483                    "id": "100",
1484                    "key": "PROJ-100",
1485                    "self": "https://org.atlassian.net/rest/api/3/issue/100"
1486                })),
1487            )
1488            .expect(1)
1489            .mount(&server)
1490            .await;
1491
1492        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1493        client
1494            .create_issue("PROJ", "Task", "Test", None, &[])
1495            .await
1496            .unwrap();
1497    }
1498
1499    #[tokio::test]
1500    async fn get_createmeta_parses_nested_fields() {
1501        let server = wiremock::MockServer::start().await;
1502
1503        wiremock::Mock::given(wiremock::matchers::method("GET"))
1504            .and(wiremock::matchers::path("/rest/api/3/issue/createmeta"))
1505            .and(wiremock::matchers::query_param("projectKeys", "PROJ"))
1506            .and(wiremock::matchers::query_param("issuetypeNames", "Task"))
1507            .and(wiremock::matchers::query_param(
1508                "expand",
1509                "projects.issuetypes.fields",
1510            ))
1511            .respond_with(
1512                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1513                    "projects": [{
1514                        "key": "PROJ",
1515                        "issuetypes": [{
1516                            "name": "Task",
1517                            "fields": {
1518                                "customfield_10001": {
1519                                    "name": "Planned / Unplanned Work",
1520                                    "schema": {
1521                                        "type": "option",
1522                                        "custom": "com.atlassian.jira.plugin.system.customfieldtypes:select",
1523                                        "customId": 10001
1524                                    },
1525                                    "allowedValues": [
1526                                        {"value": "Planned", "id": "10100"},
1527                                        {"value": "Unplanned", "id": "10101"}
1528                                    ]
1529                                }
1530                            }
1531                        }]
1532                    }]
1533                })),
1534            )
1535            .expect(1)
1536            .mount(&server)
1537            .await;
1538
1539        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1540        let meta = client.get_createmeta("PROJ", "Task").await.unwrap();
1541        assert_eq!(meta.fields.len(), 1);
1542        let field = meta.fields.get("customfield_10001").unwrap();
1543        assert_eq!(field.name, "Planned / Unplanned Work");
1544        assert_eq!(field.schema.kind, "option");
1545        // allowedValues flow into EditMetaField for --set-field validation.
1546        assert_eq!(field.allowed_values, vec!["Planned", "Unplanned"]);
1547    }
1548
1549    #[tokio::test]
1550    async fn get_createmeta_empty_projects_returns_empty_meta() {
1551        let server = wiremock::MockServer::start().await;
1552
1553        wiremock::Mock::given(wiremock::matchers::method("GET"))
1554            .and(wiremock::matchers::path("/rest/api/3/issue/createmeta"))
1555            .respond_with(
1556                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1557                    "projects": []
1558                })),
1559            )
1560            .mount(&server)
1561            .await;
1562
1563        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1564        let meta = client.get_createmeta("PROJ", "Task").await.unwrap();
1565        assert!(meta.fields.is_empty());
1566    }
1567
1568    #[tokio::test]
1569    async fn get_createmeta_api_error_surfaces_status() {
1570        let server = wiremock::MockServer::start().await;
1571
1572        wiremock::Mock::given(wiremock::matchers::method("GET"))
1573            .and(wiremock::matchers::path("/rest/api/3/issue/createmeta"))
1574            .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not found"))
1575            .mount(&server)
1576            .await;
1577
1578        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1579        let err = client.get_createmeta("NOPE", "Task").await.unwrap_err();
1580        assert!(err.to_string().contains("404"));
1581    }
1582
1583    #[tokio::test]
1584    async fn get_project_create_meta_parses_required_allowed_and_default() {
1585        let server = wiremock::MockServer::start().await;
1586
1587        wiremock::Mock::given(wiremock::matchers::method("GET"))
1588            .and(wiremock::matchers::path("/rest/api/3/issue/createmeta"))
1589            .respond_with(
1590                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1591                    "projects": [{
1592                        "issuetypes": [{
1593                            "fields": {
1594                                "summary": {
1595                                    "name": "Summary",
1596                                    "required": true,
1597                                    "schema": { "type": "string" }
1598                                },
1599                                "customfield_10001": {
1600                                    "name": "Work Type",
1601                                    "required": true,
1602                                    "schema": {
1603                                        "type": "option",
1604                                        "custom": "com.atlassian.jira.plugin.system.customfieldtypes:select"
1605                                    },
1606                                    "defaultValue": { "id": "10100", "value": "Planned" },
1607                                    "allowedValues": [
1608                                        { "id": "10100", "value": "Planned" },
1609                                        { "id": "10101", "value": "Unplanned" }
1610                                    ]
1611                                },
1612                                "customfield_10002": {
1613                                    "name": "Region",
1614                                    "required": false,
1615                                    "schema": {
1616                                        "type": "option-with-child",
1617                                        "custom": "com.atlassian.jira.plugin.system.customfieldtypes:cascadingselect"
1618                                    },
1619                                    "allowedValues": [
1620                                        {
1621                                            "id": "20000",
1622                                            "value": "APAC",
1623                                            "children": [
1624                                                { "id": "20001", "value": "AU" },
1625                                                { "id": "20002", "value": "NZ" }
1626                                            ]
1627                                        }
1628                                    ]
1629                                },
1630                                "labels": {
1631                                    "name": "Labels",
1632                                    "required": false,
1633                                    "schema": { "type": "array", "items": "string" }
1634                                }
1635                            }
1636                        }]
1637                    }]
1638                })),
1639            )
1640            .mount(&server)
1641            .await;
1642
1643        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1644        let meta = client
1645            .get_project_create_meta("PROJ", "Task")
1646            .await
1647            .unwrap();
1648
1649        assert_eq!(meta.project, "PROJ");
1650        assert_eq!(meta.issue_type, "Task");
1651        assert_eq!(meta.fields.len(), 4);
1652
1653        // Required fields sort first (Summary before Work Type by name).
1654        assert_eq!(meta.fields[0].field_id, "summary");
1655        assert!(meta.fields[0].required);
1656        assert_eq!(meta.fields[1].field_id, "customfield_10001");
1657        assert!(meta.fields[1].required);
1658        // Optional fields follow, alphabetically by name (Labels, Region).
1659        assert!(!meta.fields[2].required);
1660        assert_eq!(meta.fields[2].name, "Labels");
1661        assert!(!meta.fields[3].required);
1662        assert_eq!(meta.fields[3].name, "Region");
1663
1664        let work_type = &meta.fields[1];
1665        assert_eq!(work_type.schema_type, "option");
1666        assert_eq!(
1667            work_type.custom.as_deref(),
1668            Some("com.atlassian.jira.plugin.system.customfieldtypes:select")
1669        );
1670        assert_eq!(work_type.allowed_values.len(), 2);
1671        assert_eq!(
1672            work_type.allowed_values[0].value.as_deref(),
1673            Some("Planned")
1674        );
1675        assert!(work_type.default_value.is_some());
1676
1677        // labels (array) carries its element type.
1678        let labels = &meta.fields[2];
1679        assert_eq!(labels.schema_type, "array");
1680        assert_eq!(labels.items.as_deref(), Some("string"));
1681
1682        // Cascading select resolves nested children.
1683        let region = &meta.fields[3];
1684        assert_eq!(region.allowed_values.len(), 1);
1685        assert_eq!(region.allowed_values[0].value.as_deref(), Some("APAC"));
1686        assert_eq!(region.allowed_values[0].children.len(), 2);
1687        assert_eq!(
1688            region.allowed_values[0].children[0].value.as_deref(),
1689            Some("AU")
1690        );
1691    }
1692
1693    #[tokio::test]
1694    async fn get_project_create_meta_empty_projects_returns_empty_fields() {
1695        let server = wiremock::MockServer::start().await;
1696
1697        wiremock::Mock::given(wiremock::matchers::method("GET"))
1698            .and(wiremock::matchers::path("/rest/api/3/issue/createmeta"))
1699            .respond_with(
1700                wiremock::ResponseTemplate::new(200)
1701                    .set_body_json(serde_json::json!({ "projects": [] })),
1702            )
1703            .mount(&server)
1704            .await;
1705
1706        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1707        let meta = client
1708            .get_project_create_meta("PROJ", "Task")
1709            .await
1710            .unwrap();
1711        assert!(meta.fields.is_empty());
1712        assert_eq!(meta.project, "PROJ");
1713    }
1714
1715    #[tokio::test]
1716    async fn get_project_create_meta_api_error_surfaces_status() {
1717        let server = wiremock::MockServer::start().await;
1718
1719        wiremock::Mock::given(wiremock::matchers::method("GET"))
1720            .and(wiremock::matchers::path("/rest/api/3/issue/createmeta"))
1721            .respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
1722            .mount(&server)
1723            .await;
1724
1725        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1726        let err = client
1727            .get_project_create_meta("NOPE", "Task")
1728            .await
1729            .unwrap_err();
1730        assert!(err.to_string().contains("403"));
1731    }
1732
1733    #[tokio::test]
1734    async fn get_comments_success() {
1735        let server = wiremock::MockServer::start().await;
1736
1737        wiremock::Mock::given(wiremock::matchers::method("GET"))
1738            .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1/comment"))
1739            .respond_with(
1740                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1741                    "startAt": 0,
1742                    "maxResults": 100,
1743                    "total": 2,
1744                    "comments": [
1745                        {
1746                            "id": "100",
1747                            "author": {"displayName": "Alice"},
1748                            "body": {"version": 1, "type": "doc", "content": []},
1749                            "created": "2026-04-01T10:00:00.000+0000"
1750                        },
1751                        {
1752                            "id": "101",
1753                            "author": {"displayName": "Bob"},
1754                            "body": null,
1755                            "created": "2026-04-02T14:00:00.000+0000"
1756                        }
1757                    ]
1758                })),
1759            )
1760            .expect(1)
1761            .mount(&server)
1762            .await;
1763
1764        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1765        let comments = client.get_comments("PROJ-1", 0).await.unwrap();
1766
1767        assert_eq!(comments.len(), 2);
1768        assert_eq!(comments[0].id, "100");
1769        assert_eq!(comments[0].author, "Alice");
1770        assert!(comments[0].body_adf.is_some());
1771        assert!(comments[0].created.contains("2026-04-01"));
1772        assert_eq!(comments[1].id, "101");
1773        assert_eq!(comments[1].author, "Bob");
1774        assert!(comments[1].body_adf.is_none());
1775    }
1776
1777    #[tokio::test]
1778    async fn get_comments_empty() {
1779        let server = wiremock::MockServer::start().await;
1780
1781        wiremock::Mock::given(wiremock::matchers::method("GET"))
1782            .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1/comment"))
1783            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
1784                serde_json::json!({"startAt": 0, "maxResults": 100, "total": 0, "comments": []}),
1785            ))
1786            .expect(1)
1787            .mount(&server)
1788            .await;
1789
1790        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1791        let comments = client.get_comments("PROJ-1", 0).await.unwrap();
1792        assert!(comments.is_empty());
1793    }
1794
1795    #[tokio::test]
1796    async fn get_comments_api_error() {
1797        let server = wiremock::MockServer::start().await;
1798
1799        wiremock::Mock::given(wiremock::matchers::method("GET"))
1800            .and(wiremock::matchers::path("/rest/api/3/issue/NOPE-1/comment"))
1801            .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
1802            .expect(1)
1803            .mount(&server)
1804            .await;
1805
1806        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1807        let err = client.get_comments("NOPE-1", 0).await.unwrap_err();
1808        assert!(err.to_string().contains("404"));
1809    }
1810
1811    #[tokio::test]
1812    async fn get_comments_paginates_with_offset() {
1813        let server = wiremock::MockServer::start().await;
1814
1815        wiremock::Mock::given(wiremock::matchers::method("GET"))
1816            .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1/comment"))
1817            .and(wiremock::matchers::query_param("startAt", "0"))
1818            .respond_with(
1819                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1820                    "startAt": 0,
1821                    "maxResults": 2,
1822                    "total": 3,
1823                    "comments": [
1824                        {"id": "1", "author": {"displayName": "A"}, "body": null, "created": "2026-04-01T10:00:00.000+0000"},
1825                        {"id": "2", "author": {"displayName": "B"}, "body": null, "created": "2026-04-02T10:00:00.000+0000"}
1826                    ]
1827                })),
1828            )
1829            .up_to_n_times(1)
1830            .mount(&server)
1831            .await;
1832
1833        wiremock::Mock::given(wiremock::matchers::method("GET"))
1834            .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1/comment"))
1835            .and(wiremock::matchers::query_param("startAt", "2"))
1836            .respond_with(
1837                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1838                    "startAt": 2,
1839                    "maxResults": 2,
1840                    "total": 3,
1841                    "comments": [
1842                        {"id": "3", "author": {"displayName": "C"}, "body": null, "created": "2026-04-03T10:00:00.000+0000"}
1843                    ]
1844                })),
1845            )
1846            .up_to_n_times(1)
1847            .mount(&server)
1848            .await;
1849
1850        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1851        let comments = client.get_comments("PROJ-1", 0).await.unwrap();
1852
1853        assert_eq!(comments.len(), 3);
1854        assert_eq!(comments[0].id, "1");
1855        assert_eq!(comments[1].id, "2");
1856        assert_eq!(comments[2].id, "3");
1857    }
1858
1859    #[tokio::test]
1860    async fn get_comments_respects_limit_single_page() {
1861        let server = wiremock::MockServer::start().await;
1862
1863        // Only one page should be fetched because limit (2) < total (5)
1864        wiremock::Mock::given(wiremock::matchers::method("GET"))
1865            .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1/comment"))
1866            .and(wiremock::matchers::query_param("maxResults", "2"))
1867            .and(wiremock::matchers::query_param("startAt", "0"))
1868            .respond_with(
1869                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1870                    "startAt": 0,
1871                    "maxResults": 2,
1872                    "total": 5,
1873                    "comments": [
1874                        {"id": "1", "author": {"displayName": "A"}, "body": null, "created": "2026-04-01T10:00:00.000+0000"},
1875                        {"id": "2", "author": {"displayName": "B"}, "body": null, "created": "2026-04-02T10:00:00.000+0000"}
1876                    ]
1877                })),
1878            )
1879            .expect(1)
1880            .mount(&server)
1881            .await;
1882
1883        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1884        let comments = client.get_comments("PROJ-1", 2).await.unwrap();
1885
1886        assert_eq!(comments.len(), 2);
1887    }
1888
1889    #[tokio::test]
1890    async fn add_comment_success() {
1891        let server = wiremock::MockServer::start().await;
1892
1893        wiremock::Mock::given(wiremock::matchers::method("POST"))
1894            .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1/comment"))
1895            .respond_with(
1896                wiremock::ResponseTemplate::new(201).set_body_json(
1897                    serde_json::json!({"id": "200", "author": {"displayName": "Me"}}),
1898                ),
1899            )
1900            .expect(1)
1901            .mount(&server)
1902            .await;
1903
1904        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1905        let adf = ValidatedAdfDocument::empty();
1906        let result = client.add_comment("PROJ-1", &adf).await;
1907        assert!(result.is_ok());
1908    }
1909
1910    #[tokio::test]
1911    async fn add_comment_api_error() {
1912        let server = wiremock::MockServer::start().await;
1913
1914        wiremock::Mock::given(wiremock::matchers::method("POST"))
1915            .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1/comment"))
1916            .respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
1917            .expect(1)
1918            .mount(&server)
1919            .await;
1920
1921        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1922        let adf = ValidatedAdfDocument::empty();
1923        let err = client.add_comment("PROJ-1", &adf).await.unwrap_err();
1924        assert!(err.to_string().contains("403"));
1925    }
1926
1927    #[tokio::test]
1928    async fn update_comment_success() {
1929        let server = wiremock::MockServer::start().await;
1930
1931        wiremock::Mock::given(wiremock::matchers::method("PUT"))
1932            .and(wiremock::matchers::path(
1933                "/rest/api/3/issue/PROJ-1/comment/100",
1934            ))
1935            .respond_with(
1936                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1937                    "id": "100",
1938                    "author": {"displayName": "Me"},
1939                    "created": "2026-04-01T10:00:00.000+0000",
1940                    "updated": "2026-05-10T12:00:00.000+0000",
1941                    "body": {"type": "doc", "version": 1, "content": []}
1942                })),
1943            )
1944            .expect(1)
1945            .mount(&server)
1946            .await;
1947
1948        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1949        let adf = ValidatedAdfDocument::empty();
1950        let comment = client
1951            .update_comment("PROJ-1", "100", &adf, None)
1952            .await
1953            .unwrap();
1954        assert_eq!(comment.id, "100");
1955        assert_eq!(comment.author, "Me");
1956        assert_eq!(
1957            comment.updated.as_deref(),
1958            Some("2026-05-10T12:00:00.000+0000")
1959        );
1960    }
1961
1962    #[tokio::test]
1963    async fn update_comment_sends_visibility() {
1964        let server = wiremock::MockServer::start().await;
1965
1966        wiremock::Mock::given(wiremock::matchers::method("PUT"))
1967            .and(wiremock::matchers::path(
1968                "/rest/api/3/issue/PROJ-1/comment/100",
1969            ))
1970            .and(wiremock::matchers::body_partial_json(serde_json::json!({
1971                "visibility": {"type": "role", "identifier": "Administrators"}
1972            })))
1973            .respond_with(
1974                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1975                    "id": "100",
1976                    "author": {"displayName": "Me"},
1977                    "created": "2026-04-01T10:00:00.000+0000",
1978                    "updated": "2026-05-10T12:00:00.000+0000",
1979                    "body": null
1980                })),
1981            )
1982            .expect(1)
1983            .mount(&server)
1984            .await;
1985
1986        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1987        let adf = ValidatedAdfDocument::empty();
1988        let visibility = JiraVisibility {
1989            ty: JiraVisibilityType::Role,
1990            value: "Administrators".to_string(),
1991        };
1992        client
1993            .update_comment("PROJ-1", "100", &adf, Some(&visibility))
1994            .await
1995            .unwrap();
1996    }
1997
1998    #[tokio::test]
1999    async fn update_comment_forbidden_surfaces_jira_message() {
2000        let server = wiremock::MockServer::start().await;
2001
2002        wiremock::Mock::given(wiremock::matchers::method("PUT"))
2003            .and(wiremock::matchers::path(
2004                "/rest/api/3/issue/PROJ-1/comment/100",
2005            ))
2006            .respond_with(
2007                wiremock::ResponseTemplate::new(403).set_body_json(serde_json::json!({
2008                    "errorMessages": ["You do not have permission to edit this comment"],
2009                    "errors": {}
2010                })),
2011            )
2012            .expect(1)
2013            .mount(&server)
2014            .await;
2015
2016        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2017        let adf = ValidatedAdfDocument::empty();
2018        let err = client
2019            .update_comment("PROJ-1", "100", &adf, None)
2020            .await
2021            .unwrap_err();
2022        let msg = err.to_string();
2023        assert!(msg.contains("403"));
2024        assert!(msg.contains("permission to edit"));
2025    }
2026
2027    #[tokio::test]
2028    async fn update_comment_not_found() {
2029        let server = wiremock::MockServer::start().await;
2030
2031        wiremock::Mock::given(wiremock::matchers::method("PUT"))
2032            .and(wiremock::matchers::path(
2033                "/rest/api/3/issue/PROJ-1/comment/9999",
2034            ))
2035            .respond_with(
2036                wiremock::ResponseTemplate::new(404).set_body_json(serde_json::json!({
2037                    "errorMessages": ["Comment not found"]
2038                })),
2039            )
2040            .expect(1)
2041            .mount(&server)
2042            .await;
2043
2044        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2045        let adf = ValidatedAdfDocument::empty();
2046        let err = client
2047            .update_comment("PROJ-1", "9999", &adf, None)
2048            .await
2049            .unwrap_err();
2050        let msg = err.to_string();
2051        assert!(msg.contains("404"));
2052        assert!(msg.contains("Comment not found"));
2053    }
2054
2055    #[tokio::test]
2056    async fn get_transitions_success() {
2057        let server = wiremock::MockServer::start().await;
2058
2059        wiremock::Mock::given(wiremock::matchers::method("GET"))
2060            .and(wiremock::matchers::path(
2061                "/rest/api/3/issue/PROJ-1/transitions",
2062            ))
2063            .respond_with(
2064                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2065                    "transitions": [
2066                        {"id": "11", "name": "In Progress"},
2067                        {"id": "21", "name": "Done"},
2068                        {"id": "31", "name": "Won't Do"}
2069                    ]
2070                })),
2071            )
2072            .expect(1)
2073            .mount(&server)
2074            .await;
2075
2076        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2077        let transitions = client.get_transitions("PROJ-1").await.unwrap();
2078
2079        assert_eq!(transitions.len(), 3);
2080        assert_eq!(transitions[0].id, "11");
2081        assert_eq!(transitions[0].name, "In Progress");
2082        assert_eq!(transitions[1].id, "21");
2083        assert_eq!(transitions[2].name, "Won't Do");
2084    }
2085
2086    #[tokio::test]
2087    async fn get_transitions_empty() {
2088        let server = wiremock::MockServer::start().await;
2089
2090        wiremock::Mock::given(wiremock::matchers::method("GET"))
2091            .and(wiremock::matchers::path(
2092                "/rest/api/3/issue/PROJ-1/transitions",
2093            ))
2094            .respond_with(
2095                wiremock::ResponseTemplate::new(200)
2096                    .set_body_json(serde_json::json!({"transitions": []})),
2097            )
2098            .expect(1)
2099            .mount(&server)
2100            .await;
2101
2102        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2103        let transitions = client.get_transitions("PROJ-1").await.unwrap();
2104        assert!(transitions.is_empty());
2105    }
2106
2107    #[tokio::test]
2108    async fn get_transitions_rich_fields() {
2109        let server = wiremock::MockServer::start().await;
2110
2111        wiremock::Mock::given(wiremock::matchers::method("GET"))
2112            .and(wiremock::matchers::path(
2113                "/rest/api/3/issue/PROJ-1/transitions",
2114            ))
2115            .respond_with(
2116                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2117                    "transitions": [
2118                        {
2119                            "id": "21",
2120                            "name": "In Progress",
2121                            "hasScreen": false,
2122                            "to": {
2123                                "id": "3",
2124                                "name": "In Progress",
2125                                "statusCategory": {"key": "indeterminate"}
2126                            }
2127                        },
2128                        {
2129                            "id": "31",
2130                            "name": "Done",
2131                            "hasScreen": true,
2132                            "to": {
2133                                "id": "10000",
2134                                "name": "Done",
2135                                "statusCategory": {"key": "done"}
2136                            }
2137                        }
2138                    ]
2139                })),
2140            )
2141            .expect(1)
2142            .mount(&server)
2143            .await;
2144
2145        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2146        let transitions = client.get_transitions("PROJ-1").await.unwrap();
2147
2148        assert_eq!(transitions.len(), 2);
2149        assert_eq!(transitions[0].id, "21");
2150        assert_eq!(transitions[0].has_screen, Some(false));
2151        let to0 = transitions[0].to_status.as_ref().unwrap();
2152        assert_eq!(to0.id, "3");
2153        assert_eq!(to0.name, "In Progress");
2154        assert_eq!(to0.category.as_deref(), Some("indeterminate"));
2155        assert_eq!(transitions[1].has_screen, Some(true));
2156        let to1 = transitions[1].to_status.as_ref().unwrap();
2157        assert_eq!(to1.category.as_deref(), Some("done"));
2158    }
2159
2160    #[tokio::test]
2161    async fn get_transitions_api_error() {
2162        let server = wiremock::MockServer::start().await;
2163
2164        wiremock::Mock::given(wiremock::matchers::method("GET"))
2165            .and(wiremock::matchers::path(
2166                "/rest/api/3/issue/NOPE-1/transitions",
2167            ))
2168            .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
2169            .expect(1)
2170            .mount(&server)
2171            .await;
2172
2173        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2174        let err = client.get_transitions("NOPE-1").await.unwrap_err();
2175        assert!(err.to_string().contains("404"));
2176    }
2177
2178    #[tokio::test]
2179    async fn do_transition_success() {
2180        let server = wiremock::MockServer::start().await;
2181
2182        wiremock::Mock::given(wiremock::matchers::method("POST"))
2183            .and(wiremock::matchers::path(
2184                "/rest/api/3/issue/PROJ-1/transitions",
2185            ))
2186            .respond_with(wiremock::ResponseTemplate::new(204))
2187            .expect(1)
2188            .mount(&server)
2189            .await;
2190
2191        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2192        let result = client.do_transition("PROJ-1", "21").await;
2193        assert!(result.is_ok());
2194    }
2195
2196    #[tokio::test]
2197    async fn do_transition_api_error() {
2198        let server = wiremock::MockServer::start().await;
2199
2200        wiremock::Mock::given(wiremock::matchers::method("POST"))
2201            .and(wiremock::matchers::path(
2202                "/rest/api/3/issue/PROJ-1/transitions",
2203            ))
2204            .respond_with(
2205                wiremock::ResponseTemplate::new(400).set_body_string("Invalid transition"),
2206            )
2207            .expect(1)
2208            .mount(&server)
2209            .await;
2210
2211        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2212        let err = client.do_transition("PROJ-1", "999").await.unwrap_err();
2213        assert!(err.to_string().contains("400"));
2214    }
2215
2216    #[tokio::test]
2217    async fn search_confluence_success() {
2218        let server = wiremock::MockServer::start().await;
2219
2220        wiremock::Mock::given(wiremock::matchers::method("GET"))
2221            .and(wiremock::matchers::path("/wiki/rest/api/content/search"))
2222            .respond_with(
2223                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2224                    "results": [
2225                        {
2226                            "id": "12345",
2227                            "title": "Architecture Overview",
2228                            "_expandable": {"space": "/wiki/rest/api/space/ENG"}
2229                        },
2230                        {
2231                            "id": "67890",
2232                            "title": "Getting Started",
2233                            "_expandable": {"space": "/wiki/rest/api/space/DOC"}
2234                        }
2235                    ],
2236                    "size": 2
2237                })),
2238            )
2239            .expect(1)
2240            .mount(&server)
2241            .await;
2242
2243        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2244        let result = client.search_confluence("type = page", 25).await.unwrap();
2245
2246        assert_eq!(result.total, 2);
2247        assert_eq!(result.results.len(), 2);
2248        assert_eq!(result.results[0].id, "12345");
2249        assert_eq!(result.results[0].title, "Architecture Overview");
2250        assert_eq!(result.results[0].space_key, "ENG");
2251        assert_eq!(result.results[1].space_key, "DOC");
2252    }
2253
2254    #[tokio::test]
2255    async fn search_confluence_empty() {
2256        let server = wiremock::MockServer::start().await;
2257
2258        wiremock::Mock::given(wiremock::matchers::method("GET"))
2259            .and(wiremock::matchers::path("/wiki/rest/api/content/search"))
2260            .respond_with(
2261                wiremock::ResponseTemplate::new(200)
2262                    .set_body_json(serde_json::json!({"results": [], "size": 0})),
2263            )
2264            .expect(1)
2265            .mount(&server)
2266            .await;
2267
2268        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2269        let result = client
2270            .search_confluence("title = \"Nonexistent\"", 25)
2271            .await
2272            .unwrap();
2273        assert_eq!(result.total, 0);
2274        assert!(result.results.is_empty());
2275    }
2276
2277    #[tokio::test]
2278    async fn search_confluence_api_error() {
2279        let server = wiremock::MockServer::start().await;
2280
2281        wiremock::Mock::given(wiremock::matchers::method("GET"))
2282            .and(wiremock::matchers::path("/wiki/rest/api/content/search"))
2283            .respond_with(wiremock::ResponseTemplate::new(400).set_body_string("Invalid CQL"))
2284            .expect(1)
2285            .mount(&server)
2286            .await;
2287
2288        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2289        let err = client
2290            .search_confluence("bad cql !!!", 25)
2291            .await
2292            .unwrap_err();
2293        assert!(err.to_string().contains("400"));
2294    }
2295
2296    #[tokio::test]
2297    async fn search_confluence_missing_space() {
2298        let server = wiremock::MockServer::start().await;
2299
2300        wiremock::Mock::given(wiremock::matchers::method("GET"))
2301            .and(wiremock::matchers::path("/wiki/rest/api/content/search"))
2302            .respond_with(
2303                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2304                    "results": [{"id": "111", "title": "No Space"}],
2305                    "size": 1
2306                })),
2307            )
2308            .expect(1)
2309            .mount(&server)
2310            .await;
2311
2312        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2313        let result = client.search_confluence("type = page", 10).await.unwrap();
2314        assert_eq!(result.results[0].space_key, "");
2315    }
2316
2317    // ── search_jira_users ───────────────────────────────────────
2318
2319    #[tokio::test]
2320    async fn search_jira_users_returns_decoded_results() {
2321        let server = wiremock::MockServer::start().await;
2322        wiremock::Mock::given(wiremock::matchers::method("GET"))
2323            .and(wiremock::matchers::path("/rest/api/3/user/search"))
2324            .and(wiremock::matchers::query_param("query", "alice"))
2325            .respond_with(
2326                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!([
2327                    {
2328                        "accountId": "abc123",
2329                        "displayName": "Alice Smith",
2330                        "emailAddress": "alice@example.com",
2331                        "active": true,
2332                        "accountType": "atlassian"
2333                    },
2334                    {
2335                        "accountId": "def456",
2336                        "displayName": "Alice Jones",
2337                        "active": true,
2338                        "accountType": "atlassian"
2339                    }
2340                ])),
2341            )
2342            .expect(1)
2343            .mount(&server)
2344            .await;
2345
2346        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2347        let result = client.search_jira_users("alice", 25).await.unwrap();
2348        assert_eq!(result.count, 2);
2349        assert_eq!(result.users[0].account_id, "abc123");
2350        assert_eq!(result.users[0].display_name.as_deref(), Some("Alice Smith"));
2351        assert_eq!(
2352            result.users[0].email_address.as_deref(),
2353            Some("alice@example.com")
2354        );
2355        assert!(result.users[0].active);
2356        // The second user has email redacted by GDPR.
2357        assert!(result.users[1].email_address.is_none());
2358    }
2359
2360    #[tokio::test]
2361    async fn search_jira_users_empty_returns_empty_list() {
2362        let server = wiremock::MockServer::start().await;
2363        wiremock::Mock::given(wiremock::matchers::method("GET"))
2364            .and(wiremock::matchers::path("/rest/api/3/user/search"))
2365            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
2366            .expect(1)
2367            .mount(&server)
2368            .await;
2369        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2370        let result = client.search_jira_users("nobody", 25).await.unwrap();
2371        assert_eq!(result.count, 0);
2372        assert!(result.users.is_empty());
2373    }
2374
2375    #[tokio::test]
2376    async fn search_jira_users_truncates_at_limit() {
2377        let server = wiremock::MockServer::start().await;
2378        let users_page_1 = serde_json::json!([
2379            {"accountId": "u1", "displayName": "U1", "active": true, "accountType": "atlassian"},
2380            {"accountId": "u2", "displayName": "U2", "active": true, "accountType": "atlassian"}
2381        ]);
2382
2383        // limit=2 fits the first page exactly, so only one request should fire.
2384        wiremock::Mock::given(wiremock::matchers::method("GET"))
2385            .and(wiremock::matchers::path("/rest/api/3/user/search"))
2386            .and(wiremock::matchers::query_param("startAt", "0"))
2387            .and(wiremock::matchers::query_param("maxResults", "2"))
2388            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&users_page_1))
2389            .expect(1)
2390            .mount(&server)
2391            .await;
2392
2393        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2394        let result = client.search_jira_users("u", 2).await.unwrap();
2395        assert_eq!(result.count, 2);
2396    }
2397
2398    #[tokio::test]
2399    async fn search_jira_users_unlimited_paginates_to_completion() {
2400        let server = wiremock::MockServer::start().await;
2401
2402        // Build a full page of PAGE_SIZE (100) users, then a short page of 3.
2403        let full_page: Vec<serde_json::Value> = (0..100)
2404            .map(|i| {
2405                serde_json::json!({
2406                    "accountId": format!("u{i}"),
2407                    "displayName": format!("User {i}"),
2408                    "active": true,
2409                    "accountType": "atlassian"
2410                })
2411            })
2412            .collect();
2413        let short_page: Vec<serde_json::Value> = (100..103)
2414            .map(|i| {
2415                serde_json::json!({
2416                    "accountId": format!("u{i}"),
2417                    "displayName": format!("User {i}"),
2418                    "active": true,
2419                    "accountType": "atlassian"
2420                })
2421            })
2422            .collect();
2423
2424        wiremock::Mock::given(wiremock::matchers::method("GET"))
2425            .and(wiremock::matchers::path("/rest/api/3/user/search"))
2426            .and(wiremock::matchers::query_param("startAt", "0"))
2427            .respond_with(
2428                wiremock::ResponseTemplate::new(200)
2429                    .set_body_json(serde_json::Value::Array(full_page)),
2430            )
2431            .expect(1)
2432            .mount(&server)
2433            .await;
2434
2435        wiremock::Mock::given(wiremock::matchers::method("GET"))
2436            .and(wiremock::matchers::path("/rest/api/3/user/search"))
2437            .and(wiremock::matchers::query_param("startAt", "100"))
2438            .respond_with(
2439                wiremock::ResponseTemplate::new(200)
2440                    .set_body_json(serde_json::Value::Array(short_page)),
2441            )
2442            .expect(1)
2443            .mount(&server)
2444            .await;
2445
2446        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2447        let result = client.search_jira_users("u", 0).await.unwrap();
2448        assert_eq!(result.count, 103);
2449    }
2450
2451    #[tokio::test]
2452    async fn search_jira_users_propagates_403() {
2453        let server = wiremock::MockServer::start().await;
2454        wiremock::Mock::given(wiremock::matchers::method("GET"))
2455            .and(wiremock::matchers::path("/rest/api/3/user/search"))
2456            .respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
2457            .expect(1)
2458            .mount(&server)
2459            .await;
2460
2461        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2462        let err = client.search_jira_users("alice", 25).await.unwrap_err();
2463        assert!(err.to_string().contains("403"));
2464    }
2465
2466    #[tokio::test]
2467    async fn search_jira_users_inactive_user_passes_through() {
2468        let server = wiremock::MockServer::start().await;
2469        wiremock::Mock::given(wiremock::matchers::method("GET"))
2470            .and(wiremock::matchers::path("/rest/api/3/user/search"))
2471            .respond_with(
2472                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!([
2473                    {
2474                        "accountId": "old1",
2475                        "displayName": "Former Employee",
2476                        "active": false,
2477                        "accountType": "atlassian"
2478                    }
2479                ])),
2480            )
2481            .expect(1)
2482            .mount(&server)
2483            .await;
2484        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2485        let result = client.search_jira_users("former", 25).await.unwrap();
2486        assert_eq!(result.count, 1);
2487        assert!(!result.users[0].active);
2488    }
2489
2490    // ── search_confluence_users ─────────────────────────────────
2491
2492    #[tokio::test]
2493    async fn search_confluence_users_success() {
2494        let server = wiremock::MockServer::start().await;
2495
2496        wiremock::Mock::given(wiremock::matchers::method("GET"))
2497            .and(wiremock::matchers::path("/wiki/rest/api/search/user"))
2498            .respond_with(
2499                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2500                    "results": [
2501                        {
2502                            "user": {
2503                                "accountId": "abc123",
2504                                "displayName": "Alice Smith",
2505                                "email": "alice@example.com"
2506                            },
2507                            "entityType": "user"
2508                        },
2509                        {
2510                            "user": {
2511                                "accountId": "def456",
2512                                "displayName": "Bob Jones",
2513                                "email": "bob@example.com"
2514                            },
2515                            "entityType": "user"
2516                        }
2517                    ]
2518                })),
2519            )
2520            .expect(1)
2521            .mount(&server)
2522            .await;
2523
2524        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2525        let result = client.search_confluence_users("alice", 25).await.unwrap();
2526
2527        assert_eq!(result.total, 2);
2528        assert_eq!(result.users.len(), 2);
2529        assert_eq!(result.users[0].account_id.as_deref(), Some("abc123"));
2530        assert_eq!(result.users[0].display_name, "Alice Smith");
2531        assert_eq!(result.users[0].email.as_deref(), Some("alice@example.com"));
2532        assert_eq!(result.users[1].account_id.as_deref(), Some("def456"));
2533        assert_eq!(result.users[1].display_name, "Bob Jones");
2534    }
2535
2536    #[tokio::test]
2537    async fn search_confluence_users_empty() {
2538        let server = wiremock::MockServer::start().await;
2539
2540        wiremock::Mock::given(wiremock::matchers::method("GET"))
2541            .and(wiremock::matchers::path("/wiki/rest/api/search/user"))
2542            .respond_with(
2543                wiremock::ResponseTemplate::new(200)
2544                    .set_body_json(serde_json::json!({"results": []})),
2545            )
2546            .expect(1)
2547            .mount(&server)
2548            .await;
2549
2550        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2551        let result = client
2552            .search_confluence_users("nonexistent", 25)
2553            .await
2554            .unwrap();
2555        assert_eq!(result.total, 0);
2556        assert!(result.users.is_empty());
2557    }
2558
2559    #[tokio::test]
2560    async fn search_confluence_users_api_error() {
2561        let server = wiremock::MockServer::start().await;
2562
2563        wiremock::Mock::given(wiremock::matchers::method("GET"))
2564            .and(wiremock::matchers::path("/wiki/rest/api/search/user"))
2565            .respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
2566            .expect(1)
2567            .mount(&server)
2568            .await;
2569
2570        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2571        let err = client
2572            .search_confluence_users("alice", 25)
2573            .await
2574            .unwrap_err();
2575        assert!(err.to_string().contains("403"));
2576    }
2577
2578    #[tokio::test]
2579    async fn search_confluence_users_missing_email() {
2580        let server = wiremock::MockServer::start().await;
2581
2582        wiremock::Mock::given(wiremock::matchers::method("GET"))
2583            .and(wiremock::matchers::path("/wiki/rest/api/search/user"))
2584            .respond_with(
2585                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2586                    "results": [
2587                        {
2588                            "user": {
2589                                "accountId": "xyz789",
2590                                "displayName": "No Email User"
2591                            }
2592                        }
2593                    ]
2594                })),
2595            )
2596            .expect(1)
2597            .mount(&server)
2598            .await;
2599
2600        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2601        let result = client
2602            .search_confluence_users("no email", 25)
2603            .await
2604            .unwrap();
2605        assert_eq!(result.users.len(), 1);
2606        assert_eq!(result.users[0].display_name, "No Email User");
2607        assert!(result.users[0].email.is_none());
2608    }
2609
2610    #[tokio::test]
2611    async fn search_confluence_users_missing_account_id() {
2612        // Regression for rust-works/omni-dev#542: some user records (e.g. app
2613        // users, deactivated users) return no `accountId`. Such entries must
2614        // not fail deserialization.
2615        let server = wiremock::MockServer::start().await;
2616
2617        wiremock::Mock::given(wiremock::matchers::method("GET"))
2618            .and(wiremock::matchers::path("/wiki/rest/api/search/user"))
2619            .respond_with(
2620                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2621                    "results": [
2622                        {
2623                            "user": {
2624                                "accountId": "abc123",
2625                                "displayName": "Alice Smith",
2626                                "email": "alice@example.com"
2627                            }
2628                        },
2629                        {
2630                            "user": {
2631                                "displayName": "App Bot",
2632                                "accountType": "app"
2633                            }
2634                        }
2635                    ]
2636                })),
2637            )
2638            .expect(1)
2639            .mount(&server)
2640            .await;
2641
2642        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2643        let result = client.search_confluence_users("any", 25).await.unwrap();
2644        assert_eq!(result.users.len(), 2);
2645        assert_eq!(result.users[0].account_id.as_deref(), Some("abc123"));
2646        assert!(result.users[1].account_id.is_none());
2647        assert_eq!(result.users[1].display_name, "App Bot");
2648    }
2649
2650    #[tokio::test]
2651    async fn search_confluence_users_uses_public_name_when_no_display_name() {
2652        let server = wiremock::MockServer::start().await;
2653
2654        wiremock::Mock::given(wiremock::matchers::method("GET"))
2655            .and(wiremock::matchers::path("/wiki/rest/api/search/user"))
2656            .respond_with(
2657                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2658                    "results": [
2659                        {
2660                            "user": {
2661                                "accountId": "abc123",
2662                                "publicName": "alice.smith"
2663                            }
2664                        }
2665                    ]
2666                })),
2667            )
2668            .expect(1)
2669            .mount(&server)
2670            .await;
2671
2672        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2673        let result = client.search_confluence_users("alice", 25).await.unwrap();
2674        assert_eq!(result.users.len(), 1);
2675        assert_eq!(result.users[0].display_name, "alice.smith");
2676    }
2677
2678    #[tokio::test]
2679    async fn search_confluence_users_skips_entries_without_user() {
2680        // Defensive: the search endpoint may return non-user entries if filters
2681        // are relaxed server-side; skip them rather than failing.
2682        let server = wiremock::MockServer::start().await;
2683
2684        wiremock::Mock::given(wiremock::matchers::method("GET"))
2685            .and(wiremock::matchers::path("/wiki/rest/api/search/user"))
2686            .respond_with(
2687                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2688                    "results": [
2689                        {"title": "Not a user", "entityType": "content"},
2690                        {
2691                            "user": {
2692                                "accountId": "abc123",
2693                                "displayName": "Alice Smith"
2694                            }
2695                        }
2696                    ]
2697                })),
2698            )
2699            .expect(1)
2700            .mount(&server)
2701            .await;
2702
2703        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2704        let result = client.search_confluence_users("alice", 25).await.unwrap();
2705        assert_eq!(result.users.len(), 1);
2706        assert_eq!(result.users[0].account_id.as_deref(), Some("abc123"));
2707    }
2708
2709    #[tokio::test]
2710    async fn search_confluence_users_pagination() {
2711        let server = wiremock::MockServer::start().await;
2712
2713        // First page returns one result with a next link
2714        wiremock::Mock::given(wiremock::matchers::method("GET"))
2715            .and(wiremock::matchers::path("/wiki/rest/api/search/user"))
2716            .and(wiremock::matchers::query_param("start", "0"))
2717            .respond_with(
2718                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2719                    "results": [
2720                        {
2721                            "user": {
2722                                "accountId": "page1",
2723                                "displayName": "User One"
2724                            }
2725                        }
2726                    ],
2727                    "_links": {"next": "/wiki/rest/api/search/user?start=1"}
2728                })),
2729            )
2730            .expect(1)
2731            .mount(&server)
2732            .await;
2733
2734        // Second page returns one result with no next link
2735        wiremock::Mock::given(wiremock::matchers::method("GET"))
2736            .and(wiremock::matchers::path("/wiki/rest/api/search/user"))
2737            .and(wiremock::matchers::query_param("start", "1"))
2738            .respond_with(
2739                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2740                    "results": [
2741                        {
2742                            "user": {
2743                                "accountId": "page2",
2744                                "displayName": "User Two"
2745                            }
2746                        }
2747                    ]
2748                })),
2749            )
2750            .expect(1)
2751            .mount(&server)
2752            .await;
2753
2754        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2755        let result = client.search_confluence_users("user", 0).await.unwrap();
2756
2757        assert_eq!(result.total, 2);
2758        assert_eq!(result.users[0].account_id.as_deref(), Some("page1"));
2759        assert_eq!(result.users[1].account_id.as_deref(), Some("page2"));
2760    }
2761
2762    #[tokio::test]
2763    async fn get_boards_success() {
2764        let server = wiremock::MockServer::start().await;
2765
2766        wiremock::Mock::given(wiremock::matchers::method("GET"))
2767            .and(wiremock::matchers::path("/rest/agile/1.0/board"))
2768            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
2769                serde_json::json!({
2770                    "values": [
2771                        {"id": 1, "name": "PROJ Board", "type": "scrum", "location": {"projectKey": "PROJ"}},
2772                        {"id": 2, "name": "Kanban", "type": "kanban"}
2773                    ],
2774                    "total": 2, "isLast": true
2775                }),
2776            ))
2777            .expect(1)
2778            .mount(&server)
2779            .await;
2780
2781        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2782        let result = client.get_boards(None, None, 50).await.unwrap();
2783
2784        assert_eq!(result.total, 2);
2785        assert_eq!(result.boards.len(), 2);
2786        assert_eq!(result.boards[0].id, 1);
2787        assert_eq!(result.boards[0].name, "PROJ Board");
2788        assert_eq!(result.boards[0].board_type, "scrum");
2789        assert_eq!(result.boards[0].project_key.as_deref(), Some("PROJ"));
2790        assert!(result.boards[1].project_key.is_none());
2791    }
2792
2793    #[tokio::test]
2794    async fn get_boards_with_filters() {
2795        let server = wiremock::MockServer::start().await;
2796
2797        wiremock::Mock::given(wiremock::matchers::method("GET"))
2798            .and(wiremock::matchers::path("/rest/agile/1.0/board"))
2799            .and(wiremock::matchers::query_param("projectKeyOrId", "PROJ"))
2800            .and(wiremock::matchers::query_param("type", "scrum"))
2801            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
2802                serde_json::json!({
2803                    "values": [{"id": 1, "name": "PROJ Board", "type": "scrum", "location": {"projectKey": "PROJ"}}],
2804                    "total": 1, "isLast": true
2805                }),
2806            ))
2807            .expect(1)
2808            .mount(&server)
2809            .await;
2810
2811        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2812        let result = client
2813            .get_boards(Some("PROJ"), Some("scrum"), 50)
2814            .await
2815            .unwrap();
2816
2817        assert_eq!(result.boards.len(), 1);
2818        assert_eq!(result.boards[0].project_key.as_deref(), Some("PROJ"));
2819    }
2820
2821    #[tokio::test]
2822    async fn search_issues_paginates_with_token() {
2823        let server = wiremock::MockServer::start().await;
2824
2825        // First page returns a nextPageToken
2826        wiremock::Mock::given(wiremock::matchers::method("POST"))
2827            .and(wiremock::matchers::path("/rest/api/3/search/jql"))
2828            .and(wiremock::matchers::body_partial_json(serde_json::json!({"jql": "project = PROJ"})))
2829            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
2830                serde_json::json!({
2831                    "issues": [{"key": "PROJ-1", "fields": {"summary": "First", "description": null, "status": null, "issuetype": null, "assignee": null, "priority": null, "labels": []}}],
2832                    "nextPageToken": "token123"
2833                }),
2834            ))
2835            .up_to_n_times(1)
2836            .mount(&server)
2837            .await;
2838
2839        // Second page has no nextPageToken (last page)
2840        wiremock::Mock::given(wiremock::matchers::method("POST"))
2841            .and(wiremock::matchers::path("/rest/api/3/search/jql"))
2842            .and(wiremock::matchers::body_partial_json(serde_json::json!({"nextPageToken": "token123"})))
2843            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
2844                serde_json::json!({
2845                    "issues": [{"key": "PROJ-2", "fields": {"summary": "Second", "description": null, "status": null, "issuetype": null, "assignee": null, "priority": null, "labels": []}}]
2846                }),
2847            ))
2848            .up_to_n_times(1)
2849            .mount(&server)
2850            .await;
2851
2852        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2853        let result = client.search_issues("project = PROJ", 0).await.unwrap();
2854
2855        assert_eq!(result.issues.len(), 2);
2856        assert_eq!(result.issues[0].key, "PROJ-1");
2857        assert_eq!(result.issues[1].key, "PROJ-2");
2858    }
2859
2860    #[tokio::test]
2861    async fn search_issues_respects_limit() {
2862        let server = wiremock::MockServer::start().await;
2863
2864        wiremock::Mock::given(wiremock::matchers::method("POST"))
2865            .and(wiremock::matchers::path("/rest/api/3/search/jql"))
2866            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
2867                serde_json::json!({
2868                    "issues": [
2869                        {"key": "PROJ-1", "fields": {"summary": "A", "description": null, "status": null, "issuetype": null, "assignee": null, "priority": null, "labels": []}},
2870                        {"key": "PROJ-2", "fields": {"summary": "B", "description": null, "status": null, "issuetype": null, "assignee": null, "priority": null, "labels": []}}
2871                    ],
2872                    "nextPageToken": "more"
2873                }),
2874            ))
2875            .up_to_n_times(1)
2876            .mount(&server)
2877            .await;
2878
2879        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2880        // Limit to 2 — should not fetch second page
2881        let result = client.search_issues("project = PROJ", 2).await.unwrap();
2882        assert_eq!(result.issues.len(), 2);
2883    }
2884
2885    #[tokio::test]
2886    async fn get_boards_paginates_with_offset() {
2887        let server = wiremock::MockServer::start().await;
2888
2889        // First page
2890        wiremock::Mock::given(wiremock::matchers::method("GET"))
2891            .and(wiremock::matchers::path("/rest/agile/1.0/board"))
2892            .and(wiremock::matchers::query_param("startAt", "0"))
2893            .respond_with(
2894                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2895                    "values": [{"id": 1, "name": "Board 1", "type": "scrum"}],
2896                    "total": 2, "isLast": false
2897                })),
2898            )
2899            .up_to_n_times(1)
2900            .mount(&server)
2901            .await;
2902
2903        // Second page
2904        wiremock::Mock::given(wiremock::matchers::method("GET"))
2905            .and(wiremock::matchers::path("/rest/agile/1.0/board"))
2906            .and(wiremock::matchers::query_param("startAt", "1"))
2907            .respond_with(
2908                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2909                    "values": [{"id": 2, "name": "Board 2", "type": "kanban"}],
2910                    "total": 2, "isLast": true
2911                })),
2912            )
2913            .up_to_n_times(1)
2914            .mount(&server)
2915            .await;
2916
2917        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2918        let result = client.get_boards(None, None, 0).await.unwrap();
2919
2920        assert_eq!(result.boards.len(), 2);
2921        assert_eq!(result.boards[0].name, "Board 1");
2922        assert_eq!(result.boards[1].name, "Board 2");
2923    }
2924
2925    #[tokio::test]
2926    async fn get_boards_empty() {
2927        let server = wiremock::MockServer::start().await;
2928
2929        wiremock::Mock::given(wiremock::matchers::method("GET"))
2930            .and(wiremock::matchers::path("/rest/agile/1.0/board"))
2931            .respond_with(
2932                wiremock::ResponseTemplate::new(200)
2933                    .set_body_json(serde_json::json!({"values": [], "total": 0})),
2934            )
2935            .expect(1)
2936            .mount(&server)
2937            .await;
2938
2939        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2940        let result = client.get_boards(None, None, 50).await.unwrap();
2941        assert!(result.boards.is_empty());
2942    }
2943
2944    #[tokio::test]
2945    async fn get_boards_api_error() {
2946        let server = wiremock::MockServer::start().await;
2947
2948        wiremock::Mock::given(wiremock::matchers::method("GET"))
2949            .and(wiremock::matchers::path("/rest/agile/1.0/board"))
2950            .respond_with(wiremock::ResponseTemplate::new(401).set_body_string("Unauthorized"))
2951            .expect(1)
2952            .mount(&server)
2953            .await;
2954
2955        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2956        let err = client.get_boards(None, None, 50).await.unwrap_err();
2957        assert!(err.to_string().contains("401"));
2958    }
2959
2960    #[tokio::test]
2961    async fn get_board_issues_success() {
2962        let server = wiremock::MockServer::start().await;
2963
2964        wiremock::Mock::given(wiremock::matchers::method("GET"))
2965            .and(wiremock::matchers::path("/rest/agile/1.0/board/1/issue"))
2966            .respond_with(
2967                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2968                    "issues": [{
2969                        "key": "PROJ-1",
2970                        "fields": {
2971                            "summary": "Board issue",
2972                            "description": null,
2973                            "status": {"name": "Open"},
2974                            "issuetype": {"name": "Task"},
2975                            "assignee": null,
2976                            "priority": null,
2977                            "labels": []
2978                        }
2979                    }],
2980                    "total": 1, "isLast": true
2981                })),
2982            )
2983            .expect(1)
2984            .mount(&server)
2985            .await;
2986
2987        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2988        let result = client.get_board_issues(1, None, 50).await.unwrap();
2989
2990        assert_eq!(result.total, 1);
2991        assert_eq!(result.issues[0].key, "PROJ-1");
2992        assert_eq!(result.issues[0].summary, "Board issue");
2993    }
2994
2995    #[tokio::test]
2996    async fn get_board_issues_api_error() {
2997        let server = wiremock::MockServer::start().await;
2998
2999        wiremock::Mock::given(wiremock::matchers::method("GET"))
3000            .and(wiremock::matchers::path("/rest/agile/1.0/board/999/issue"))
3001            .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
3002            .expect(1)
3003            .mount(&server)
3004            .await;
3005
3006        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3007        let err = client.get_board_issues(999, None, 50).await.unwrap_err();
3008        assert!(err.to_string().contains("404"));
3009    }
3010
3011    #[tokio::test]
3012    async fn get_sprints_success() {
3013        let server = wiremock::MockServer::start().await;
3014
3015        wiremock::Mock::given(wiremock::matchers::method("GET"))
3016            .and(wiremock::matchers::path("/rest/agile/1.0/board/1/sprint"))
3017            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
3018                serde_json::json!({
3019                    "values": [
3020                        {"id": 10, "name": "Sprint 1", "state": "closed", "startDate": "2026-03-01", "endDate": "2026-03-14", "goal": "MVP"},
3021                        {"id": 11, "name": "Sprint 2", "state": "active", "startDate": "2026-03-15", "endDate": "2026-03-28"}
3022                    ],
3023                    "total": 2, "isLast": true
3024                }),
3025            ))
3026            .expect(1)
3027            .mount(&server)
3028            .await;
3029
3030        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3031        let result = client.get_sprints(1, None, 50).await.unwrap();
3032
3033        assert_eq!(result.total, 2);
3034        assert_eq!(result.sprints.len(), 2);
3035        assert_eq!(result.sprints[0].id, 10);
3036        assert_eq!(result.sprints[0].name, "Sprint 1");
3037        assert_eq!(result.sprints[0].state, "closed");
3038        assert_eq!(result.sprints[0].goal.as_deref(), Some("MVP"));
3039        assert!(result.sprints[1].goal.is_none());
3040    }
3041
3042    #[tokio::test]
3043    async fn get_sprints_with_state_filter() {
3044        let server = wiremock::MockServer::start().await;
3045
3046        wiremock::Mock::given(wiremock::matchers::method("GET"))
3047            .and(wiremock::matchers::path("/rest/agile/1.0/board/1/sprint"))
3048            .and(wiremock::matchers::query_param("state", "active"))
3049            .respond_with(
3050                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
3051                    "values": [{"id": 11, "name": "Sprint 2", "state": "active"}],
3052                    "total": 1, "isLast": true
3053                })),
3054            )
3055            .expect(1)
3056            .mount(&server)
3057            .await;
3058
3059        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3060        let result = client.get_sprints(1, Some("active"), 50).await.unwrap();
3061        assert_eq!(result.sprints.len(), 1);
3062        assert_eq!(result.sprints[0].state, "active");
3063    }
3064
3065    #[tokio::test]
3066    async fn get_sprints_api_error() {
3067        let server = wiremock::MockServer::start().await;
3068
3069        wiremock::Mock::given(wiremock::matchers::method("GET"))
3070            .and(wiremock::matchers::path("/rest/agile/1.0/board/999/sprint"))
3071            .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
3072            .expect(1)
3073            .mount(&server)
3074            .await;
3075
3076        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3077        let err = client.get_sprints(999, None, 50).await.unwrap_err();
3078        assert!(err.to_string().contains("404"));
3079    }
3080
3081    #[tokio::test]
3082    async fn get_sprint_issues_success() {
3083        let server = wiremock::MockServer::start().await;
3084
3085        wiremock::Mock::given(wiremock::matchers::method("GET"))
3086            .and(wiremock::matchers::path("/rest/agile/1.0/sprint/10/issue"))
3087            .respond_with(
3088                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
3089                    "issues": [{
3090                        "key": "PROJ-1",
3091                        "fields": {
3092                            "summary": "Sprint issue",
3093                            "description": null,
3094                            "status": {"name": "In Progress"},
3095                            "issuetype": {"name": "Story"},
3096                            "assignee": {"displayName": "Alice"},
3097                            "priority": null,
3098                            "labels": []
3099                        }
3100                    }],
3101                    "total": 1, "isLast": true
3102                })),
3103            )
3104            .expect(1)
3105            .mount(&server)
3106            .await;
3107
3108        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3109        let result = client.get_sprint_issues(10, None, 50).await.unwrap();
3110
3111        assert_eq!(result.total, 1);
3112        assert_eq!(result.issues[0].key, "PROJ-1");
3113        assert_eq!(result.issues[0].assignee.as_deref(), Some("Alice"));
3114    }
3115
3116    #[tokio::test]
3117    async fn get_sprint_issues_api_error() {
3118        let server = wiremock::MockServer::start().await;
3119
3120        wiremock::Mock::given(wiremock::matchers::method("GET"))
3121            .and(wiremock::matchers::path("/rest/agile/1.0/sprint/999/issue"))
3122            .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
3123            .expect(1)
3124            .mount(&server)
3125            .await;
3126
3127        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3128        let err = client.get_sprint_issues(999, None, 50).await.unwrap_err();
3129        assert!(err.to_string().contains("404"));
3130    }
3131
3132    #[tokio::test]
3133    async fn add_issues_to_sprint_success() {
3134        let server = wiremock::MockServer::start().await;
3135
3136        wiremock::Mock::given(wiremock::matchers::method("POST"))
3137            .and(wiremock::matchers::path("/rest/agile/1.0/sprint/10/issue"))
3138            .respond_with(wiremock::ResponseTemplate::new(204))
3139            .expect(1)
3140            .mount(&server)
3141            .await;
3142
3143        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3144        let result = client.add_issues_to_sprint(10, &["PROJ-1", "PROJ-2"]).await;
3145        assert!(result.is_ok());
3146    }
3147
3148    #[tokio::test]
3149    async fn add_issues_to_sprint_api_error() {
3150        let server = wiremock::MockServer::start().await;
3151
3152        wiremock::Mock::given(wiremock::matchers::method("POST"))
3153            .and(wiremock::matchers::path("/rest/agile/1.0/sprint/999/issue"))
3154            .respond_with(wiremock::ResponseTemplate::new(400).set_body_string("Bad Request"))
3155            .expect(1)
3156            .mount(&server)
3157            .await;
3158
3159        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3160        let err = client
3161            .add_issues_to_sprint(999, &["NOPE-1"])
3162            .await
3163            .unwrap_err();
3164        assert!(err.to_string().contains("400"));
3165    }
3166
3167    #[tokio::test]
3168    async fn create_sprint_success() {
3169        let server = wiremock::MockServer::start().await;
3170
3171        wiremock::Mock::given(wiremock::matchers::method("POST"))
3172            .and(wiremock::matchers::path("/rest/agile/1.0/sprint"))
3173            .respond_with(
3174                wiremock::ResponseTemplate::new(201).set_body_json(serde_json::json!({
3175                    "id": 42,
3176                    "name": "Sprint 5",
3177                    "state": "future",
3178                    "startDate": "2026-05-01",
3179                    "endDate": "2026-05-14",
3180                    "goal": "Ship v2"
3181                })),
3182            )
3183            .expect(1)
3184            .mount(&server)
3185            .await;
3186
3187        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3188        let sprint = client
3189            .create_sprint(
3190                1,
3191                "Sprint 5",
3192                Some("2026-05-01"),
3193                Some("2026-05-14"),
3194                Some("Ship v2"),
3195            )
3196            .await
3197            .unwrap();
3198
3199        assert_eq!(sprint.id, 42);
3200        assert_eq!(sprint.name, "Sprint 5");
3201        assert_eq!(sprint.state, "future");
3202        assert_eq!(sprint.goal.as_deref(), Some("Ship v2"));
3203    }
3204
3205    #[tokio::test]
3206    async fn create_sprint_minimal() {
3207        let server = wiremock::MockServer::start().await;
3208
3209        wiremock::Mock::given(wiremock::matchers::method("POST"))
3210            .and(wiremock::matchers::path("/rest/agile/1.0/sprint"))
3211            .respond_with(wiremock::ResponseTemplate::new(201).set_body_json(
3212                serde_json::json!({"id": 43, "name": "Sprint 6", "state": "future"}),
3213            ))
3214            .expect(1)
3215            .mount(&server)
3216            .await;
3217
3218        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3219        let sprint = client
3220            .create_sprint(1, "Sprint 6", None, None, None)
3221            .await
3222            .unwrap();
3223
3224        assert_eq!(sprint.id, 43);
3225        assert!(sprint.start_date.is_none());
3226    }
3227
3228    #[tokio::test]
3229    async fn create_sprint_api_error() {
3230        let server = wiremock::MockServer::start().await;
3231
3232        wiremock::Mock::given(wiremock::matchers::method("POST"))
3233            .and(wiremock::matchers::path("/rest/agile/1.0/sprint"))
3234            .respond_with(wiremock::ResponseTemplate::new(400).set_body_string("Bad Request"))
3235            .expect(1)
3236            .mount(&server)
3237            .await;
3238
3239        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3240        let err = client
3241            .create_sprint(999, "Bad", None, None, None)
3242            .await
3243            .unwrap_err();
3244        assert!(err.to_string().contains("400"));
3245    }
3246
3247    #[tokio::test]
3248    async fn update_sprint_success() {
3249        let server = wiremock::MockServer::start().await;
3250
3251        wiremock::Mock::given(wiremock::matchers::method("PUT"))
3252            .and(wiremock::matchers::path("/rest/agile/1.0/sprint/42"))
3253            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
3254                serde_json::json!({"id": 42, "name": "Sprint 5 Updated", "state": "active"}),
3255            ))
3256            .expect(1)
3257            .mount(&server)
3258            .await;
3259
3260        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3261        let result = client
3262            .update_sprint(
3263                42,
3264                Some("Sprint 5 Updated"),
3265                Some("active"),
3266                None,
3267                None,
3268                None,
3269            )
3270            .await;
3271        assert!(result.is_ok());
3272    }
3273
3274    #[tokio::test]
3275    async fn update_sprint_all_fields() {
3276        let server = wiremock::MockServer::start().await;
3277
3278        wiremock::Mock::given(wiremock::matchers::method("PUT"))
3279            .and(wiremock::matchers::path("/rest/agile/1.0/sprint/42"))
3280            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
3281                serde_json::json!({"id": 42, "name": "Sprint 5", "state": "active"}),
3282            ))
3283            .expect(1)
3284            .mount(&server)
3285            .await;
3286
3287        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3288        let result = client
3289            .update_sprint(
3290                42,
3291                Some("Sprint 5"),
3292                Some("active"),
3293                Some("2026-05-01"),
3294                Some("2026-05-14"),
3295                Some("Ship v2"),
3296            )
3297            .await;
3298        assert!(result.is_ok());
3299    }
3300
3301    #[tokio::test]
3302    async fn update_sprint_api_error() {
3303        let server = wiremock::MockServer::start().await;
3304
3305        wiremock::Mock::given(wiremock::matchers::method("PUT"))
3306            .and(wiremock::matchers::path("/rest/agile/1.0/sprint/999"))
3307            .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
3308            .expect(1)
3309            .mount(&server)
3310            .await;
3311
3312        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3313        let err = client
3314            .update_sprint(999, Some("Nope"), None, None, None, None)
3315            .await
3316            .unwrap_err();
3317        assert!(err.to_string().contains("404"));
3318    }
3319
3320    #[tokio::test]
3321    async fn get_project_versions_success() {
3322        let server = wiremock::MockServer::start().await;
3323
3324        wiremock::Mock::given(wiremock::matchers::method("GET"))
3325            .and(wiremock::matchers::path(
3326                "/rest/api/3/project/PROJ/versions",
3327            ))
3328            .respond_with(
3329                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!([
3330                    {
3331                        "id": "10000",
3332                        "name": "1.0.0",
3333                        "description": "First release",
3334                        "released": true,
3335                        "archived": false,
3336                        "releaseDate": "2026-04-01",
3337                        "startDate": "2026-03-01",
3338                    },
3339                    {
3340                        "id": "10001",
3341                        "name": "1.1.0",
3342                        "released": false,
3343                        "archived": false,
3344                    }
3345                ])),
3346            )
3347            .expect(1)
3348            .mount(&server)
3349            .await;
3350
3351        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3352        let result = client
3353            .get_project_versions("PROJ", None, None)
3354            .await
3355            .unwrap();
3356
3357        assert_eq!(result.total, 2);
3358        assert_eq!(result.versions[0].id, "10000");
3359        assert_eq!(result.versions[0].name, "1.0.0");
3360        assert_eq!(result.versions[0].project_key, "PROJ");
3361        assert!(result.versions[0].released);
3362        assert_eq!(
3363            result.versions[0].release_date.as_deref(),
3364            Some("2026-04-01")
3365        );
3366        assert_eq!(result.versions[1].name, "1.1.0");
3367        assert!(!result.versions[1].released);
3368    }
3369
3370    #[tokio::test]
3371    async fn get_project_versions_filters_released() {
3372        let server = wiremock::MockServer::start().await;
3373
3374        wiremock::Mock::given(wiremock::matchers::method("GET"))
3375            .and(wiremock::matchers::path(
3376                "/rest/api/3/project/PROJ/versions",
3377            ))
3378            .respond_with(
3379                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!([
3380                    {"id": "1", "name": "1.0", "released": true, "archived": false},
3381                    {"id": "2", "name": "2.0", "released": false, "archived": false},
3382                    {"id": "3", "name": "0.9", "released": true, "archived": true},
3383                ])),
3384            )
3385            .expect(1)
3386            .mount(&server)
3387            .await;
3388
3389        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3390        let result = client
3391            .get_project_versions("PROJ", Some(true), Some(false))
3392            .await
3393            .unwrap();
3394
3395        assert_eq!(result.total, 1);
3396        assert_eq!(result.versions[0].name, "1.0");
3397    }
3398
3399    #[tokio::test]
3400    async fn get_project_versions_api_error() {
3401        let server = wiremock::MockServer::start().await;
3402
3403        wiremock::Mock::given(wiremock::matchers::method("GET"))
3404            .and(wiremock::matchers::path(
3405                "/rest/api/3/project/NONE/versions",
3406            ))
3407            .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
3408            .expect(1)
3409            .mount(&server)
3410            .await;
3411
3412        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3413        let err = client
3414            .get_project_versions("NONE", None, None)
3415            .await
3416            .unwrap_err();
3417        assert!(err.to_string().contains("404"));
3418    }
3419
3420    #[tokio::test]
3421    async fn create_project_version_success() {
3422        let server = wiremock::MockServer::start().await;
3423
3424        wiremock::Mock::given(wiremock::matchers::method("POST"))
3425            .and(wiremock::matchers::path("/rest/api/3/version"))
3426            .respond_with(
3427                wiremock::ResponseTemplate::new(201).set_body_json(serde_json::json!({
3428                    "id": "10010",
3429                    "name": "1.2.0",
3430                    "description": "Bugfix release",
3431                    "released": false,
3432                    "archived": false,
3433                    "releaseDate": "2026-06-01",
3434                    "startDate": "2026-05-01",
3435                })),
3436            )
3437            .expect(1)
3438            .mount(&server)
3439            .await;
3440
3441        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3442        let version = client
3443            .create_project_version(
3444                "PROJ",
3445                "1.2.0",
3446                Some("Bugfix release"),
3447                Some("2026-06-01"),
3448                Some("2026-05-01"),
3449                false,
3450                false,
3451            )
3452            .await
3453            .unwrap();
3454
3455        assert_eq!(version.id, "10010");
3456        assert_eq!(version.name, "1.2.0");
3457        assert_eq!(version.project_key, "PROJ");
3458        assert_eq!(version.description.as_deref(), Some("Bugfix release"));
3459        assert_eq!(version.release_date.as_deref(), Some("2026-06-01"));
3460    }
3461
3462    #[tokio::test]
3463    async fn create_project_version_minimal() {
3464        let server = wiremock::MockServer::start().await;
3465
3466        wiremock::Mock::given(wiremock::matchers::method("POST"))
3467            .and(wiremock::matchers::path("/rest/api/3/version"))
3468            .respond_with(wiremock::ResponseTemplate::new(201).set_body_json(
3469                serde_json::json!({"id": "10011", "name": "2.0.0", "released": false, "archived": false}),
3470            ))
3471            .expect(1)
3472            .mount(&server)
3473            .await;
3474
3475        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3476        let version = client
3477            .create_project_version("PROJ", "2.0.0", None, None, None, false, false)
3478            .await
3479            .unwrap();
3480
3481        assert_eq!(version.id, "10011");
3482        assert!(version.release_date.is_none());
3483    }
3484
3485    #[tokio::test]
3486    async fn create_project_version_forbidden() {
3487        let server = wiremock::MockServer::start().await;
3488
3489        wiremock::Mock::given(wiremock::matchers::method("POST"))
3490            .and(wiremock::matchers::path("/rest/api/3/version"))
3491            .respond_with(
3492                wiremock::ResponseTemplate::new(403)
3493                    .set_body_string("You do not have permission to administer this project."),
3494            )
3495            .expect(1)
3496            .mount(&server)
3497            .await;
3498
3499        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3500        let err = client
3501            .create_project_version("PROJ", "1.0", None, None, None, false, false)
3502            .await
3503            .unwrap_err();
3504        assert!(err.to_string().contains("403"));
3505    }
3506
3507    #[tokio::test]
3508    async fn create_project_version_invalid_date_short_circuits() {
3509        // Server should never be hit because validation fails client-side.
3510        let server = wiremock::MockServer::start().await;
3511        wiremock::Mock::given(wiremock::matchers::method("POST"))
3512            .and(wiremock::matchers::path("/rest/api/3/version"))
3513            .respond_with(wiremock::ResponseTemplate::new(500))
3514            .expect(0)
3515            .mount(&server)
3516            .await;
3517
3518        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3519        let err = client
3520            .create_project_version("PROJ", "1.0", None, Some("06-01-2026"), None, false, false)
3521            .await
3522            .unwrap_err();
3523        let msg = err.to_string();
3524        assert!(msg.contains("release_date"));
3525        assert!(msg.contains("YYYY-MM-DD"));
3526    }
3527
3528    #[tokio::test]
3529    async fn create_project_version_invalid_start_date_short_circuits() {
3530        // start_date validation runs after release_date; this test drives that
3531        // second branch by passing a valid release_date with a malformed
3532        // start_date.
3533        let server = wiremock::MockServer::start().await;
3534        wiremock::Mock::given(wiremock::matchers::method("POST"))
3535            .and(wiremock::matchers::path("/rest/api/3/version"))
3536            .respond_with(wiremock::ResponseTemplate::new(500))
3537            .expect(0)
3538            .mount(&server)
3539            .await;
3540
3541        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3542        let err = client
3543            .create_project_version(
3544                "PROJ",
3545                "1.0",
3546                None,
3547                Some("2026-06-01"),
3548                Some("not-a-date"),
3549                false,
3550                false,
3551            )
3552            .await
3553            .unwrap_err();
3554        let msg = err.to_string();
3555        assert!(msg.contains("start_date"));
3556        assert!(msg.contains("YYYY-MM-DD"));
3557    }
3558
3559    #[test]
3560    fn validate_iso_date_accepts_valid() {
3561        assert!(validate_iso_date(Some("2026-05-10"), "release_date").is_ok());
3562        assert!(validate_iso_date(None, "release_date").is_ok());
3563    }
3564
3565    #[test]
3566    fn validate_iso_date_rejects_bad_shape() {
3567        let err = validate_iso_date(Some("2026/05/10"), "release_date").unwrap_err();
3568        assert!(err.to_string().contains("release_date"));
3569    }
3570
3571    #[test]
3572    fn validate_iso_date_rejects_impossible() {
3573        let err = validate_iso_date(Some("2026-13-40"), "start_date").unwrap_err();
3574        assert!(err.to_string().contains("start_date"));
3575    }
3576
3577    /// Exercises the `?` Err propagation on the `get_json` call in
3578    /// `get_project_versions` by pointing the client at an unreachable port.
3579    #[tokio::test]
3580    async fn get_project_versions_transport_error() {
3581        // Port 1 is reserved for `tcpmux` and almost never has a listener,
3582        // so connection attempts fail before any response.
3583        let client = AtlassianClient::new("http://127.0.0.1:1", "user@test.com", "token").unwrap();
3584        let err = client
3585            .get_project_versions("PROJ", None, None)
3586            .await
3587            .unwrap_err();
3588        // Transport failures bubble up via anyhow `Context` from `get_json`.
3589        assert!(err.to_string().contains("Failed to send GET request"));
3590    }
3591
3592    /// Exercises the `?` Err propagation on the `.json().context(...)?`
3593    /// call in `get_project_versions` by returning a 200 with a body that
3594    /// can't be parsed as the expected JSON shape.
3595    #[tokio::test]
3596    async fn get_project_versions_invalid_json() {
3597        let server = wiremock::MockServer::start().await;
3598        wiremock::Mock::given(wiremock::matchers::method("GET"))
3599            .and(wiremock::matchers::path(
3600                "/rest/api/3/project/PROJ/versions",
3601            ))
3602            .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("not-json"))
3603            .expect(1)
3604            .mount(&server)
3605            .await;
3606
3607        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3608        let err = client
3609            .get_project_versions("PROJ", None, None)
3610            .await
3611            .unwrap_err();
3612        assert!(err
3613            .to_string()
3614            .contains("Failed to parse project versions response"));
3615    }
3616
3617    /// Exercises the `?` Err propagation on the `post_json` call in
3618    /// `create_project_version`.
3619    #[tokio::test]
3620    async fn create_project_version_transport_error() {
3621        let client = AtlassianClient::new("http://127.0.0.1:1", "user@test.com", "token").unwrap();
3622        let err = client
3623            .create_project_version("PROJ", "1.0", None, None, None, false, false)
3624            .await
3625            .unwrap_err();
3626        assert!(err.to_string().contains("Failed to send POST request"));
3627    }
3628
3629    /// Exercises the `?` Err propagation on the `.json().context(...)?`
3630    /// call in `create_project_version`.
3631    #[tokio::test]
3632    async fn create_project_version_invalid_json() {
3633        let server = wiremock::MockServer::start().await;
3634        wiremock::Mock::given(wiremock::matchers::method("POST"))
3635            .and(wiremock::matchers::path("/rest/api/3/version"))
3636            .respond_with(wiremock::ResponseTemplate::new(201).set_body_string("not-json"))
3637            .expect(1)
3638            .mount(&server)
3639            .await;
3640
3641        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3642        let err = client
3643            .create_project_version("PROJ", "1.0", None, None, None, false, false)
3644            .await
3645            .unwrap_err();
3646        assert!(err
3647            .to_string()
3648            .contains("Failed to parse version create response"));
3649    }
3650
3651    #[tokio::test]
3652    async fn get_issue_links_success() {
3653        let server = wiremock::MockServer::start().await;
3654
3655        wiremock::Mock::given(wiremock::matchers::method("GET"))
3656            .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1"))
3657            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
3658                serde_json::json!({
3659                    "fields": {
3660                        "issuelinks": [
3661                            {
3662                                "id": "100",
3663                                "type": {"name": "Blocks"},
3664                                "outwardIssue": {"key": "PROJ-2", "fields": {"summary": "Blocked issue"}}
3665                            },
3666                            {
3667                                "id": "101",
3668                                "type": {"name": "Relates"},
3669                                "inwardIssue": {"key": "PROJ-3", "fields": {"summary": "Related issue"}}
3670                            }
3671                        ]
3672                    }
3673                }),
3674            ))
3675            .expect(1)
3676            .mount(&server)
3677            .await;
3678
3679        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3680        let links = client.get_issue_links("PROJ-1").await.unwrap();
3681
3682        assert_eq!(links.len(), 2);
3683        assert_eq!(links[0].id, "100");
3684        assert_eq!(links[0].link_type, "Blocks");
3685        assert_eq!(links[0].direction, "outward");
3686        assert_eq!(links[0].linked_issue_key, "PROJ-2");
3687        assert_eq!(links[0].linked_issue_summary, "Blocked issue");
3688        assert_eq!(links[1].id, "101");
3689        assert_eq!(links[1].direction, "inward");
3690        assert_eq!(links[1].linked_issue_key, "PROJ-3");
3691    }
3692
3693    #[tokio::test]
3694    async fn get_issue_links_empty() {
3695        let server = wiremock::MockServer::start().await;
3696
3697        wiremock::Mock::given(wiremock::matchers::method("GET"))
3698            .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1"))
3699            .respond_with(
3700                wiremock::ResponseTemplate::new(200)
3701                    .set_body_json(serde_json::json!({"fields": {"issuelinks": []}})),
3702            )
3703            .expect(1)
3704            .mount(&server)
3705            .await;
3706
3707        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3708        let links = client.get_issue_links("PROJ-1").await.unwrap();
3709        assert!(links.is_empty());
3710    }
3711
3712    #[tokio::test]
3713    async fn get_issue_links_api_error() {
3714        let server = wiremock::MockServer::start().await;
3715
3716        wiremock::Mock::given(wiremock::matchers::method("GET"))
3717            .and(wiremock::matchers::path("/rest/api/3/issue/NOPE-1"))
3718            .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
3719            .expect(1)
3720            .mount(&server)
3721            .await;
3722
3723        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3724        let err = client.get_issue_links("NOPE-1").await.unwrap_err();
3725        assert!(err.to_string().contains("404"));
3726    }
3727
3728    #[tokio::test]
3729    async fn get_remote_issue_links_success() {
3730        let server = wiremock::MockServer::start().await;
3731
3732        wiremock::Mock::given(wiremock::matchers::method("GET"))
3733            .and(wiremock::matchers::path(
3734                "/rest/api/3/issue/PROJ-1/remotelink",
3735            ))
3736            .respond_with(
3737                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!([
3738                    {
3739                        "id": 10001,
3740                        "globalId": "system=https://example.atlassian.net/wiki&id=12345",
3741                        "relationship": "mentioned in",
3742                        "object": {
3743                            "url": "https://example.atlassian.net/wiki/spaces/X/pages/12345",
3744                            "title": "Design doc",
3745                            "summary": "Architecture overview",
3746                            "icon": {
3747                                "url16x16": "https://example.atlassian.net/icons/page.png",
3748                                "title": "Confluence Page"
3749                            }
3750                        }
3751                    },
3752                    {
3753                        "id": "10002",
3754                        "object": {
3755                            "url": "https://bitbucket.org/acme/repo/pull-requests/42"
3756                        }
3757                    }
3758                ])),
3759            )
3760            .expect(1)
3761            .mount(&server)
3762            .await;
3763
3764        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3765        let links = client.get_remote_issue_links("PROJ-1").await.unwrap();
3766
3767        assert_eq!(links.len(), 2);
3768
3769        // First entry: full payload, numeric id normalized to string.
3770        assert_eq!(links[0].id, "10001");
3771        assert_eq!(
3772            links[0].global_id.as_deref(),
3773            Some("system=https://example.atlassian.net/wiki&id=12345")
3774        );
3775        assert_eq!(links[0].relationship.as_deref(), Some("mentioned in"));
3776        assert_eq!(
3777            links[0].object.url,
3778            "https://example.atlassian.net/wiki/spaces/X/pages/12345"
3779        );
3780        assert_eq!(links[0].object.title.as_deref(), Some("Design doc"));
3781        assert_eq!(
3782            links[0].object.summary.as_deref(),
3783            Some("Architecture overview")
3784        );
3785        let icon = links[0].object.icon.as_ref().expect("icon present");
3786        assert_eq!(
3787            icon.url.as_deref(),
3788            Some("https://example.atlassian.net/icons/page.png")
3789        );
3790        assert_eq!(icon.title.as_deref(), Some("Confluence Page"));
3791
3792        // Second entry: minimal payload, string id, no optional fields.
3793        assert_eq!(links[1].id, "10002");
3794        assert!(links[1].global_id.is_none());
3795        assert!(links[1].relationship.is_none());
3796        assert_eq!(
3797            links[1].object.url,
3798            "https://bitbucket.org/acme/repo/pull-requests/42"
3799        );
3800        assert!(links[1].object.title.is_none());
3801        assert!(links[1].object.summary.is_none());
3802        assert!(links[1].object.icon.is_none());
3803    }
3804
3805    #[tokio::test]
3806    async fn get_remote_issue_links_empty() {
3807        let server = wiremock::MockServer::start().await;
3808        wiremock::Mock::given(wiremock::matchers::method("GET"))
3809            .and(wiremock::matchers::path(
3810                "/rest/api/3/issue/PROJ-1/remotelink",
3811            ))
3812            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
3813            .expect(1)
3814            .mount(&server)
3815            .await;
3816        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3817        let links = client.get_remote_issue_links("PROJ-1").await.unwrap();
3818        assert!(links.is_empty());
3819    }
3820
3821    #[tokio::test]
3822    async fn get_remote_issue_links_rejects_unexpected_id_type() {
3823        // Exercise the defensive `other =>` arm of the id-normalisation
3824        // match. JIRA's wire contract is number-or-string; anything else
3825        // should be surfaced as a clear error rather than silently
3826        // accepted.
3827        let server = wiremock::MockServer::start().await;
3828        wiremock::Mock::given(wiremock::matchers::method("GET"))
3829            .and(wiremock::matchers::path(
3830                "/rest/api/3/issue/PROJ-1/remotelink",
3831            ))
3832            .respond_with(
3833                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!([
3834                    {
3835                        "id": null,
3836                        "object": {"url": "https://example.com/x"}
3837                    }
3838                ])),
3839            )
3840            .expect(1)
3841            .mount(&server)
3842            .await;
3843        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3844        let err = client.get_remote_issue_links("PROJ-1").await.unwrap_err();
3845        assert!(err.to_string().contains("unexpected remote link id type"));
3846    }
3847
3848    #[tokio::test]
3849    async fn get_remote_issue_links_api_error() {
3850        let server = wiremock::MockServer::start().await;
3851        wiremock::Mock::given(wiremock::matchers::method("GET"))
3852            .and(wiremock::matchers::path(
3853                "/rest/api/3/issue/NOPE-1/remotelink",
3854            ))
3855            .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
3856            .expect(1)
3857            .mount(&server)
3858            .await;
3859        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3860        let err = client.get_remote_issue_links("NOPE-1").await.unwrap_err();
3861        assert!(err.to_string().contains("404"));
3862    }
3863
3864    #[tokio::test]
3865    async fn get_link_types_success() {
3866        let server = wiremock::MockServer::start().await;
3867        wiremock::Mock::given(wiremock::matchers::method("GET"))
3868            .and(wiremock::matchers::path("/rest/api/3/issueLinkType"))
3869            .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"}]})))
3870            .expect(1).mount(&server).await;
3871        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3872        let types = client.get_link_types().await.unwrap();
3873        assert_eq!(types.len(), 2);
3874        assert_eq!(types[0].name, "Blocks");
3875        assert_eq!(types[0].inward, "is blocked by");
3876    }
3877
3878    #[tokio::test]
3879    async fn get_link_types_api_error() {
3880        let server = wiremock::MockServer::start().await;
3881        wiremock::Mock::given(wiremock::matchers::method("GET"))
3882            .and(wiremock::matchers::path("/rest/api/3/issueLinkType"))
3883            .respond_with(wiremock::ResponseTemplate::new(401).set_body_string("Unauthorized"))
3884            .expect(1)
3885            .mount(&server)
3886            .await;
3887        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3888        let err = client.get_link_types().await.unwrap_err();
3889        assert!(err.to_string().contains("401"));
3890    }
3891
3892    #[tokio::test]
3893    async fn create_issue_link_success() {
3894        let server = wiremock::MockServer::start().await;
3895        wiremock::Mock::given(wiremock::matchers::method("POST"))
3896            .and(wiremock::matchers::path("/rest/api/3/issueLink"))
3897            .respond_with(wiremock::ResponseTemplate::new(201))
3898            .expect(1)
3899            .mount(&server)
3900            .await;
3901        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3902        assert!(client
3903            .create_issue_link("Blocks", "PROJ-1", "PROJ-2")
3904            .await
3905            .is_ok());
3906    }
3907
3908    #[tokio::test]
3909    async fn create_issue_link_api_error() {
3910        let server = wiremock::MockServer::start().await;
3911        wiremock::Mock::given(wiremock::matchers::method("POST"))
3912            .and(wiremock::matchers::path("/rest/api/3/issueLink"))
3913            .respond_with(wiremock::ResponseTemplate::new(400).set_body_string("Bad Request"))
3914            .expect(1)
3915            .mount(&server)
3916            .await;
3917        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3918        let err = client
3919            .create_issue_link("Invalid", "NOPE-1", "NOPE-2")
3920            .await
3921            .unwrap_err();
3922        assert!(err.to_string().contains("400"));
3923    }
3924
3925    #[tokio::test]
3926    async fn remove_issue_link_success() {
3927        let server = wiremock::MockServer::start().await;
3928        wiremock::Mock::given(wiremock::matchers::method("DELETE"))
3929            .and(wiremock::matchers::path("/rest/api/3/issueLink/12345"))
3930            .respond_with(wiremock::ResponseTemplate::new(204))
3931            .expect(1)
3932            .mount(&server)
3933            .await;
3934        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3935        assert!(client.remove_issue_link("12345").await.is_ok());
3936    }
3937
3938    #[tokio::test]
3939    async fn remove_issue_link_api_error() {
3940        let server = wiremock::MockServer::start().await;
3941        wiremock::Mock::given(wiremock::matchers::method("DELETE"))
3942            .and(wiremock::matchers::path("/rest/api/3/issueLink/99999"))
3943            .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
3944            .expect(1)
3945            .mount(&server)
3946            .await;
3947        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3948        let err = client.remove_issue_link("99999").await.unwrap_err();
3949        assert!(err.to_string().contains("404"));
3950    }
3951
3952    #[tokio::test]
3953    async fn set_issue_parent_success() {
3954        let server = wiremock::MockServer::start().await;
3955        wiremock::Mock::given(wiremock::matchers::method("PUT"))
3956            .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-2"))
3957            .and(wiremock::matchers::body_json(serde_json::json!({
3958                "fields": {"parent": {"key": "EPIC-1"}}
3959            })))
3960            .respond_with(wiremock::ResponseTemplate::new(204))
3961            .expect(1)
3962            .mount(&server)
3963            .await;
3964        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3965        assert!(client.set_issue_parent("PROJ-2", "EPIC-1").await.is_ok());
3966    }
3967
3968    #[tokio::test]
3969    async fn set_issue_parent_api_error() {
3970        let server = wiremock::MockServer::start().await;
3971        wiremock::Mock::given(wiremock::matchers::method("PUT"))
3972            .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-2"))
3973            .respond_with(wiremock::ResponseTemplate::new(400).set_body_string("Not allowed"))
3974            .expect(1)
3975            .mount(&server)
3976            .await;
3977        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3978        let err = client
3979            .set_issue_parent("PROJ-2", "NOPE-1")
3980            .await
3981            .unwrap_err();
3982        assert!(err.to_string().contains("400"));
3983    }
3984
3985    #[tokio::test]
3986    async fn get_bytes_success() {
3987        let server = wiremock::MockServer::start().await;
3988        wiremock::Mock::given(wiremock::matchers::method("GET"))
3989            .and(wiremock::matchers::path("/file.bin"))
3990            .and(wiremock::matchers::header("Accept", "*/*"))
3991            .respond_with(wiremock::ResponseTemplate::new(200).set_body_bytes(b"binary content"))
3992            .expect(1)
3993            .mount(&server)
3994            .await;
3995
3996        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3997        let data = client
3998            .get_bytes(&format!("{}/file.bin", server.uri()))
3999            .await
4000            .unwrap();
4001        assert_eq!(&data[..], b"binary content");
4002    }
4003
4004    #[tokio::test]
4005    async fn get_bytes_api_error() {
4006        let server = wiremock::MockServer::start().await;
4007        wiremock::Mock::given(wiremock::matchers::method("GET"))
4008            .and(wiremock::matchers::path("/missing.bin"))
4009            .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
4010            .expect(1)
4011            .mount(&server)
4012            .await;
4013
4014        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4015        let err = client
4016            .get_bytes(&format!("{}/missing.bin", server.uri()))
4017            .await
4018            .unwrap_err();
4019        assert!(err.to_string().contains("404"));
4020    }
4021
4022    #[tokio::test]
4023    async fn get_attachments_success() {
4024        let server = wiremock::MockServer::start().await;
4025        wiremock::Mock::given(wiremock::matchers::method("GET"))
4026            .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1"))
4027            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
4028                serde_json::json!({
4029                    "fields": {
4030                        "attachment": [
4031                            {"id": "1", "filename": "screenshot.png", "mimeType": "image/png", "size": 12345, "content": "https://org.atlassian.net/attachment/1"},
4032                            {"id": "2", "filename": "report.pdf", "mimeType": "application/pdf", "size": 99999, "content": "https://org.atlassian.net/attachment/2"}
4033                        ]
4034                    }
4035                }),
4036            ))
4037            .expect(1)
4038            .mount(&server)
4039            .await;
4040
4041        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4042        let attachments = client.get_attachments("PROJ-1").await.unwrap();
4043
4044        assert_eq!(attachments.len(), 2);
4045        assert_eq!(attachments[0].filename, "screenshot.png");
4046        assert_eq!(attachments[0].mime_type, "image/png");
4047        assert_eq!(attachments[0].size, 12345);
4048        assert_eq!(attachments[1].filename, "report.pdf");
4049    }
4050
4051    #[tokio::test]
4052    async fn get_attachments_empty() {
4053        let server = wiremock::MockServer::start().await;
4054        wiremock::Mock::given(wiremock::matchers::method("GET"))
4055            .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1"))
4056            .respond_with(
4057                wiremock::ResponseTemplate::new(200)
4058                    .set_body_json(serde_json::json!({"fields": {"attachment": []}})),
4059            )
4060            .expect(1)
4061            .mount(&server)
4062            .await;
4063
4064        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4065        let attachments = client.get_attachments("PROJ-1").await.unwrap();
4066        assert!(attachments.is_empty());
4067    }
4068
4069    #[tokio::test]
4070    async fn get_attachments_api_error() {
4071        let server = wiremock::MockServer::start().await;
4072        wiremock::Mock::given(wiremock::matchers::method("GET"))
4073            .and(wiremock::matchers::path("/rest/api/3/issue/NOPE-1"))
4074            .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
4075            .expect(1)
4076            .mount(&server)
4077            .await;
4078
4079        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4080        let err = client.get_attachments("NOPE-1").await.unwrap_err();
4081        assert!(err.to_string().contains("404"));
4082    }
4083
4084    #[tokio::test]
4085    async fn get_changelog_success() {
4086        let server = wiremock::MockServer::start().await;
4087
4088        wiremock::Mock::given(wiremock::matchers::method("GET"))
4089            .and(wiremock::matchers::path(
4090                "/rest/api/3/issue/PROJ-1/changelog",
4091            ))
4092            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
4093                serde_json::json!({
4094                    "values": [
4095                        {
4096                            "id": "100",
4097                            "author": {"displayName": "Alice"},
4098                            "created": "2026-04-01T10:00:00.000+0000",
4099                            "items": [
4100                                {"field": "status", "fromString": "Open", "toString": "In Progress"},
4101                                {"field": "assignee", "fromString": null, "toString": "Bob"}
4102                            ]
4103                        },
4104                        {
4105                            "id": "101",
4106                            "author": null,
4107                            "created": "2026-04-02T14:00:00.000+0000",
4108                            "items": [{"field": "priority", "fromString": "Medium", "toString": "High"}]
4109                        }
4110                    ],
4111                    "isLast": true
4112                }),
4113            ))
4114            .expect(1)
4115            .mount(&server)
4116            .await;
4117
4118        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4119        let entries = client.get_changelog("PROJ-1", 50).await.unwrap();
4120
4121        assert_eq!(entries.len(), 2);
4122        assert_eq!(entries[0].id, "100");
4123        assert_eq!(entries[0].author, "Alice");
4124        assert_eq!(entries[0].items.len(), 2);
4125        assert_eq!(entries[0].items[0].field, "status");
4126        assert_eq!(entries[0].items[0].from_string.as_deref(), Some("Open"));
4127        assert_eq!(
4128            entries[0].items[0].to_string.as_deref(),
4129            Some("In Progress")
4130        );
4131        assert_eq!(entries[0].items[1].from_string, None);
4132        assert_eq!(entries[1].author, "");
4133    }
4134
4135    #[tokio::test]
4136    async fn get_changelog_empty() {
4137        let server = wiremock::MockServer::start().await;
4138
4139        wiremock::Mock::given(wiremock::matchers::method("GET"))
4140            .and(wiremock::matchers::path(
4141                "/rest/api/3/issue/PROJ-1/changelog",
4142            ))
4143            .respond_with(
4144                wiremock::ResponseTemplate::new(200)
4145                    .set_body_json(serde_json::json!({"values": []})),
4146            )
4147            .expect(1)
4148            .mount(&server)
4149            .await;
4150
4151        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4152        let entries = client.get_changelog("PROJ-1", 50).await.unwrap();
4153        assert!(entries.is_empty());
4154    }
4155
4156    #[tokio::test]
4157    async fn get_changelog_api_error() {
4158        let server = wiremock::MockServer::start().await;
4159
4160        wiremock::Mock::given(wiremock::matchers::method("GET"))
4161            .and(wiremock::matchers::path(
4162                "/rest/api/3/issue/NOPE-1/changelog",
4163            ))
4164            .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
4165            .expect(1)
4166            .mount(&server)
4167            .await;
4168
4169        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4170        let err = client.get_changelog("NOPE-1", 50).await.unwrap_err();
4171        assert!(err.to_string().contains("404"));
4172    }
4173
4174    #[tokio::test]
4175    async fn get_fields_success() {
4176        let server = wiremock::MockServer::start().await;
4177
4178        wiremock::Mock::given(wiremock::matchers::method("GET"))
4179            .and(wiremock::matchers::path("/rest/api/3/field"))
4180            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
4181                serde_json::json!([
4182                    {"id": "summary", "name": "Summary", "custom": false, "schema": {"type": "string"}},
4183                    {"id": "customfield_10001", "name": "Story Points", "custom": true, "schema": {"type": "number"}},
4184                    {"id": "labels", "name": "Labels", "custom": false},
4185                    {
4186                        "id": "customfield_19300",
4187                        "name": "Acceptance Criteria",
4188                        "custom": true,
4189                        "schema": {
4190                            "type": "string",
4191                            "custom": "com.atlassian.jira.plugin.system.customfieldtypes:textarea"
4192                        }
4193                    }
4194                ]),
4195            ))
4196            .expect(1)
4197            .mount(&server)
4198            .await;
4199
4200        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4201        let fields = client.get_fields().await.unwrap();
4202
4203        assert_eq!(fields.len(), 4);
4204        assert_eq!(fields[0].id, "summary");
4205        assert_eq!(fields[0].name, "Summary");
4206        assert!(!fields[0].custom);
4207        assert_eq!(fields[0].schema_type.as_deref(), Some("string"));
4208        assert!(fields[0].schema_custom.is_none());
4209        assert_eq!(fields[1].id, "customfield_10001");
4210        assert!(fields[1].custom);
4211        assert_eq!(fields[1].schema_type.as_deref(), Some("number"));
4212        assert!(fields[1].schema_custom.is_none());
4213        assert!(fields[2].schema_type.is_none());
4214        assert!(fields[2].schema_custom.is_none());
4215        assert_eq!(fields[3].id, "customfield_19300");
4216        assert!(fields[3].custom);
4217        assert_eq!(fields[3].schema_type.as_deref(), Some("richtext"));
4218        assert_eq!(
4219            fields[3].schema_custom.as_deref(),
4220            Some("com.atlassian.jira.plugin.system.customfieldtypes:textarea")
4221        );
4222    }
4223
4224    #[tokio::test]
4225    async fn get_fields_api_error() {
4226        let server = wiremock::MockServer::start().await;
4227
4228        wiremock::Mock::given(wiremock::matchers::method("GET"))
4229            .and(wiremock::matchers::path("/rest/api/3/field"))
4230            .respond_with(wiremock::ResponseTemplate::new(401).set_body_string("Unauthorized"))
4231            .expect(1)
4232            .mount(&server)
4233            .await;
4234
4235        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4236        let err = client.get_fields().await.unwrap_err();
4237        assert!(err.to_string().contains("401"));
4238    }
4239
4240    #[tokio::test]
4241    async fn get_field_contexts_success() {
4242        let server = wiremock::MockServer::start().await;
4243
4244        wiremock::Mock::given(wiremock::matchers::method("GET"))
4245            .and(wiremock::matchers::path(
4246                "/rest/api/3/field/customfield_10001/context",
4247            ))
4248            .respond_with(
4249                wiremock::ResponseTemplate::new(200).set_body_json(
4250                    serde_json::json!({"values": [{"id": "12345"}, {"id": "67890"}]}),
4251                ),
4252            )
4253            .expect(1)
4254            .mount(&server)
4255            .await;
4256
4257        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4258        let contexts = client
4259            .get_field_contexts("customfield_10001")
4260            .await
4261            .unwrap();
4262
4263        assert_eq!(contexts.len(), 2);
4264        assert_eq!(contexts[0], "12345");
4265    }
4266
4267    #[tokio::test]
4268    async fn get_field_contexts_api_error() {
4269        let server = wiremock::MockServer::start().await;
4270
4271        wiremock::Mock::given(wiremock::matchers::method("GET"))
4272            .and(wiremock::matchers::path(
4273                "/rest/api/3/field/nonexistent/context",
4274            ))
4275            .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
4276            .expect(1)
4277            .mount(&server)
4278            .await;
4279
4280        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4281        let err = client.get_field_contexts("nonexistent").await.unwrap_err();
4282        assert!(err.to_string().contains("404"));
4283    }
4284
4285    #[tokio::test]
4286    async fn get_field_contexts_empty() {
4287        let server = wiremock::MockServer::start().await;
4288
4289        wiremock::Mock::given(wiremock::matchers::method("GET"))
4290            .and(wiremock::matchers::path(
4291                "/rest/api/3/field/customfield_99999/context",
4292            ))
4293            .respond_with(
4294                wiremock::ResponseTemplate::new(200)
4295                    .set_body_json(serde_json::json!({"values": []})),
4296            )
4297            .expect(1)
4298            .mount(&server)
4299            .await;
4300
4301        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4302        let contexts = client
4303            .get_field_contexts("customfield_99999")
4304            .await
4305            .unwrap();
4306        assert!(contexts.is_empty());
4307    }
4308
4309    #[tokio::test]
4310    async fn get_field_options_auto_discovers_context() {
4311        let server = wiremock::MockServer::start().await;
4312
4313        // Context discovery
4314        wiremock::Mock::given(wiremock::matchers::method("GET"))
4315            .and(wiremock::matchers::path(
4316                "/rest/api/3/field/customfield_10001/context",
4317            ))
4318            .respond_with(
4319                wiremock::ResponseTemplate::new(200)
4320                    .set_body_json(serde_json::json!({"values": [{"id": "12345"}]})),
4321            )
4322            .expect(1)
4323            .mount(&server)
4324            .await;
4325
4326        // Options for discovered context
4327        wiremock::Mock::given(wiremock::matchers::method("GET"))
4328            .and(wiremock::matchers::path(
4329                "/rest/api/3/field/customfield_10001/context/12345/option",
4330            ))
4331            .respond_with(
4332                wiremock::ResponseTemplate::new(200)
4333                    .set_body_json(serde_json::json!({"values": [{"id": "1", "value": "High"}]})),
4334            )
4335            .expect(1)
4336            .mount(&server)
4337            .await;
4338
4339        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4340        let options = client
4341            .get_field_options("customfield_10001", None)
4342            .await
4343            .unwrap();
4344
4345        assert_eq!(options.len(), 1);
4346        assert_eq!(options[0].value, "High");
4347    }
4348
4349    #[tokio::test]
4350    async fn get_field_options_no_context_errors() {
4351        let server = wiremock::MockServer::start().await;
4352
4353        wiremock::Mock::given(wiremock::matchers::method("GET"))
4354            .and(wiremock::matchers::path(
4355                "/rest/api/3/field/customfield_99999/context",
4356            ))
4357            .respond_with(
4358                wiremock::ResponseTemplate::new(200)
4359                    .set_body_json(serde_json::json!({"values": []})),
4360            )
4361            .expect(1)
4362            .mount(&server)
4363            .await;
4364
4365        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4366        let err = client
4367            .get_field_options("customfield_99999", None)
4368            .await
4369            .unwrap_err();
4370        assert!(err.to_string().contains("No contexts found"));
4371    }
4372
4373    #[tokio::test]
4374    async fn get_field_options_with_explicit_context() {
4375        let server = wiremock::MockServer::start().await;
4376
4377        wiremock::Mock::given(wiremock::matchers::method("GET"))
4378            .and(wiremock::matchers::path(
4379                "/rest/api/3/field/customfield_10001/context/12345/option",
4380            ))
4381            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
4382                serde_json::json!({"values": [
4383                    {"id": "1", "value": "High"},
4384                    {"id": "2", "value": "Medium"},
4385                    {"id": "3", "value": "Low"}
4386                ]}),
4387            ))
4388            .expect(1)
4389            .mount(&server)
4390            .await;
4391
4392        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4393        let options = client
4394            .get_field_options("customfield_10001", Some("12345"))
4395            .await
4396            .unwrap();
4397
4398        assert_eq!(options.len(), 3);
4399        assert_eq!(options[0].id, "1");
4400        assert_eq!(options[0].value, "High");
4401    }
4402
4403    #[tokio::test]
4404    async fn get_field_options_with_context() {
4405        let server = wiremock::MockServer::start().await;
4406
4407        wiremock::Mock::given(wiremock::matchers::method("GET"))
4408            .and(wiremock::matchers::path(
4409                "/rest/api/3/field/customfield_10001/context/12345/option",
4410            ))
4411            .respond_with(
4412                wiremock::ResponseTemplate::new(200).set_body_json(
4413                    serde_json::json!({"values": [{"id": "1", "value": "Option A"}]}),
4414                ),
4415            )
4416            .expect(1)
4417            .mount(&server)
4418            .await;
4419
4420        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4421        let options = client
4422            .get_field_options("customfield_10001", Some("12345"))
4423            .await
4424            .unwrap();
4425
4426        assert_eq!(options.len(), 1);
4427        assert_eq!(options[0].value, "Option A");
4428    }
4429
4430    #[tokio::test]
4431    async fn get_field_options_api_error() {
4432        let server = wiremock::MockServer::start().await;
4433
4434        wiremock::Mock::given(wiremock::matchers::method("GET"))
4435            .and(wiremock::matchers::path(
4436                "/rest/api/3/field/nonexistent/context/99999/option",
4437            ))
4438            .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
4439            .expect(1)
4440            .mount(&server)
4441            .await;
4442
4443        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4444        let err = client
4445            .get_field_options("nonexistent", Some("99999"))
4446            .await
4447            .unwrap_err();
4448        assert!(err.to_string().contains("404"));
4449    }
4450
4451    #[tokio::test]
4452    async fn get_projects_success() {
4453        let server = wiremock::MockServer::start().await;
4454
4455        wiremock::Mock::given(wiremock::matchers::method("GET"))
4456            .and(wiremock::matchers::path("/rest/api/3/project/search"))
4457            .respond_with(
4458                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
4459                    "values": [
4460                        {
4461                            "id": "10001",
4462                            "key": "PROJ",
4463                            "name": "My Project",
4464                            "projectTypeKey": "software",
4465                            "lead": {"displayName": "Alice"}
4466                        },
4467                        {
4468                            "id": "10002",
4469                            "key": "OPS",
4470                            "name": "Operations",
4471                            "projectTypeKey": "business",
4472                            "lead": null
4473                        }
4474                    ],
4475                    "total": 2, "isLast": true
4476                })),
4477            )
4478            .expect(1)
4479            .mount(&server)
4480            .await;
4481
4482        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4483        let result = client.get_projects(50).await.unwrap();
4484
4485        assert_eq!(result.total, 2);
4486        assert_eq!(result.projects.len(), 2);
4487        assert_eq!(result.projects[0].key, "PROJ");
4488        assert_eq!(result.projects[0].name, "My Project");
4489        assert_eq!(result.projects[0].project_type.as_deref(), Some("software"));
4490        assert_eq!(result.projects[0].lead.as_deref(), Some("Alice"));
4491        assert_eq!(result.projects[1].key, "OPS");
4492        assert!(result.projects[1].lead.is_none());
4493    }
4494
4495    #[tokio::test]
4496    async fn get_projects_empty() {
4497        let server = wiremock::MockServer::start().await;
4498
4499        wiremock::Mock::given(wiremock::matchers::method("GET"))
4500            .and(wiremock::matchers::path("/rest/api/3/project/search"))
4501            .respond_with(
4502                wiremock::ResponseTemplate::new(200)
4503                    .set_body_json(serde_json::json!({"values": [], "total": 0})),
4504            )
4505            .expect(1)
4506            .mount(&server)
4507            .await;
4508
4509        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4510        let result = client.get_projects(50).await.unwrap();
4511        assert_eq!(result.total, 0);
4512        assert!(result.projects.is_empty());
4513    }
4514
4515    #[tokio::test]
4516    async fn get_projects_api_error() {
4517        let server = wiremock::MockServer::start().await;
4518
4519        wiremock::Mock::given(wiremock::matchers::method("GET"))
4520            .and(wiremock::matchers::path("/rest/api/3/project/search"))
4521            .respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
4522            .expect(1)
4523            .mount(&server)
4524            .await;
4525
4526        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4527        let err = client.get_projects(50).await.unwrap_err();
4528        assert!(err.to_string().contains("403"));
4529    }
4530
4531    #[tokio::test]
4532    async fn delete_issue_success() {
4533        let server = wiremock::MockServer::start().await;
4534
4535        wiremock::Mock::given(wiremock::matchers::method("DELETE"))
4536            .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-42"))
4537            .respond_with(wiremock::ResponseTemplate::new(204))
4538            .expect(1)
4539            .mount(&server)
4540            .await;
4541
4542        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4543        let result = client.delete_issue("PROJ-42").await;
4544        assert!(result.is_ok());
4545    }
4546
4547    #[tokio::test]
4548    async fn delete_issue_not_found() {
4549        let server = wiremock::MockServer::start().await;
4550
4551        wiremock::Mock::given(wiremock::matchers::method("DELETE"))
4552            .and(wiremock::matchers::path("/rest/api/3/issue/NOPE-1"))
4553            .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
4554            .expect(1)
4555            .mount(&server)
4556            .await;
4557
4558        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4559        let err = client.delete_issue("NOPE-1").await.unwrap_err();
4560        assert!(err.to_string().contains("404"));
4561    }
4562
4563    #[tokio::test]
4564    async fn delete_issue_forbidden() {
4565        let server = wiremock::MockServer::start().await;
4566
4567        wiremock::Mock::given(wiremock::matchers::method("DELETE"))
4568            .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1"))
4569            .respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
4570            .expect(1)
4571            .mount(&server)
4572            .await;
4573
4574        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4575        let err = client.delete_issue("PROJ-1").await.unwrap_err();
4576        assert!(err.to_string().contains("403"));
4577    }
4578
4579    // ── get_watchers ──────────────────────────────────────────────
4580
4581    #[tokio::test]
4582    async fn get_watchers_success() {
4583        let server = wiremock::MockServer::start().await;
4584
4585        wiremock::Mock::given(wiremock::matchers::method("GET"))
4586            .and(wiremock::matchers::path(
4587                "/rest/api/3/issue/PROJ-1/watchers",
4588            ))
4589            .respond_with(
4590                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
4591                    "watchCount": 2,
4592                    "watchers": [
4593                        {
4594                            "accountId": "abc123",
4595                            "displayName": "Alice",
4596                            "emailAddress": "alice@example.com"
4597                        },
4598                        {
4599                            "accountId": "def456",
4600                            "displayName": "Bob"
4601                        }
4602                    ]
4603                })),
4604            )
4605            .expect(1)
4606            .mount(&server)
4607            .await;
4608
4609        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4610        let result = client.get_watchers("PROJ-1").await.unwrap();
4611
4612        assert_eq!(result.watch_count, 2);
4613        assert_eq!(result.watchers.len(), 2);
4614        assert_eq!(result.watchers[0].display_name, "Alice");
4615        assert_eq!(result.watchers[0].account_id, "abc123");
4616        assert_eq!(
4617            result.watchers[0].email_address.as_deref(),
4618            Some("alice@example.com")
4619        );
4620        assert_eq!(result.watchers[1].display_name, "Bob");
4621        assert!(result.watchers[1].email_address.is_none());
4622    }
4623
4624    #[tokio::test]
4625    async fn get_watchers_empty() {
4626        let server = wiremock::MockServer::start().await;
4627
4628        wiremock::Mock::given(wiremock::matchers::method("GET"))
4629            .and(wiremock::matchers::path(
4630                "/rest/api/3/issue/PROJ-1/watchers",
4631            ))
4632            .respond_with(
4633                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
4634                    "watchCount": 0,
4635                    "watchers": []
4636                })),
4637            )
4638            .expect(1)
4639            .mount(&server)
4640            .await;
4641
4642        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4643        let result = client.get_watchers("PROJ-1").await.unwrap();
4644
4645        assert_eq!(result.watch_count, 0);
4646        assert!(result.watchers.is_empty());
4647    }
4648
4649    #[tokio::test]
4650    async fn get_watchers_api_error() {
4651        let server = wiremock::MockServer::start().await;
4652
4653        wiremock::Mock::given(wiremock::matchers::method("GET"))
4654            .and(wiremock::matchers::path(
4655                "/rest/api/3/issue/NOPE-1/watchers",
4656            ))
4657            .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
4658            .expect(1)
4659            .mount(&server)
4660            .await;
4661
4662        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4663        let err = client.get_watchers("NOPE-1").await.unwrap_err();
4664        assert!(err.to_string().contains("404"));
4665    }
4666
4667    // ── add_watcher ───────────────────────────────────────────────
4668
4669    #[tokio::test]
4670    async fn add_watcher_success() {
4671        let server = wiremock::MockServer::start().await;
4672
4673        wiremock::Mock::given(wiremock::matchers::method("POST"))
4674            .and(wiremock::matchers::path(
4675                "/rest/api/3/issue/PROJ-1/watchers",
4676            ))
4677            .and(wiremock::matchers::body_json(serde_json::json!("abc123")))
4678            .respond_with(wiremock::ResponseTemplate::new(204))
4679            .expect(1)
4680            .mount(&server)
4681            .await;
4682
4683        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4684        let result = client.add_watcher("PROJ-1", "abc123").await;
4685        assert!(result.is_ok());
4686    }
4687
4688    #[tokio::test]
4689    async fn add_watcher_api_error() {
4690        let server = wiremock::MockServer::start().await;
4691
4692        wiremock::Mock::given(wiremock::matchers::method("POST"))
4693            .and(wiremock::matchers::path(
4694                "/rest/api/3/issue/PROJ-1/watchers",
4695            ))
4696            .respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
4697            .expect(1)
4698            .mount(&server)
4699            .await;
4700
4701        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4702        let err = client.add_watcher("PROJ-1", "abc123").await.unwrap_err();
4703        assert!(err.to_string().contains("403"));
4704    }
4705
4706    // ── remove_watcher ────────────────────────────────────────────
4707
4708    #[tokio::test]
4709    async fn remove_watcher_success() {
4710        let server = wiremock::MockServer::start().await;
4711
4712        wiremock::Mock::given(wiremock::matchers::method("DELETE"))
4713            .and(wiremock::matchers::path(
4714                "/rest/api/3/issue/PROJ-1/watchers",
4715            ))
4716            .and(wiremock::matchers::query_param("accountId", "abc123"))
4717            .respond_with(wiremock::ResponseTemplate::new(204))
4718            .expect(1)
4719            .mount(&server)
4720            .await;
4721
4722        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4723        let result = client.remove_watcher("PROJ-1", "abc123").await;
4724        assert!(result.is_ok());
4725    }
4726
4727    #[tokio::test]
4728    async fn remove_watcher_api_error() {
4729        let server = wiremock::MockServer::start().await;
4730
4731        wiremock::Mock::given(wiremock::matchers::method("DELETE"))
4732            .and(wiremock::matchers::path(
4733                "/rest/api/3/issue/PROJ-1/watchers",
4734            ))
4735            .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
4736            .expect(1)
4737            .mount(&server)
4738            .await;
4739
4740        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4741        let err = client.remove_watcher("PROJ-1", "abc123").await.unwrap_err();
4742        assert!(err.to_string().contains("404"));
4743    }
4744
4745    #[tokio::test]
4746    async fn get_myself_success() {
4747        let server = wiremock::MockServer::start().await;
4748
4749        wiremock::Mock::given(wiremock::matchers::method("GET"))
4750            .and(wiremock::matchers::path("/rest/api/3/myself"))
4751            .respond_with(
4752                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
4753                    "displayName": "Alice Smith",
4754                    "emailAddress": "alice@example.com",
4755                    "accountId": "abc123"
4756                })),
4757            )
4758            .expect(1)
4759            .mount(&server)
4760            .await;
4761
4762        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4763        let user = client.get_myself().await.unwrap();
4764        assert_eq!(user.display_name, "Alice Smith");
4765        assert_eq!(user.email_address.as_deref(), Some("alice@example.com"));
4766        assert_eq!(user.account_id, "abc123");
4767    }
4768
4769    #[tokio::test]
4770    async fn get_myself_api_error() {
4771        let server = wiremock::MockServer::start().await;
4772
4773        wiremock::Mock::given(wiremock::matchers::method("GET"))
4774            .and(wiremock::matchers::path("/rest/api/3/myself"))
4775            .respond_with(wiremock::ResponseTemplate::new(401).set_body_string("Unauthorized"))
4776            .expect(1)
4777            .mount(&server)
4778            .await;
4779
4780        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4781        let err = client.get_myself().await.unwrap_err();
4782        assert!(err.to_string().contains("401"));
4783    }
4784
4785    // ── get_issue_id ──────────────────────────────────────────────
4786
4787    #[tokio::test]
4788    async fn get_issue_id_success() {
4789        let server = wiremock::MockServer::start().await;
4790
4791        wiremock::Mock::given(wiremock::matchers::method("GET"))
4792            .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1"))
4793            .respond_with(
4794                wiremock::ResponseTemplate::new(200).set_body_json(
4795                    serde_json::json!({"id": "12345", "key": "PROJ-1", "fields": {}}),
4796                ),
4797            )
4798            .expect(1)
4799            .mount(&server)
4800            .await;
4801
4802        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4803        let id = client.get_issue_id("PROJ-1").await.unwrap();
4804        assert_eq!(id, "12345");
4805    }
4806
4807    #[tokio::test]
4808    async fn get_issue_id_api_error() {
4809        let server = wiremock::MockServer::start().await;
4810
4811        wiremock::Mock::given(wiremock::matchers::method("GET"))
4812            .and(wiremock::matchers::path("/rest/api/3/issue/NOPE-1"))
4813            .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
4814            .expect(1)
4815            .mount(&server)
4816            .await;
4817
4818        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4819        let err = client.get_issue_id("NOPE-1").await.unwrap_err();
4820        assert!(err.to_string().contains("404"));
4821    }
4822
4823    // ── get_dev_status_summary ────────────────────────────────────
4824
4825    #[tokio::test]
4826    async fn get_dev_status_summary_success() {
4827        let server = wiremock::MockServer::start().await;
4828
4829        // Mock issue ID resolution.
4830        wiremock::Mock::given(wiremock::matchers::method("GET"))
4831            .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1"))
4832            .respond_with(
4833                wiremock::ResponseTemplate::new(200).set_body_json(
4834                    serde_json::json!({"id": "10001", "key": "PROJ-1", "fields": {}}),
4835                ),
4836            )
4837            .mount(&server)
4838            .await;
4839
4840        // Mock summary endpoint.
4841        wiremock::Mock::given(wiremock::matchers::method("GET"))
4842            .and(wiremock::matchers::path(
4843                "/rest/dev-status/1.0/issue/summary",
4844            ))
4845            .respond_with(
4846                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
4847                    "summary": {
4848                        "pullrequest": {
4849                            "overall": {"count": 2},
4850                            "byInstanceType": {"GitHub": {"count": 2, "name": "GitHub"}}
4851                        },
4852                        "branch": {
4853                            "overall": {"count": 1},
4854                            "byInstanceType": {"GitHub": {"count": 1, "name": "GitHub"}}
4855                        },
4856                        "repository": {
4857                            "overall": {"count": 1},
4858                            "byInstanceType": {}
4859                        }
4860                    }
4861                })),
4862            )
4863            .expect(1)
4864            .mount(&server)
4865            .await;
4866
4867        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4868        let summary = client.get_dev_status_summary("PROJ-1").await.unwrap();
4869        assert_eq!(summary.pullrequest.count, 2);
4870        assert_eq!(
4871            summary.pullrequest.providers,
4872            vec![JiraDevProvider {
4873                instance_type: "GitHub".to_string(),
4874                name: "GitHub".to_string(),
4875            }]
4876        );
4877        assert_eq!(summary.branch.count, 1);
4878        assert_eq!(summary.repository.count, 1);
4879        assert!(summary.repository.providers.is_empty());
4880    }
4881
4882    #[tokio::test]
4883    async fn get_dev_status_summary_api_error() {
4884        let server = wiremock::MockServer::start().await;
4885
4886        wiremock::Mock::given(wiremock::matchers::method("GET"))
4887            .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1"))
4888            .respond_with(
4889                wiremock::ResponseTemplate::new(200).set_body_json(
4890                    serde_json::json!({"id": "10001", "key": "PROJ-1", "fields": {}}),
4891                ),
4892            )
4893            .mount(&server)
4894            .await;
4895
4896        wiremock::Mock::given(wiremock::matchers::method("GET"))
4897            .and(wiremock::matchers::path(
4898                "/rest/dev-status/1.0/issue/summary",
4899            ))
4900            .respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
4901            .expect(1)
4902            .mount(&server)
4903            .await;
4904
4905        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4906        let err = client.get_dev_status_summary("PROJ-1").await.unwrap_err();
4907        assert!(err.to_string().contains("403"));
4908    }
4909
4910    // ── get_dev_status ────────────────────────────────────────────
4911
4912    /// Helper: mounts a mock for issue ID resolution returning id "10001".
4913    async fn mount_issue_id_mock(server: &wiremock::MockServer) {
4914        wiremock::Mock::given(wiremock::matchers::method("GET"))
4915            .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1"))
4916            .respond_with(
4917                wiremock::ResponseTemplate::new(200).set_body_json(
4918                    serde_json::json!({"id": "10001", "key": "PROJ-1", "fields": {}}),
4919                ),
4920            )
4921            .mount(server)
4922            .await;
4923    }
4924
4925    /// Helper: mounts a mock for the dev-status summary returning GitHub as the only provider.
4926    async fn mount_summary_mock(server: &wiremock::MockServer) {
4927        wiremock::Mock::given(wiremock::matchers::method("GET"))
4928            .and(wiremock::matchers::path(
4929                "/rest/dev-status/1.0/issue/summary",
4930            ))
4931            .respond_with(
4932                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
4933                    "summary": {
4934                        "pullrequest": {
4935                            "overall": {"count": 1},
4936                            "byInstanceType": {"GitHub": {"count": 1, "name": "GitHub"}}
4937                        },
4938                        "branch": {
4939                            "overall": {"count": 0},
4940                            "byInstanceType": {}
4941                        },
4942                        "repository": {
4943                            "overall": {"count": 0},
4944                            "byInstanceType": {}
4945                        }
4946                    }
4947                })),
4948            )
4949            .mount(server)
4950            .await;
4951    }
4952
4953    fn dev_status_detail_response() -> serde_json::Value {
4954        serde_json::json!({
4955            "detail": [{
4956                "pullRequests": [{
4957                    "id": "#42",
4958                    "name": "Fix login bug",
4959                    "status": "MERGED",
4960                    "url": "https://github.com/org/repo/pull/42",
4961                    "repositoryName": "org/repo",
4962                    "source": {"branch": "fix-login"},
4963                    "destination": {"branch": "main"},
4964                    "author": {"name": "Alice"},
4965                    "reviewers": [{"name": "Bob"}],
4966                    "commentCount": 3,
4967                    "lastUpdate": "2024-01-15T10:30:00.000+0000"
4968                }],
4969                "branches": [{
4970                    "name": "fix-login",
4971                    "url": "https://github.com/org/repo/tree/fix-login",
4972                    "repositoryName": "org/repo",
4973                    "createPullRequestUrl": "https://github.com/org/repo/compare/fix-login",
4974                    "lastCommit": {
4975                        "id": "abc123def456",
4976                        "displayId": "abc123d",
4977                        "message": "Fix the login",
4978                        "author": {"name": "Alice"},
4979                        "authorTimestamp": "2024-01-14T08:00:00.000+0000",
4980                        "url": "https://github.com/org/repo/commit/abc123d",
4981                        "fileCount": 2,
4982                        "merge": false
4983                    }
4984                }],
4985                "repositories": [{
4986                    "name": "org/repo",
4987                    "url": "https://github.com/org/repo",
4988                    "commits": [{
4989                        "id": "abc123def456",
4990                        "displayId": "abc123d",
4991                        "message": "Fix the login",
4992                        "author": {"name": "Alice"},
4993                        "authorTimestamp": "2024-01-14T08:00:00.000+0000",
4994                        "url": "https://github.com/org/repo/commit/abc123d",
4995                        "fileCount": 2,
4996                        "merge": false
4997                    }]
4998                }],
4999                "_instance": {"name": "GitHub", "type": "GitHub"}
5000            }]
5001        })
5002    }
5003
5004    #[tokio::test]
5005    async fn get_dev_status_pullrequest_fields() {
5006        let server = wiremock::MockServer::start().await;
5007        mount_issue_id_mock(&server).await;
5008
5009        wiremock::Mock::given(wiremock::matchers::method("GET"))
5010            .and(wiremock::matchers::path(
5011                "/rest/dev-status/1.0/issue/detail",
5012            ))
5013            .and(wiremock::matchers::query_param("dataType", "pullrequest"))
5014            .respond_with(
5015                wiremock::ResponseTemplate::new(200).set_body_json(dev_status_detail_response()),
5016            )
5017            .mount(&server)
5018            .await;
5019
5020        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
5021        let status = client
5022            .get_dev_status("PROJ-1", Some("pullrequest"), Some("GitHub"))
5023            .await
5024            .unwrap();
5025
5026        assert_eq!(status.pull_requests.len(), 1);
5027        let pr = &status.pull_requests[0];
5028        assert_eq!(pr.id, "#42");
5029        assert_eq!(pr.status, "MERGED");
5030        assert_eq!(pr.author.as_deref(), Some("Alice"));
5031        assert_eq!(pr.reviewers, vec!["Bob"]);
5032        assert_eq!(pr.comment_count, Some(3));
5033        assert!(pr.last_update.is_some());
5034        assert_eq!(pr.source_branch, "fix-login");
5035        assert_eq!(pr.destination_branch, "main");
5036    }
5037
5038    #[tokio::test]
5039    async fn get_dev_status_branch_fields() {
5040        let server = wiremock::MockServer::start().await;
5041        mount_issue_id_mock(&server).await;
5042
5043        wiremock::Mock::given(wiremock::matchers::method("GET"))
5044            .and(wiremock::matchers::path(
5045                "/rest/dev-status/1.0/issue/detail",
5046            ))
5047            .and(wiremock::matchers::query_param("dataType", "branch"))
5048            .respond_with(
5049                wiremock::ResponseTemplate::new(200).set_body_json(dev_status_detail_response()),
5050            )
5051            .mount(&server)
5052            .await;
5053
5054        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
5055        let status = client
5056            .get_dev_status("PROJ-1", Some("branch"), Some("GitHub"))
5057            .await
5058            .unwrap();
5059
5060        assert_eq!(status.branches.len(), 1);
5061        let branch = &status.branches[0];
5062        assert_eq!(branch.name, "fix-login");
5063        assert!(branch.create_pr_url.is_some());
5064        let commit = branch.last_commit.as_ref().unwrap();
5065        assert_eq!(commit.display_id, "abc123d");
5066        assert_eq!(commit.file_count, 2);
5067        assert!(!commit.merge);
5068    }
5069
5070    #[tokio::test]
5071    async fn get_dev_status_repository_with_commits() {
5072        let server = wiremock::MockServer::start().await;
5073        mount_issue_id_mock(&server).await;
5074
5075        wiremock::Mock::given(wiremock::matchers::method("GET"))
5076            .and(wiremock::matchers::path(
5077                "/rest/dev-status/1.0/issue/detail",
5078            ))
5079            .and(wiremock::matchers::query_param("dataType", "repository"))
5080            .respond_with(
5081                wiremock::ResponseTemplate::new(200).set_body_json(dev_status_detail_response()),
5082            )
5083            .mount(&server)
5084            .await;
5085
5086        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
5087        let status = client
5088            .get_dev_status("PROJ-1", Some("repository"), Some("GitHub"))
5089            .await
5090            .unwrap();
5091
5092        assert_eq!(status.repositories.len(), 1);
5093        assert_eq!(status.repositories[0].commits.len(), 1);
5094        assert_eq!(status.repositories[0].commits[0].display_id, "abc123d");
5095        assert_eq!(
5096            status.repositories[0].commits[0].author.as_deref(),
5097            Some("Alice")
5098        );
5099    }
5100
5101    #[tokio::test]
5102    async fn get_dev_status_auto_discovers_providers() {
5103        let server = wiremock::MockServer::start().await;
5104        mount_issue_id_mock(&server).await;
5105        mount_summary_mock(&server).await;
5106
5107        wiremock::Mock::given(wiremock::matchers::method("GET"))
5108            .and(wiremock::matchers::path(
5109                "/rest/dev-status/1.0/issue/detail",
5110            ))
5111            .respond_with(
5112                wiremock::ResponseTemplate::new(200).set_body_json(dev_status_detail_response()),
5113            )
5114            .mount(&server)
5115            .await;
5116
5117        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
5118        let status = client
5119            .get_dev_status("PROJ-1", Some("pullrequest"), None)
5120            .await
5121            .unwrap();
5122
5123        assert_eq!(status.pull_requests.len(), 1);
5124        assert_eq!(status.pull_requests[0].name, "Fix login bug");
5125    }
5126
5127    /// Regression test for #924: a Bitbucket Server PR is keyed under `stash`
5128    /// in the summary's `byInstanceType` map (with the display name "Bitbucket
5129    /// Server"). Auto-discovery must query the detail endpoint with the *key*
5130    /// (`applicationType=stash`), not the display name, or the PR is missed and
5131    /// the result is empty.
5132    #[tokio::test]
5133    async fn get_dev_status_auto_discovers_bitbucket_server() {
5134        let server = wiremock::MockServer::start().await;
5135        mount_issue_id_mock(&server).await;
5136
5137        wiremock::Mock::given(wiremock::matchers::method("GET"))
5138            .and(wiremock::matchers::path(
5139                "/rest/dev-status/1.0/issue/summary",
5140            ))
5141            .respond_with(
5142                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
5143                    "summary": {
5144                        "pullrequest": {
5145                            "overall": {"count": 1},
5146                            "byInstanceType": {"stash": {"count": 1, "name": "Bitbucket Server"}}
5147                        },
5148                        "branch": {"overall": {"count": 0}, "byInstanceType": {}},
5149                        "repository": {
5150                            "overall": {"count": 1},
5151                            "byInstanceType": {"stash": {"count": 1, "name": "Bitbucket Server"}}
5152                        }
5153                    }
5154                })),
5155            )
5156            .mount(&server)
5157            .await;
5158
5159        // Only respond when the detail query carries `applicationType=stash`.
5160        // The buggy code queried `applicationType=Bitbucket Server`, which would
5161        // not match this mock and surface as an API error.
5162        wiremock::Mock::given(wiremock::matchers::method("GET"))
5163            .and(wiremock::matchers::path(
5164                "/rest/dev-status/1.0/issue/detail",
5165            ))
5166            .and(wiremock::matchers::query_param("applicationType", "stash"))
5167            .respond_with(
5168                wiremock::ResponseTemplate::new(200).set_body_json(dev_status_detail_response()),
5169            )
5170            .mount(&server)
5171            .await;
5172
5173        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
5174        let status = client
5175            .get_dev_status("PROJ-1", Some("pullrequest"), None)
5176            .await
5177            .unwrap();
5178
5179        assert_eq!(status.pull_requests.len(), 1);
5180        assert_eq!(status.pull_requests[0].name, "Fix login bug");
5181    }
5182
5183    /// The summary must keep *both* halves of a `byInstanceType` entry: the key
5184    /// (`stash`) as `instance_type` for the detail round-trip, and the value's
5185    /// `name` ("Bitbucket Server") for display. Earlier behaviour collapsed them
5186    /// onto one or the other.
5187    #[tokio::test]
5188    async fn get_dev_status_summary_keeps_key_and_name() {
5189        let server = wiremock::MockServer::start().await;
5190        mount_issue_id_mock(&server).await;
5191
5192        wiremock::Mock::given(wiremock::matchers::method("GET"))
5193            .and(wiremock::matchers::path(
5194                "/rest/dev-status/1.0/issue/summary",
5195            ))
5196            .respond_with(
5197                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
5198                    "summary": {
5199                        "pullrequest": {
5200                            "overall": {"count": 1},
5201                            "byInstanceType": {"stash": {"count": 1, "name": "Bitbucket Server"}}
5202                        },
5203                        "branch": {"overall": {"count": 0}, "byInstanceType": {}},
5204                        "repository": {"overall": {"count": 0}, "byInstanceType": {}}
5205                    }
5206                })),
5207            )
5208            .mount(&server)
5209            .await;
5210
5211        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
5212        let summary = client.get_dev_status_summary("PROJ-1").await.unwrap();
5213
5214        assert_eq!(
5215            summary.pullrequest.providers,
5216            vec![JiraDevProvider {
5217                instance_type: "stash".to_string(),
5218                name: "Bitbucket Server".to_string(),
5219            }]
5220        );
5221    }
5222
5223    #[tokio::test]
5224    async fn get_dev_status_empty_response() {
5225        let server = wiremock::MockServer::start().await;
5226        mount_issue_id_mock(&server).await;
5227
5228        wiremock::Mock::given(wiremock::matchers::method("GET"))
5229            .and(wiremock::matchers::path(
5230                "/rest/dev-status/1.0/issue/detail",
5231            ))
5232            .respond_with(
5233                wiremock::ResponseTemplate::new(200)
5234                    .set_body_json(serde_json::json!({"detail": []})),
5235            )
5236            .mount(&server)
5237            .await;
5238
5239        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
5240        let status = client
5241            .get_dev_status("PROJ-1", None, Some("GitHub"))
5242            .await
5243            .unwrap();
5244
5245        assert!(status.pull_requests.is_empty());
5246        assert!(status.branches.is_empty());
5247        assert!(status.repositories.is_empty());
5248    }
5249
5250    #[tokio::test]
5251    async fn get_dev_status_detail_api_error() {
5252        let server = wiremock::MockServer::start().await;
5253        mount_issue_id_mock(&server).await;
5254
5255        wiremock::Mock::given(wiremock::matchers::method("GET"))
5256            .and(wiremock::matchers::path(
5257                "/rest/dev-status/1.0/issue/detail",
5258            ))
5259            .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("Server Error"))
5260            .mount(&server)
5261            .await;
5262
5263        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
5264        let err = client
5265            .get_dev_status("PROJ-1", Some("pullrequest"), Some("GitHub"))
5266            .await
5267            .unwrap_err();
5268        assert!(err.to_string().contains("500"));
5269    }
5270
5271    #[tokio::test]
5272    async fn get_dev_status_with_data_type_filter() {
5273        let server = wiremock::MockServer::start().await;
5274        mount_issue_id_mock(&server).await;
5275
5276        // Only return branch data.
5277        wiremock::Mock::given(wiremock::matchers::method("GET"))
5278            .and(wiremock::matchers::path(
5279                "/rest/dev-status/1.0/issue/detail",
5280            ))
5281            .and(wiremock::matchers::query_param("dataType", "branch"))
5282            .respond_with(
5283                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
5284                    "detail": [{
5285                        "pullRequests": [],
5286                        "branches": [{
5287                            "name": "feature-x",
5288                            "url": "https://github.com/org/repo/tree/feature-x",
5289                            "repositoryName": "org/repo"
5290                        }],
5291                        "repositories": []
5292                    }]
5293                })),
5294            )
5295            .mount(&server)
5296            .await;
5297
5298        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
5299        let status = client
5300            .get_dev_status("PROJ-1", Some("branch"), Some("GitHub"))
5301            .await
5302            .unwrap();
5303
5304        assert!(status.pull_requests.is_empty());
5305        assert_eq!(status.branches.len(), 1);
5306        assert_eq!(status.branches[0].name, "feature-x");
5307        assert!(status.branches[0].last_commit.is_none());
5308        assert!(status.branches[0].create_pr_url.is_none());
5309        assert!(status.repositories.is_empty());
5310    }
5311
5312    #[tokio::test]
5313    async fn get_dev_status_summary_empty() {
5314        let server = wiremock::MockServer::start().await;
5315        mount_issue_id_mock(&server).await;
5316
5317        wiremock::Mock::given(wiremock::matchers::method("GET"))
5318            .and(wiremock::matchers::path(
5319                "/rest/dev-status/1.0/issue/summary",
5320            ))
5321            .respond_with(
5322                wiremock::ResponseTemplate::new(200)
5323                    .set_body_json(serde_json::json!({"summary": {}})),
5324            )
5325            .expect(1)
5326            .mount(&server)
5327            .await;
5328
5329        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
5330        let summary = client.get_dev_status_summary("PROJ-1").await.unwrap();
5331        assert_eq!(summary.pullrequest.count, 0);
5332        assert_eq!(summary.branch.count, 0);
5333        assert_eq!(summary.repository.count, 0);
5334    }
5335
5336    #[tokio::test]
5337    async fn convert_commit_maps_all_fields() {
5338        let internal = DevStatusCommit {
5339            id: "abc123".to_string(),
5340            display_id: "abc".to_string(),
5341            message: "Test commit".to_string(),
5342            author: Some(DevStatusAuthor {
5343                name: "Alice".to_string(),
5344            }),
5345            author_timestamp: Some("2024-01-01T00:00:00.000+0000".to_string()),
5346            url: "https://example.com/commit/abc".to_string(),
5347            file_count: 5,
5348            merge: true,
5349        };
5350        let public = AtlassianClient::convert_commit(internal);
5351        assert_eq!(public.id, "abc123");
5352        assert_eq!(public.display_id, "abc");
5353        assert_eq!(public.message, "Test commit");
5354        assert_eq!(public.author.as_deref(), Some("Alice"));
5355        assert!(public.timestamp.is_some());
5356        assert_eq!(public.file_count, 5);
5357        assert!(public.merge);
5358    }
5359
5360    #[tokio::test]
5361    async fn convert_commit_no_author() {
5362        let internal = DevStatusCommit {
5363            id: "def456".to_string(),
5364            display_id: "def".to_string(),
5365            message: "Anonymous".to_string(),
5366            author: None,
5367            author_timestamp: None,
5368            url: "https://example.com/commit/def".to_string(),
5369            file_count: 0,
5370            merge: false,
5371        };
5372        let public = AtlassianClient::convert_commit(internal);
5373        assert!(public.author.is_none());
5374        assert!(public.timestamp.is_none());
5375    }
5376
5377    // ── extract_worklog_comment ────────────────────────────────────
5378
5379    #[test]
5380    fn extract_worklog_comment_none() {
5381        assert_eq!(AtlassianClient::extract_worklog_comment(None), None);
5382    }
5383
5384    #[test]
5385    fn extract_worklog_comment_valid_adf() {
5386        let adf = serde_json::json!({
5387            "version": 1,
5388            "type": "doc",
5389            "content": [{
5390                "type": "paragraph",
5391                "content": [{"type": "text", "text": "Fixed the login bug"}]
5392            }]
5393        });
5394        let result = AtlassianClient::extract_worklog_comment(Some(&adf));
5395        assert_eq!(result.as_deref(), Some("Fixed the login bug"));
5396    }
5397
5398    #[test]
5399    fn extract_worklog_comment_empty_adf() {
5400        let adf = serde_json::json!({
5401            "version": 1,
5402            "type": "doc",
5403            "content": []
5404        });
5405        let result = AtlassianClient::extract_worklog_comment(Some(&adf));
5406        assert_eq!(result, None);
5407    }
5408
5409    #[test]
5410    fn extract_worklog_comment_invalid_json() {
5411        let invalid = serde_json::json!({"not": "adf"});
5412        let result = AtlassianClient::extract_worklog_comment(Some(&invalid));
5413        assert_eq!(result, None);
5414    }
5415
5416    // ── worklog deserialization ────────────────────────────────────
5417
5418    #[test]
5419    fn worklog_response_deserializes() {
5420        let json = r#"{
5421            "worklogs": [
5422                {
5423                    "id": "100",
5424                    "author": {"displayName": "Alice"},
5425                    "timeSpent": "2h",
5426                    "timeSpentSeconds": 7200,
5427                    "started": "2026-04-16T09:00:00.000+0000",
5428                    "comment": {
5429                        "version": 1,
5430                        "type": "doc",
5431                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Debugging"}]}]
5432                    }
5433                },
5434                {
5435                    "id": "101",
5436                    "author": {"displayName": "Bob"},
5437                    "timeSpent": "1d",
5438                    "timeSpentSeconds": 28800,
5439                    "started": "2026-04-15T10:00:00.000+0000"
5440                }
5441            ],
5442            "total": 2
5443        }"#;
5444        let resp: JiraWorklogResponse = serde_json::from_str(json).unwrap();
5445        assert_eq!(resp.total, 2);
5446        assert_eq!(resp.worklogs.len(), 2);
5447        assert_eq!(resp.worklogs[0].id, "100");
5448        assert_eq!(resp.worklogs[0].time_spent.as_deref(), Some("2h"));
5449        assert_eq!(resp.worklogs[0].time_spent_seconds, 7200);
5450        assert!(resp.worklogs[0].comment.is_some());
5451        assert!(resp.worklogs[1].comment.is_none());
5452    }
5453
5454    #[test]
5455    fn worklog_response_empty() {
5456        let json = r#"{"worklogs": [], "total": 0}"#;
5457        let resp: JiraWorklogResponse = serde_json::from_str(json).unwrap();
5458        assert_eq!(resp.total, 0);
5459        assert!(resp.worklogs.is_empty());
5460    }
5461
5462    #[test]
5463    fn worklog_response_missing_optional_fields() {
5464        let json = r#"{
5465            "worklogs": [{
5466                "id": "200",
5467                "timeSpentSeconds": 3600
5468            }],
5469            "total": 1
5470        }"#;
5471        let resp: JiraWorklogResponse = serde_json::from_str(json).unwrap();
5472        assert!(resp.worklogs[0].author.is_none());
5473        assert!(resp.worklogs[0].time_spent.is_none());
5474        assert!(resp.worklogs[0].started.is_none());
5475    }
5476
5477    // ── worklog wiremock tests ────────────────────────────────────
5478
5479    #[tokio::test]
5480    async fn get_worklogs_success() {
5481        let server = wiremock::MockServer::start().await;
5482
5483        let worklog_json = serde_json::json!({
5484            "worklogs": [
5485                {
5486                    "id": "100",
5487                    "author": {"displayName": "Alice"},
5488                    "timeSpent": "2h",
5489                    "timeSpentSeconds": 7200,
5490                    "started": "2026-04-16T09:00:00.000+0000",
5491                    "comment": {
5492                        "version": 1,
5493                        "type": "doc",
5494                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Debugging login"}]}]
5495                    }
5496                },
5497                {
5498                    "id": "101",
5499                    "author": {"displayName": "Bob"},
5500                    "timeSpent": "1d",
5501                    "timeSpentSeconds": 28800,
5502                    "started": "2026-04-15T10:00:00.000+0000"
5503                }
5504            ],
5505            "total": 2
5506        });
5507
5508        wiremock::Mock::given(wiremock::matchers::method("GET"))
5509            .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1/worklog"))
5510            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(worklog_json))
5511            .expect(1)
5512            .mount(&server)
5513            .await;
5514
5515        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
5516        let result = client.get_worklogs("PROJ-1", 50).await.unwrap();
5517
5518        assert_eq!(result.total, 2);
5519        assert_eq!(result.worklogs.len(), 2);
5520        assert_eq!(result.worklogs[0].author, "Alice");
5521        assert_eq!(result.worklogs[0].time_spent, "2h");
5522        assert_eq!(result.worklogs[0].time_spent_seconds, 7200);
5523        assert_eq!(
5524            result.worklogs[0].comment.as_deref(),
5525            Some("Debugging login")
5526        );
5527        assert_eq!(result.worklogs[1].author, "Bob");
5528        assert_eq!(result.worklogs[1].comment, None);
5529    }
5530
5531    #[tokio::test]
5532    async fn get_worklogs_empty() {
5533        let server = wiremock::MockServer::start().await;
5534
5535        wiremock::Mock::given(wiremock::matchers::method("GET"))
5536            .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1/worklog"))
5537            .respond_with(
5538                wiremock::ResponseTemplate::new(200)
5539                    .set_body_json(serde_json::json!({"worklogs": [], "total": 0})),
5540            )
5541            .expect(1)
5542            .mount(&server)
5543            .await;
5544
5545        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
5546        let result = client.get_worklogs("PROJ-1", 50).await.unwrap();
5547
5548        assert_eq!(result.total, 0);
5549        assert!(result.worklogs.is_empty());
5550    }
5551
5552    #[tokio::test]
5553    async fn get_worklogs_api_error() {
5554        let server = wiremock::MockServer::start().await;
5555
5556        wiremock::Mock::given(wiremock::matchers::method("GET"))
5557            .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1/worklog"))
5558            .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
5559            .expect(1)
5560            .mount(&server)
5561            .await;
5562
5563        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
5564        let result = client.get_worklogs("PROJ-1", 50).await;
5565        assert!(result.is_err());
5566    }
5567
5568    #[tokio::test]
5569    async fn add_worklog_success() {
5570        let server = wiremock::MockServer::start().await;
5571
5572        wiremock::Mock::given(wiremock::matchers::method("POST"))
5573            .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1/worklog"))
5574            .respond_with(wiremock::ResponseTemplate::new(201))
5575            .expect(1)
5576            .mount(&server)
5577            .await;
5578
5579        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
5580        let result = client.add_worklog("PROJ-1", "2h", None, None).await;
5581        assert!(result.is_ok());
5582    }
5583
5584    #[tokio::test]
5585    async fn add_worklog_with_all_fields() {
5586        let server = wiremock::MockServer::start().await;
5587
5588        wiremock::Mock::given(wiremock::matchers::method("POST"))
5589            .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1/worklog"))
5590            .respond_with(wiremock::ResponseTemplate::new(201))
5591            .expect(1)
5592            .mount(&server)
5593            .await;
5594
5595        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
5596        let result = client
5597            .add_worklog(
5598                "PROJ-1",
5599                "2h 30m",
5600                Some("2026-04-16T09:00:00.000+0000"),
5601                Some("Fixed the bug"),
5602            )
5603            .await;
5604        assert!(result.is_ok());
5605    }
5606
5607    #[tokio::test]
5608    async fn add_worklog_api_error() {
5609        let server = wiremock::MockServer::start().await;
5610
5611        wiremock::Mock::given(wiremock::matchers::method("POST"))
5612            .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1/worklog"))
5613            .respond_with(wiremock::ResponseTemplate::new(400).set_body_string("Bad Request"))
5614            .expect(1)
5615            .mount(&server)
5616            .await;
5617
5618        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
5619        let result = client.add_worklog("PROJ-1", "2h", None, None).await;
5620        assert!(result.is_err());
5621    }
5622
5623    #[tokio::test]
5624    async fn get_worklogs_respects_limit() {
5625        let server = wiremock::MockServer::start().await;
5626
5627        let worklog_json = serde_json::json!({
5628            "worklogs": [
5629                {"id": "1", "author": {"displayName": "A"}, "timeSpent": "1h", "timeSpentSeconds": 3600, "started": "2026-04-16T09:00:00.000+0000"},
5630                {"id": "2", "author": {"displayName": "B"}, "timeSpent": "2h", "timeSpentSeconds": 7200, "started": "2026-04-16T10:00:00.000+0000"},
5631                {"id": "3", "author": {"displayName": "C"}, "timeSpent": "3h", "timeSpentSeconds": 10800, "started": "2026-04-16T11:00:00.000+0000"}
5632            ],
5633            "total": 3
5634        });
5635
5636        wiremock::Mock::given(wiremock::matchers::method("GET"))
5637            .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1/worklog"))
5638            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(worklog_json))
5639            .expect(1)
5640            .mount(&server)
5641            .await;
5642
5643        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
5644        let result = client.get_worklogs("PROJ-1", 2).await.unwrap();
5645
5646        assert_eq!(result.worklogs.len(), 2);
5647        assert_eq!(result.total, 3);
5648    }
5649}
5650
5651impl AtlassianClient {
5652    /// Creates a new Atlassian API client.
5653    ///
5654    /// Constructs the Basic Auth header from the email and API token.
5655    pub fn new(instance_url: &str, email: &str, api_token: &str) -> Result<Self> {
5656        let client = Client::builder()
5657            .timeout(REQUEST_TIMEOUT)
5658            .build()
5659            .context("Failed to build HTTP client")?;
5660
5661        let credentials = format!("{email}:{api_token}");
5662        let encoded = base64::engine::general_purpose::STANDARD.encode(credentials);
5663        let auth_header = format!("Basic {encoded}");
5664
5665        Ok(Self {
5666            client,
5667            instance_url: instance_url.trim_end_matches('/').to_string(),
5668            auth_header,
5669        })
5670    }
5671
5672    /// Creates a client from stored credentials.
5673    pub fn from_credentials(creds: &crate::atlassian::auth::AtlassianCredentials) -> Result<Self> {
5674        Self::new(
5675            &creds.instance_url,
5676            &creds.email,
5677            creds.api_token.expose_secret(),
5678        )
5679    }
5680
5681    /// Returns the instance URL.
5682    #[must_use]
5683    pub fn instance_url(&self) -> &str {
5684        &self.instance_url
5685    }
5686
5687    /// Appends a best-effort HTTP record for one request attempt. The service
5688    /// tag is `confluence` for `/wiki/` paths, else `jira`.
5689    fn log_request(
5690        &self,
5691        method: &str,
5692        url: &str,
5693        started: Instant,
5694        result: &reqwest::Result<reqwest::Response>,
5695    ) {
5696        let service = if url.contains("/wiki/") {
5697            "confluence"
5698        } else {
5699            "jira"
5700        };
5701        request_log::record_http_result(service, method, url, started, result);
5702    }
5703
5704    /// Sends an authenticated GET request and returns the raw response.
5705    ///
5706    /// Shared transport method used by both JIRA and Confluence API
5707    /// implementations.
5708    pub async fn get_json(&self, url: &str) -> Result<reqwest::Response> {
5709        retry_429(
5710            || {
5711                self.client
5712                    .get(url)
5713                    .header("Authorization", &self.auth_header)
5714                    .header("Accept", "application/json")
5715            },
5716            |started, result| self.log_request("GET", url, started, result),
5717        )
5718        .await
5719        .context("Failed to send GET request to Atlassian API")
5720    }
5721
5722    /// Sends an authenticated PUT request with a JSON body and returns the raw response.
5723    ///
5724    /// Shared transport method used by both JIRA and Confluence API
5725    /// implementations.
5726    pub async fn put_json<T: serde::Serialize + Sync + ?Sized>(
5727        &self,
5728        url: &str,
5729        body: &T,
5730    ) -> Result<reqwest::Response> {
5731        retry_429(
5732            || {
5733                self.client
5734                    .put(url)
5735                    .header("Authorization", &self.auth_header)
5736                    .header("Content-Type", "application/json")
5737                    .json(body)
5738            },
5739            |started, result| self.log_request("PUT", url, started, result),
5740        )
5741        .await
5742        .context("Failed to send PUT request to Atlassian API")
5743    }
5744
5745    /// Sends an authenticated POST request with a JSON body and returns the raw response.
5746    pub async fn post_json<T: serde::Serialize + Sync + ?Sized>(
5747        &self,
5748        url: &str,
5749        body: &T,
5750    ) -> Result<reqwest::Response> {
5751        retry_429(
5752            || {
5753                self.client
5754                    .post(url)
5755                    .header("Authorization", &self.auth_header)
5756                    .header("Content-Type", "application/json")
5757                    .json(body)
5758            },
5759            |started, result| self.log_request("POST", url, started, result),
5760        )
5761        .await
5762        .context("Failed to send POST request to Atlassian API")
5763    }
5764
5765    /// Sends an authenticated GET request and returns raw bytes.
5766    pub async fn get_bytes(&self, url: &str) -> Result<Vec<u8>> {
5767        let response = self.get_json_raw_accept(url, "*/*").await?;
5768
5769        let response = Self::ensure_success(response).await?;
5770
5771        let bytes = response
5772            .bytes()
5773            .await
5774            .context("Failed to read response bytes")?;
5775        Ok(bytes.to_vec())
5776    }
5777
5778    /// Sends an authenticated DELETE request and returns the raw response.
5779    pub async fn delete(&self, url: &str) -> Result<reqwest::Response> {
5780        retry_429(
5781            || {
5782                self.client
5783                    .delete(url)
5784                    .header("Authorization", &self.auth_header)
5785            },
5786            |started, result| self.log_request("DELETE", url, started, result),
5787        )
5788        .await
5789        .context("Failed to send DELETE request to Atlassian API")
5790    }
5791
5792    /// Sends an authenticated POST request with a multipart body and returns the raw response.
5793    ///
5794    /// Does not retry on 429: a streamed multipart body cannot be replayed. Callers
5795    /// that need retry must rebuild the form and call again.
5796    pub async fn post_multipart(
5797        &self,
5798        url: &str,
5799        form: reqwest::multipart::Form,
5800        extra_headers: &[(&str, &str)],
5801    ) -> Result<reqwest::Response> {
5802        let mut req = self
5803            .client
5804            .post(url)
5805            .header("Authorization", &self.auth_header)
5806            .multipart(form);
5807        for (name, value) in extra_headers {
5808            req = req.header(*name, *value);
5809        }
5810        let started = Instant::now();
5811        let result = req.send().await;
5812        self.log_request("POST", url, started, &result);
5813        result.context("Failed to send multipart POST request to Atlassian API")
5814    }
5815
5816    /// Internal: GET with custom Accept header and 429 retry.
5817    async fn get_json_raw_accept(&self, url: &str, accept: &str) -> Result<reqwest::Response> {
5818        retry_429(
5819            || {
5820                self.client
5821                    .get(url)
5822                    .header("Authorization", &self.auth_header)
5823                    .header("Accept", accept)
5824            },
5825            |started, result| self.log_request("GET", url, started, result),
5826        )
5827        .await
5828        .context("Failed to send GET request to Atlassian API")
5829    }
5830
5831    /// Returns `response` unchanged if its status is a success, otherwise reads
5832    /// the body and fails with [`AtlassianError::ApiRequestFailed`].
5833    ///
5834    /// Centralises the "check status → read body → build error" block copied
5835    /// after nearly every request. Call sites that need bespoke diagnostics for
5836    /// specific status codes (e.g. `jira_write_error`, `confluence_write_error`)
5837    /// build their error directly instead of using this helper.
5838    pub(crate) async fn ensure_success(response: reqwest::Response) -> Result<reqwest::Response> {
5839        if response.status().is_success() {
5840            return Ok(response);
5841        }
5842        let status = response.status().as_u16();
5843        let body = response.text().await.unwrap_or_default();
5844        Err(AtlassianError::ApiRequestFailed { status, body }.into())
5845    }
5846
5847    /// Deserialises `response`'s JSON body into `T`, attaching `context` on
5848    /// failure.
5849    ///
5850    /// Pairs with [`Self::ensure_success`]; the common
5851    /// `Self::parse_json(Self::ensure_success(resp).await?, "…").await?`
5852    /// spelling replaces the hand-copied status-check + `json().context(…)`
5853    /// block.
5854    pub(crate) async fn parse_json<T: serde::de::DeserializeOwned>(
5855        response: reqwest::Response,
5856        context: &'static str,
5857    ) -> Result<T> {
5858        response.json().await.context(context)
5859    }
5860
5861    /// Fetches a JIRA issue by key with only the standard fields.
5862    ///
5863    /// Thin shim over [`Self::get_issue_with_fields`] with
5864    /// [`FieldSelection::Standard`]. Preserved for callers that do not need
5865    /// custom field data.
5866    pub async fn get_issue(&self, key: &str) -> Result<JiraIssue> {
5867        self.get_issue_with_fields(key, FieldSelection::Standard)
5868            .await
5869    }
5870
5871    /// Fetches a JIRA issue by key with the given field selection.
5872    ///
5873    /// Always requests `expand=names,schema` so human-readable field names
5874    /// and type metadata are available for rendering custom fields. When
5875    /// `selection` is [`FieldSelection::Standard`], `custom_fields` on the
5876    /// returned issue will be empty.
5877    pub async fn get_issue_with_fields(
5878        &self,
5879        key: &str,
5880        selection: FieldSelection,
5881    ) -> Result<JiraIssue> {
5882        const STANDARD_FIELDS: &str =
5883            "summary,description,status,issuetype,assignee,priority,labels";
5884
5885        let fields_param = match &selection {
5886            FieldSelection::Standard => STANDARD_FIELDS.to_string(),
5887            FieldSelection::Named(names) => {
5888                let mut parts: Vec<&str> = STANDARD_FIELDS.split(',').collect();
5889                parts.extend(names.iter().map(String::as_str));
5890                parts.join(",")
5891            }
5892            FieldSelection::All => "*all".to_string(),
5893        };
5894
5895        let base = format!("{}/rest/api/3/issue/{}", self.instance_url, key);
5896        let url = reqwest::Url::parse_with_params(
5897            &base,
5898            &[
5899                ("fields", fields_param.as_str()),
5900                ("expand", "names,schema"),
5901            ],
5902        )
5903        .context("Failed to build JIRA issue URL")?;
5904
5905        let response = self
5906            .client
5907            .get(url)
5908            .header("Authorization", &self.auth_header)
5909            .header("Accept", "application/json")
5910            .send()
5911            .await
5912            .context("Failed to send request to JIRA API")?;
5913
5914        let envelope: JiraIssueEnvelope = Self::parse_json(
5915            Self::ensure_success(response).await?,
5916            "Failed to parse JIRA issue response",
5917        )
5918        .await?;
5919
5920        Ok(envelope.into_issue(&selection))
5921    }
5922
5923    /// Updates a JIRA issue's description and optionally its summary.
5924    ///
5925    /// Thin shim over [`Self::update_issue_with_custom_fields`] that sends no
5926    /// custom field changes.
5927    pub async fn update_issue(
5928        &self,
5929        key: &str,
5930        description_adf: &ValidatedAdfDocument,
5931        summary: Option<&str>,
5932    ) -> Result<()> {
5933        self.update_issue_with_custom_fields(
5934            key,
5935            Some(description_adf),
5936            summary,
5937            &std::collections::BTreeMap::new(),
5938        )
5939        .await
5940    }
5941
5942    /// Updates a JIRA issue with any subset of supported fields.
5943    ///
5944    /// `description_adf` and `summary` are each `Option`: `None` leaves the
5945    /// field untouched, `Some` overwrites it. `custom_fields` is merged
5946    /// verbatim into the `fields` payload, keyed by stable JIRA field id —
5947    /// both standard fields (`assignee`, `reporter`, `priority`, `labels`)
5948    /// and custom fields (`customfield_19300`). The system `parent` field is
5949    /// set via [`Self::set_issue_parent`], not here. Returns an error when
5950    /// nothing would be sent (avoids a no-op PUT that JIRA still validates).
5951    pub async fn update_issue_with_custom_fields(
5952        &self,
5953        key: &str,
5954        description_adf: Option<&ValidatedAdfDocument>,
5955        summary: Option<&str>,
5956        custom_fields: &std::collections::BTreeMap<String, serde_json::Value>,
5957    ) -> Result<()> {
5958        let url = format!("{}/rest/api/3/issue/{}", self.instance_url, key);
5959
5960        let mut fields = serde_json::Map::new();
5961        if let Some(adf) = description_adf {
5962            fields.insert(
5963                "description".to_string(),
5964                serde_json::to_value(adf).context("Failed to serialize ADF document")?,
5965            );
5966        }
5967        if let Some(summary_text) = summary {
5968            fields.insert(
5969                "summary".to_string(),
5970                serde_json::Value::String(summary_text.to_string()),
5971            );
5972        }
5973        for (id, value) in custom_fields {
5974            fields.insert(id.clone(), value.clone());
5975        }
5976
5977        if fields.is_empty() {
5978            anyhow::bail!("update_issue_with_custom_fields: no fields to update");
5979        }
5980
5981        let body = serde_json::json!({ "fields": fields });
5982
5983        let response = self
5984            .client
5985            .put(&url)
5986            .header("Authorization", &self.auth_header)
5987            .header("Content-Type", "application/json")
5988            .json(&body)
5989            .send()
5990            .await
5991            .context("Failed to send update request to JIRA API")?;
5992
5993        if !response.status().is_success() {
5994            let status = response.status().as_u16();
5995            let body = response.text().await.unwrap_or_default();
5996            return Err(jira_write_error(status, body));
5997        }
5998
5999        Ok(())
6000    }
6001
6002    /// Fetches editable field metadata scoped to an issue's edit screen.
6003    ///
6004    /// `GET /rest/api/3/issue/{key}/editmeta` returns only fields on the
6005    /// issue's screen, so field names are unambiguous even when multiple
6006    /// custom fields share a display name globally.
6007    pub async fn get_editmeta(&self, key: &str) -> Result<EditMeta> {
6008        let url = format!("{}/rest/api/3/issue/{}/editmeta", self.instance_url, key);
6009
6010        let response = self
6011            .client
6012            .get(&url)
6013            .header("Authorization", &self.auth_header)
6014            .header("Accept", "application/json")
6015            .send()
6016            .await
6017            .context("Failed to send editmeta request to JIRA API")?;
6018
6019        let raw: JiraEditMetaResponse = Self::parse_json(
6020            Self::ensure_success(response).await?,
6021            "Failed to parse JIRA editmeta response",
6022        )
6023        .await?;
6024
6025        let fields = raw
6026            .fields
6027            .into_iter()
6028            .map(|(id, field)| {
6029                let allowed_values = field.allowed_value_strings();
6030                (
6031                    id,
6032                    EditMetaField {
6033                        name: field.name.unwrap_or_default(),
6034                        schema: field.schema.into(),
6035                        allowed_values,
6036                    },
6037                )
6038            })
6039            .collect();
6040        Ok(EditMeta { fields })
6041    }
6042
6043    /// Creates a new JIRA issue.
6044    ///
6045    /// Thin shim over [`Self::create_issue_with_custom_fields`] that sends no
6046    /// custom field values.
6047    pub async fn create_issue(
6048        &self,
6049        project_key: &str,
6050        issue_type: &str,
6051        summary: &str,
6052        description_adf: Option<&ValidatedAdfDocument>,
6053        labels: &[String],
6054    ) -> Result<JiraCreatedIssue> {
6055        self.create_issue_with_custom_fields(
6056            project_key,
6057            issue_type,
6058            summary,
6059            description_adf,
6060            labels,
6061            &std::collections::BTreeMap::new(),
6062        )
6063        .await
6064    }
6065
6066    /// Creates a new JIRA issue with standard fields and any custom fields
6067    /// keyed by stable ID (e.g., `customfield_19300`).
6068    pub async fn create_issue_with_custom_fields(
6069        &self,
6070        project_key: &str,
6071        issue_type: &str,
6072        summary: &str,
6073        description_adf: Option<&ValidatedAdfDocument>,
6074        labels: &[String],
6075        custom_fields: &std::collections::BTreeMap<String, serde_json::Value>,
6076    ) -> Result<JiraCreatedIssue> {
6077        let url = format!("{}/rest/api/3/issue", self.instance_url);
6078
6079        let mut fields = serde_json::Map::new();
6080        fields.insert(
6081            "project".to_string(),
6082            serde_json::json!({ "key": project_key }),
6083        );
6084        fields.insert(
6085            "issuetype".to_string(),
6086            serde_json::json!({ "name": issue_type }),
6087        );
6088        fields.insert(
6089            "summary".to_string(),
6090            serde_json::Value::String(summary.to_string()),
6091        );
6092        if let Some(adf) = description_adf {
6093            fields.insert(
6094                "description".to_string(),
6095                serde_json::to_value(adf).context("Failed to serialize ADF document")?,
6096            );
6097        }
6098        if !labels.is_empty() {
6099            fields.insert("labels".to_string(), serde_json::to_value(labels)?);
6100        }
6101        for (id, value) in custom_fields {
6102            fields.insert(id.clone(), value.clone());
6103        }
6104
6105        let body = serde_json::json!({ "fields": fields });
6106
6107        let response = self
6108            .post_json(&url, &body)
6109            .await
6110            .context("Failed to send create request to JIRA API")?;
6111
6112        if !response.status().is_success() {
6113            let status = response.status().as_u16();
6114            let body = response.text().await.unwrap_or_default();
6115            // Parity with update: surface JIRA's `{ "errors": {...} }` envelope
6116            // as the actionable `JiraAdfFieldRequired` when a field reports it
6117            // needs ADF, instead of an opaque `ApiRequestFailed` (issue #1047).
6118            return Err(jira_write_error(status, body));
6119        }
6120
6121        let create_response: JiraCreateResponse = response
6122            .json()
6123            .await
6124            .context("Failed to parse JIRA create response")?;
6125
6126        Ok(JiraCreatedIssue {
6127            key: create_response.key,
6128            id: create_response.id,
6129            self_url: create_response.self_url,
6130        })
6131    }
6132
6133    /// Fetches field metadata for creating a JIRA issue of a given project
6134    /// and issue type.
6135    ///
6136    /// `GET /rest/api/3/issue/createmeta?projectKeys={p}&issuetypeNames={t}&expand=projects.issuetypes.fields`
6137    /// returns fields on the create screen, which is the write-time source
6138    /// of truth for custom-field resolution prior to issue creation.
6139    pub async fn get_createmeta(&self, project_key: &str, issue_type: &str) -> Result<EditMeta> {
6140        let base = format!("{}/rest/api/3/issue/createmeta", self.instance_url);
6141        let url = reqwest::Url::parse_with_params(
6142            &base,
6143            &[
6144                ("projectKeys", project_key),
6145                ("issuetypeNames", issue_type),
6146                ("expand", "projects.issuetypes.fields"),
6147            ],
6148        )
6149        .context("Failed to build JIRA createmeta URL")?;
6150
6151        let response = self
6152            .client
6153            .get(url)
6154            .header("Authorization", &self.auth_header)
6155            .header("Accept", "application/json")
6156            .send()
6157            .await
6158            .context("Failed to send createmeta request to JIRA API")?;
6159
6160        let raw: JiraCreateMetaResponse = Self::parse_json(
6161            Self::ensure_success(response).await?,
6162            "Failed to parse JIRA createmeta response",
6163        )
6164        .await?;
6165
6166        let Some(project) = raw.projects.into_iter().next() else {
6167            return Ok(EditMeta::default());
6168        };
6169        let Some(issuetype) = project.issuetypes.into_iter().next() else {
6170            return Ok(EditMeta::default());
6171        };
6172
6173        let fields = issuetype
6174            .fields
6175            .into_iter()
6176            .map(|(id, field)| {
6177                let allowed_values = field.allowed_value_strings();
6178                (
6179                    id,
6180                    EditMetaField {
6181                        name: field.name.unwrap_or_default(),
6182                        schema: field.schema.into(),
6183                        allowed_values,
6184                    },
6185                )
6186            })
6187            .collect();
6188        Ok(EditMeta { fields })
6189    }
6190
6191    /// Introspects the create screen for a project + issue type, returning each
6192    /// field with its `required` flag, schema type, allowed values, and default.
6193    ///
6194    /// `GET /rest/api/3/issue/createmeta?projectKeys={p}&issuetypeNames={t}&expand=projects.issuetypes.fields`
6195    /// — the same endpoint as [`get_createmeta`](Self::get_createmeta), parsed
6196    /// for the full field metadata an agent needs to prompt before creating.
6197    pub async fn get_project_create_meta(
6198        &self,
6199        project_key: &str,
6200        issue_type: &str,
6201    ) -> Result<CreateMeta> {
6202        let base = format!("{}/rest/api/3/issue/createmeta", self.instance_url);
6203        let url = reqwest::Url::parse_with_params(
6204            &base,
6205            &[
6206                ("projectKeys", project_key),
6207                ("issuetypeNames", issue_type),
6208                ("expand", "projects.issuetypes.fields"),
6209            ],
6210        )
6211        .context("Failed to build JIRA createmeta URL")?;
6212
6213        let response = self
6214            .client
6215            .get(url)
6216            .header("Authorization", &self.auth_header)
6217            .header("Accept", "application/json")
6218            .send()
6219            .await
6220            .context("Failed to send createmeta request to JIRA API")?;
6221
6222        let raw: JiraCreateMetaFullResponse = Self::parse_json(
6223            Self::ensure_success(response).await?,
6224            "Failed to parse JIRA createmeta response",
6225        )
6226        .await?;
6227
6228        let mut fields: Vec<CreateMetaField> = raw
6229            .projects
6230            .into_iter()
6231            .next()
6232            .and_then(|p| p.issuetypes.into_iter().next())
6233            .map(|it| {
6234                it.fields
6235                    .into_iter()
6236                    .map(|(field_id, field)| {
6237                        let schema = field.schema.unwrap_or(JiraCreateMetaSchemaRaw {
6238                            kind: None,
6239                            items: None,
6240                            custom: None,
6241                        });
6242                        CreateMetaField {
6243                            field_id,
6244                            name: field.name.unwrap_or_default(),
6245                            required: field.required,
6246                            schema_type: schema.kind.unwrap_or_default(),
6247                            items: schema.items,
6248                            custom: schema.custom,
6249                            allowed_values: field
6250                                .allowed_values
6251                                .into_iter()
6252                                .map(JiraAllowedValueRaw::into_allowed_value)
6253                                .collect(),
6254                            default_value: field.default_value,
6255                        }
6256                    })
6257                    .collect()
6258            })
6259            .unwrap_or_default();
6260
6261        // Required fields first, then alphabetically by name for stable output.
6262        fields.sort_by(|a, b| {
6263            b.required
6264                .cmp(&a.required)
6265                .then_with(|| a.name.cmp(&b.name))
6266        });
6267
6268        Ok(CreateMeta {
6269            project: project_key.to_string(),
6270            issue_type: issue_type.to_string(),
6271            fields,
6272        })
6273    }
6274
6275    /// Lists comments on a JIRA issue with auto-pagination.
6276    ///
6277    /// `limit` caps the total number of comments returned. Pass `0` for unlimited.
6278    pub async fn get_comments(&self, key: &str, limit: u32) -> Result<Vec<JiraComment>> {
6279        let effective_limit = if limit == 0 { u32::MAX } else { limit };
6280        let mut all_comments = Vec::new();
6281        let mut start_at: u32 = 0;
6282
6283        loop {
6284            let remaining = effective_limit.saturating_sub(all_comments.len() as u32);
6285            if remaining == 0 {
6286                break;
6287            }
6288            let page_size = remaining.min(PAGE_SIZE);
6289
6290            let url = format!(
6291                "{}/rest/api/3/issue/{}/comment?orderBy=created&maxResults={}&startAt={}",
6292                self.instance_url, key, page_size, start_at
6293            );
6294
6295            let response = self.get_json(&url).await?;
6296
6297            let resp: JiraCommentsResponse = Self::parse_json(
6298                Self::ensure_success(response).await?,
6299                "Failed to parse comments response",
6300            )
6301            .await?;
6302
6303            let page_count = resp.comments.len() as u32;
6304            for c in resp.comments {
6305                all_comments.push(JiraComment {
6306                    id: c.id,
6307                    author: c.author.and_then(|a| a.display_name).unwrap_or_default(),
6308                    body_adf: c.body,
6309                    created: c.created.unwrap_or_default(),
6310                    updated: c.updated,
6311                });
6312            }
6313
6314            if page_count == 0 {
6315                break;
6316            }
6317
6318            let fetched = resp.start_at.saturating_add(page_count);
6319            if fetched >= resp.total {
6320                break;
6321            }
6322
6323            start_at += page_count;
6324        }
6325
6326        Ok(all_comments)
6327    }
6328
6329    /// Adds a comment to a JIRA issue.
6330    pub async fn add_comment(&self, key: &str, body_adf: &ValidatedAdfDocument) -> Result<()> {
6331        let url = format!("{}/rest/api/3/issue/{}/comment", self.instance_url, key);
6332
6333        let body = serde_json::json!({
6334            "body": body_adf
6335        });
6336
6337        let response = self.post_json(&url, &body).await?;
6338
6339        Self::ensure_success(response).await?;
6340
6341        Ok(())
6342    }
6343
6344    /// Updates an existing comment on a JIRA issue.
6345    ///
6346    /// Issues a `PUT /rest/api/3/issue/{key}/comment/{id}` with the new ADF
6347    /// body and an optional visibility restriction. Returns the updated
6348    /// comment as parsed from the JIRA response so callers can surface the
6349    /// `updated` timestamp and any author/body changes JIRA applied.
6350    pub async fn update_comment(
6351        &self,
6352        key: &str,
6353        comment_id: &str,
6354        body_adf: &ValidatedAdfDocument,
6355        visibility: Option<&JiraVisibility>,
6356    ) -> Result<JiraComment> {
6357        let url = format!(
6358            "{}/rest/api/3/issue/{}/comment/{}",
6359            self.instance_url, key, comment_id
6360        );
6361
6362        let mut body = serde_json::json!({ "body": body_adf });
6363        if let Some(v) = visibility {
6364            body["visibility"] =
6365                serde_json::to_value(v).context("Failed to serialize comment visibility")?;
6366        }
6367
6368        let response = self.put_json(&url, &body).await?;
6369
6370        let entry: JiraCommentEntry = Self::parse_json(
6371            Self::ensure_success(response).await?,
6372            "Failed to parse updated comment response",
6373        )
6374        .await?;
6375
6376        Ok(JiraComment {
6377            id: entry.id,
6378            author: entry
6379                .author
6380                .and_then(|a| a.display_name)
6381                .unwrap_or_default(),
6382            body_adf: entry.body,
6383            created: entry.created.unwrap_or_default(),
6384            updated: entry.updated,
6385        })
6386    }
6387
6388    /// Lists worklogs for a JIRA issue.
6389    pub async fn get_worklogs(&self, key: &str, limit: u32) -> Result<JiraWorklogList> {
6390        let effective_limit = if limit == 0 { u32::MAX } else { limit };
6391        let url = format!(
6392            "{}/rest/api/3/issue/{}/worklog?maxResults={}",
6393            self.instance_url,
6394            key,
6395            effective_limit.min(5000)
6396        );
6397
6398        let response = self.get_json(&url).await?;
6399
6400        let resp: JiraWorklogResponse = Self::parse_json(
6401            Self::ensure_success(response).await?,
6402            "Failed to parse worklog response",
6403        )
6404        .await?;
6405
6406        let worklogs: Vec<JiraWorklog> = resp
6407            .worklogs
6408            .into_iter()
6409            .take(effective_limit as usize)
6410            .map(|w| JiraWorklog {
6411                id: w.id,
6412                author: w.author.and_then(|a| a.display_name).unwrap_or_default(),
6413                time_spent: w.time_spent.unwrap_or_default(),
6414                time_spent_seconds: w.time_spent_seconds,
6415                started: w.started.unwrap_or_default(),
6416                comment: Self::extract_worklog_comment(w.comment.as_ref()),
6417            })
6418            .collect();
6419
6420        Ok(JiraWorklogList {
6421            total: resp.total,
6422            worklogs,
6423        })
6424    }
6425
6426    /// Adds a worklog entry to a JIRA issue.
6427    pub async fn add_worklog(
6428        &self,
6429        key: &str,
6430        time_spent: &str,
6431        started: Option<&str>,
6432        comment: Option<&str>,
6433    ) -> Result<()> {
6434        let url = format!("{}/rest/api/3/issue/{}/worklog", self.instance_url, key);
6435
6436        let mut body = serde_json::json!({
6437            "timeSpent": time_spent,
6438        });
6439
6440        if let Some(started) = started {
6441            body["started"] = serde_json::Value::String(started.to_string());
6442        }
6443
6444        if let Some(comment_text) = comment {
6445            body["comment"] = serde_json::json!({
6446                "type": "doc",
6447                "version": 1,
6448                "content": [{
6449                    "type": "paragraph",
6450                    "content": [{
6451                        "type": "text",
6452                        "text": comment_text
6453                    }]
6454                }]
6455            });
6456        }
6457
6458        let response = self.post_json(&url, &body).await?;
6459
6460        Self::ensure_success(response).await?;
6461
6462        Ok(())
6463    }
6464
6465    /// Extracts plain text from a worklog comment ADF value.
6466    fn extract_worklog_comment(adf_value: Option<&serde_json::Value>) -> Option<String> {
6467        let adf_value = adf_value?;
6468        let adf: AdfDocument = serde_json::from_value(adf_value.clone()).ok()?;
6469        let md = adf_to_markdown(&adf).ok()?;
6470        let trimmed = md.trim();
6471        if trimmed.is_empty() {
6472            None
6473        } else {
6474            Some(trimmed.to_string())
6475        }
6476    }
6477
6478    /// Lists available transitions for a JIRA issue.
6479    pub async fn get_transitions(&self, key: &str) -> Result<Vec<JiraTransition>> {
6480        let url = format!("{}/rest/api/3/issue/{}/transitions", self.instance_url, key);
6481
6482        let response = self.get_json(&url).await?;
6483
6484        let resp: JiraTransitionsResponse = Self::parse_json(
6485            Self::ensure_success(response).await?,
6486            "Failed to parse transitions response",
6487        )
6488        .await?;
6489
6490        Ok(resp
6491            .transitions
6492            .into_iter()
6493            .map(|t| JiraTransition {
6494                id: t.id,
6495                name: t.name,
6496                to_status: t.to.map(|to| JiraTransitionToStatus {
6497                    id: to.id,
6498                    name: to.name,
6499                    category: to.status_category.and_then(|sc| sc.key),
6500                }),
6501                has_screen: t.has_screen,
6502            })
6503            .collect())
6504    }
6505
6506    /// Executes a transition on a JIRA issue.
6507    pub async fn do_transition(&self, key: &str, transition_id: &str) -> Result<()> {
6508        let url = format!("{}/rest/api/3/issue/{}/transitions", self.instance_url, key);
6509
6510        let body = serde_json::json!({
6511            "transition": { "id": transition_id }
6512        });
6513
6514        let response = self.post_json(&url, &body).await?;
6515
6516        Self::ensure_success(response).await?;
6517
6518        Ok(())
6519    }
6520
6521    /// Searches JIRA issues using JQL with auto-pagination.
6522    ///
6523    /// `limit` controls total results: 0 means unlimited.
6524    pub async fn search_issues(&self, jql: &str, limit: u32) -> Result<JiraSearchResult> {
6525        let url = format!("{}/rest/api/3/search/jql", self.instance_url);
6526        let effective_limit = if limit == 0 { u32::MAX } else { limit };
6527        let mut all_issues = Vec::new();
6528        let mut next_token: Option<String> = None;
6529
6530        loop {
6531            let remaining = effective_limit.saturating_sub(all_issues.len() as u32);
6532            if remaining == 0 {
6533                break;
6534            }
6535            let page_size = remaining.min(PAGE_SIZE);
6536
6537            let mut body = serde_json::json!({
6538                "jql": jql,
6539                "maxResults": page_size,
6540                "fields": ["summary", "status", "issuetype", "assignee", "priority"]
6541            });
6542            if let Some(ref token) = next_token {
6543                body["nextPageToken"] = serde_json::Value::String(token.clone());
6544            }
6545
6546            let response = self
6547                .post_json(&url, &body)
6548                .await
6549                .context("Failed to send search request to JIRA API")?;
6550
6551            let page: JiraSearchResponse = Self::parse_json(
6552                Self::ensure_success(response).await?,
6553                "Failed to parse JIRA search response",
6554            )
6555            .await?;
6556
6557            let page_count = page.issues.len();
6558            for r in page.issues {
6559                all_issues.push(JiraIssue {
6560                    key: r.key,
6561                    summary: r.fields.summary.unwrap_or_default(),
6562                    description_adf: r.fields.description,
6563                    status: r.fields.status.and_then(|s| s.name),
6564                    issue_type: r.fields.issuetype.and_then(|t| t.name),
6565                    assignee: r.fields.assignee.and_then(|a| a.display_name),
6566                    priority: r.fields.priority.and_then(|p| p.name),
6567                    labels: r.fields.labels,
6568                    custom_fields: Vec::new(),
6569                });
6570            }
6571
6572            match page.next_page_token {
6573                Some(token) if page_count > 0 => next_token = Some(token),
6574                _ => break,
6575            }
6576        }
6577
6578        let total = all_issues.len() as u32;
6579        Ok(JiraSearchResult {
6580            issues: all_issues,
6581            total,
6582        })
6583    }
6584
6585    /// Searches Confluence pages using CQL with auto-pagination.
6586    pub async fn search_confluence(
6587        &self,
6588        cql: &str,
6589        limit: u32,
6590    ) -> Result<ConfluenceSearchResults> {
6591        let effective_limit = if limit == 0 { u32::MAX } else { limit };
6592        let mut all_results = Vec::new();
6593        let mut start: u32 = 0;
6594
6595        loop {
6596            let remaining = effective_limit.saturating_sub(all_results.len() as u32);
6597            if remaining == 0 {
6598                break;
6599            }
6600            let page_size = remaining.min(PAGE_SIZE);
6601
6602            let base = format!("{}/wiki/rest/api/content/search", self.instance_url);
6603            let url = reqwest::Url::parse_with_params(
6604                &base,
6605                &[
6606                    ("cql", cql),
6607                    ("limit", &page_size.to_string()),
6608                    ("start", &start.to_string()),
6609                    ("expand", "space"),
6610                ],
6611            )
6612            .context("Failed to build Confluence search URL")?;
6613
6614            let response = self.get_json(url.as_str()).await?;
6615
6616            let resp: ConfluenceContentSearchResponse = Self::parse_json(
6617                Self::ensure_success(response).await?,
6618                "Failed to parse Confluence search response",
6619            )
6620            .await?;
6621
6622            let page_count = resp.results.len() as u32;
6623            for r in resp.results {
6624                let space_key = r
6625                    .expandable
6626                    .and_then(|e| e.space)
6627                    .and_then(|s| s.rsplit('/').next().map(String::from))
6628                    .unwrap_or_default();
6629                all_results.push(ConfluenceSearchResult {
6630                    id: r.id,
6631                    title: r.title,
6632                    space_key,
6633                });
6634            }
6635
6636            let has_next = resp.links.and_then(|l| l.next).is_some();
6637            if !has_next || page_count == 0 {
6638                break;
6639            }
6640            start += page_count;
6641        }
6642
6643        let total = all_results.len() as u32;
6644        Ok(ConfluenceSearchResults {
6645            results: all_results,
6646            total,
6647        })
6648    }
6649
6650    /// Searches JIRA users by display name or email substring.
6651    ///
6652    /// `query` is matched against `displayName` and `emailAddress` server-
6653    /// side; matching is substring and case-insensitive. `limit` of `0`
6654    /// returns every match (paginating internally), otherwise the result
6655    /// is truncated. Inactive users and app/customer account types are
6656    /// included — callers that need only assignable atlassian-account
6657    /// users should filter on `active` and `account_type`.
6658    ///
6659    /// Note: many tenants strip `emailAddress` from search results due to
6660    /// GDPR / privacy settings, even when the user has an email on file.
6661    pub async fn search_jira_users(
6662        &self,
6663        query: &str,
6664        limit: u32,
6665    ) -> Result<JiraUserSearchResults> {
6666        let effective_limit = if limit == 0 { u32::MAX } else { limit };
6667        let mut all_results: Vec<JiraUserSearchResult> = Vec::new();
6668        let mut start_at: u32 = 0;
6669
6670        loop {
6671            let remaining = effective_limit.saturating_sub(all_results.len() as u32);
6672            if remaining == 0 {
6673                break;
6674            }
6675            let page_size = remaining.min(PAGE_SIZE);
6676
6677            let base = format!("{}/rest/api/3/user/search", self.instance_url);
6678            let url = reqwest::Url::parse_with_params(
6679                &base,
6680                &[
6681                    ("query", query),
6682                    ("maxResults", &page_size.to_string()),
6683                    ("startAt", &start_at.to_string()),
6684                ],
6685            )
6686            .context("Failed to build JIRA user search URL")?;
6687
6688            let response = self.get_json(url.as_str()).await?;
6689
6690            let page: Vec<JiraUserSearchEntry> = Self::parse_json(
6691                Self::ensure_success(response).await?,
6692                "Failed to parse JIRA user search response",
6693            )
6694            .await?;
6695
6696            let page_count = page.len() as u32;
6697            for entry in page {
6698                all_results.push(JiraUserSearchResult {
6699                    account_id: entry.account_id,
6700                    display_name: entry.display_name,
6701                    email_address: entry.email_address,
6702                    active: entry.active,
6703                    account_type: entry.account_type,
6704                });
6705            }
6706
6707            // The API has no `isLast` / `next` envelope; when the page comes
6708            // back shorter than the page size, we've reached the end.
6709            if page_count < page_size {
6710                break;
6711            }
6712            start_at += page_count;
6713        }
6714
6715        let count = all_results.len() as u32;
6716        Ok(JiraUserSearchResults {
6717            users: all_results,
6718            count,
6719        })
6720    }
6721
6722    /// Searches Confluence users by display name or email.
6723    pub async fn search_confluence_users(
6724        &self,
6725        query: &str,
6726        limit: u32,
6727    ) -> Result<ConfluenceUserSearchResults> {
6728        let effective_limit = if limit == 0 { u32::MAX } else { limit };
6729        let mut all_results = Vec::new();
6730        let mut start: u32 = 0;
6731
6732        let cql = format!("user.fullname~\"{query}\"");
6733
6734        loop {
6735            let remaining = effective_limit.saturating_sub(all_results.len() as u32);
6736            if remaining == 0 {
6737                break;
6738            }
6739            let page_size = remaining.min(PAGE_SIZE);
6740
6741            let base = format!("{}/wiki/rest/api/search/user", self.instance_url);
6742            let url = reqwest::Url::parse_with_params(
6743                &base,
6744                &[
6745                    ("cql", cql.as_str()),
6746                    ("limit", &page_size.to_string()),
6747                    ("start", &start.to_string()),
6748                ],
6749            )
6750            .context("Failed to build Confluence user search URL")?;
6751
6752            let response = self.get_json(url.as_str()).await?;
6753
6754            let resp: ConfluenceUserSearchResponse = Self::parse_json(
6755                Self::ensure_success(response).await?,
6756                "Failed to parse Confluence user search response",
6757            )
6758            .await?;
6759
6760            let page_count = resp.results.len() as u32;
6761            for r in resp.results {
6762                let Some(user) = r.user else {
6763                    continue;
6764                };
6765                let display_name = user.display_name.or(user.public_name).unwrap_or_default();
6766                all_results.push(ConfluenceUserSearchResult {
6767                    account_id: user.account_id,
6768                    display_name,
6769                    email: user.email,
6770                });
6771            }
6772
6773            let has_next = resp.links.and_then(|l| l.next).is_some();
6774            if !has_next || page_count == 0 {
6775                break;
6776            }
6777            start += page_count;
6778        }
6779
6780        let total = all_results.len() as u32;
6781        Ok(ConfluenceUserSearchResults {
6782            users: all_results,
6783            total,
6784        })
6785    }
6786
6787    /// Resolves a single JIRA user by account ID
6788    /// (`GET /rest/api/3/user?accountId=`).
6789    ///
6790    /// Failure-tolerant: an unknown / anonymised account (HTTP 404) or any
6791    /// other non-auth failure resolves to a stub record with `error` set rather
6792    /// than an `Err`, so a batch lookup never aborts for one bad ID. A `401`
6793    /// (bad credentials) is a hard error worth surfacing. Deactivated accounts
6794    /// come back from Atlassian as a real `200` record with `active: false`.
6795    pub async fn get_jira_user(&self, account_id: &str) -> Result<JiraUserRecord> {
6796        let base = format!("{}/rest/api/3/user", self.instance_url);
6797        let url = reqwest::Url::parse_with_params(&base, &[("accountId", account_id)])
6798            .context("Failed to build JIRA user get URL")?;
6799
6800        let response = self.get_json(url.as_str()).await?;
6801        let status = response.status();
6802
6803        if status.is_success() {
6804            let entry: JiraUserSearchEntry = response
6805                .json()
6806                .await
6807                .context("Failed to parse JIRA user get response")?;
6808            return Ok(JiraUserRecord {
6809                account_id: entry.account_id,
6810                display_name: entry.display_name,
6811                email_address: entry.email_address,
6812                active: Some(entry.active),
6813                account_type: entry.account_type,
6814                error: None,
6815            });
6816        }
6817
6818        if status.as_u16() == 401 {
6819            let body = response.text().await.unwrap_or_default();
6820            return Err(AtlassianError::ApiRequestFailed { status: 401, body }.into());
6821        }
6822
6823        let code = status.as_u16();
6824        let body = response.text().await.unwrap_or_default();
6825        Ok(JiraUserRecord {
6826            account_id: account_id.to_string(),
6827            display_name: None,
6828            email_address: None,
6829            active: None,
6830            account_type: None,
6831            error: Some(user_lookup_error(code, &body)),
6832        })
6833    }
6834
6835    /// Resolves multiple JIRA users by account ID, concurrently.
6836    ///
6837    /// Each ID is fetched independently via [`Self::get_jira_user`]; per-ID
6838    /// failures become stub records, so the batch only errors on a genuine auth
6839    /// failure (or transport error). Results preserve request order.
6840    pub async fn get_jira_users(&self, account_ids: &[String]) -> Result<JiraUserGetResults> {
6841        let lookups = account_ids.iter().map(|id| self.get_jira_user(id));
6842        let users = futures::future::join_all(lookups)
6843            .await
6844            .into_iter()
6845            .collect::<Result<Vec<_>>>()?;
6846        Ok(JiraUserGetResults { users })
6847    }
6848
6849    /// Resolves a single Confluence user by account ID
6850    /// (`GET /wiki/rest/api/user?accountId=`).
6851    ///
6852    /// Failure-tolerant in the same way as [`Self::get_jira_user`]. The v1 user
6853    /// object has no `active` flag, so [`ConfluenceUserRecord::active`] is
6854    /// always `None`; `displayName` falls back to `publicName`.
6855    pub async fn get_confluence_user(&self, account_id: &str) -> Result<ConfluenceUserRecord> {
6856        let base = format!("{}/wiki/rest/api/user", self.instance_url);
6857        let url = reqwest::Url::parse_with_params(&base, &[("accountId", account_id)])
6858            .context("Failed to build Confluence user get URL")?;
6859
6860        let response = self.get_json(url.as_str()).await?;
6861        let status = response.status();
6862
6863        if status.is_success() {
6864            let entry: ConfluenceUserGetEntry = response
6865                .json()
6866                .await
6867                .context("Failed to parse Confluence user get response")?;
6868            return Ok(ConfluenceUserRecord {
6869                account_id: entry.account_id.unwrap_or_else(|| account_id.to_string()),
6870                display_name: entry.display_name.or(entry.public_name),
6871                email: entry.email,
6872                account_type: entry.account_type,
6873                active: None,
6874                error: None,
6875            });
6876        }
6877
6878        if status.as_u16() == 401 {
6879            let body = response.text().await.unwrap_or_default();
6880            return Err(AtlassianError::ApiRequestFailed { status: 401, body }.into());
6881        }
6882
6883        let code = status.as_u16();
6884        let body = response.text().await.unwrap_or_default();
6885        Ok(ConfluenceUserRecord {
6886            account_id: account_id.to_string(),
6887            display_name: None,
6888            email: None,
6889            account_type: None,
6890            active: None,
6891            error: Some(user_lookup_error(code, &body)),
6892        })
6893    }
6894
6895    /// Resolves multiple Confluence users by account ID, concurrently.
6896    ///
6897    /// Behaves like [`Self::get_jira_users`]: per-ID failures become stub
6898    /// records; the batch only errors on a genuine auth / transport failure.
6899    pub async fn get_confluence_users(
6900        &self,
6901        account_ids: &[String],
6902    ) -> Result<ConfluenceUserGetResults> {
6903        let lookups = account_ids.iter().map(|id| self.get_confluence_user(id));
6904        let users = futures::future::join_all(lookups)
6905            .await
6906            .into_iter()
6907            .collect::<Result<Vec<_>>>()?;
6908        Ok(ConfluenceUserGetResults { users })
6909    }
6910
6911    /// Lists agile boards with auto-pagination.
6912    pub async fn get_boards(
6913        &self,
6914        project: Option<&str>,
6915        board_type: Option<&str>,
6916        limit: u32,
6917    ) -> Result<AgileBoardList> {
6918        let effective_limit = if limit == 0 { u32::MAX } else { limit };
6919        let mut all_boards = Vec::new();
6920        let mut start_at: u32 = 0;
6921
6922        loop {
6923            let remaining = effective_limit.saturating_sub(all_boards.len() as u32);
6924            if remaining == 0 {
6925                break;
6926            }
6927            let page_size = remaining.min(PAGE_SIZE);
6928
6929            let mut url = format!(
6930                "{}/rest/agile/1.0/board?maxResults={}&startAt={}",
6931                self.instance_url, page_size, start_at
6932            );
6933            if let Some(proj) = project {
6934                url.push_str(&format!("&projectKeyOrId={proj}"));
6935            }
6936            if let Some(bt) = board_type {
6937                url.push_str(&format!("&type={bt}"));
6938            }
6939
6940            let response = self.get_json(&url).await?;
6941
6942            let resp: AgileBoardListResponse = Self::parse_json(
6943                Self::ensure_success(response).await?,
6944                "Failed to parse board list response",
6945            )
6946            .await?;
6947
6948            let page_count = resp.values.len() as u32;
6949            for b in resp.values {
6950                all_boards.push(AgileBoard {
6951                    id: b.id,
6952                    name: b.name,
6953                    board_type: b.board_type,
6954                    project_key: b.location.and_then(|l| l.project_key),
6955                });
6956            }
6957
6958            if resp.is_last || page_count == 0 {
6959                break;
6960            }
6961            start_at += page_count;
6962        }
6963
6964        let total = all_boards.len() as u32;
6965        Ok(AgileBoardList {
6966            boards: all_boards,
6967            total,
6968        })
6969    }
6970
6971    /// Lists issues on an agile board with auto-pagination.
6972    pub async fn get_board_issues(
6973        &self,
6974        board_id: u64,
6975        jql: Option<&str>,
6976        limit: u32,
6977    ) -> Result<JiraSearchResult> {
6978        let effective_limit = if limit == 0 { u32::MAX } else { limit };
6979        let mut all_issues = Vec::new();
6980        let mut start_at: u32 = 0;
6981
6982        loop {
6983            let remaining = effective_limit.saturating_sub(all_issues.len() as u32);
6984            if remaining == 0 {
6985                break;
6986            }
6987            let page_size = remaining.min(PAGE_SIZE);
6988
6989            let base = format!(
6990                "{}/rest/agile/1.0/board/{}/issue",
6991                self.instance_url, board_id
6992            );
6993            let mut params: Vec<(&str, String)> = vec![
6994                ("maxResults", page_size.to_string()),
6995                ("startAt", start_at.to_string()),
6996            ];
6997            if let Some(jql_str) = jql {
6998                params.push(("jql", jql_str.to_string()));
6999            }
7000            let url = reqwest::Url::parse_with_params(
7001                &base,
7002                params.iter().map(|(k, v)| (*k, v.as_str())),
7003            )
7004            .context("Failed to build board issues URL")?;
7005
7006            let response = self.get_json(url.as_str()).await?;
7007
7008            let resp: AgileIssueListResponse = Self::parse_json(
7009                Self::ensure_success(response).await?,
7010                "Failed to parse board issues response",
7011            )
7012            .await?;
7013
7014            let page_count = resp.issues.len() as u32;
7015            for r in resp.issues {
7016                all_issues.push(JiraIssue {
7017                    key: r.key,
7018                    summary: r.fields.summary.unwrap_or_default(),
7019                    description_adf: r.fields.description,
7020                    status: r.fields.status.and_then(|s| s.name),
7021                    issue_type: r.fields.issuetype.and_then(|t| t.name),
7022                    assignee: r.fields.assignee.and_then(|a| a.display_name),
7023                    priority: r.fields.priority.and_then(|p| p.name),
7024                    labels: r.fields.labels,
7025                    custom_fields: Vec::new(),
7026                });
7027            }
7028
7029            if resp.is_last || page_count == 0 {
7030                break;
7031            }
7032            start_at += page_count;
7033        }
7034
7035        let total = all_issues.len() as u32;
7036        Ok(JiraSearchResult {
7037            issues: all_issues,
7038            total,
7039        })
7040    }
7041
7042    /// Lists sprints for an agile board with auto-pagination.
7043    pub async fn get_sprints(
7044        &self,
7045        board_id: u64,
7046        state: Option<&str>,
7047        limit: u32,
7048    ) -> Result<AgileSprintList> {
7049        let effective_limit = if limit == 0 { u32::MAX } else { limit };
7050        let mut all_sprints = Vec::new();
7051        let mut start_at: u32 = 0;
7052
7053        loop {
7054            let remaining = effective_limit.saturating_sub(all_sprints.len() as u32);
7055            if remaining == 0 {
7056                break;
7057            }
7058            let page_size = remaining.min(PAGE_SIZE);
7059
7060            let mut url = format!(
7061                "{}/rest/agile/1.0/board/{}/sprint?maxResults={}&startAt={}",
7062                self.instance_url, board_id, page_size, start_at
7063            );
7064            if let Some(s) = state {
7065                url.push_str(&format!("&state={s}"));
7066            }
7067
7068            let response = self.get_json(&url).await?;
7069
7070            let resp: AgileSprintListResponse = Self::parse_json(
7071                Self::ensure_success(response).await?,
7072                "Failed to parse sprint list response",
7073            )
7074            .await?;
7075
7076            let page_count = resp.values.len() as u32;
7077            for s in resp.values {
7078                all_sprints.push(AgileSprint {
7079                    id: s.id,
7080                    name: s.name,
7081                    state: s.state,
7082                    start_date: s.start_date,
7083                    end_date: s.end_date,
7084                    goal: s.goal,
7085                });
7086            }
7087
7088            if resp.is_last || page_count == 0 {
7089                break;
7090            }
7091            start_at += page_count;
7092        }
7093
7094        let total = all_sprints.len() as u32;
7095        Ok(AgileSprintList {
7096            sprints: all_sprints,
7097            total,
7098        })
7099    }
7100
7101    /// Lists issues in an agile sprint with auto-pagination.
7102    pub async fn get_sprint_issues(
7103        &self,
7104        sprint_id: u64,
7105        jql: Option<&str>,
7106        limit: u32,
7107    ) -> Result<JiraSearchResult> {
7108        let effective_limit = if limit == 0 { u32::MAX } else { limit };
7109        let mut all_issues = Vec::new();
7110        let mut start_at: u32 = 0;
7111
7112        loop {
7113            let remaining = effective_limit.saturating_sub(all_issues.len() as u32);
7114            if remaining == 0 {
7115                break;
7116            }
7117            let page_size = remaining.min(PAGE_SIZE);
7118
7119            let base = format!(
7120                "{}/rest/agile/1.0/sprint/{}/issue",
7121                self.instance_url, sprint_id
7122            );
7123            let mut params: Vec<(&str, String)> = vec![
7124                ("maxResults", page_size.to_string()),
7125                ("startAt", start_at.to_string()),
7126            ];
7127            if let Some(jql_str) = jql {
7128                params.push(("jql", jql_str.to_string()));
7129            }
7130            let url = reqwest::Url::parse_with_params(
7131                &base,
7132                params.iter().map(|(k, v)| (*k, v.as_str())),
7133            )
7134            .context("Failed to build sprint issues URL")?;
7135
7136            let response = self.get_json(url.as_str()).await?;
7137
7138            let resp: AgileIssueListResponse = Self::parse_json(
7139                Self::ensure_success(response).await?,
7140                "Failed to parse sprint issues response",
7141            )
7142            .await?;
7143
7144            let page_count = resp.issues.len() as u32;
7145            for r in resp.issues {
7146                all_issues.push(JiraIssue {
7147                    key: r.key,
7148                    summary: r.fields.summary.unwrap_or_default(),
7149                    description_adf: r.fields.description,
7150                    status: r.fields.status.and_then(|s| s.name),
7151                    issue_type: r.fields.issuetype.and_then(|t| t.name),
7152                    assignee: r.fields.assignee.and_then(|a| a.display_name),
7153                    priority: r.fields.priority.and_then(|p| p.name),
7154                    labels: r.fields.labels,
7155                    custom_fields: Vec::new(),
7156                });
7157            }
7158
7159            if resp.is_last || page_count == 0 {
7160                break;
7161            }
7162            start_at += page_count;
7163        }
7164
7165        let total = all_issues.len() as u32;
7166        Ok(JiraSearchResult {
7167            issues: all_issues,
7168            total,
7169        })
7170    }
7171
7172    /// Adds issues to an agile sprint.
7173    pub async fn add_issues_to_sprint(&self, sprint_id: u64, issue_keys: &[&str]) -> Result<()> {
7174        let url = format!(
7175            "{}/rest/agile/1.0/sprint/{}/issue",
7176            self.instance_url, sprint_id
7177        );
7178
7179        let body = serde_json::json!({ "issues": issue_keys });
7180
7181        let response = self.post_json(&url, &body).await?;
7182
7183        Self::ensure_success(response).await?;
7184
7185        Ok(())
7186    }
7187
7188    /// Creates a new sprint on an agile board.
7189    pub async fn create_sprint(
7190        &self,
7191        board_id: u64,
7192        name: &str,
7193        start_date: Option<&str>,
7194        end_date: Option<&str>,
7195        goal: Option<&str>,
7196    ) -> Result<AgileSprint> {
7197        let url = format!("{}/rest/agile/1.0/sprint", self.instance_url);
7198
7199        let mut body = serde_json::json!({
7200            "originBoardId": board_id,
7201            "name": name
7202        });
7203        if let Some(sd) = start_date {
7204            body["startDate"] = serde_json::Value::String(sd.to_string());
7205        }
7206        if let Some(ed) = end_date {
7207            body["endDate"] = serde_json::Value::String(ed.to_string());
7208        }
7209        if let Some(g) = goal {
7210            body["goal"] = serde_json::Value::String(g.to_string());
7211        }
7212
7213        let response = self.post_json(&url, &body).await?;
7214
7215        let entry: AgileSprintEntry = Self::parse_json(
7216            Self::ensure_success(response).await?,
7217            "Failed to parse sprint create response",
7218        )
7219        .await?;
7220
7221        Ok(AgileSprint {
7222            id: entry.id,
7223            name: entry.name,
7224            state: entry.state,
7225            start_date: entry.start_date,
7226            end_date: entry.end_date,
7227            goal: entry.goal,
7228        })
7229    }
7230
7231    /// Updates an existing sprint.
7232    pub async fn update_sprint(
7233        &self,
7234        sprint_id: u64,
7235        name: Option<&str>,
7236        state: Option<&str>,
7237        start_date: Option<&str>,
7238        end_date: Option<&str>,
7239        goal: Option<&str>,
7240    ) -> Result<()> {
7241        let url = format!("{}/rest/agile/1.0/sprint/{}", self.instance_url, sprint_id);
7242
7243        let mut body = serde_json::Map::new();
7244        if let Some(n) = name {
7245            body.insert("name".to_string(), serde_json::Value::String(n.to_string()));
7246        }
7247        if let Some(s) = state {
7248            body.insert(
7249                "state".to_string(),
7250                serde_json::Value::String(s.to_string()),
7251            );
7252        }
7253        if let Some(sd) = start_date {
7254            body.insert(
7255                "startDate".to_string(),
7256                serde_json::Value::String(sd.to_string()),
7257            );
7258        }
7259        if let Some(ed) = end_date {
7260            body.insert(
7261                "endDate".to_string(),
7262                serde_json::Value::String(ed.to_string()),
7263            );
7264        }
7265        if let Some(g) = goal {
7266            body.insert("goal".to_string(), serde_json::Value::String(g.to_string()));
7267        }
7268
7269        let response = self
7270            .put_json(&url, &serde_json::Value::Object(body))
7271            .await?;
7272
7273        Self::ensure_success(response).await?;
7274
7275        Ok(())
7276    }
7277
7278    /// Lists versions for a JIRA project.
7279    ///
7280    /// Uses the lightweight `GET /rest/api/3/project/{key}/versions` endpoint,
7281    /// which returns all versions in a single response without pagination.
7282    /// `released` and `archived` filters are applied client-side.
7283    pub async fn get_project_versions(
7284        &self,
7285        project_key: &str,
7286        released: Option<bool>,
7287        archived: Option<bool>,
7288    ) -> Result<JiraProjectVersionList> {
7289        let url = format!(
7290            "{}/rest/api/3/project/{}/versions",
7291            self.instance_url, project_key
7292        );
7293
7294        let response = self.get_json(&url).await?;
7295
7296        let entries: Vec<JiraProjectVersionEntry> = Self::parse_json(
7297            Self::ensure_success(response).await?,
7298            "Failed to parse project versions response",
7299        )
7300        .await?;
7301
7302        let versions: Vec<JiraProjectVersion> = entries
7303            .into_iter()
7304            .filter(|e| released.map_or(true, |r| e.released == r))
7305            .filter(|e| archived.map_or(true, |a| e.archived == a))
7306            .map(|e| JiraProjectVersion {
7307                id: e.id,
7308                name: e.name,
7309                description: e.description,
7310                project_key: project_key.to_string(),
7311                released: e.released,
7312                archived: e.archived,
7313                release_date: e.release_date,
7314                start_date: e.start_date,
7315            })
7316            .collect();
7317
7318        let total = versions.len() as u32;
7319        Ok(JiraProjectVersionList { versions, total })
7320    }
7321
7322    /// Creates a new version in a JIRA project.
7323    ///
7324    /// Validates `release_date` and `start_date` as `YYYY-MM-DD` client-side
7325    /// to surface clear errors before JIRA rejects the request with an
7326    /// opaque 400.
7327    #[allow(clippy::too_many_arguments)]
7328    pub async fn create_project_version(
7329        &self,
7330        project_key: &str,
7331        name: &str,
7332        description: Option<&str>,
7333        release_date: Option<&str>,
7334        start_date: Option<&str>,
7335        released: bool,
7336        archived: bool,
7337    ) -> Result<JiraProjectVersion> {
7338        validate_iso_date(release_date, "release_date")?;
7339        validate_iso_date(start_date, "start_date")?;
7340
7341        let url = format!("{}/rest/api/3/version", self.instance_url);
7342
7343        let mut body = serde_json::json!({
7344            "project": project_key,
7345            "name": name,
7346            "released": released,
7347            "archived": archived,
7348        });
7349        if let Some(d) = description {
7350            body["description"] = serde_json::Value::String(d.to_string());
7351        }
7352        if let Some(rd) = release_date {
7353            body["releaseDate"] = serde_json::Value::String(rd.to_string());
7354        }
7355        if let Some(sd) = start_date {
7356            body["startDate"] = serde_json::Value::String(sd.to_string());
7357        }
7358
7359        let response = self.post_json(&url, &body).await?;
7360
7361        let entry: JiraProjectVersionEntry = Self::parse_json(
7362            Self::ensure_success(response).await?,
7363            "Failed to parse version create response",
7364        )
7365        .await?;
7366
7367        Ok(JiraProjectVersion {
7368            id: entry.id,
7369            name: entry.name,
7370            description: entry.description,
7371            project_key: project_key.to_string(),
7372            released: entry.released,
7373            archived: entry.archived,
7374            release_date: entry.release_date,
7375            start_date: entry.start_date,
7376        })
7377    }
7378
7379    /// Lists links on a JIRA issue.
7380    pub async fn get_issue_links(&self, key: &str) -> Result<Vec<JiraIssueLink>> {
7381        let url = format!(
7382            "{}/rest/api/3/issue/{}?fields=issuelinks",
7383            self.instance_url, key
7384        );
7385
7386        let response = self.get_json(&url).await?;
7387
7388        let resp: JiraIssueLinksResponse = Self::parse_json(
7389            Self::ensure_success(response).await?,
7390            "Failed to parse issue links response",
7391        )
7392        .await?;
7393
7394        let mut links = Vec::new();
7395        for entry in resp.fields.issuelinks {
7396            if let Some(inward) = entry.inward_issue {
7397                links.push(JiraIssueLink {
7398                    id: entry.id.clone(),
7399                    link_type: entry.link_type.name.clone(),
7400                    direction: "inward".to_string(),
7401                    linked_issue_key: inward.key,
7402                    linked_issue_summary: inward.fields.and_then(|f| f.summary).unwrap_or_default(),
7403                });
7404            }
7405            if let Some(outward) = entry.outward_issue {
7406                links.push(JiraIssueLink {
7407                    id: entry.id,
7408                    link_type: entry.link_type.name,
7409                    direction: "outward".to_string(),
7410                    linked_issue_key: outward.key,
7411                    linked_issue_summary: outward
7412                        .fields
7413                        .and_then(|f| f.summary)
7414                        .unwrap_or_default(),
7415                });
7416            }
7417        }
7418
7419        Ok(links)
7420    }
7421
7422    /// Lists remote (external URL) issue links on a JIRA issue.
7423    ///
7424    /// Endpoint: `GET /rest/api/3/issue/{key}/remotelink` — returns a bare
7425    /// JSON array (not a wrapped `{ links: [...] }` envelope).
7426    pub async fn get_remote_issue_links(&self, key: &str) -> Result<Vec<JiraRemoteIssueLink>> {
7427        let url = format!("{}/rest/api/3/issue/{}/remotelink", self.instance_url, key);
7428
7429        let response = self.get_json(&url).await?;
7430
7431        let entries: Vec<JiraRemoteIssueLinkEntry> = Self::parse_json(
7432            Self::ensure_success(response).await?,
7433            "Failed to parse remote issue links response",
7434        )
7435        .await?;
7436
7437        let mut links = Vec::with_capacity(entries.len());
7438        for entry in entries {
7439            // JIRA returns the remote link id as a number; normalize to String
7440            // so callers don't have to care about the wire shape.
7441            let id = match entry.id {
7442                serde_json::Value::String(s) => s,
7443                serde_json::Value::Number(n) => n.to_string(),
7444                other => {
7445                    return Err(anyhow::anyhow!(
7446                        "unexpected remote link id type in response: {other:?}"
7447                    ));
7448                }
7449            };
7450            links.push(JiraRemoteIssueLink {
7451                id,
7452                global_id: entry.global_id,
7453                relationship: entry.relationship,
7454                object: JiraRemoteIssueLinkObject {
7455                    url: entry.object.url,
7456                    title: entry.object.title,
7457                    summary: entry.object.summary,
7458                    icon: entry.object.icon.map(|i| JiraRemoteIssueLinkIcon {
7459                        url: i.url,
7460                        title: i.title,
7461                    }),
7462                },
7463            });
7464        }
7465        Ok(links)
7466    }
7467
7468    /// Lists available issue link types.
7469    pub async fn get_link_types(&self) -> Result<Vec<JiraLinkType>> {
7470        let url = format!("{}/rest/api/3/issueLinkType", self.instance_url);
7471        let response = self.get_json(&url).await?;
7472        let resp: JiraLinkTypesResponse = Self::parse_json(
7473            Self::ensure_success(response).await?,
7474            "Failed to parse link types response",
7475        )
7476        .await?;
7477        Ok(resp
7478            .issue_link_types
7479            .into_iter()
7480            .map(|t| JiraLinkType {
7481                id: t.id,
7482                name: t.name,
7483                inward: t.inward,
7484                outward: t.outward,
7485            })
7486            .collect())
7487    }
7488
7489    /// Creates a link between two JIRA issues.
7490    pub async fn create_issue_link(
7491        &self,
7492        type_name: &str,
7493        inward_key: &str,
7494        outward_key: &str,
7495    ) -> Result<()> {
7496        let url = format!("{}/rest/api/3/issueLink", self.instance_url);
7497        let body = serde_json::json!({"type": {"name": type_name}, "inwardIssue": {"key": inward_key}, "outwardIssue": {"key": outward_key}});
7498        let response = self.post_json(&url, &body).await?;
7499        Self::ensure_success(response).await?;
7500        Ok(())
7501    }
7502
7503    /// Removes an issue link by ID.
7504    pub async fn remove_issue_link(&self, link_id: &str) -> Result<()> {
7505        let url = format!("{}/rest/api/3/issueLink/{}", self.instance_url, link_id);
7506        let response = self.delete(&url).await?;
7507        Self::ensure_success(response).await?;
7508        Ok(())
7509    }
7510
7511    /// Sets the parent of a JIRA issue (e.g., links a Story to its Epic, a
7512    /// Sub-task to its Story, or any issue to a parent of a hierarchy-allowed
7513    /// type).
7514    pub async fn set_issue_parent(&self, issue_key: &str, parent_key: &str) -> Result<()> {
7515        let url = format!("{}/rest/api/3/issue/{}", self.instance_url, issue_key);
7516        let body = serde_json::json!({"fields": {"parent": {"key": parent_key}}});
7517        let response = self.put_json(&url, &body).await?;
7518        Self::ensure_success(response).await?;
7519        Ok(())
7520    }
7521
7522    /// Resolves a JIRA issue key to its numeric ID.
7523    pub async fn get_issue_id(&self, key: &str) -> Result<String> {
7524        let url = format!("{}/rest/api/3/issue/{}?fields=", self.instance_url, key);
7525        let response = self.get_json(&url).await?;
7526        let resp: JiraIssueIdResponse = Self::parse_json(
7527            Self::ensure_success(response).await?,
7528            "Failed to parse issue ID response",
7529        )
7530        .await?;
7531        Ok(resp.id)
7532    }
7533
7534    /// Fetches a development status summary (counts per category) for a JIRA issue.
7535    ///
7536    /// Uses the DevStatus summary endpoint. Returns counts and providers (each
7537    /// carrying both the `applicationType` instance-type key and its display
7538    /// name) for each category (pull requests, branches, repositories).
7539    pub async fn get_dev_status_summary(&self, key: &str) -> Result<JiraDevStatusSummary> {
7540        let issue_id = self.get_issue_id(key).await?;
7541        let url = format!(
7542            "{}/rest/dev-status/1.0/issue/summary?issueId={}",
7543            self.instance_url, issue_id
7544        );
7545        let response = self.get_json(&url).await?;
7546        let resp: DevStatusSummaryResponse = Self::parse_json(
7547            Self::ensure_success(response).await?,
7548            "Failed to parse DevStatus summary response",
7549        )
7550        .await?;
7551
7552        fn extract_count(cat: Option<DevStatusSummaryCategory>) -> JiraDevStatusCount {
7553            match cat {
7554                Some(c) => JiraDevStatusCount {
7555                    count: c.overall.map_or(0, |o| o.count),
7556                    // The `byInstanceType` map is keyed by the instance-type
7557                    // identifier (e.g. "github", "stash", "bitbucket") — this
7558                    // key, not the human-readable `name` ("Bitbucket Server"),
7559                    // is what the detail endpoint expects as `applicationType`.
7560                    // Keep the key as `instance_type` for provider auto-discovery
7561                    // in `get_dev_status`, and the value's `name` for display,
7562                    // falling back to the key when the API omits a name.
7563                    providers: c
7564                        .by_instance_type
7565                        .into_iter()
7566                        .filter(|(k, _)| !k.is_empty())
7567                        .map(|(k, v)| JiraDevProvider {
7568                            name: v
7569                                .get("name")
7570                                .and_then(|n| n.as_str())
7571                                .filter(|s| !s.is_empty())
7572                                .unwrap_or(&k)
7573                                .to_string(),
7574                            instance_type: k,
7575                        })
7576                        .collect(),
7577                },
7578                None => JiraDevStatusCount {
7579                    count: 0,
7580                    providers: Vec::new(),
7581                },
7582            }
7583        }
7584
7585        Ok(JiraDevStatusSummary {
7586            pullrequest: extract_count(resp.summary.pullrequest),
7587            branch: extract_count(resp.summary.branch),
7588            repository: extract_count(resp.summary.repository),
7589        })
7590    }
7591
7592    /// Fetches development status (PRs, branches, repositories) for a JIRA issue.
7593    ///
7594    /// Uses the DevStatus API which requires the numeric issue ID. The key is
7595    /// resolved automatically via [`get_issue_id`](Self::get_issue_id).
7596    ///
7597    /// If `application_type` is `None`, discovers available providers via the
7598    /// summary endpoint and queries each one. If `Some`, queries only that
7599    /// provider (e.g., "GitHub", "bitbucket", "stash").
7600    pub async fn get_dev_status(
7601        &self,
7602        key: &str,
7603        data_type: Option<&str>,
7604        application_type: Option<&str>,
7605    ) -> Result<JiraDevStatus> {
7606        let issue_id = self.get_issue_id(key).await?;
7607
7608        let app_types: Vec<String> = if let Some(app) = application_type {
7609            vec![app.to_string()]
7610        } else {
7611            // Discover available providers via the summary endpoint. The
7612            // `instance_type` key — not the display name — is what the detail
7613            // endpoint expects as `applicationType`.
7614            let summary = self.get_dev_status_summary(key).await?;
7615            let mut providers: Vec<String> = Vec::new();
7616            for p in summary
7617                .pullrequest
7618                .providers
7619                .into_iter()
7620                .chain(summary.branch.providers)
7621                .chain(summary.repository.providers)
7622            {
7623                if !providers.contains(&p.instance_type) {
7624                    providers.push(p.instance_type);
7625                }
7626            }
7627            if providers.is_empty() {
7628                providers.push("GitHub".to_string());
7629            }
7630            providers
7631        };
7632
7633        let data_types: Vec<&str> = match data_type {
7634            Some(dt) => vec![dt],
7635            None => vec!["pullrequest", "branch", "repository"],
7636        };
7637
7638        let mut status = JiraDevStatus {
7639            pull_requests: Vec::new(),
7640            branches: Vec::new(),
7641            repositories: Vec::new(),
7642        };
7643
7644        for app in &app_types {
7645            for dt in &data_types {
7646                let url = format!(
7647                    "{}/rest/dev-status/1.0/issue/detail?issueId={}&applicationType={}&dataType={}",
7648                    self.instance_url, issue_id, app, dt
7649                );
7650                let response = self.get_json(&url).await?;
7651                let resp: DevStatusResponse = Self::parse_json(
7652                    Self::ensure_success(response).await?,
7653                    "Failed to parse DevStatus response",
7654                )
7655                .await?;
7656
7657                for detail in resp.detail {
7658                    for pr in detail.pull_requests {
7659                        status.pull_requests.push(JiraDevPullRequest {
7660                            id: pr.id,
7661                            name: pr.name,
7662                            status: pr.status,
7663                            url: pr.url,
7664                            repository_name: pr.repository_name,
7665                            source_branch: pr.source.map(|s| s.branch).unwrap_or_default(),
7666                            destination_branch: pr
7667                                .destination
7668                                .map(|d| d.branch)
7669                                .unwrap_or_default(),
7670                            author: pr.author.map(|a| a.name),
7671                            reviewers: pr.reviewers.into_iter().map(|r| r.name).collect(),
7672                            comment_count: pr.comment_count,
7673                            last_update: pr.last_update,
7674                        });
7675                    }
7676                    for branch in detail.branches {
7677                        status.branches.push(JiraDevBranch {
7678                            name: branch.name,
7679                            url: branch.url,
7680                            repository_name: branch.repository_name,
7681                            create_pr_url: branch.create_pr_url,
7682                            last_commit: branch.last_commit.map(Self::convert_commit),
7683                        });
7684                    }
7685                    for repo in detail.repositories {
7686                        status.repositories.push(JiraDevRepository {
7687                            name: repo.name,
7688                            url: repo.url,
7689                            commits: repo.commits.into_iter().map(Self::convert_commit).collect(),
7690                        });
7691                    }
7692                }
7693            }
7694        }
7695
7696        Ok(status)
7697    }
7698
7699    /// Converts an internal `DevStatusCommit` to a public `JiraDevCommit`.
7700    fn convert_commit(c: DevStatusCommit) -> JiraDevCommit {
7701        JiraDevCommit {
7702            id: c.id,
7703            display_id: c.display_id,
7704            message: c.message,
7705            author: c.author.map(|a| a.name),
7706            timestamp: c.author_timestamp,
7707            url: c.url,
7708            file_count: c.file_count,
7709            merge: c.merge,
7710        }
7711    }
7712
7713    /// Gets attachment metadata for a JIRA issue.
7714    pub async fn get_attachments(&self, key: &str) -> Result<Vec<JiraAttachment>> {
7715        let url = format!(
7716            "{}/rest/api/3/issue/{}?fields=attachment",
7717            self.instance_url, key
7718        );
7719
7720        let response = self.get_json(&url).await?;
7721
7722        let resp: JiraAttachmentIssueResponse = Self::parse_json(
7723            Self::ensure_success(response).await?,
7724            "Failed to parse attachment response",
7725        )
7726        .await?;
7727
7728        Ok(resp
7729            .fields
7730            .attachment
7731            .into_iter()
7732            .map(|a| JiraAttachment {
7733                id: a.id,
7734                filename: a.filename,
7735                mime_type: a.mime_type,
7736                size: a.size,
7737                content_url: a.content,
7738            })
7739            .collect())
7740    }
7741
7742    /// Gets the changelog for a JIRA issue with auto-pagination.
7743    pub async fn get_changelog(&self, key: &str, limit: u32) -> Result<Vec<JiraChangelogEntry>> {
7744        let effective_limit = if limit == 0 { u32::MAX } else { limit };
7745        let mut all_entries = Vec::new();
7746        let mut start_at: u32 = 0;
7747
7748        loop {
7749            let remaining = effective_limit.saturating_sub(all_entries.len() as u32);
7750            if remaining == 0 {
7751                break;
7752            }
7753            let page_size = remaining.min(PAGE_SIZE);
7754
7755            let url = format!(
7756                "{}/rest/api/3/issue/{}/changelog?maxResults={}&startAt={}",
7757                self.instance_url, key, page_size, start_at
7758            );
7759
7760            let response = self.get_json(&url).await?;
7761
7762            let resp: JiraChangelogResponse = Self::parse_json(
7763                Self::ensure_success(response).await?,
7764                "Failed to parse changelog response",
7765            )
7766            .await?;
7767
7768            let page_count = resp.values.len() as u32;
7769            for e in resp.values {
7770                all_entries.push(JiraChangelogEntry {
7771                    id: e.id,
7772                    author: e.author.and_then(|a| a.display_name).unwrap_or_default(),
7773                    created: e.created.unwrap_or_default(),
7774                    items: e
7775                        .items
7776                        .into_iter()
7777                        .map(|i| JiraChangelogItem {
7778                            field: i.field,
7779                            from_string: i.from_string,
7780                            to_string: i.to_string,
7781                        })
7782                        .collect(),
7783                });
7784            }
7785
7786            if resp.is_last || page_count == 0 {
7787                break;
7788            }
7789            start_at += page_count;
7790        }
7791
7792        Ok(all_entries)
7793    }
7794
7795    /// Lists all JIRA field definitions.
7796    pub async fn get_fields(&self) -> Result<Vec<JiraField>> {
7797        let url = format!("{}/rest/api/3/field", self.instance_url);
7798
7799        let response = self.get_json(&url).await?;
7800
7801        let entries: Vec<JiraFieldEntry> = Self::parse_json(
7802            Self::ensure_success(response).await?,
7803            "Failed to parse field list response",
7804        )
7805        .await?;
7806
7807        Ok(entries
7808            .into_iter()
7809            .map(|f| {
7810                let (raw_type, raw_custom) = match f.schema {
7811                    Some(s) => (s.schema_type, s.custom),
7812                    None => (None, None),
7813                };
7814                JiraField {
7815                    id: f.id,
7816                    name: f.name,
7817                    custom: f.custom,
7818                    schema_type: map_schema_type(raw_type, raw_custom.as_deref()),
7819                    schema_custom: raw_custom,
7820                }
7821            })
7822            .collect())
7823    }
7824
7825    /// Lists options for a JIRA custom field.
7826    /// Lists contexts for a JIRA custom field.
7827    pub async fn get_field_contexts(&self, field_id: &str) -> Result<Vec<String>> {
7828        let url = format!(
7829            "{}/rest/api/3/field/{}/context",
7830            self.instance_url, field_id
7831        );
7832
7833        let response = self.get_json(&url).await?;
7834
7835        let resp: JiraFieldContextsResponse = Self::parse_json(
7836            Self::ensure_success(response).await?,
7837            "Failed to parse field contexts response",
7838        )
7839        .await?;
7840
7841        Ok(resp.values.into_iter().map(|c| c.id).collect())
7842    }
7843
7844    /// Lists options for a JIRA custom field.
7845    ///
7846    /// When `context_id` is `None`, auto-discovers the first context for the field.
7847    pub async fn get_field_options(
7848        &self,
7849        field_id: &str,
7850        context_id: Option<&str>,
7851    ) -> Result<Vec<JiraFieldOption>> {
7852        let ctx = if let Some(id) = context_id {
7853            id.to_string()
7854        } else {
7855            let contexts = self.get_field_contexts(field_id).await?;
7856            contexts.into_iter().next().ok_or_else(|| {
7857                anyhow::anyhow!(
7858                    "No contexts found for field \"{field_id}\". \
7859                     Use --context-id to specify one explicitly."
7860                )
7861            })?
7862        };
7863
7864        let url = format!(
7865            "{}/rest/api/3/field/{}/context/{}/option",
7866            self.instance_url, field_id, ctx
7867        );
7868
7869        let response = self.get_json(&url).await?;
7870
7871        let resp: JiraFieldOptionsResponse = Self::parse_json(
7872            Self::ensure_success(response).await?,
7873            "Failed to parse field options response",
7874        )
7875        .await?;
7876
7877        Ok(resp
7878            .values
7879            .into_iter()
7880            .map(|o| JiraFieldOption {
7881                id: o.id,
7882                value: o.value,
7883            })
7884            .collect())
7885    }
7886
7887    /// Lists JIRA projects.
7888    pub async fn get_projects(&self, limit: u32) -> Result<JiraProjectList> {
7889        let effective_limit = if limit == 0 { u32::MAX } else { limit };
7890        let mut all_projects = Vec::new();
7891        let mut start_at: u32 = 0;
7892
7893        loop {
7894            let remaining = effective_limit.saturating_sub(all_projects.len() as u32);
7895            if remaining == 0 {
7896                break;
7897            }
7898            let page_size = remaining.min(PAGE_SIZE);
7899
7900            let url = format!(
7901                "{}/rest/api/3/project/search?maxResults={}&startAt={}",
7902                self.instance_url, page_size, start_at
7903            );
7904
7905            let response = self.get_json(&url).await?;
7906
7907            let resp: JiraProjectSearchResponse = Self::parse_json(
7908                Self::ensure_success(response).await?,
7909                "Failed to parse project search response",
7910            )
7911            .await?;
7912
7913            let page_count = resp.values.len() as u32;
7914            for p in resp.values {
7915                all_projects.push(JiraProject {
7916                    id: p.id,
7917                    key: p.key,
7918                    name: p.name,
7919                    project_type: p.project_type_key,
7920                    lead: p.lead.and_then(|l| l.display_name),
7921                });
7922            }
7923
7924            if resp.is_last || page_count == 0 {
7925                break;
7926            }
7927            start_at += page_count;
7928        }
7929
7930        let total = all_projects.len() as u32;
7931        Ok(JiraProjectList {
7932            projects: all_projects,
7933            total,
7934        })
7935    }
7936
7937    /// Deletes a JIRA issue.
7938    pub async fn delete_issue(&self, key: &str) -> Result<()> {
7939        let url = format!("{}/rest/api/3/issue/{}", self.instance_url, key);
7940
7941        let response = self.delete(&url).await?;
7942
7943        Self::ensure_success(response).await?;
7944
7945        Ok(())
7946    }
7947
7948    /// Lists watchers on a JIRA issue.
7949    pub async fn get_watchers(&self, key: &str) -> Result<JiraWatcherList> {
7950        let url = format!("{}/rest/api/3/issue/{}/watchers", self.instance_url, key);
7951
7952        let response = self.get_json(&url).await?;
7953
7954        let json: serde_json::Value = Self::parse_json(
7955            Self::ensure_success(response).await?,
7956            "Failed to parse watchers response",
7957        )
7958        .await?;
7959
7960        let watch_count = json["watchCount"].as_u64().unwrap_or(0) as u32;
7961
7962        let watchers = json["watchers"]
7963            .as_array()
7964            .map(|arr| {
7965                arr.iter()
7966                    .filter_map(|v| serde_json::from_value::<JiraUser>(v.clone()).ok())
7967                    .collect()
7968            })
7969            .unwrap_or_default();
7970
7971        Ok(JiraWatcherList {
7972            watchers,
7973            watch_count,
7974        })
7975    }
7976
7977    /// Adds a user as a watcher on a JIRA issue.
7978    pub async fn add_watcher(&self, key: &str, account_id: &str) -> Result<()> {
7979        let url = format!("{}/rest/api/3/issue/{}/watchers", self.instance_url, key);
7980
7981        let body = serde_json::json!(account_id);
7982
7983        let response = self.post_json(&url, &body).await?;
7984
7985        Self::ensure_success(response).await?;
7986
7987        Ok(())
7988    }
7989
7990    /// Removes a user from watchers on a JIRA issue.
7991    pub async fn remove_watcher(&self, key: &str, account_id: &str) -> Result<()> {
7992        let url = format!(
7993            "{}/rest/api/3/issue/{}/watchers?accountId={}",
7994            self.instance_url, key, account_id
7995        );
7996
7997        let response = self.delete(&url).await?;
7998
7999        Self::ensure_success(response).await?;
8000
8001        Ok(())
8002    }
8003
8004    /// Verifies authentication by fetching the current user.
8005    pub async fn get_myself(&self) -> Result<JiraUser> {
8006        let url = format!("{}/rest/api/3/myself", self.instance_url);
8007
8008        let response = self
8009            .client
8010            .get(&url)
8011            .header("Authorization", &self.auth_header)
8012            .header("Accept", "application/json")
8013            .send()
8014            .await
8015            .context("Failed to send request to JIRA API")?;
8016
8017        let response = Self::ensure_success(response).await?;
8018
8019        response
8020            .json()
8021            .await
8022            .context("Failed to parse user response")
8023    }
8024}