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