Skip to main content

omni_dev/atlassian/
client.rs

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