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::{connect_timeout, read_timeout, retry_429};
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            .connect_timeout(connect_timeout())
6506            .read_timeout(read_timeout())
6507            .build()
6508            .context("Failed to build HTTP client")?;
6509
6510        let credentials = format!("{email}:{api_token}");
6511        let encoded = base64::engine::general_purpose::STANDARD.encode(credentials);
6512        let auth_header = format!("Basic {encoded}");
6513
6514        Ok(Self {
6515            client,
6516            instance_url: instance_url.trim_end_matches('/').to_string(),
6517            auth_header,
6518        })
6519    }
6520
6521    /// Creates a client from stored credentials.
6522    pub fn from_credentials(creds: &crate::atlassian::auth::AtlassianCredentials) -> Result<Self> {
6523        Self::new(
6524            &creds.instance_url,
6525            &creds.email,
6526            creds.api_token.expose_secret(),
6527        )
6528    }
6529
6530    /// Returns the instance URL.
6531    #[must_use]
6532    pub fn instance_url(&self) -> &str {
6533        &self.instance_url
6534    }
6535
6536    /// Appends a best-effort HTTP record for one request attempt. The service
6537    /// tag is `confluence` for `/wiki/` paths, else `jira`.
6538    fn log_request(
6539        &self,
6540        method: &str,
6541        url: &str,
6542        started: Instant,
6543        result: &reqwest::Result<reqwest::Response>,
6544    ) {
6545        let service = if url.contains("/wiki/") {
6546            "confluence"
6547        } else {
6548            "jira"
6549        };
6550        request_log::record_http_result(service, method, url, started, result);
6551    }
6552
6553    /// Sends an authenticated GET request and returns the raw response.
6554    ///
6555    /// Shared transport method used by both JIRA and Confluence API
6556    /// implementations.
6557    pub async fn get_json(&self, url: &str) -> Result<reqwest::Response> {
6558        retry_429(
6559            || {
6560                self.client
6561                    .get(url)
6562                    .header("Authorization", &self.auth_header)
6563                    .header("Accept", "application/json")
6564            },
6565            |started, result| self.log_request("GET", url, started, result),
6566        )
6567        .await
6568        .context("Failed to send GET request to Atlassian API")
6569    }
6570
6571    /// Sends an authenticated PUT request with a JSON body and returns the raw response.
6572    ///
6573    /// Shared transport method used by both JIRA and Confluence API
6574    /// implementations.
6575    pub async fn put_json<T: serde::Serialize + Sync + ?Sized>(
6576        &self,
6577        url: &str,
6578        body: &T,
6579    ) -> Result<reqwest::Response> {
6580        retry_429(
6581            || {
6582                self.client
6583                    .put(url)
6584                    .header("Authorization", &self.auth_header)
6585                    .header("Content-Type", "application/json")
6586                    .json(body)
6587            },
6588            |started, result| self.log_request("PUT", url, started, result),
6589        )
6590        .await
6591        .context("Failed to send PUT request to Atlassian API")
6592    }
6593
6594    /// Sends an authenticated POST request with a JSON body and returns the raw response.
6595    pub async fn post_json<T: serde::Serialize + Sync + ?Sized>(
6596        &self,
6597        url: &str,
6598        body: &T,
6599    ) -> Result<reqwest::Response> {
6600        retry_429(
6601            || {
6602                self.client
6603                    .post(url)
6604                    .header("Authorization", &self.auth_header)
6605                    .header("Content-Type", "application/json")
6606                    .json(body)
6607            },
6608            |started, result| self.log_request("POST", url, started, result),
6609        )
6610        .await
6611        .context("Failed to send POST request to Atlassian API")
6612    }
6613
6614    /// Sends an authenticated GET request and returns raw bytes.
6615    pub async fn get_bytes(&self, url: &str) -> Result<Vec<u8>> {
6616        let response = self.get_json_raw_accept(url, "*/*").await?;
6617
6618        let response = Self::ensure_success(response).await?;
6619
6620        let bytes = response
6621            .bytes()
6622            .await
6623            .context("Failed to read response bytes")?;
6624        Ok(bytes.to_vec())
6625    }
6626
6627    /// Sends an authenticated DELETE request and returns the raw response.
6628    pub async fn delete(&self, url: &str) -> Result<reqwest::Response> {
6629        retry_429(
6630            || {
6631                self.client
6632                    .delete(url)
6633                    .header("Authorization", &self.auth_header)
6634            },
6635            |started, result| self.log_request("DELETE", url, started, result),
6636        )
6637        .await
6638        .context("Failed to send DELETE request to Atlassian API")
6639    }
6640
6641    /// Sends an authenticated POST request with a multipart body and returns the raw response.
6642    ///
6643    /// Does not retry on 429: a streamed multipart body cannot be replayed. Callers
6644    /// that need retry must rebuild the form and call again.
6645    pub async fn post_multipart(
6646        &self,
6647        url: &str,
6648        form: reqwest::multipart::Form,
6649        extra_headers: &[(&str, &str)],
6650    ) -> Result<reqwest::Response> {
6651        let mut req = self
6652            .client
6653            .post(url)
6654            .header("Authorization", &self.auth_header)
6655            .multipart(form);
6656        for (name, value) in extra_headers {
6657            req = req.header(*name, *value);
6658        }
6659        let started = Instant::now();
6660        let result = req.send().await;
6661        self.log_request("POST", url, started, &result);
6662        result.context("Failed to send multipart POST request to Atlassian API")
6663    }
6664
6665    /// Internal: GET with custom Accept header and 429 retry.
6666    async fn get_json_raw_accept(&self, url: &str, accept: &str) -> Result<reqwest::Response> {
6667        retry_429(
6668            || {
6669                self.client
6670                    .get(url)
6671                    .header("Authorization", &self.auth_header)
6672                    .header("Accept", accept)
6673            },
6674            |started, result| self.log_request("GET", url, started, result),
6675        )
6676        .await
6677        .context("Failed to send GET request to Atlassian API")
6678    }
6679
6680    /// Returns `response` unchanged if its status is a success, otherwise reads
6681    /// the body and fails with [`AtlassianError::ApiRequestFailed`].
6682    ///
6683    /// Centralises the "check status → read body → build error" block copied
6684    /// after nearly every request. Call sites that need bespoke diagnostics for
6685    /// specific status codes (e.g. `jira_write_error`, `confluence_write_error`)
6686    /// build their error directly instead of using this helper.
6687    pub(crate) async fn ensure_success(response: reqwest::Response) -> Result<reqwest::Response> {
6688        if response.status().is_success() {
6689            return Ok(response);
6690        }
6691        let status = response.status().as_u16();
6692        let body = response.text().await.unwrap_or_default();
6693        Err(AtlassianError::ApiRequestFailed { status, body }.into())
6694    }
6695
6696    /// Deserialises `response`'s JSON body into `T`, attaching `context` on
6697    /// failure.
6698    ///
6699    /// Pairs with [`Self::ensure_success`]; the common
6700    /// `Self::parse_json(Self::ensure_success(resp).await?, "…").await?`
6701    /// spelling replaces the hand-copied status-check + `json().context(…)`
6702    /// block.
6703    pub(crate) async fn parse_json<T: serde::de::DeserializeOwned>(
6704        response: reqwest::Response,
6705        context: &'static str,
6706    ) -> Result<T> {
6707        response.json().await.context(context)
6708    }
6709
6710    /// Fetches a JIRA issue by key with only the standard fields.
6711    ///
6712    /// Thin shim over [`Self::get_issue_with_fields`] with
6713    /// [`FieldSelection::Standard`]. Preserved for callers that do not need
6714    /// custom field data.
6715    pub async fn get_issue(&self, key: &str) -> Result<JiraIssue> {
6716        self.get_issue_with_fields(key, FieldSelection::Standard)
6717            .await
6718    }
6719
6720    /// Fetches a JIRA issue by key with the given field selection.
6721    ///
6722    /// Always requests `expand=names,schema` so human-readable field names
6723    /// and type metadata are available for rendering custom fields. When
6724    /// `selection` is [`FieldSelection::Standard`], `custom_fields` on the
6725    /// returned issue will be empty.
6726    pub async fn get_issue_with_fields(
6727        &self,
6728        key: &str,
6729        selection: FieldSelection,
6730    ) -> Result<JiraIssue> {
6731        const STANDARD_FIELDS: &str =
6732            "summary,description,status,issuetype,assignee,priority,labels";
6733
6734        let fields_param = match &selection {
6735            FieldSelection::Standard => STANDARD_FIELDS.to_string(),
6736            FieldSelection::Named(names) => {
6737                let mut parts: Vec<&str> = STANDARD_FIELDS.split(',').collect();
6738                parts.extend(names.iter().map(String::as_str));
6739                parts.join(",")
6740            }
6741            FieldSelection::All => "*all".to_string(),
6742        };
6743
6744        let base = format!("{}/rest/api/3/issue/{}", self.instance_url, key);
6745        let url = reqwest::Url::parse_with_params(
6746            &base,
6747            &[
6748                ("fields", fields_param.as_str()),
6749                ("expand", "names,schema"),
6750            ],
6751        )
6752        .context("Failed to build JIRA issue URL")?;
6753
6754        let response = self
6755            .client
6756            .get(url)
6757            .header("Authorization", &self.auth_header)
6758            .header("Accept", "application/json")
6759            .send()
6760            .await
6761            .context("Failed to send request to JIRA API")?;
6762
6763        let envelope: JiraIssueEnvelope = Self::parse_json(
6764            Self::ensure_success(response).await?,
6765            "Failed to parse JIRA issue response",
6766        )
6767        .await?;
6768
6769        Ok(envelope.into_issue(&selection))
6770    }
6771
6772    /// Updates a JIRA issue's description and optionally its summary.
6773    ///
6774    /// Thin shim over [`Self::update_issue_with_custom_fields`] that sends no
6775    /// custom field changes.
6776    pub async fn update_issue(
6777        &self,
6778        key: &str,
6779        description_adf: &ValidatedAdfDocument,
6780        summary: Option<&str>,
6781    ) -> Result<()> {
6782        self.update_issue_with_custom_fields(
6783            key,
6784            Some(description_adf),
6785            summary,
6786            &std::collections::BTreeMap::new(),
6787        )
6788        .await
6789    }
6790
6791    /// Updates a JIRA issue with any subset of supported fields.
6792    ///
6793    /// `description_adf` and `summary` are each `Option`: `None` leaves the
6794    /// field untouched, `Some` overwrites it. `custom_fields` is merged
6795    /// verbatim into the `fields` payload, keyed by stable JIRA field id —
6796    /// both standard fields (`assignee`, `reporter`, `priority`, `labels`)
6797    /// and custom fields (`customfield_19300`). The system `parent` field is
6798    /// set via [`Self::set_issue_parent`], not here. Returns an error when
6799    /// nothing would be sent (avoids a no-op PUT that JIRA still validates).
6800    pub async fn update_issue_with_custom_fields(
6801        &self,
6802        key: &str,
6803        description_adf: Option<&ValidatedAdfDocument>,
6804        summary: Option<&str>,
6805        custom_fields: &std::collections::BTreeMap<String, serde_json::Value>,
6806    ) -> Result<()> {
6807        let url = format!("{}/rest/api/3/issue/{}", self.instance_url, key);
6808
6809        let mut fields = serde_json::Map::new();
6810        if let Some(adf) = description_adf {
6811            fields.insert(
6812                "description".to_string(),
6813                serde_json::to_value(adf).context("Failed to serialize ADF document")?,
6814            );
6815        }
6816        if let Some(summary_text) = summary {
6817            fields.insert(
6818                "summary".to_string(),
6819                serde_json::Value::String(summary_text.to_string()),
6820            );
6821        }
6822        for (id, value) in custom_fields {
6823            fields.insert(id.clone(), value.clone());
6824        }
6825
6826        if fields.is_empty() {
6827            anyhow::bail!("update_issue_with_custom_fields: no fields to update");
6828        }
6829
6830        let body = serde_json::json!({ "fields": fields });
6831
6832        let response = self
6833            .client
6834            .put(&url)
6835            .header("Authorization", &self.auth_header)
6836            .header("Content-Type", "application/json")
6837            .json(&body)
6838            .send()
6839            .await
6840            .context("Failed to send update request to JIRA API")?;
6841
6842        if !response.status().is_success() {
6843            let status = response.status().as_u16();
6844            let body = response.text().await.unwrap_or_default();
6845            return Err(jira_write_error(status, body));
6846        }
6847
6848        Ok(())
6849    }
6850
6851    /// Fetches editable field metadata scoped to an issue's edit screen.
6852    ///
6853    /// `GET /rest/api/3/issue/{key}/editmeta` returns only fields on the
6854    /// issue's screen, so field names are unambiguous even when multiple
6855    /// custom fields share a display name globally.
6856    pub async fn get_editmeta(&self, key: &str) -> Result<EditMeta> {
6857        let url = format!("{}/rest/api/3/issue/{}/editmeta", self.instance_url, key);
6858
6859        let response = self
6860            .client
6861            .get(&url)
6862            .header("Authorization", &self.auth_header)
6863            .header("Accept", "application/json")
6864            .send()
6865            .await
6866            .context("Failed to send editmeta request to JIRA API")?;
6867
6868        let raw: JiraEditMetaResponse = Self::parse_json(
6869            Self::ensure_success(response).await?,
6870            "Failed to parse JIRA editmeta response",
6871        )
6872        .await?;
6873
6874        Ok(edit_meta_from_raw_fields(raw.fields))
6875    }
6876
6877    /// Creates a new JIRA issue.
6878    ///
6879    /// Thin shim over [`Self::create_issue_with_custom_fields`] that sends no
6880    /// custom field values.
6881    pub async fn create_issue(
6882        &self,
6883        project_key: &str,
6884        issue_type: &str,
6885        summary: &str,
6886        description_adf: Option<&ValidatedAdfDocument>,
6887        labels: &[String],
6888    ) -> Result<JiraCreatedIssue> {
6889        self.create_issue_with_custom_fields(
6890            project_key,
6891            issue_type,
6892            summary,
6893            description_adf,
6894            labels,
6895            &std::collections::BTreeMap::new(),
6896        )
6897        .await
6898    }
6899
6900    /// Creates a new JIRA issue with standard fields and any custom fields
6901    /// keyed by stable ID (e.g., `customfield_19300`).
6902    pub async fn create_issue_with_custom_fields(
6903        &self,
6904        project_key: &str,
6905        issue_type: &str,
6906        summary: &str,
6907        description_adf: Option<&ValidatedAdfDocument>,
6908        labels: &[String],
6909        custom_fields: &std::collections::BTreeMap<String, serde_json::Value>,
6910    ) -> Result<JiraCreatedIssue> {
6911        let url = format!("{}/rest/api/3/issue", self.instance_url);
6912
6913        let mut fields = serde_json::Map::new();
6914        fields.insert(
6915            "project".to_string(),
6916            serde_json::json!({ "key": project_key }),
6917        );
6918        fields.insert(
6919            "issuetype".to_string(),
6920            serde_json::json!({ "name": issue_type }),
6921        );
6922        fields.insert(
6923            "summary".to_string(),
6924            serde_json::Value::String(summary.to_string()),
6925        );
6926        if let Some(adf) = description_adf {
6927            fields.insert(
6928                "description".to_string(),
6929                serde_json::to_value(adf).context("Failed to serialize ADF document")?,
6930            );
6931        }
6932        if !labels.is_empty() {
6933            fields.insert("labels".to_string(), serde_json::to_value(labels)?);
6934        }
6935        for (id, value) in custom_fields {
6936            fields.insert(id.clone(), value.clone());
6937        }
6938
6939        let body = serde_json::json!({ "fields": fields });
6940
6941        let response = self
6942            .post_json(&url, &body)
6943            .await
6944            .context("Failed to send create request to JIRA API")?;
6945
6946        if !response.status().is_success() {
6947            let status = response.status().as_u16();
6948            let body = response.text().await.unwrap_or_default();
6949            // Parity with update: surface JIRA's `{ "errors": {...} }` envelope
6950            // as the actionable `JiraAdfFieldRequired` when a field reports it
6951            // needs ADF, instead of an opaque `ApiRequestFailed` (issue #1047).
6952            return Err(jira_write_error(status, body));
6953        }
6954
6955        let create_response: JiraCreateResponse = response
6956            .json()
6957            .await
6958            .context("Failed to parse JIRA create response")?;
6959
6960        Ok(JiraCreatedIssue {
6961            key: create_response.key,
6962            id: create_response.id,
6963            self_url: create_response.self_url,
6964        })
6965    }
6966
6967    /// Fetches field metadata for creating a JIRA issue of a given project
6968    /// and issue type.
6969    ///
6970    /// `GET /rest/api/3/issue/createmeta?projectKeys={p}&issuetypeNames={t}&expand=projects.issuetypes.fields`
6971    /// returns fields on the create screen, which is the write-time source
6972    /// of truth for custom-field resolution prior to issue creation.
6973    pub async fn get_createmeta(&self, project_key: &str, issue_type: &str) -> Result<EditMeta> {
6974        let base = format!("{}/rest/api/3/issue/createmeta", self.instance_url);
6975        let url = reqwest::Url::parse_with_params(
6976            &base,
6977            &[
6978                ("projectKeys", project_key),
6979                ("issuetypeNames", issue_type),
6980                ("expand", "projects.issuetypes.fields"),
6981            ],
6982        )
6983        .context("Failed to build JIRA createmeta URL")?;
6984
6985        let response = self
6986            .client
6987            .get(url)
6988            .header("Authorization", &self.auth_header)
6989            .header("Accept", "application/json")
6990            .send()
6991            .await
6992            .context("Failed to send createmeta request to JIRA API")?;
6993
6994        let raw: JiraCreateMetaResponse = Self::parse_json(
6995            Self::ensure_success(response).await?,
6996            "Failed to parse JIRA createmeta response",
6997        )
6998        .await?;
6999
7000        let Some(project) = raw.projects.into_iter().next() else {
7001            return Ok(EditMeta::default());
7002        };
7003        let Some(issuetype) = project.issuetypes.into_iter().next() else {
7004            return Ok(EditMeta::default());
7005        };
7006
7007        Ok(edit_meta_from_raw_fields(issuetype.fields))
7008    }
7009
7010    /// Introspects the create screen for a project + issue type, returning each
7011    /// field with its `required` flag, schema type, allowed values, and default.
7012    ///
7013    /// `GET /rest/api/3/issue/createmeta?projectKeys={p}&issuetypeNames={t}&expand=projects.issuetypes.fields`
7014    /// — the same endpoint as [`get_createmeta`](Self::get_createmeta), parsed
7015    /// for the full field metadata an agent needs to prompt before creating.
7016    pub async fn get_project_create_meta(
7017        &self,
7018        project_key: &str,
7019        issue_type: &str,
7020    ) -> Result<CreateMeta> {
7021        let base = format!("{}/rest/api/3/issue/createmeta", self.instance_url);
7022        let url = reqwest::Url::parse_with_params(
7023            &base,
7024            &[
7025                ("projectKeys", project_key),
7026                ("issuetypeNames", issue_type),
7027                ("expand", "projects.issuetypes.fields"),
7028            ],
7029        )
7030        .context("Failed to build JIRA createmeta URL")?;
7031
7032        let response = self
7033            .client
7034            .get(url)
7035            .header("Authorization", &self.auth_header)
7036            .header("Accept", "application/json")
7037            .send()
7038            .await
7039            .context("Failed to send createmeta request to JIRA API")?;
7040
7041        let raw: JiraCreateMetaFullResponse = Self::parse_json(
7042            Self::ensure_success(response).await?,
7043            "Failed to parse JIRA createmeta response",
7044        )
7045        .await?;
7046
7047        let mut fields: Vec<CreateMetaField> = raw
7048            .projects
7049            .into_iter()
7050            .next()
7051            .and_then(|p| p.issuetypes.into_iter().next())
7052            .map(|it| {
7053                it.fields
7054                    .into_iter()
7055                    .map(|(field_id, field)| {
7056                        let schema = field.schema.unwrap_or(JiraCreateMetaSchemaRaw {
7057                            kind: None,
7058                            items: None,
7059                            custom: None,
7060                        });
7061                        CreateMetaField {
7062                            field_id,
7063                            name: field.name.unwrap_or_default(),
7064                            required: field.required,
7065                            schema_type: schema.kind.unwrap_or_default(),
7066                            items: schema.items,
7067                            custom: schema.custom,
7068                            allowed_values: field
7069                                .allowed_values
7070                                .into_iter()
7071                                .map(JiraAllowedValueRaw::into_allowed_value)
7072                                .collect(),
7073                            default_value: field.default_value,
7074                        }
7075                    })
7076                    .collect()
7077            })
7078            .unwrap_or_default();
7079
7080        // Required fields first, then alphabetically by name for stable output.
7081        fields.sort_by(|a, b| {
7082            b.required
7083                .cmp(&a.required)
7084                .then_with(|| a.name.cmp(&b.name))
7085        });
7086
7087        Ok(CreateMeta {
7088            project: project_key.to_string(),
7089            issue_type: issue_type.to_string(),
7090            fields,
7091        })
7092    }
7093
7094    /// Lists comments on a JIRA issue with auto-pagination.
7095    ///
7096    /// `limit` caps the total number of comments returned. Pass `0` for unlimited.
7097    pub async fn get_comments(&self, key: &str, limit: u32) -> Result<Vec<JiraComment>> {
7098        let effective_limit = if limit == 0 { u32::MAX } else { limit };
7099        let mut all_comments = Vec::new();
7100        let mut start_at: u32 = 0;
7101
7102        loop {
7103            let remaining = effective_limit.saturating_sub(all_comments.len() as u32);
7104            if remaining == 0 {
7105                break;
7106            }
7107            let page_size = remaining.min(PAGE_SIZE);
7108
7109            let url = format!(
7110                "{}/rest/api/3/issue/{}/comment?orderBy=created&maxResults={}&startAt={}",
7111                self.instance_url, key, page_size, start_at
7112            );
7113
7114            let response = self.get_json(&url).await?;
7115
7116            let resp: JiraCommentsResponse = Self::parse_json(
7117                Self::ensure_success(response).await?,
7118                "Failed to parse comments response",
7119            )
7120            .await?;
7121
7122            let page_count = resp.comments.len() as u32;
7123            for c in resp.comments {
7124                all_comments.push(JiraComment {
7125                    id: c.id,
7126                    author: c.author.and_then(|a| a.display_name).unwrap_or_default(),
7127                    body_adf: c.body,
7128                    created: c.created.unwrap_or_default(),
7129                    updated: c.updated,
7130                });
7131            }
7132
7133            if page_count == 0 {
7134                break;
7135            }
7136
7137            let fetched = resp.start_at.saturating_add(page_count);
7138            if fetched >= resp.total {
7139                break;
7140            }
7141
7142            start_at += page_count;
7143        }
7144
7145        Ok(all_comments)
7146    }
7147
7148    /// Adds a comment to a JIRA issue.
7149    pub async fn add_comment(&self, key: &str, body_adf: &ValidatedAdfDocument) -> Result<()> {
7150        let url = format!("{}/rest/api/3/issue/{}/comment", self.instance_url, key);
7151
7152        let body = serde_json::json!({
7153            "body": body_adf
7154        });
7155
7156        let response = self.post_json(&url, &body).await?;
7157
7158        Self::ensure_success(response).await?;
7159
7160        Ok(())
7161    }
7162
7163    /// Updates an existing comment on a JIRA issue.
7164    ///
7165    /// Issues a `PUT /rest/api/3/issue/{key}/comment/{id}` with the new ADF
7166    /// body and an optional visibility restriction. Returns the updated
7167    /// comment as parsed from the JIRA response so callers can surface the
7168    /// `updated` timestamp and any author/body changes JIRA applied.
7169    pub async fn update_comment(
7170        &self,
7171        key: &str,
7172        comment_id: &str,
7173        body_adf: &ValidatedAdfDocument,
7174        visibility: Option<&JiraVisibility>,
7175    ) -> Result<JiraComment> {
7176        let url = format!(
7177            "{}/rest/api/3/issue/{}/comment/{}",
7178            self.instance_url, key, comment_id
7179        );
7180
7181        let mut body = serde_json::json!({ "body": body_adf });
7182        if let Some(v) = visibility {
7183            body["visibility"] =
7184                serde_json::to_value(v).context("Failed to serialize comment visibility")?;
7185        }
7186
7187        let response = self.put_json(&url, &body).await?;
7188
7189        let entry: JiraCommentEntry = Self::parse_json(
7190            Self::ensure_success(response).await?,
7191            "Failed to parse updated comment response",
7192        )
7193        .await?;
7194
7195        Ok(JiraComment {
7196            id: entry.id,
7197            author: entry
7198                .author
7199                .and_then(|a| a.display_name)
7200                .unwrap_or_default(),
7201            body_adf: entry.body,
7202            created: entry.created.unwrap_or_default(),
7203            updated: entry.updated,
7204        })
7205    }
7206
7207    /// Deletes a comment from a JIRA issue.
7208    ///
7209    /// Issues a `DELETE /rest/api/3/issue/{key}/comment/{id}` (204 on success).
7210    pub async fn delete_comment(&self, key: &str, comment_id: &str) -> Result<()> {
7211        let url = format!(
7212            "{}/rest/api/3/issue/{}/comment/{}",
7213            self.instance_url, key, comment_id
7214        );
7215
7216        let response = self.delete(&url).await?;
7217
7218        Self::ensure_success(response).await?;
7219
7220        Ok(())
7221    }
7222
7223    /// Lists worklogs for a JIRA issue.
7224    pub async fn get_worklogs(&self, key: &str, limit: u32) -> Result<JiraWorklogList> {
7225        let effective_limit = if limit == 0 { u32::MAX } else { limit };
7226        let url = format!(
7227            "{}/rest/api/3/issue/{}/worklog?maxResults={}",
7228            self.instance_url,
7229            key,
7230            effective_limit.min(5000)
7231        );
7232
7233        let response = self.get_json(&url).await?;
7234
7235        let resp: JiraWorklogResponse = Self::parse_json(
7236            Self::ensure_success(response).await?,
7237            "Failed to parse worklog response",
7238        )
7239        .await?;
7240
7241        let worklogs: Vec<JiraWorklog> = resp
7242            .worklogs
7243            .into_iter()
7244            .take(effective_limit as usize)
7245            .map(|w| JiraWorklog {
7246                id: w.id,
7247                author: w.author.and_then(|a| a.display_name).unwrap_or_default(),
7248                time_spent: w.time_spent.unwrap_or_default(),
7249                time_spent_seconds: w.time_spent_seconds,
7250                started: w.started.unwrap_or_default(),
7251                comment: Self::extract_worklog_comment(w.comment.as_ref()),
7252            })
7253            .collect();
7254
7255        Ok(JiraWorklogList {
7256            total: resp.total,
7257            worklogs,
7258        })
7259    }
7260
7261    /// Adds a worklog entry to a JIRA issue.
7262    pub async fn add_worklog(
7263        &self,
7264        key: &str,
7265        time_spent: &str,
7266        started: Option<&str>,
7267        comment: Option<&str>,
7268    ) -> Result<()> {
7269        let url = format!("{}/rest/api/3/issue/{}/worklog", self.instance_url, key);
7270
7271        let mut body = serde_json::json!({
7272            "timeSpent": time_spent,
7273        });
7274
7275        if let Some(started) = started {
7276            body["started"] = serde_json::Value::String(started.to_string());
7277        }
7278
7279        if let Some(comment_text) = comment {
7280            body["comment"] = serde_json::json!({
7281                "type": "doc",
7282                "version": 1,
7283                "content": [{
7284                    "type": "paragraph",
7285                    "content": [{
7286                        "type": "text",
7287                        "text": comment_text
7288                    }]
7289                }]
7290            });
7291        }
7292
7293        let response = self.post_json(&url, &body).await?;
7294
7295        Self::ensure_success(response).await?;
7296
7297        Ok(())
7298    }
7299
7300    /// Updates an existing worklog entry on a JIRA issue.
7301    ///
7302    /// Issues a `PUT /rest/api/3/issue/{key}/worklog/{id}` with only the fields
7303    /// that are `Some`; omitted fields keep their current value. The comment is
7304    /// sent as an ADF document, mirroring [`Self::add_worklog`].
7305    pub async fn update_worklog(
7306        &self,
7307        key: &str,
7308        worklog_id: &str,
7309        time_spent: Option<&str>,
7310        started: Option<&str>,
7311        comment: Option<&str>,
7312    ) -> Result<()> {
7313        let url = format!(
7314            "{}/rest/api/3/issue/{}/worklog/{}",
7315            self.instance_url, key, worklog_id
7316        );
7317
7318        let mut body = serde_json::Map::new();
7319        if let Some(time_spent) = time_spent {
7320            body.insert(
7321                "timeSpent".to_string(),
7322                serde_json::Value::String(time_spent.to_string()),
7323            );
7324        }
7325        if let Some(started) = started {
7326            body.insert(
7327                "started".to_string(),
7328                serde_json::Value::String(started.to_string()),
7329            );
7330        }
7331        if let Some(comment_text) = comment {
7332            body.insert(
7333                "comment".to_string(),
7334                serde_json::json!({
7335                    "type": "doc",
7336                    "version": 1,
7337                    "content": [{
7338                        "type": "paragraph",
7339                        "content": [{
7340                            "type": "text",
7341                            "text": comment_text
7342                        }]
7343                    }]
7344                }),
7345            );
7346        }
7347
7348        let response = self
7349            .put_json(&url, &serde_json::Value::Object(body))
7350            .await?;
7351
7352        Self::ensure_success(response).await?;
7353
7354        Ok(())
7355    }
7356
7357    /// Deletes a worklog entry from a JIRA issue.
7358    ///
7359    /// Issues a `DELETE /rest/api/3/issue/{key}/worklog/{id}` (204 on success).
7360    pub async fn delete_worklog(&self, key: &str, worklog_id: &str) -> Result<()> {
7361        let url = format!(
7362            "{}/rest/api/3/issue/{}/worklog/{}",
7363            self.instance_url, key, worklog_id
7364        );
7365
7366        let response = self.delete(&url).await?;
7367
7368        Self::ensure_success(response).await?;
7369
7370        Ok(())
7371    }
7372
7373    /// Extracts plain text from a worklog comment ADF value.
7374    fn extract_worklog_comment(adf_value: Option<&serde_json::Value>) -> Option<String> {
7375        let adf_value = adf_value?;
7376        let adf: AdfDocument = serde_json::from_value(adf_value.clone()).ok()?;
7377        let md = adf_to_markdown(&adf).ok()?;
7378        let trimmed = md.trim();
7379        if trimmed.is_empty() {
7380            None
7381        } else {
7382            Some(trimmed.to_string())
7383        }
7384    }
7385
7386    /// Lists available transitions for a JIRA issue.
7387    pub async fn get_transitions(&self, key: &str) -> Result<Vec<JiraTransition>> {
7388        let url = format!("{}/rest/api/3/issue/{}/transitions", self.instance_url, key);
7389
7390        let response = self.get_json(&url).await?;
7391
7392        let resp: JiraTransitionsResponse = Self::parse_json(
7393            Self::ensure_success(response).await?,
7394            "Failed to parse transitions response",
7395        )
7396        .await?;
7397
7398        Ok(resp
7399            .transitions
7400            .into_iter()
7401            .map(transition_from_entry)
7402            .collect())
7403    }
7404
7405    /// Lists available transitions for a JIRA issue together with each
7406    /// transition's screen-field metadata.
7407    ///
7408    /// Requests `expand=transitions.fields` so screen fields can be resolved
7409    /// for `execute --set-field`/`--resolution` and so a mandatory-comment
7410    /// screen can be detected. Returns the same [`JiraTransition`] list as
7411    /// [`Self::get_transitions`] plus a map keyed by transition id holding the
7412    /// screen [`EditMeta`]; screenless transitions have no entry.
7413    pub async fn get_transitions_with_fields(
7414        &self,
7415        key: &str,
7416    ) -> Result<(
7417        Vec<JiraTransition>,
7418        std::collections::BTreeMap<String, EditMeta>,
7419    )> {
7420        let url = format!(
7421            "{}/rest/api/3/issue/{}/transitions?expand=transitions.fields",
7422            self.instance_url, key
7423        );
7424
7425        let response = self.get_json(&url).await?;
7426
7427        let resp: JiraTransitionsResponse = Self::parse_json(
7428            Self::ensure_success(response).await?,
7429            "Failed to parse transitions response",
7430        )
7431        .await?;
7432
7433        let mut metas: std::collections::BTreeMap<String, EditMeta> =
7434            std::collections::BTreeMap::new();
7435        let mut transitions = Vec::with_capacity(resp.transitions.len());
7436        for mut t in resp.transitions {
7437            if !t.fields.is_empty() {
7438                let fields = std::mem::take(&mut t.fields);
7439                metas.insert(t.id.clone(), edit_meta_from_raw_fields(fields));
7440            }
7441            transitions.push(transition_from_entry(t));
7442        }
7443
7444        Ok((transitions, metas))
7445    }
7446
7447    /// Executes a transition on a JIRA issue.
7448    ///
7449    /// Thin shim over [`Self::do_transition_with_fields`] that sends no screen
7450    /// fields and no transition comment.
7451    pub async fn do_transition(&self, key: &str, transition_id: &str) -> Result<()> {
7452        self.do_transition_with_fields(key, transition_id, &std::collections::BTreeMap::new(), None)
7453            .await
7454    }
7455
7456    /// Executes a transition, optionally setting transition-screen `fields` and
7457    /// adding a comment in the same request.
7458    ///
7459    /// `fields` is a map of stable JIRA field id → API-shaped value (e.g.
7460    /// `resolution` → `{"name": "Fixed"}`); it is omitted from the body when
7461    /// empty. When `comment` is `Some`, it is added via the transition
7462    /// `update.comment` operation so it lands atomically with the transition —
7463    /// the only way to satisfy a transition screen that mandates a comment.
7464    pub async fn do_transition_with_fields(
7465        &self,
7466        key: &str,
7467        transition_id: &str,
7468        fields: &std::collections::BTreeMap<String, serde_json::Value>,
7469        comment: Option<&ValidatedAdfDocument>,
7470    ) -> Result<()> {
7471        let url = format!("{}/rest/api/3/issue/{}/transitions", self.instance_url, key);
7472
7473        let mut body = serde_json::json!({
7474            "transition": { "id": transition_id }
7475        });
7476        if !fields.is_empty() {
7477            body["fields"] = serde_json::to_value(fields)
7478                .context("Failed to serialize transition fields payload")?;
7479        }
7480        if let Some(adf) = comment {
7481            body["update"] = serde_json::json!({
7482                "comment": [ { "add": { "body": adf } } ]
7483            });
7484        }
7485
7486        let response = self.post_json(&url, &body).await?;
7487
7488        Self::ensure_success(response).await?;
7489
7490        Ok(())
7491    }
7492
7493    /// Searches JIRA issues using JQL with auto-pagination.
7494    ///
7495    /// `limit` controls total results: 0 means unlimited.
7496    pub async fn search_issues(&self, jql: &str, limit: u32) -> Result<JiraSearchResult> {
7497        let url = format!("{}/rest/api/3/search/jql", self.instance_url);
7498        let effective_limit = if limit == 0 { u32::MAX } else { limit };
7499        let mut all_issues = Vec::new();
7500        let mut next_token: Option<String> = None;
7501
7502        loop {
7503            let remaining = effective_limit.saturating_sub(all_issues.len() as u32);
7504            if remaining == 0 {
7505                break;
7506            }
7507            let page_size = remaining.min(PAGE_SIZE);
7508
7509            let mut body = serde_json::json!({
7510                "jql": jql,
7511                "maxResults": page_size,
7512                "fields": ["summary", "status", "issuetype", "assignee", "priority"]
7513            });
7514            if let Some(ref token) = next_token {
7515                body["nextPageToken"] = serde_json::Value::String(token.clone());
7516            }
7517
7518            let response = self
7519                .post_json(&url, &body)
7520                .await
7521                .context("Failed to send search request to JIRA API")?;
7522
7523            let page: JiraSearchResponse = Self::parse_json(
7524                Self::ensure_success(response).await?,
7525                "Failed to parse JIRA search response",
7526            )
7527            .await?;
7528
7529            let page_count = page.issues.len();
7530            for r in page.issues {
7531                all_issues.push(JiraIssue {
7532                    key: r.key,
7533                    summary: r.fields.summary.unwrap_or_default(),
7534                    description_adf: r.fields.description,
7535                    status: r.fields.status.and_then(|s| s.name),
7536                    issue_type: r.fields.issuetype.and_then(|t| t.name),
7537                    assignee: r.fields.assignee.and_then(|a| a.display_name),
7538                    priority: r.fields.priority.and_then(|p| p.name),
7539                    labels: r.fields.labels,
7540                    custom_fields: Vec::new(),
7541                });
7542            }
7543
7544            match page.next_page_token {
7545                Some(token) if page_count > 0 => next_token = Some(token),
7546                _ => break,
7547            }
7548        }
7549
7550        let total = all_issues.len() as u32;
7551        Ok(JiraSearchResult {
7552            issues: all_issues,
7553            total,
7554        })
7555    }
7556
7557    /// Searches Confluence pages using CQL with auto-pagination.
7558    pub async fn search_confluence(
7559        &self,
7560        cql: &str,
7561        limit: u32,
7562    ) -> Result<ConfluenceSearchResults> {
7563        let effective_limit = if limit == 0 { u32::MAX } else { limit };
7564        let mut all_results = Vec::new();
7565        let mut start: u32 = 0;
7566
7567        loop {
7568            let remaining = effective_limit.saturating_sub(all_results.len() as u32);
7569            if remaining == 0 {
7570                break;
7571            }
7572            let page_size = remaining.min(PAGE_SIZE);
7573
7574            let base = format!("{}/wiki/rest/api/content/search", self.instance_url);
7575            let url = reqwest::Url::parse_with_params(
7576                &base,
7577                &[
7578                    ("cql", cql),
7579                    ("limit", &page_size.to_string()),
7580                    ("start", &start.to_string()),
7581                    ("expand", "space"),
7582                ],
7583            )
7584            .context("Failed to build Confluence search URL")?;
7585
7586            let response = self.get_json(url.as_str()).await?;
7587
7588            let resp: ConfluenceContentSearchResponse = Self::parse_json(
7589                Self::ensure_success(response).await?,
7590                "Failed to parse Confluence search response",
7591            )
7592            .await?;
7593
7594            let page_count = resp.results.len() as u32;
7595            for r in resp.results {
7596                let space_key = r
7597                    .expandable
7598                    .and_then(|e| e.space)
7599                    .and_then(|s| s.rsplit('/').next().map(String::from))
7600                    .unwrap_or_default();
7601                all_results.push(ConfluenceSearchResult {
7602                    id: r.id,
7603                    title: r.title,
7604                    space_key,
7605                });
7606            }
7607
7608            let has_next = resp.links.and_then(|l| l.next).is_some();
7609            if !has_next || page_count == 0 {
7610                break;
7611            }
7612            start += page_count;
7613        }
7614
7615        let total = all_results.len() as u32;
7616        Ok(ConfluenceSearchResults {
7617            results: all_results,
7618            total,
7619        })
7620    }
7621
7622    /// Searches JIRA users by display name or email substring.
7623    ///
7624    /// `query` is matched against `displayName` and `emailAddress` server-
7625    /// side; matching is substring and case-insensitive. `limit` of `0`
7626    /// returns every match (paginating internally), otherwise the result
7627    /// is truncated. Inactive users and app/customer account types are
7628    /// included — callers that need only assignable atlassian-account
7629    /// users should filter on `active` and `account_type`.
7630    ///
7631    /// Note: many tenants strip `emailAddress` from search results due to
7632    /// GDPR / privacy settings, even when the user has an email on file.
7633    pub async fn search_jira_users(
7634        &self,
7635        query: &str,
7636        limit: u32,
7637    ) -> Result<JiraUserSearchResults> {
7638        let effective_limit = if limit == 0 { u32::MAX } else { limit };
7639        let mut all_results: Vec<JiraUserSearchResult> = Vec::new();
7640        let mut start_at: u32 = 0;
7641
7642        loop {
7643            let remaining = effective_limit.saturating_sub(all_results.len() as u32);
7644            if remaining == 0 {
7645                break;
7646            }
7647            let page_size = remaining.min(PAGE_SIZE);
7648
7649            let base = format!("{}/rest/api/3/user/search", self.instance_url);
7650            let url = reqwest::Url::parse_with_params(
7651                &base,
7652                &[
7653                    ("query", query),
7654                    ("maxResults", &page_size.to_string()),
7655                    ("startAt", &start_at.to_string()),
7656                ],
7657            )
7658            .context("Failed to build JIRA user search URL")?;
7659
7660            let response = self.get_json(url.as_str()).await?;
7661
7662            let page: Vec<JiraUserSearchEntry> = Self::parse_json(
7663                Self::ensure_success(response).await?,
7664                "Failed to parse JIRA user search response",
7665            )
7666            .await?;
7667
7668            let page_count = page.len() as u32;
7669            for entry in page {
7670                all_results.push(JiraUserSearchResult {
7671                    account_id: entry.account_id,
7672                    display_name: entry.display_name,
7673                    email_address: entry.email_address,
7674                    active: entry.active,
7675                    account_type: entry.account_type,
7676                });
7677            }
7678
7679            // The API has no `isLast` / `next` envelope; when the page comes
7680            // back shorter than the page size, we've reached the end.
7681            if page_count < page_size {
7682                break;
7683            }
7684            start_at += page_count;
7685        }
7686
7687        let count = all_results.len() as u32;
7688        Ok(JiraUserSearchResults {
7689            users: all_results,
7690            count,
7691        })
7692    }
7693
7694    /// Searches Confluence users by display name or email.
7695    pub async fn search_confluence_users(
7696        &self,
7697        query: &str,
7698        limit: u32,
7699    ) -> Result<ConfluenceUserSearchResults> {
7700        let effective_limit = if limit == 0 { u32::MAX } else { limit };
7701        let mut all_results = Vec::new();
7702        let mut start: u32 = 0;
7703
7704        let cql = format!("user.fullname~\"{query}\"");
7705
7706        loop {
7707            let remaining = effective_limit.saturating_sub(all_results.len() as u32);
7708            if remaining == 0 {
7709                break;
7710            }
7711            let page_size = remaining.min(PAGE_SIZE);
7712
7713            let base = format!("{}/wiki/rest/api/search/user", self.instance_url);
7714            let url = reqwest::Url::parse_with_params(
7715                &base,
7716                &[
7717                    ("cql", cql.as_str()),
7718                    ("limit", &page_size.to_string()),
7719                    ("start", &start.to_string()),
7720                ],
7721            )
7722            .context("Failed to build Confluence user search URL")?;
7723
7724            let response = self.get_json(url.as_str()).await?;
7725
7726            let resp: ConfluenceUserSearchResponse = Self::parse_json(
7727                Self::ensure_success(response).await?,
7728                "Failed to parse Confluence user search response",
7729            )
7730            .await?;
7731
7732            let page_count = resp.results.len() as u32;
7733            for r in resp.results {
7734                let Some(user) = r.user else {
7735                    continue;
7736                };
7737                let display_name = user.display_name.or(user.public_name).unwrap_or_default();
7738                all_results.push(ConfluenceUserSearchResult {
7739                    account_id: user.account_id,
7740                    display_name,
7741                    email: user.email,
7742                });
7743            }
7744
7745            let has_next = resp.links.and_then(|l| l.next).is_some();
7746            if !has_next || page_count == 0 {
7747                break;
7748            }
7749            start += page_count;
7750        }
7751
7752        let total = all_results.len() as u32;
7753        Ok(ConfluenceUserSearchResults {
7754            users: all_results,
7755            total,
7756        })
7757    }
7758
7759    /// Resolves a single JIRA user by account ID
7760    /// (`GET /rest/api/3/user?accountId=`).
7761    ///
7762    /// Failure-tolerant: an unknown / anonymised account (HTTP 404) or any
7763    /// other non-auth failure resolves to a stub record with `error` set rather
7764    /// than an `Err`, so a batch lookup never aborts for one bad ID. A `401`
7765    /// (bad credentials) is a hard error worth surfacing. Deactivated accounts
7766    /// come back from Atlassian as a real `200` record with `active: false`.
7767    pub async fn get_jira_user(&self, account_id: &str) -> Result<JiraUserRecord> {
7768        let base = format!("{}/rest/api/3/user", self.instance_url);
7769        let url = reqwest::Url::parse_with_params(&base, &[("accountId", account_id)])
7770            .context("Failed to build JIRA user get URL")?;
7771
7772        let response = self.get_json(url.as_str()).await?;
7773        let status = response.status();
7774
7775        if status.is_success() {
7776            let entry: JiraUserSearchEntry = response
7777                .json()
7778                .await
7779                .context("Failed to parse JIRA user get response")?;
7780            return Ok(JiraUserRecord {
7781                account_id: entry.account_id,
7782                display_name: entry.display_name,
7783                email_address: entry.email_address,
7784                active: Some(entry.active),
7785                account_type: entry.account_type,
7786                error: None,
7787            });
7788        }
7789
7790        if status.as_u16() == 401 {
7791            let body = response.text().await.unwrap_or_default();
7792            return Err(AtlassianError::ApiRequestFailed { status: 401, body }.into());
7793        }
7794
7795        let code = status.as_u16();
7796        let body = response.text().await.unwrap_or_default();
7797        Ok(JiraUserRecord {
7798            account_id: account_id.to_string(),
7799            display_name: None,
7800            email_address: None,
7801            active: None,
7802            account_type: None,
7803            error: Some(user_lookup_error(code, &body)),
7804        })
7805    }
7806
7807    /// Resolves multiple JIRA users by account ID, concurrently.
7808    ///
7809    /// Each ID is fetched independently via [`Self::get_jira_user`]; per-ID
7810    /// failures become stub records, so the batch only errors on a genuine auth
7811    /// failure (or transport error). Results preserve request order.
7812    pub async fn get_jira_users(&self, account_ids: &[String]) -> Result<JiraUserGetResults> {
7813        let lookups = account_ids.iter().map(|id| self.get_jira_user(id));
7814        let users = futures::future::join_all(lookups)
7815            .await
7816            .into_iter()
7817            .collect::<Result<Vec<_>>>()?;
7818        Ok(JiraUserGetResults { users })
7819    }
7820
7821    /// Resolves a single Confluence user by account ID
7822    /// (`GET /wiki/rest/api/user?accountId=`).
7823    ///
7824    /// Failure-tolerant in the same way as [`Self::get_jira_user`]. The v1 user
7825    /// object has no `active` flag, so [`ConfluenceUserRecord::active`] is
7826    /// always `None`; `displayName` falls back to `publicName`.
7827    pub async fn get_confluence_user(&self, account_id: &str) -> Result<ConfluenceUserRecord> {
7828        let base = format!("{}/wiki/rest/api/user", self.instance_url);
7829        let url = reqwest::Url::parse_with_params(&base, &[("accountId", account_id)])
7830            .context("Failed to build Confluence user get URL")?;
7831
7832        let response = self.get_json(url.as_str()).await?;
7833        let status = response.status();
7834
7835        if status.is_success() {
7836            let entry: ConfluenceUserGetEntry = response
7837                .json()
7838                .await
7839                .context("Failed to parse Confluence user get response")?;
7840            return Ok(ConfluenceUserRecord {
7841                account_id: entry.account_id.unwrap_or_else(|| account_id.to_string()),
7842                display_name: entry.display_name.or(entry.public_name),
7843                email: entry.email,
7844                account_type: entry.account_type,
7845                active: None,
7846                error: None,
7847            });
7848        }
7849
7850        if status.as_u16() == 401 {
7851            let body = response.text().await.unwrap_or_default();
7852            return Err(AtlassianError::ApiRequestFailed { status: 401, body }.into());
7853        }
7854
7855        let code = status.as_u16();
7856        let body = response.text().await.unwrap_or_default();
7857        Ok(ConfluenceUserRecord {
7858            account_id: account_id.to_string(),
7859            display_name: None,
7860            email: None,
7861            account_type: None,
7862            active: None,
7863            error: Some(user_lookup_error(code, &body)),
7864        })
7865    }
7866
7867    /// Resolves multiple Confluence users by account ID, concurrently.
7868    ///
7869    /// Behaves like [`Self::get_jira_users`]: per-ID failures become stub
7870    /// records; the batch only errors on a genuine auth / transport failure.
7871    pub async fn get_confluence_users(
7872        &self,
7873        account_ids: &[String],
7874    ) -> Result<ConfluenceUserGetResults> {
7875        let lookups = account_ids.iter().map(|id| self.get_confluence_user(id));
7876        let users = futures::future::join_all(lookups)
7877            .await
7878            .into_iter()
7879            .collect::<Result<Vec<_>>>()?;
7880        Ok(ConfluenceUserGetResults { users })
7881    }
7882
7883    /// Lists agile boards with auto-pagination.
7884    pub async fn get_boards(
7885        &self,
7886        project: Option<&str>,
7887        board_type: Option<&str>,
7888        limit: u32,
7889    ) -> Result<AgileBoardList> {
7890        let effective_limit = if limit == 0 { u32::MAX } else { limit };
7891        let mut all_boards = Vec::new();
7892        let mut start_at: u32 = 0;
7893
7894        loop {
7895            let remaining = effective_limit.saturating_sub(all_boards.len() as u32);
7896            if remaining == 0 {
7897                break;
7898            }
7899            let page_size = remaining.min(PAGE_SIZE);
7900
7901            let mut url = format!(
7902                "{}/rest/agile/1.0/board?maxResults={}&startAt={}",
7903                self.instance_url, page_size, start_at
7904            );
7905            if let Some(proj) = project {
7906                url.push_str(&format!("&projectKeyOrId={proj}"));
7907            }
7908            if let Some(bt) = board_type {
7909                url.push_str(&format!("&type={bt}"));
7910            }
7911
7912            let response = self.get_json(&url).await?;
7913
7914            let resp: AgileBoardListResponse = Self::parse_json(
7915                Self::ensure_success(response).await?,
7916                "Failed to parse board list response",
7917            )
7918            .await?;
7919
7920            let page_count = resp.values.len() as u32;
7921            for b in resp.values {
7922                all_boards.push(AgileBoard {
7923                    id: b.id,
7924                    name: b.name,
7925                    board_type: b.board_type,
7926                    project_key: b.location.and_then(|l| l.project_key),
7927                });
7928            }
7929
7930            if resp.is_last || page_count == 0 {
7931                break;
7932            }
7933            start_at += page_count;
7934        }
7935
7936        let total = all_boards.len() as u32;
7937        Ok(AgileBoardList {
7938            boards: all_boards,
7939            total,
7940        })
7941    }
7942
7943    /// Lists issues on an agile board with auto-pagination.
7944    pub async fn get_board_issues(
7945        &self,
7946        board_id: u64,
7947        jql: Option<&str>,
7948        limit: u32,
7949    ) -> Result<JiraSearchResult> {
7950        let effective_limit = if limit == 0 { u32::MAX } else { limit };
7951        let mut all_issues = Vec::new();
7952        let mut start_at: u32 = 0;
7953
7954        loop {
7955            let remaining = effective_limit.saturating_sub(all_issues.len() as u32);
7956            if remaining == 0 {
7957                break;
7958            }
7959            let page_size = remaining.min(PAGE_SIZE);
7960
7961            let base = format!(
7962                "{}/rest/agile/1.0/board/{}/issue",
7963                self.instance_url, board_id
7964            );
7965            let mut params: Vec<(&str, String)> = vec![
7966                ("maxResults", page_size.to_string()),
7967                ("startAt", start_at.to_string()),
7968            ];
7969            if let Some(jql_str) = jql {
7970                params.push(("jql", jql_str.to_string()));
7971            }
7972            let url = reqwest::Url::parse_with_params(
7973                &base,
7974                params.iter().map(|(k, v)| (*k, v.as_str())),
7975            )
7976            .context("Failed to build board issues URL")?;
7977
7978            let response = self.get_json(url.as_str()).await?;
7979
7980            let resp: AgileIssueListResponse = Self::parse_json(
7981                Self::ensure_success(response).await?,
7982                "Failed to parse board issues response",
7983            )
7984            .await?;
7985
7986            let page_count = resp.issues.len() as u32;
7987            for r in resp.issues {
7988                all_issues.push(JiraIssue {
7989                    key: r.key,
7990                    summary: r.fields.summary.unwrap_or_default(),
7991                    description_adf: r.fields.description,
7992                    status: r.fields.status.and_then(|s| s.name),
7993                    issue_type: r.fields.issuetype.and_then(|t| t.name),
7994                    assignee: r.fields.assignee.and_then(|a| a.display_name),
7995                    priority: r.fields.priority.and_then(|p| p.name),
7996                    labels: r.fields.labels,
7997                    custom_fields: Vec::new(),
7998                });
7999            }
8000
8001            if resp.is_last || page_count == 0 {
8002                break;
8003            }
8004            start_at += page_count;
8005        }
8006
8007        let total = all_issues.len() as u32;
8008        Ok(JiraSearchResult {
8009            issues: all_issues,
8010            total,
8011        })
8012    }
8013
8014    /// Lists sprints for an agile board with auto-pagination.
8015    pub async fn get_sprints(
8016        &self,
8017        board_id: u64,
8018        state: Option<&str>,
8019        limit: u32,
8020    ) -> Result<AgileSprintList> {
8021        let effective_limit = if limit == 0 { u32::MAX } else { limit };
8022        let mut all_sprints = Vec::new();
8023        let mut start_at: u32 = 0;
8024
8025        loop {
8026            let remaining = effective_limit.saturating_sub(all_sprints.len() as u32);
8027            if remaining == 0 {
8028                break;
8029            }
8030            let page_size = remaining.min(PAGE_SIZE);
8031
8032            let mut url = format!(
8033                "{}/rest/agile/1.0/board/{}/sprint?maxResults={}&startAt={}",
8034                self.instance_url, board_id, page_size, start_at
8035            );
8036            if let Some(s) = state {
8037                url.push_str(&format!("&state={s}"));
8038            }
8039
8040            let response = self.get_json(&url).await?;
8041
8042            let resp: AgileSprintListResponse = Self::parse_json(
8043                Self::ensure_success(response).await?,
8044                "Failed to parse sprint list response",
8045            )
8046            .await?;
8047
8048            let page_count = resp.values.len() as u32;
8049            for s in resp.values {
8050                all_sprints.push(AgileSprint {
8051                    id: s.id,
8052                    name: s.name,
8053                    state: s.state,
8054                    start_date: s.start_date,
8055                    end_date: s.end_date,
8056                    goal: s.goal,
8057                });
8058            }
8059
8060            if resp.is_last || page_count == 0 {
8061                break;
8062            }
8063            start_at += page_count;
8064        }
8065
8066        let total = all_sprints.len() as u32;
8067        Ok(AgileSprintList {
8068            sprints: all_sprints,
8069            total,
8070        })
8071    }
8072
8073    /// Lists issues in an agile sprint with auto-pagination.
8074    pub async fn get_sprint_issues(
8075        &self,
8076        sprint_id: u64,
8077        jql: Option<&str>,
8078        limit: u32,
8079    ) -> Result<JiraSearchResult> {
8080        let effective_limit = if limit == 0 { u32::MAX } else { limit };
8081        let mut all_issues = Vec::new();
8082        let mut start_at: u32 = 0;
8083
8084        loop {
8085            let remaining = effective_limit.saturating_sub(all_issues.len() as u32);
8086            if remaining == 0 {
8087                break;
8088            }
8089            let page_size = remaining.min(PAGE_SIZE);
8090
8091            let base = format!(
8092                "{}/rest/agile/1.0/sprint/{}/issue",
8093                self.instance_url, sprint_id
8094            );
8095            let mut params: Vec<(&str, String)> = vec![
8096                ("maxResults", page_size.to_string()),
8097                ("startAt", start_at.to_string()),
8098            ];
8099            if let Some(jql_str) = jql {
8100                params.push(("jql", jql_str.to_string()));
8101            }
8102            let url = reqwest::Url::parse_with_params(
8103                &base,
8104                params.iter().map(|(k, v)| (*k, v.as_str())),
8105            )
8106            .context("Failed to build sprint issues URL")?;
8107
8108            let response = self.get_json(url.as_str()).await?;
8109
8110            let resp: AgileIssueListResponse = Self::parse_json(
8111                Self::ensure_success(response).await?,
8112                "Failed to parse sprint issues response",
8113            )
8114            .await?;
8115
8116            let page_count = resp.issues.len() as u32;
8117            for r in resp.issues {
8118                all_issues.push(JiraIssue {
8119                    key: r.key,
8120                    summary: r.fields.summary.unwrap_or_default(),
8121                    description_adf: r.fields.description,
8122                    status: r.fields.status.and_then(|s| s.name),
8123                    issue_type: r.fields.issuetype.and_then(|t| t.name),
8124                    assignee: r.fields.assignee.and_then(|a| a.display_name),
8125                    priority: r.fields.priority.and_then(|p| p.name),
8126                    labels: r.fields.labels,
8127                    custom_fields: Vec::new(),
8128                });
8129            }
8130
8131            if resp.is_last || page_count == 0 {
8132                break;
8133            }
8134            start_at += page_count;
8135        }
8136
8137        let total = all_issues.len() as u32;
8138        Ok(JiraSearchResult {
8139            issues: all_issues,
8140            total,
8141        })
8142    }
8143
8144    /// Adds issues to an agile sprint.
8145    pub async fn add_issues_to_sprint(&self, sprint_id: u64, issue_keys: &[&str]) -> Result<()> {
8146        let url = format!(
8147            "{}/rest/agile/1.0/sprint/{}/issue",
8148            self.instance_url, sprint_id
8149        );
8150
8151        let body = serde_json::json!({ "issues": issue_keys });
8152
8153        let response = self.post_json(&url, &body).await?;
8154
8155        Self::ensure_success(response).await?;
8156
8157        Ok(())
8158    }
8159
8160    /// Creates a new sprint on an agile board.
8161    pub async fn create_sprint(
8162        &self,
8163        board_id: u64,
8164        name: &str,
8165        start_date: Option<&str>,
8166        end_date: Option<&str>,
8167        goal: Option<&str>,
8168    ) -> Result<AgileSprint> {
8169        let url = format!("{}/rest/agile/1.0/sprint", self.instance_url);
8170
8171        let mut body = serde_json::json!({
8172            "originBoardId": board_id,
8173            "name": name
8174        });
8175        if let Some(sd) = start_date {
8176            body["startDate"] = serde_json::Value::String(sd.to_string());
8177        }
8178        if let Some(ed) = end_date {
8179            body["endDate"] = serde_json::Value::String(ed.to_string());
8180        }
8181        if let Some(g) = goal {
8182            body["goal"] = serde_json::Value::String(g.to_string());
8183        }
8184
8185        let response = self.post_json(&url, &body).await?;
8186
8187        let entry: AgileSprintEntry = Self::parse_json(
8188            Self::ensure_success(response).await?,
8189            "Failed to parse sprint create response",
8190        )
8191        .await?;
8192
8193        Ok(AgileSprint {
8194            id: entry.id,
8195            name: entry.name,
8196            state: entry.state,
8197            start_date: entry.start_date,
8198            end_date: entry.end_date,
8199            goal: entry.goal,
8200        })
8201    }
8202
8203    /// Updates an existing sprint.
8204    pub async fn update_sprint(
8205        &self,
8206        sprint_id: u64,
8207        name: Option<&str>,
8208        state: Option<&str>,
8209        start_date: Option<&str>,
8210        end_date: Option<&str>,
8211        goal: Option<&str>,
8212    ) -> Result<()> {
8213        let url = format!("{}/rest/agile/1.0/sprint/{}", self.instance_url, sprint_id);
8214
8215        let mut body = serde_json::Map::new();
8216        if let Some(n) = name {
8217            body.insert("name".to_string(), serde_json::Value::String(n.to_string()));
8218        }
8219        if let Some(s) = state {
8220            body.insert(
8221                "state".to_string(),
8222                serde_json::Value::String(s.to_string()),
8223            );
8224        }
8225        if let Some(sd) = start_date {
8226            body.insert(
8227                "startDate".to_string(),
8228                serde_json::Value::String(sd.to_string()),
8229            );
8230        }
8231        if let Some(ed) = end_date {
8232            body.insert(
8233                "endDate".to_string(),
8234                serde_json::Value::String(ed.to_string()),
8235            );
8236        }
8237        if let Some(g) = goal {
8238            body.insert("goal".to_string(), serde_json::Value::String(g.to_string()));
8239        }
8240
8241        let response = self
8242            .put_json(&url, &serde_json::Value::Object(body))
8243            .await?;
8244
8245        Self::ensure_success(response).await?;
8246
8247        Ok(())
8248    }
8249
8250    /// Deletes a sprint.
8251    ///
8252    /// Issues `DELETE /rest/agile/1.0/sprint/{id}`.
8253    pub async fn delete_sprint(&self, sprint_id: u64) -> Result<()> {
8254        let url = format!("{}/rest/agile/1.0/sprint/{}", self.instance_url, sprint_id);
8255        let response = self.delete(&url).await?;
8256        Self::ensure_success(response).await?;
8257        Ok(())
8258    }
8259
8260    /// Lists versions for a JIRA project.
8261    ///
8262    /// Uses the lightweight `GET /rest/api/3/project/{key}/versions` endpoint,
8263    /// which returns all versions in a single response without pagination.
8264    /// `released` and `archived` filters are applied client-side.
8265    pub async fn get_project_versions(
8266        &self,
8267        project_key: &str,
8268        released: Option<bool>,
8269        archived: Option<bool>,
8270    ) -> Result<JiraProjectVersionList> {
8271        let url = format!(
8272            "{}/rest/api/3/project/{}/versions",
8273            self.instance_url, project_key
8274        );
8275
8276        let response = self.get_json(&url).await?;
8277
8278        let entries: Vec<JiraProjectVersionEntry> = Self::parse_json(
8279            Self::ensure_success(response).await?,
8280            "Failed to parse project versions response",
8281        )
8282        .await?;
8283
8284        let versions: Vec<JiraProjectVersion> = entries
8285            .into_iter()
8286            .filter(|e| released.map_or(true, |r| e.released == r))
8287            .filter(|e| archived.map_or(true, |a| e.archived == a))
8288            .map(|e| JiraProjectVersion {
8289                id: e.id,
8290                name: e.name,
8291                description: e.description,
8292                project_key: project_key.to_string(),
8293                released: e.released,
8294                archived: e.archived,
8295                release_date: e.release_date,
8296                start_date: e.start_date,
8297            })
8298            .collect();
8299
8300        let total = versions.len() as u32;
8301        Ok(JiraProjectVersionList { versions, total })
8302    }
8303
8304    /// Creates a new version in a JIRA project.
8305    ///
8306    /// Validates `release_date` and `start_date` as `YYYY-MM-DD` client-side
8307    /// to surface clear errors before JIRA rejects the request with an
8308    /// opaque 400.
8309    #[allow(clippy::too_many_arguments)]
8310    pub async fn create_project_version(
8311        &self,
8312        project_key: &str,
8313        name: &str,
8314        description: Option<&str>,
8315        release_date: Option<&str>,
8316        start_date: Option<&str>,
8317        released: bool,
8318        archived: bool,
8319    ) -> Result<JiraProjectVersion> {
8320        validate_iso_date(release_date, "release_date")?;
8321        validate_iso_date(start_date, "start_date")?;
8322
8323        let url = format!("{}/rest/api/3/version", self.instance_url);
8324
8325        let mut body = serde_json::json!({
8326            "project": project_key,
8327            "name": name,
8328            "released": released,
8329            "archived": archived,
8330        });
8331        if let Some(d) = description {
8332            body["description"] = serde_json::Value::String(d.to_string());
8333        }
8334        if let Some(rd) = release_date {
8335            body["releaseDate"] = serde_json::Value::String(rd.to_string());
8336        }
8337        if let Some(sd) = start_date {
8338            body["startDate"] = serde_json::Value::String(sd.to_string());
8339        }
8340
8341        let response = self.post_json(&url, &body).await?;
8342
8343        let entry: JiraProjectVersionEntry = Self::parse_json(
8344            Self::ensure_success(response).await?,
8345            "Failed to parse version create response",
8346        )
8347        .await?;
8348
8349        Ok(JiraProjectVersion {
8350            id: entry.id,
8351            name: entry.name,
8352            description: entry.description,
8353            project_key: project_key.to_string(),
8354            released: entry.released,
8355            archived: entry.archived,
8356            release_date: entry.release_date,
8357            start_date: entry.start_date,
8358        })
8359    }
8360
8361    /// Updates an existing project version — the shared backend for
8362    /// release / archive / rename / edit.
8363    ///
8364    /// Issues `PUT /rest/api/3/version/{id}` with only the `Some` fields;
8365    /// omitted fields keep their current value. Any provided dates are
8366    /// validated first. Returns `()` (mirroring [`Self::update_sprint`]); the
8367    /// caller already knows the mutation it requested.
8368    #[allow(clippy::too_many_arguments)]
8369    pub async fn update_project_version(
8370        &self,
8371        version_id: &str,
8372        name: Option<&str>,
8373        description: Option<&str>,
8374        released: Option<bool>,
8375        release_date: Option<&str>,
8376        archived: Option<bool>,
8377        start_date: Option<&str>,
8378    ) -> Result<()> {
8379        validate_iso_date(release_date, "release_date")?;
8380        validate_iso_date(start_date, "start_date")?;
8381
8382        let url = format!("{}/rest/api/3/version/{}", self.instance_url, version_id);
8383
8384        let mut body = serde_json::Map::new();
8385        if let Some(n) = name {
8386            body.insert("name".to_string(), serde_json::Value::String(n.to_string()));
8387        }
8388        if let Some(d) = description {
8389            body.insert(
8390                "description".to_string(),
8391                serde_json::Value::String(d.to_string()),
8392            );
8393        }
8394        if let Some(r) = released {
8395            body.insert("released".to_string(), serde_json::Value::Bool(r));
8396        }
8397        if let Some(rd) = release_date {
8398            body.insert(
8399                "releaseDate".to_string(),
8400                serde_json::Value::String(rd.to_string()),
8401            );
8402        }
8403        if let Some(a) = archived {
8404            body.insert("archived".to_string(), serde_json::Value::Bool(a));
8405        }
8406        if let Some(sd) = start_date {
8407            body.insert(
8408                "startDate".to_string(),
8409                serde_json::Value::String(sd.to_string()),
8410            );
8411        }
8412
8413        let response = self
8414            .put_json(&url, &serde_json::Value::Object(body))
8415            .await?;
8416
8417        Self::ensure_success(response).await?;
8418
8419        Ok(())
8420    }
8421
8422    /// Deletes a project version.
8423    ///
8424    /// Issues `DELETE /rest/api/3/version/{id}`. Issues that reference the
8425    /// version can be reassigned first via `moveFixIssuesTo` /
8426    /// `moveAffectedIssuesTo` (each a target version id); omit both to delete
8427    /// and simply drop the references.
8428    pub async fn delete_project_version(
8429        &self,
8430        version_id: &str,
8431        move_fix_issues_to: Option<&str>,
8432        move_affected_issues_to: Option<&str>,
8433    ) -> Result<()> {
8434        let mut url = format!("{}/rest/api/3/version/{}", self.instance_url, version_id);
8435        let mut params = Vec::new();
8436        if let Some(v) = move_fix_issues_to {
8437            params.push(format!("moveFixIssuesTo={v}"));
8438        }
8439        if let Some(v) = move_affected_issues_to {
8440            params.push(format!("moveAffectedIssuesTo={v}"));
8441        }
8442        if !params.is_empty() {
8443            url.push('?');
8444            url.push_str(&params.join("&"));
8445        }
8446
8447        let response = self.delete(&url).await?;
8448
8449        Self::ensure_success(response).await?;
8450
8451        Ok(())
8452    }
8453
8454    /// Lists the components of a JIRA project.
8455    ///
8456    /// `GET /rest/api/3/project/{key}/components`.
8457    pub async fn get_project_components(&self, project_key: &str) -> Result<Vec<JiraComponent>> {
8458        let url = format!(
8459            "{}/rest/api/3/project/{}/components",
8460            self.instance_url, project_key
8461        );
8462        let response = self.get_json(&url).await?;
8463        let entries: Vec<JiraComponentEntry> = Self::parse_json(
8464            Self::ensure_success(response).await?,
8465            "Failed to parse project components response",
8466        )
8467        .await?;
8468        Ok(entries
8469            .into_iter()
8470            .map(|e| JiraComponent {
8471                id: e.id,
8472                name: e.name,
8473                description: e.description,
8474            })
8475            .collect())
8476    }
8477
8478    /// Creates a component on a JIRA project.
8479    ///
8480    /// `POST /rest/api/3/component`.
8481    pub async fn create_component(
8482        &self,
8483        project_key: &str,
8484        name: &str,
8485        description: Option<&str>,
8486    ) -> Result<JiraComponent> {
8487        let url = format!("{}/rest/api/3/component", self.instance_url);
8488        let mut body = serde_json::json!({ "project": project_key, "name": name });
8489        if let Some(d) = description {
8490            body["description"] = serde_json::Value::String(d.to_string());
8491        }
8492        let response = self.post_json(&url, &body).await?;
8493        let entry: JiraComponentEntry = Self::parse_json(
8494            Self::ensure_success(response).await?,
8495            "Failed to parse component create response",
8496        )
8497        .await?;
8498        Ok(JiraComponent {
8499            id: entry.id,
8500            name: entry.name,
8501            description: entry.description,
8502        })
8503    }
8504
8505    /// Updates a JIRA component's name and/or description.
8506    ///
8507    /// `PUT /rest/api/3/component/{id}` with only the `Some` fields.
8508    pub async fn update_component(
8509        &self,
8510        component_id: &str,
8511        name: Option<&str>,
8512        description: Option<&str>,
8513    ) -> Result<()> {
8514        let url = format!(
8515            "{}/rest/api/3/component/{}",
8516            self.instance_url, component_id
8517        );
8518        let mut body = serde_json::Map::new();
8519        if let Some(n) = name {
8520            body.insert("name".to_string(), serde_json::Value::String(n.to_string()));
8521        }
8522        if let Some(d) = description {
8523            body.insert(
8524                "description".to_string(),
8525                serde_json::Value::String(d.to_string()),
8526            );
8527        }
8528        let response = self
8529            .put_json(&url, &serde_json::Value::Object(body))
8530            .await?;
8531        Self::ensure_success(response).await?;
8532        Ok(())
8533    }
8534
8535    /// Deletes a JIRA component.
8536    ///
8537    /// `DELETE /rest/api/3/component/{id}`. Issues that reference the component
8538    /// can be reassigned via `moveIssuesTo` (another component id).
8539    pub async fn delete_component(
8540        &self,
8541        component_id: &str,
8542        move_issues_to: Option<&str>,
8543    ) -> Result<()> {
8544        let mut url = format!(
8545            "{}/rest/api/3/component/{}",
8546            self.instance_url, component_id
8547        );
8548        if let Some(target) = move_issues_to {
8549            url.push_str("?moveIssuesTo=");
8550            url.push_str(target);
8551        }
8552        let response = self.delete(&url).await?;
8553        Self::ensure_success(response).await?;
8554        Ok(())
8555    }
8556
8557    /// Lists links on a JIRA issue.
8558    pub async fn get_issue_links(&self, key: &str) -> Result<Vec<JiraIssueLink>> {
8559        let url = format!(
8560            "{}/rest/api/3/issue/{}?fields=issuelinks",
8561            self.instance_url, key
8562        );
8563
8564        let response = self.get_json(&url).await?;
8565
8566        let resp: JiraIssueLinksResponse = Self::parse_json(
8567            Self::ensure_success(response).await?,
8568            "Failed to parse issue links response",
8569        )
8570        .await?;
8571
8572        let mut links = Vec::new();
8573        for entry in resp.fields.issuelinks {
8574            if let Some(inward) = entry.inward_issue {
8575                links.push(JiraIssueLink {
8576                    id: entry.id.clone(),
8577                    link_type: entry.link_type.name.clone(),
8578                    direction: "inward".to_string(),
8579                    linked_issue_key: inward.key,
8580                    linked_issue_summary: inward.fields.and_then(|f| f.summary).unwrap_or_default(),
8581                });
8582            }
8583            if let Some(outward) = entry.outward_issue {
8584                links.push(JiraIssueLink {
8585                    id: entry.id,
8586                    link_type: entry.link_type.name,
8587                    direction: "outward".to_string(),
8588                    linked_issue_key: outward.key,
8589                    linked_issue_summary: outward
8590                        .fields
8591                        .and_then(|f| f.summary)
8592                        .unwrap_or_default(),
8593                });
8594            }
8595        }
8596
8597        Ok(links)
8598    }
8599
8600    /// Lists remote (external URL) issue links on a JIRA issue.
8601    ///
8602    /// Endpoint: `GET /rest/api/3/issue/{key}/remotelink` — returns a bare
8603    /// JSON array (not a wrapped `{ links: [...] }` envelope).
8604    pub async fn get_remote_issue_links(&self, key: &str) -> Result<Vec<JiraRemoteIssueLink>> {
8605        let url = format!("{}/rest/api/3/issue/{}/remotelink", self.instance_url, key);
8606
8607        let response = self.get_json(&url).await?;
8608
8609        let entries: Vec<JiraRemoteIssueLinkEntry> = Self::parse_json(
8610            Self::ensure_success(response).await?,
8611            "Failed to parse remote issue links response",
8612        )
8613        .await?;
8614
8615        let mut links = Vec::with_capacity(entries.len());
8616        for entry in entries {
8617            // JIRA returns the remote link id as a number; normalize to String
8618            // so callers don't have to care about the wire shape.
8619            let id = match entry.id {
8620                serde_json::Value::String(s) => s,
8621                serde_json::Value::Number(n) => n.to_string(),
8622                other => {
8623                    return Err(anyhow::anyhow!(
8624                        "unexpected remote link id type in response: {other:?}"
8625                    ));
8626                }
8627            };
8628            links.push(JiraRemoteIssueLink {
8629                id,
8630                global_id: entry.global_id,
8631                relationship: entry.relationship,
8632                object: JiraRemoteIssueLinkObject {
8633                    url: entry.object.url,
8634                    title: entry.object.title,
8635                    summary: entry.object.summary,
8636                    icon: entry.object.icon.map(|i| JiraRemoteIssueLinkIcon {
8637                        url: i.url,
8638                        title: i.title,
8639                    }),
8640                },
8641            });
8642        }
8643        Ok(links)
8644    }
8645
8646    /// Creates (or, when `global_id` matches an existing link, updates) a
8647    /// remote (external-URL) link on a JIRA issue.
8648    ///
8649    /// Issues `POST /rest/api/3/issue/{key}/remotelink`. Returns the new/updated
8650    /// link's id (normalized to `String`, since JIRA returns it as a number).
8651    pub async fn create_remote_issue_link(
8652        &self,
8653        key: &str,
8654        url: &str,
8655        title: &str,
8656        summary: Option<&str>,
8657        relationship: Option<&str>,
8658        global_id: Option<&str>,
8659    ) -> Result<String> {
8660        let endpoint = format!("{}/rest/api/3/issue/{}/remotelink", self.instance_url, key);
8661
8662        let mut object = serde_json::Map::new();
8663        object.insert(
8664            "url".to_string(),
8665            serde_json::Value::String(url.to_string()),
8666        );
8667        object.insert(
8668            "title".to_string(),
8669            serde_json::Value::String(title.to_string()),
8670        );
8671        if let Some(s) = summary {
8672            object.insert(
8673                "summary".to_string(),
8674                serde_json::Value::String(s.to_string()),
8675            );
8676        }
8677
8678        let mut body = serde_json::Map::new();
8679        body.insert("object".to_string(), serde_json::Value::Object(object));
8680        if let Some(r) = relationship {
8681            body.insert(
8682                "relationship".to_string(),
8683                serde_json::Value::String(r.to_string()),
8684            );
8685        }
8686        if let Some(g) = global_id {
8687            body.insert(
8688                "globalId".to_string(),
8689                serde_json::Value::String(g.to_string()),
8690            );
8691        }
8692
8693        let response = self
8694            .post_json(&endpoint, &serde_json::Value::Object(body))
8695            .await?;
8696
8697        let created: JiraRemoteLinkCreateResponse = Self::parse_json(
8698            Self::ensure_success(response).await?,
8699            "Failed to parse remote link create response",
8700        )
8701        .await?;
8702
8703        Ok(match created.id {
8704            serde_json::Value::String(s) => s,
8705            serde_json::Value::Number(n) => n.to_string(),
8706            other => {
8707                return Err(anyhow::anyhow!(
8708                    "unexpected remote link id type in create response: {other:?}"
8709                ));
8710            }
8711        })
8712    }
8713
8714    /// Deletes a remote (external-URL) link from a JIRA issue by its link id.
8715    ///
8716    /// Issues `DELETE /rest/api/3/issue/{key}/remotelink/{linkId}`.
8717    pub async fn delete_remote_issue_link(&self, key: &str, link_id: &str) -> Result<()> {
8718        let url = format!(
8719            "{}/rest/api/3/issue/{}/remotelink/{}",
8720            self.instance_url, key, link_id
8721        );
8722        let response = self.delete(&url).await?;
8723        Self::ensure_success(response).await?;
8724        Ok(())
8725    }
8726
8727    /// Lists available issue link types.
8728    pub async fn get_link_types(&self) -> Result<Vec<JiraLinkType>> {
8729        let url = format!("{}/rest/api/3/issueLinkType", self.instance_url);
8730        let response = self.get_json(&url).await?;
8731        let resp: JiraLinkTypesResponse = Self::parse_json(
8732            Self::ensure_success(response).await?,
8733            "Failed to parse link types response",
8734        )
8735        .await?;
8736        Ok(resp
8737            .issue_link_types
8738            .into_iter()
8739            .map(|t| JiraLinkType {
8740                id: t.id,
8741                name: t.name,
8742                inward: t.inward,
8743                outward: t.outward,
8744            })
8745            .collect())
8746    }
8747
8748    /// Creates a link between two JIRA issues.
8749    pub async fn create_issue_link(
8750        &self,
8751        type_name: &str,
8752        inward_key: &str,
8753        outward_key: &str,
8754    ) -> Result<()> {
8755        let url = format!("{}/rest/api/3/issueLink", self.instance_url);
8756        let body = serde_json::json!({"type": {"name": type_name}, "inwardIssue": {"key": inward_key}, "outwardIssue": {"key": outward_key}});
8757        let response = self.post_json(&url, &body).await?;
8758        Self::ensure_success(response).await?;
8759        Ok(())
8760    }
8761
8762    /// Removes an issue link by ID.
8763    pub async fn remove_issue_link(&self, link_id: &str) -> Result<()> {
8764        let url = format!("{}/rest/api/3/issueLink/{}", self.instance_url, link_id);
8765        let response = self.delete(&url).await?;
8766        Self::ensure_success(response).await?;
8767        Ok(())
8768    }
8769
8770    /// Sets the parent of a JIRA issue (e.g., links a Story to its Epic, a
8771    /// Sub-task to its Story, or any issue to a parent of a hierarchy-allowed
8772    /// type).
8773    pub async fn set_issue_parent(&self, issue_key: &str, parent_key: &str) -> Result<()> {
8774        let url = format!("{}/rest/api/3/issue/{}", self.instance_url, issue_key);
8775        let body = serde_json::json!({"fields": {"parent": {"key": parent_key}}});
8776        let response = self.put_json(&url, &body).await?;
8777        Self::ensure_success(response).await?;
8778        Ok(())
8779    }
8780
8781    /// Incrementally adds and/or removes labels on a JIRA issue.
8782    ///
8783    /// Issues `PUT /rest/api/3/issue/{key}` with the `update` verb
8784    /// (`{"update": {"labels": [{"add": …}, {"remove": …}]}}`), so labels not
8785    /// mentioned are preserved — unlike a full-array `fields.labels` replace
8786    /// (which `jira write --set labels=…` performs).
8787    pub async fn modify_issue_labels(
8788        &self,
8789        key: &str,
8790        add: &[String],
8791        remove: &[String],
8792    ) -> Result<()> {
8793        let mut ops: Vec<serde_json::Value> = Vec::with_capacity(add.len() + remove.len());
8794        for label in add {
8795            ops.push(serde_json::json!({ "add": label }));
8796        }
8797        for label in remove {
8798            ops.push(serde_json::json!({ "remove": label }));
8799        }
8800        let body = serde_json::json!({ "update": { "labels": ops } });
8801
8802        let url = format!("{}/rest/api/3/issue/{}", self.instance_url, key);
8803        let response = self.put_json(&url, &body).await?;
8804        Self::ensure_success(response).await?;
8805        Ok(())
8806    }
8807
8808    /// Resolves a JIRA issue key to its numeric ID.
8809    pub async fn get_issue_id(&self, key: &str) -> Result<String> {
8810        let url = format!("{}/rest/api/3/issue/{}?fields=", self.instance_url, key);
8811        let response = self.get_json(&url).await?;
8812        let resp: JiraIssueIdResponse = Self::parse_json(
8813            Self::ensure_success(response).await?,
8814            "Failed to parse issue ID response",
8815        )
8816        .await?;
8817        Ok(resp.id)
8818    }
8819
8820    /// Fetches a development status summary (counts per category) for a JIRA issue.
8821    ///
8822    /// Uses the DevStatus summary endpoint. Returns counts and providers (each
8823    /// carrying both the `applicationType` instance-type key and its display
8824    /// name) for each category (pull requests, branches, repositories).
8825    pub async fn get_dev_status_summary(&self, key: &str) -> Result<JiraDevStatusSummary> {
8826        let issue_id = self.get_issue_id(key).await?;
8827        let url = format!(
8828            "{}/rest/dev-status/1.0/issue/summary?issueId={}",
8829            self.instance_url, issue_id
8830        );
8831        let response = self.get_json(&url).await?;
8832        let resp: DevStatusSummaryResponse = Self::parse_json(
8833            Self::ensure_success(response).await?,
8834            "Failed to parse DevStatus summary response",
8835        )
8836        .await?;
8837
8838        fn extract_count(cat: Option<DevStatusSummaryCategory>) -> JiraDevStatusCount {
8839            match cat {
8840                Some(c) => JiraDevStatusCount {
8841                    count: c.overall.map_or(0, |o| o.count),
8842                    // The `byInstanceType` map is keyed by the instance-type
8843                    // identifier (e.g. "github", "stash", "bitbucket") — this
8844                    // key, not the human-readable `name` ("Bitbucket Server"),
8845                    // is what the detail endpoint expects as `applicationType`.
8846                    // Keep the key as `instance_type` for provider auto-discovery
8847                    // in `get_dev_status`, and the value's `name` for display,
8848                    // falling back to the key when the API omits a name.
8849                    providers: c
8850                        .by_instance_type
8851                        .into_iter()
8852                        .filter(|(k, _)| !k.is_empty())
8853                        .map(|(k, v)| JiraDevProvider {
8854                            name: v
8855                                .get("name")
8856                                .and_then(|n| n.as_str())
8857                                .filter(|s| !s.is_empty())
8858                                .unwrap_or(&k)
8859                                .to_string(),
8860                            instance_type: k,
8861                        })
8862                        .collect(),
8863                },
8864                None => JiraDevStatusCount {
8865                    count: 0,
8866                    providers: Vec::new(),
8867                },
8868            }
8869        }
8870
8871        Ok(JiraDevStatusSummary {
8872            pullrequest: extract_count(resp.summary.pullrequest),
8873            branch: extract_count(resp.summary.branch),
8874            repository: extract_count(resp.summary.repository),
8875        })
8876    }
8877
8878    /// Fetches development status (PRs, branches, repositories) for a JIRA issue.
8879    ///
8880    /// Uses the DevStatus API which requires the numeric issue ID. The key is
8881    /// resolved automatically via [`get_issue_id`](Self::get_issue_id).
8882    ///
8883    /// If `application_type` is `None`, discovers available providers via the
8884    /// summary endpoint and queries each one. If `Some`, queries only that
8885    /// provider (e.g., "GitHub", "bitbucket", "stash").
8886    pub async fn get_dev_status(
8887        &self,
8888        key: &str,
8889        data_type: Option<&str>,
8890        application_type: Option<&str>,
8891    ) -> Result<JiraDevStatus> {
8892        let issue_id = self.get_issue_id(key).await?;
8893
8894        let app_types: Vec<String> = if let Some(app) = application_type {
8895            vec![app.to_string()]
8896        } else {
8897            // Discover available providers via the summary endpoint. The
8898            // `instance_type` key — not the display name — is what the detail
8899            // endpoint expects as `applicationType`.
8900            let summary = self.get_dev_status_summary(key).await?;
8901            let mut providers: Vec<String> = Vec::new();
8902            for p in summary
8903                .pullrequest
8904                .providers
8905                .into_iter()
8906                .chain(summary.branch.providers)
8907                .chain(summary.repository.providers)
8908            {
8909                if !providers.contains(&p.instance_type) {
8910                    providers.push(p.instance_type);
8911                }
8912            }
8913            if providers.is_empty() {
8914                providers.push("GitHub".to_string());
8915            }
8916            providers
8917        };
8918
8919        let data_types: Vec<&str> = match data_type {
8920            Some(dt) => vec![dt],
8921            None => vec!["pullrequest", "branch", "repository"],
8922        };
8923
8924        let mut status = JiraDevStatus {
8925            pull_requests: Vec::new(),
8926            branches: Vec::new(),
8927            repositories: Vec::new(),
8928        };
8929
8930        for app in &app_types {
8931            for dt in &data_types {
8932                let url = format!(
8933                    "{}/rest/dev-status/1.0/issue/detail?issueId={}&applicationType={}&dataType={}",
8934                    self.instance_url, issue_id, app, dt
8935                );
8936                let response = self.get_json(&url).await?;
8937                let resp: DevStatusResponse = Self::parse_json(
8938                    Self::ensure_success(response).await?,
8939                    "Failed to parse DevStatus response",
8940                )
8941                .await?;
8942
8943                for detail in resp.detail {
8944                    for pr in detail.pull_requests {
8945                        status.pull_requests.push(JiraDevPullRequest {
8946                            id: pr.id,
8947                            name: pr.name,
8948                            status: pr.status,
8949                            url: pr.url,
8950                            repository_name: pr.repository_name,
8951                            source_branch: pr.source.map(|s| s.branch).unwrap_or_default(),
8952                            destination_branch: pr
8953                                .destination
8954                                .map(|d| d.branch)
8955                                .unwrap_or_default(),
8956                            author: pr.author.map(|a| a.name),
8957                            reviewers: pr.reviewers.into_iter().map(|r| r.name).collect(),
8958                            comment_count: pr.comment_count,
8959                            last_update: pr.last_update,
8960                        });
8961                    }
8962                    for branch in detail.branches {
8963                        status.branches.push(JiraDevBranch {
8964                            name: branch.name,
8965                            url: branch.url,
8966                            repository_name: branch.repository_name,
8967                            create_pr_url: branch.create_pr_url,
8968                            last_commit: branch.last_commit.map(Self::convert_commit),
8969                        });
8970                    }
8971                    for repo in detail.repositories {
8972                        status.repositories.push(JiraDevRepository {
8973                            name: repo.name,
8974                            url: repo.url,
8975                            commits: repo.commits.into_iter().map(Self::convert_commit).collect(),
8976                        });
8977                    }
8978                }
8979            }
8980        }
8981
8982        Ok(status)
8983    }
8984
8985    /// Converts an internal `DevStatusCommit` to a public `JiraDevCommit`.
8986    fn convert_commit(c: DevStatusCommit) -> JiraDevCommit {
8987        JiraDevCommit {
8988            id: c.id,
8989            display_id: c.display_id,
8990            message: c.message,
8991            author: c.author.map(|a| a.name),
8992            timestamp: c.author_timestamp,
8993            url: c.url,
8994            file_count: c.file_count,
8995            merge: c.merge,
8996        }
8997    }
8998
8999    /// Gets attachment metadata for a JIRA issue.
9000    pub async fn get_attachments(&self, key: &str) -> Result<Vec<JiraAttachment>> {
9001        let url = format!(
9002            "{}/rest/api/3/issue/{}?fields=attachment",
9003            self.instance_url, key
9004        );
9005
9006        let response = self.get_json(&url).await?;
9007
9008        let resp: JiraAttachmentIssueResponse = Self::parse_json(
9009            Self::ensure_success(response).await?,
9010            "Failed to parse attachment response",
9011        )
9012        .await?;
9013
9014        Ok(resp
9015            .fields
9016            .attachment
9017            .into_iter()
9018            .map(JiraAttachment::from)
9019            .collect())
9020    }
9021
9022    /// Uploads one or more files as attachments to a JIRA issue.
9023    ///
9024    /// Streams each file body — files are never fully buffered in memory. All
9025    /// files ride a single multipart POST (JIRA accepts repeated `file` parts),
9026    /// and the endpoint returns metadata for every created attachment.
9027    ///
9028    /// Sends `X-Atlassian-Token: no-check` (Atlassian's XSRF opt-out required
9029    /// on this endpoint). Does not retry on 429: see
9030    /// [`AtlassianClient::post_multipart`].
9031    pub async fn upload_attachments(
9032        &self,
9033        key: &str,
9034        files: &[PathBuf],
9035    ) -> Result<Vec<JiraAttachment>> {
9036        let mut form = reqwest::multipart::Form::new();
9037        for file in files {
9038            let metadata = tokio::fs::metadata(file)
9039                .await
9040                .with_context(|| format!("Failed to read file metadata for {}", file.display()))?;
9041            let size = metadata.len();
9042            let handle = tokio::fs::File::open(file)
9043                .await
9044                .with_context(|| format!("Failed to open {}", file.display()))?;
9045
9046            let filename = file
9047                .file_name()
9048                .map(|s| s.to_string_lossy().into_owned())
9049                .ok_or_else(|| {
9050                    anyhow::anyhow!("File path has no filename component: {}", file.display())
9051                })?;
9052
9053            let mime = mime_guess::from_path(file).first_or_octet_stream();
9054            let body = reqwest::Body::wrap_stream(ReaderStream::new(handle));
9055            let part = reqwest::multipart::Part::stream_with_length(body, size)
9056                .file_name(filename)
9057                .mime_str(mime.essence_str())
9058                .with_context(|| format!("Invalid MIME type for {}", file.display()))?;
9059            form = form.part("file", part);
9060        }
9061
9062        let url = format!("{}/rest/api/3/issue/{}/attachments", self.instance_url, key);
9063
9064        let response = self
9065            .post_multipart(&url, form, &[("X-Atlassian-Token", "no-check")])
9066            .await?;
9067
9068        let entries: Vec<JiraAttachmentEntry> = Self::parse_json(
9069            Self::ensure_success(response).await?,
9070            "Failed to parse attachment upload response",
9071        )
9072        .await?;
9073
9074        Ok(entries.into_iter().map(JiraAttachment::from).collect())
9075    }
9076
9077    /// Deletes a JIRA attachment by ID.
9078    ///
9079    /// `DELETE /rest/api/3/attachment/{id}` — permanent (JIRA has no trash).
9080    pub async fn delete_attachment(&self, attachment_id: &str) -> Result<()> {
9081        let url = format!(
9082            "{}/rest/api/3/attachment/{}",
9083            self.instance_url, attachment_id
9084        );
9085        let response = self.delete(&url).await?;
9086        Self::ensure_success(response).await?;
9087        Ok(())
9088    }
9089
9090    /// Gets the changelog for a JIRA issue with auto-pagination.
9091    pub async fn get_changelog(&self, key: &str, limit: u32) -> Result<Vec<JiraChangelogEntry>> {
9092        let effective_limit = if limit == 0 { u32::MAX } else { limit };
9093        let mut all_entries = Vec::new();
9094        let mut start_at: u32 = 0;
9095
9096        loop {
9097            let remaining = effective_limit.saturating_sub(all_entries.len() as u32);
9098            if remaining == 0 {
9099                break;
9100            }
9101            let page_size = remaining.min(PAGE_SIZE);
9102
9103            let url = format!(
9104                "{}/rest/api/3/issue/{}/changelog?maxResults={}&startAt={}",
9105                self.instance_url, key, page_size, start_at
9106            );
9107
9108            let response = self.get_json(&url).await?;
9109
9110            let resp: JiraChangelogResponse = Self::parse_json(
9111                Self::ensure_success(response).await?,
9112                "Failed to parse changelog response",
9113            )
9114            .await?;
9115
9116            let page_count = resp.values.len() as u32;
9117            for e in resp.values {
9118                all_entries.push(JiraChangelogEntry {
9119                    id: e.id,
9120                    author: e.author.and_then(|a| a.display_name).unwrap_or_default(),
9121                    created: e.created.unwrap_or_default(),
9122                    items: e
9123                        .items
9124                        .into_iter()
9125                        .map(|i| JiraChangelogItem {
9126                            field: i.field,
9127                            from_string: i.from_string,
9128                            to_string: i.to_string,
9129                        })
9130                        .collect(),
9131                });
9132            }
9133
9134            if resp.is_last || page_count == 0 {
9135                break;
9136            }
9137            start_at += page_count;
9138        }
9139
9140        Ok(all_entries)
9141    }
9142
9143    /// Lists all JIRA field definitions.
9144    pub async fn get_fields(&self) -> Result<Vec<JiraField>> {
9145        let url = format!("{}/rest/api/3/field", self.instance_url);
9146
9147        let response = self.get_json(&url).await?;
9148
9149        let entries: Vec<JiraFieldEntry> = Self::parse_json(
9150            Self::ensure_success(response).await?,
9151            "Failed to parse field list response",
9152        )
9153        .await?;
9154
9155        Ok(entries
9156            .into_iter()
9157            .map(|f| {
9158                let (raw_type, raw_custom) = match f.schema {
9159                    Some(s) => (s.schema_type, s.custom),
9160                    None => (None, None),
9161                };
9162                JiraField {
9163                    id: f.id,
9164                    name: f.name,
9165                    custom: f.custom,
9166                    schema_type: map_schema_type(raw_type, raw_custom.as_deref()),
9167                    schema_custom: raw_custom,
9168                }
9169            })
9170            .collect())
9171    }
9172
9173    /// Lists options for a JIRA custom field.
9174    /// Lists contexts for a JIRA custom field.
9175    pub async fn get_field_contexts(&self, field_id: &str) -> Result<Vec<String>> {
9176        let url = format!(
9177            "{}/rest/api/3/field/{}/context",
9178            self.instance_url, field_id
9179        );
9180
9181        let response = self.get_json(&url).await?;
9182
9183        let resp: JiraFieldContextsResponse = Self::parse_json(
9184            Self::ensure_success(response).await?,
9185            "Failed to parse field contexts response",
9186        )
9187        .await?;
9188
9189        Ok(resp.values.into_iter().map(|c| c.id).collect())
9190    }
9191
9192    /// Lists options for a JIRA custom field.
9193    ///
9194    /// When `context_id` is `None`, auto-discovers the first context for the field.
9195    pub async fn get_field_options(
9196        &self,
9197        field_id: &str,
9198        context_id: Option<&str>,
9199    ) -> Result<Vec<JiraFieldOption>> {
9200        let ctx = if let Some(id) = context_id {
9201            id.to_string()
9202        } else {
9203            let contexts = self.get_field_contexts(field_id).await?;
9204            contexts.into_iter().next().ok_or_else(|| {
9205                anyhow::anyhow!(
9206                    "No contexts found for field \"{field_id}\". \
9207                     Use --context-id to specify one explicitly."
9208                )
9209            })?
9210        };
9211
9212        let url = format!(
9213            "{}/rest/api/3/field/{}/context/{}/option",
9214            self.instance_url, field_id, ctx
9215        );
9216
9217        let response = self.get_json(&url).await?;
9218
9219        let resp: JiraFieldOptionsResponse = Self::parse_json(
9220            Self::ensure_success(response).await?,
9221            "Failed to parse field options response",
9222        )
9223        .await?;
9224
9225        Ok(resp
9226            .values
9227            .into_iter()
9228            .map(|o| JiraFieldOption {
9229                id: o.id,
9230                value: o.value,
9231            })
9232            .collect())
9233    }
9234
9235    /// Lists JIRA projects.
9236    pub async fn get_projects(&self, limit: u32) -> Result<JiraProjectList> {
9237        let effective_limit = if limit == 0 { u32::MAX } else { limit };
9238        let mut all_projects = Vec::new();
9239        let mut start_at: u32 = 0;
9240
9241        loop {
9242            let remaining = effective_limit.saturating_sub(all_projects.len() as u32);
9243            if remaining == 0 {
9244                break;
9245            }
9246            let page_size = remaining.min(PAGE_SIZE);
9247
9248            let url = format!(
9249                "{}/rest/api/3/project/search?maxResults={}&startAt={}",
9250                self.instance_url, page_size, start_at
9251            );
9252
9253            let response = self.get_json(&url).await?;
9254
9255            let resp: JiraProjectSearchResponse = Self::parse_json(
9256                Self::ensure_success(response).await?,
9257                "Failed to parse project search response",
9258            )
9259            .await?;
9260
9261            let page_count = resp.values.len() as u32;
9262            for p in resp.values {
9263                all_projects.push(JiraProject {
9264                    id: p.id,
9265                    key: p.key,
9266                    name: p.name,
9267                    project_type: p.project_type_key,
9268                    lead: p.lead.and_then(|l| l.display_name),
9269                });
9270            }
9271
9272            if resp.is_last || page_count == 0 {
9273                break;
9274            }
9275            start_at += page_count;
9276        }
9277
9278        let total = all_projects.len() as u32;
9279        Ok(JiraProjectList {
9280            projects: all_projects,
9281            total,
9282        })
9283    }
9284
9285    /// Deletes a JIRA issue.
9286    pub async fn delete_issue(&self, key: &str) -> Result<()> {
9287        let url = format!("{}/rest/api/3/issue/{}", self.instance_url, key);
9288
9289        let response = self.delete(&url).await?;
9290
9291        Self::ensure_success(response).await?;
9292
9293        Ok(())
9294    }
9295
9296    /// Lists watchers on a JIRA issue.
9297    pub async fn get_watchers(&self, key: &str) -> Result<JiraWatcherList> {
9298        let url = format!("{}/rest/api/3/issue/{}/watchers", self.instance_url, key);
9299
9300        let response = self.get_json(&url).await?;
9301
9302        let json: serde_json::Value = Self::parse_json(
9303            Self::ensure_success(response).await?,
9304            "Failed to parse watchers response",
9305        )
9306        .await?;
9307
9308        let watch_count = json["watchCount"].as_u64().unwrap_or(0) as u32;
9309
9310        let watchers = json["watchers"]
9311            .as_array()
9312            .map(|arr| {
9313                arr.iter()
9314                    .filter_map(|v| serde_json::from_value::<JiraUser>(v.clone()).ok())
9315                    .collect()
9316            })
9317            .unwrap_or_default();
9318
9319        Ok(JiraWatcherList {
9320            watchers,
9321            watch_count,
9322        })
9323    }
9324
9325    /// Adds a user as a watcher on a JIRA issue.
9326    pub async fn add_watcher(&self, key: &str, account_id: &str) -> Result<()> {
9327        let url = format!("{}/rest/api/3/issue/{}/watchers", self.instance_url, key);
9328
9329        let body = serde_json::json!(account_id);
9330
9331        let response = self.post_json(&url, &body).await?;
9332
9333        Self::ensure_success(response).await?;
9334
9335        Ok(())
9336    }
9337
9338    /// Removes a user from watchers on a JIRA issue.
9339    pub async fn remove_watcher(&self, key: &str, account_id: &str) -> Result<()> {
9340        let url = format!(
9341            "{}/rest/api/3/issue/{}/watchers?accountId={}",
9342            self.instance_url, key, account_id
9343        );
9344
9345        let response = self.delete(&url).await?;
9346
9347        Self::ensure_success(response).await?;
9348
9349        Ok(())
9350    }
9351
9352    /// Verifies authentication by fetching the current user.
9353    pub async fn get_myself(&self) -> Result<JiraUser> {
9354        let url = format!("{}/rest/api/3/myself", self.instance_url);
9355
9356        let response = self
9357            .client
9358            .get(&url)
9359            .header("Authorization", &self.auth_header)
9360            .header("Accept", "application/json")
9361            .send()
9362            .await
9363            .context("Failed to send request to JIRA API")?;
9364
9365        let response = Self::ensure_success(response).await?;
9366
9367        response
9368            .json()
9369            .await
9370            .context("Failed to parse user response")
9371    }
9372}