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