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