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