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