Skip to main content

omni_dev/atlassian/
client.rs

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