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