Skip to main content

omni_dev/atlassian/
client.rs

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