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