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