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