Skip to main content

omni_dev/atlassian/
jira_types.rs

1//! JIRA Cloud REST API wire types (DTOs).
2//!
3//! Response and request data-transfer objects for the JIRA endpoints served by
4//! [`crate::atlassian::client::AtlassianClient`]. Split out of `client.rs` (see
5//! issue #1156) so that module holds only transport logic.
6
7use std::collections::HashMap;
8
9use serde::{Deserialize, Serialize};
10
11/// JIRA issue data returned by `GET /rest/api/3/issue/{key}`.
12///
13/// The [`custom_fields`](Self::custom_fields) vector is selection-gated:
14/// it is empty under the default [`FieldSelection::Standard`] and only
15/// populated when the request used [`FieldSelection::Named`] or
16/// [`FieldSelection::All`].
17#[derive(Debug, Clone, Serialize)]
18pub struct JiraIssue {
19    /// Issue key (e.g., "PROJ-123").
20    pub key: String,
21
22    /// Issue summary (title).
23    pub summary: String,
24
25    /// Issue description as raw ADF JSON (may be null).
26    pub description_adf: Option<serde_json::Value>,
27
28    /// Issue status name.
29    pub status: Option<String>,
30
31    /// Issue type name.
32    pub issue_type: Option<String>,
33
34    /// Assignee display name.
35    pub assignee: Option<String>,
36
37    /// Priority name.
38    pub priority: Option<String>,
39
40    /// Labels.
41    pub labels: Vec<String>,
42
43    /// Custom fields populated on the issue. Non-empty only when the fetch
44    /// was made with [`FieldSelection::Named`] or [`FieldSelection::All`].
45    #[serde(default, skip_serializing_if = "Vec::is_empty")]
46    pub custom_fields: Vec<JiraCustomField>,
47}
48
49/// Selector for which fields to request when fetching a JIRA issue.
50///
51/// Controls both the `fields` query parameter sent to
52/// `GET /rest/api/3/issue/{key}` and which fields end up populated on the
53/// returned [`JiraIssue`]. In particular, [`JiraIssue::custom_fields`] is only
54/// populated for [`Self::Named`] and [`Self::All`].
55#[derive(Debug, Clone, Default)]
56pub enum FieldSelection {
57    /// Only the standard fields omni-dev tracks (summary, description,
58    /// status, issuetype, assignee, priority, labels).
59    #[default]
60    Standard,
61
62    /// Standard fields plus the named custom fields. Each entry may be a
63    /// field ID (e.g., `customfield_19300`) or a human name (e.g.,
64    /// `Acceptance Criteria`); the REST API accepts either.
65    Named(Vec<String>),
66
67    /// Every field populated on the issue, including all custom fields.
68    All,
69}
70
71/// A JIRA custom field value keyed by both its stable ID and human name.
72///
73/// Embedded in [`JiraIssue::custom_fields`]; populated only when the issue was
74/// fetched with [`FieldSelection::Named`] or [`FieldSelection::All`].
75#[derive(Debug, Clone, Serialize)]
76pub struct JiraCustomField {
77    /// Field ID (e.g., "customfield_19300"). Stable across renames.
78    pub id: String,
79
80    /// Human-readable field name (e.g., "Acceptance Criteria"). Falls back
81    /// to `id` when the API did not return `expand=names`.
82    pub name: String,
83
84    /// Raw field value as returned by the API (ADF JSON, option object,
85    /// scalar, etc.).
86    pub value: serde_json::Value,
87}
88
89/// Metadata returned by `GET /rest/api/3/issue/{key}/editmeta`.
90///
91/// Scoped to fields on the issue's screen, so names are unambiguous for a
92/// given issue even when multiple custom fields share a display name
93/// globally.
94#[derive(Debug, Clone, Default)]
95pub struct EditMeta {
96    /// Field metadata keyed by field ID (e.g., `customfield_19300`).
97    pub fields: std::collections::BTreeMap<String, EditMetaField>,
98}
99
100/// A single field descriptor from the editmeta response.
101#[derive(Debug, Clone)]
102pub struct EditMetaField {
103    /// Human-readable field name.
104    pub name: String,
105
106    /// Schema describing the field's wire type.
107    pub schema: EditMetaSchema,
108
109    /// Permitted option `value`s for option-like fields (`select`,
110    /// `radiobuttons`, multi-select), taken from the meta `allowedValues`.
111    /// Empty when the field does not enumerate values (free text, numbers,
112    /// user pickers, cascading selects) — in which case `--set-field` values
113    /// pass through for the API to validate. Used to reject an out-of-range
114    /// option before the request; see
115    /// [`crate::atlassian::custom_fields`].
116    pub allowed_values: Vec<String>,
117}
118
119/// Schema type information for an editable field.
120#[derive(Debug, Clone, Default)]
121pub struct EditMetaSchema {
122    /// Base type: `string`, `number`, `option`, `array`, `user`, `date`, etc.
123    pub kind: String,
124
125    /// For custom fields: the plugin type URI, e.g.
126    /// `com.atlassian.jira.plugin.system.customfieldtypes:textarea`.
127    pub custom: Option<String>,
128
129    /// For `array` fields: the element type, e.g. `string` (labels),
130    /// `option`, `component`, `version`.
131    pub items: Option<String>,
132
133    /// For system fields: the canonical system name, e.g. `labels`,
134    /// `description`. `None` for custom fields.
135    pub system: Option<String>,
136}
137
138impl EditMetaField {
139    /// Returns `true` when the field carries rich-text (ADF) content on the
140    /// wire: textarea custom fields, plus the system `description` and
141    /// `environment` fields.
142    pub fn is_adf_rich_text(&self) -> bool {
143        self.schema.custom.as_deref() == Some(TEXTAREA_CUSTOM_TYPE)
144            || matches!(
145                self.schema.system.as_deref(),
146                Some("description" | "environment")
147            )
148    }
149}
150
151/// Introspection of the create screen for a project + issue type, returned by
152/// [`AtlassianClient::get_project_create_meta`](crate::atlassian::client::AtlassianClient::get_project_create_meta).
153///
154/// Unlike [`EditMeta`] (which keeps only `name` + `schema`), this carries the
155/// `required` flag, allowed values, and defaults — collapsing the
156/// create→HTTP&nbsp;400→`field list`→`field options` recovery loop into a single
157/// pre-flight call.
158#[derive(Debug, Clone, Serialize)]
159pub struct CreateMeta {
160    /// Project key the metadata was requested for (e.g., `PROJ`).
161    pub project: String,
162
163    /// Issue type the metadata was requested for (e.g., `Task`).
164    pub issue_type: String,
165
166    /// Fields on the create screen, sorted required-first then by name.
167    pub fields: Vec<CreateMetaField>,
168}
169
170/// A single field on the create screen.
171#[derive(Debug, Clone, Serialize)]
172pub struct CreateMetaField {
173    /// Field ID (e.g., `summary` or `customfield_10001`).
174    pub field_id: String,
175
176    /// Human-readable field name.
177    pub name: String,
178
179    /// Whether the field must be supplied to create the issue.
180    pub required: bool,
181
182    /// Base schema type: `string`, `option`, `array`, `user`, `date`, etc.
183    pub schema_type: String,
184
185    /// For `array` fields: the element type (`schema.items`).
186    #[serde(skip_serializing_if = "Option::is_none")]
187    pub items: Option<String>,
188
189    /// For custom fields: the plugin type URI, e.g.
190    /// `com.atlassian.jira.plugin.system.customfieldtypes:select`.
191    #[serde(skip_serializing_if = "Option::is_none")]
192    pub custom: Option<String>,
193
194    /// Allowed values for option/select/cascading-select fields (empty for
195    /// free-form fields).
196    #[serde(skip_serializing_if = "Vec::is_empty")]
197    pub allowed_values: Vec<CreateMetaAllowedValue>,
198
199    /// The field's default value, if the create screen defines one.
200    #[serde(skip_serializing_if = "Option::is_none")]
201    pub default_value: Option<serde_json::Value>,
202}
203
204/// One allowed value for a field, normalized across the shapes JIRA returns
205/// (option `value`, version/component/priority `name`, cascading `children`).
206#[derive(Debug, Clone, Serialize)]
207pub struct CreateMetaAllowedValue {
208    /// Option ID, when present.
209    #[serde(skip_serializing_if = "Option::is_none")]
210    pub id: Option<String>,
211
212    /// Display value: the option's `value`, falling back to its `name`.
213    #[serde(skip_serializing_if = "Option::is_none")]
214    pub value: Option<String>,
215
216    /// Nested options for cascading-select fields.
217    #[serde(skip_serializing_if = "Vec::is_empty")]
218    pub children: Vec<Self>,
219}
220
221/// A JIRA user, returned by `GET /rest/api/3/myself` and embedded in
222/// [`JiraWatcherList::watchers`].
223#[derive(Debug, Clone, Serialize, Deserialize)]
224pub struct JiraUser {
225    /// User display name.
226    #[serde(rename = "displayName")]
227    pub display_name: String,
228
229    /// User email address.
230    #[serde(rename = "emailAddress")]
231    pub email_address: Option<String>,
232
233    /// Account ID.
234    #[serde(rename = "accountId")]
235    pub account_id: String,
236}
237
238/// Result from `GET /rest/api/3/issue/{key}/watchers`.
239#[derive(Debug, Clone, Serialize)]
240pub struct JiraWatcherList {
241    /// Watchers on the issue.
242    pub watchers: Vec<JiraUser>,
243
244    /// Total number of watchers.
245    pub watch_count: u32,
246}
247
248/// Response from `POST /rest/api/3/issue`.
249#[derive(Debug, Clone, Serialize)]
250pub struct JiraCreatedIssue {
251    /// Issue key (e.g., "PROJ-124").
252    pub key: String,
253    /// Issue numeric ID.
254    pub id: String,
255    /// API self URL.
256    pub self_url: String,
257}
258
259/// Paginated result from a JQL search (`POST /rest/api/3/search/jql`).
260///
261/// `total` is the aggregate hit count across all pages; `issues` holds the
262/// hits returned by the helper (auto-paginated up to the caller's limit).
263#[derive(Debug, Clone, Serialize)]
264pub struct JiraSearchResult {
265    /// Matching issues.
266    pub issues: Vec<JiraIssue>,
267
268    /// Total number of matching issues (may exceed `issues.len()` if paginated).
269    pub total: u32,
270}
271
272/// A single user hit from `GET /rest/api/3/user/search`.
273///
274/// See [`JiraUserSearchResults`] for the wrapper. JIRA's
275/// `/rest/api/3/user/search` endpoint may omit `emailAddress` and
276/// `displayName` for tenants where the operating account lacks the
277/// privacy-controlled fields permission, so both are optional. `accountId`
278/// is the canonical identifier and is always present for atlassian-account
279/// users.
280#[derive(Debug, Clone, Serialize)]
281pub struct JiraUserSearchResult {
282    /// Account ID (unique identifier).
283    pub account_id: String,
284    /// Display name. May be absent on GDPR-redacted tenants.
285    #[serde(skip_serializing_if = "Option::is_none")]
286    pub display_name: Option<String>,
287    /// Email address. Often absent due to GDPR / privacy settings.
288    #[serde(skip_serializing_if = "Option::is_none")]
289    pub email_address: Option<String>,
290    /// Whether the account is currently active.
291    pub active: bool,
292    /// Account type, e.g. `"atlassian"`, `"app"`, `"customer"`.
293    #[serde(skip_serializing_if = "Option::is_none")]
294    pub account_type: Option<String>,
295}
296
297/// Wrapper around [`JiraUserSearchResult`] hits from
298/// `GET /rest/api/3/user/search`.
299///
300/// Unlike the JQL search wrappers, the user-search endpoint does not return a
301/// total across all pages; `count` is therefore `users.len()`.
302#[derive(Debug, Clone, Serialize)]
303pub struct JiraUserSearchResults {
304    /// Matching users.
305    pub users: Vec<JiraUserSearchResult>,
306    /// Number of users returned (the JIRA API does not report a true total
307    /// across all pages; `count` is `users.len()`).
308    pub count: u32,
309}
310
311/// A single user resolved by account ID via `GET /rest/api/3/user?accountId=`.
312///
313/// Unlike the search result, this is the *reverse* direction (ID → record) and
314/// is failure-tolerant: on a per-ID lookup failure (unknown / anonymised
315/// account, or a non-auth error) all fields except `account_id` are `None` and
316/// `error` carries the reason, so a batch lookup never aborts for one bad ID.
317/// Deactivated accounts still come back as a real record with `active: false`.
318///
319/// See [`JiraUserGetResults`] for the batch wrapper.
320#[derive(Debug, Clone, Serialize)]
321pub struct JiraUserRecord {
322    /// Account ID (always present — echoed back even when the lookup failed).
323    pub account_id: String,
324    /// Display name. Absent on GDPR-redacted tenants or failed lookups.
325    #[serde(skip_serializing_if = "Option::is_none")]
326    pub display_name: Option<String>,
327    /// Email address. Often absent due to GDPR / privacy settings.
328    #[serde(skip_serializing_if = "Option::is_none")]
329    pub email_address: Option<String>,
330    /// Whether the account is currently active. `None` when the lookup failed.
331    #[serde(skip_serializing_if = "Option::is_none")]
332    pub active: Option<bool>,
333    /// Account type, e.g. `"atlassian"`, `"app"`, `"customer"`.
334    #[serde(skip_serializing_if = "Option::is_none")]
335    pub account_type: Option<String>,
336    /// Reason this ID could not be resolved (e.g. `"HTTP 404"`). Absent on
337    /// success.
338    #[serde(skip_serializing_if = "Option::is_none")]
339    pub error: Option<String>,
340}
341
342/// Batch wrapper around [`JiraUserRecord`] from resolving one or more account
343/// IDs via `GET /rest/api/3/user?accountId=`.
344#[derive(Debug, Clone, Serialize)]
345pub struct JiraUserGetResults {
346    /// Resolved users, one per requested account ID (in request order).
347    pub users: Vec<JiraUserRecord>,
348}
349
350/// A JIRA issue comment returned by `GET /rest/api/3/issue/{key}/comment`.
351#[derive(Debug, Clone, Serialize)]
352pub struct JiraComment {
353    /// Comment ID.
354    pub id: String,
355    /// Author display name.
356    pub author: String,
357    /// Comment body as raw ADF JSON.
358    pub body_adf: Option<serde_json::Value>,
359    /// ISO 8601 creation timestamp.
360    pub created: String,
361    /// ISO 8601 last-update timestamp, when present.
362    #[serde(skip_serializing_if = "Option::is_none")]
363    pub updated: Option<String>,
364}
365
366/// Visibility restriction kind sent in the body of
367/// `POST /rest/api/3/issue/{key}/comment` (and the edit endpoint).
368///
369/// Note: this is a write-path type — it is *sent* to JIRA when scoping a
370/// comment, not parsed from comment-read responses.
371#[derive(Debug, Clone, Copy, Serialize)]
372#[serde(rename_all = "lowercase")]
373pub enum JiraVisibilityType {
374    /// Restrict to members of a JIRA group.
375    Group,
376    /// Restrict to holders of a project role.
377    Role,
378}
379
380/// Visibility restriction applied when posting or editing a JIRA comment.
381///
382/// Serialised as the `visibility` object on the request body of
383/// `POST /rest/api/3/issue/{key}/comment` (and the edit endpoint).
384#[derive(Debug, Clone)]
385pub struct JiraVisibility {
386    /// Whether the restriction targets a group or a project role.
387    pub ty: JiraVisibilityType,
388    /// Group name or project role name (sent as `identifier`).
389    pub value: String,
390}
391
392impl Serialize for JiraVisibility {
393    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
394        use serde::ser::SerializeStruct;
395        let mut s = serializer.serialize_struct("JiraVisibility", 2)?;
396        s.serialize_field("type", &self.ty)?;
397        s.serialize_field("identifier", &self.value)?;
398        s.end()
399    }
400}
401
402/// A JIRA project hit from `GET /rest/api/3/project/search`.
403///
404/// See [`JiraProjectList`] for the paginated wrapper.
405#[derive(Debug, Clone, Serialize)]
406pub struct JiraProject {
407    /// Project ID.
408    pub id: String,
409    /// Project key (e.g., "PROJ").
410    pub key: String,
411    /// Project name.
412    pub name: String,
413    /// Project type key (e.g., "software", "business").
414    pub project_type: Option<String>,
415    /// Project lead display name.
416    pub lead: Option<String>,
417}
418
419/// Paginated wrapper around [`JiraProject`] hits from
420/// `GET /rest/api/3/project/search`.
421#[derive(Debug, Clone, Serialize)]
422pub struct JiraProjectList {
423    /// Projects returned.
424    pub projects: Vec<JiraProject>,
425    /// Total number of projects.
426    pub total: u32,
427}
428
429/// Plugin type URI for the rich-text "textarea" custom field. Used to
430/// distinguish ADF-required custom fields from scalar ones.
431pub(crate) const TEXTAREA_CUSTOM_TYPE: &str =
432    "com.atlassian.jira.plugin.system.customfieldtypes:textarea";
433
434/// A JIRA field definition returned by `GET /rest/api/3/field`.
435#[derive(Debug, Clone, Serialize)]
436pub struct JiraField {
437    /// Field ID (e.g., "summary", "customfield_10001").
438    pub id: String,
439    /// Human-readable field name.
440    pub name: String,
441    /// Whether this is a custom field.
442    pub custom: bool,
443    /// Schema type. Mostly the raw `schema.type` from the API (`"string"`,
444    /// `"array"`, `"option"`, ...). For rich-text custom fields this is
445    /// `"richtext"`, mapped from `schema.custom` so callers can detect
446    /// ADF-required fields without inspecting the plugin URI.
447    pub schema_type: Option<String>,
448    /// Raw `schema.custom` plugin URI for custom fields, e.g.
449    /// `com.atlassian.jira.plugin.system.customfieldtypes:textarea`. Absent
450    /// for system fields.
451    #[serde(skip_serializing_if = "Option::is_none")]
452    pub schema_custom: Option<String>,
453}
454
455/// An option value for a single-select / multi-select JIRA custom field,
456/// returned by `GET /rest/api/3/field/{id}/context/{ctxId}/option`.
457#[derive(Debug, Clone, Serialize)]
458pub struct JiraFieldOption {
459    /// Option ID.
460    pub id: String,
461    /// Option display value.
462    pub value: String,
463}
464
465/// A JIRA agile board hit from `GET /rest/agile/1.0/board`.
466///
467/// See [`AgileBoardList`] for the paginated wrapper.
468#[derive(Debug, Clone, Serialize)]
469pub struct AgileBoard {
470    /// Board ID.
471    pub id: u64,
472    /// Board name.
473    pub name: String,
474    /// Board type (e.g., "scrum", "kanban").
475    pub board_type: String,
476    /// Project key associated with the board, if available.
477    pub project_key: Option<String>,
478}
479
480/// Paginated wrapper around [`AgileBoard`] hits from
481/// `GET /rest/agile/1.0/board`.
482#[derive(Debug, Clone, Serialize)]
483pub struct AgileBoardList {
484    /// Boards returned.
485    pub boards: Vec<AgileBoard>,
486    /// Total number of boards.
487    pub total: u32,
488}
489
490/// A JIRA agile sprint hit from `GET /rest/agile/1.0/board/{boardId}/sprint`.
491///
492/// See [`AgileSprintList`] for the paginated wrapper.
493#[derive(Debug, Clone, Serialize)]
494pub struct AgileSprint {
495    /// Sprint ID.
496    pub id: u64,
497    /// Sprint name.
498    pub name: String,
499    /// Sprint state (e.g., "active", "future", "closed").
500    pub state: String,
501    /// Sprint start date (ISO 8601).
502    pub start_date: Option<String>,
503    /// Sprint end date (ISO 8601).
504    pub end_date: Option<String>,
505    /// Sprint goal.
506    pub goal: Option<String>,
507}
508
509/// Paginated wrapper around [`AgileSprint`] hits from
510/// `GET /rest/agile/1.0/board/{boardId}/sprint`.
511#[derive(Debug, Clone, Serialize)]
512pub struct AgileSprintList {
513    /// Sprints returned.
514    pub sprints: Vec<AgileSprint>,
515    /// Total number of sprints.
516    pub total: u32,
517}
518
519/// A JIRA project version (release version), returned by
520/// `GET /rest/api/3/project/{projectIdOrKey}/version` and created via
521/// `POST /rest/api/3/version`.
522///
523/// See [`JiraProjectVersionList`] for the paginated wrapper.
524#[derive(Debug, Clone, Serialize)]
525pub struct JiraProjectVersion {
526    /// Version ID.
527    pub id: String,
528    /// Version name (e.g., "1.0.0").
529    pub name: String,
530    /// Version description.
531    pub description: Option<String>,
532    /// Owning project key.
533    pub project_key: String,
534    /// Whether the version is released.
535    pub released: bool,
536    /// Whether the version is archived.
537    pub archived: bool,
538    /// Release date (ISO 8601, `YYYY-MM-DD`).
539    pub release_date: Option<String>,
540    /// Start date (ISO 8601, `YYYY-MM-DD`).
541    pub start_date: Option<String>,
542}
543
544/// Paginated wrapper around [`JiraProjectVersion`] hits from
545/// `GET /rest/api/3/project/{projectIdOrKey}/version`.
546#[derive(Debug, Clone, Serialize)]
547pub struct JiraProjectVersionList {
548    /// Versions returned.
549    pub versions: Vec<JiraProjectVersion>,
550    /// Total number of versions.
551    pub total: u32,
552}
553
554/// A JIRA issue changelog entry, returned in the `changelog.histories` array
555/// of `GET /rest/api/3/issue/{key}?expand=changelog`.
556#[derive(Debug, Clone, Serialize)]
557pub struct JiraChangelogEntry {
558    /// Entry ID.
559    pub id: String,
560    /// Author display name.
561    pub author: String,
562    /// ISO 8601 timestamp.
563    pub created: String,
564    /// Changed items.
565    pub items: Vec<JiraChangelogItem>,
566}
567
568/// A single field change embedded in a [`JiraChangelogEntry`].
569#[derive(Debug, Clone, Serialize)]
570pub struct JiraChangelogItem {
571    /// Field name that changed.
572    pub field: String,
573    /// Previous value (display string).
574    pub from_string: Option<String>,
575    /// New value (display string).
576    pub to_string: Option<String>,
577}
578
579/// A JIRA issue link type returned by `GET /rest/api/3/issueLinkType`.
580#[derive(Debug, Clone, Serialize)]
581pub struct JiraLinkType {
582    /// Link type ID.
583    pub id: String,
584    /// Link type name (e.g., "Blocks", "Clones").
585    pub name: String,
586    /// Inward description (e.g., "is blocked by").
587    pub inward: String,
588    /// Outward description (e.g., "blocks").
589    pub outward: String,
590}
591
592/// A link on a JIRA issue, as it appears in the `issuelinks` field of
593/// `GET /rest/api/3/issue/{key}`. Created via `POST /rest/api/3/issueLink` and
594/// removed via `DELETE /rest/api/3/issueLink/{id}`.
595#[derive(Debug, Clone, Serialize)]
596pub struct JiraIssueLink {
597    /// Link ID (used for removal).
598    pub id: String,
599    /// Link type name.
600    pub link_type: String,
601    /// Direction: "inward" or "outward".
602    pub direction: String,
603    /// The linked issue key.
604    pub linked_issue_key: String,
605    /// The linked issue summary.
606    pub linked_issue_summary: String,
607}
608
609/// A remote (external URL) issue link on a JIRA issue.
610///
611/// Returned by `GET /rest/api/3/issue/{issueIdOrKey}/remotelink`. These
612/// point out to non-JIRA resources (Confluence pages, Bitbucket PRs,
613/// external trackers).
614#[derive(Debug, Clone, Serialize)]
615pub struct JiraRemoteIssueLink {
616    /// Remote link ID assigned by JIRA.
617    pub id: String,
618    /// Application-defined global identifier, when the linking application
619    /// supplied one.
620    #[serde(skip_serializing_if = "Option::is_none")]
621    pub global_id: Option<String>,
622    /// Free-form description of how the issue relates to the remote object
623    /// (e.g., "mentioned in", "causes").
624    #[serde(skip_serializing_if = "Option::is_none")]
625    pub relationship: Option<String>,
626    /// The remote object the link points at.
627    pub object: JiraRemoteIssueLinkObject,
628}
629
630/// The remote object an entry of [`JiraRemoteIssueLink`] points at.
631#[derive(Debug, Clone, Serialize)]
632pub struct JiraRemoteIssueLinkObject {
633    /// Remote URL.
634    pub url: String,
635    /// Display title for the remote object.
636    #[serde(skip_serializing_if = "Option::is_none")]
637    pub title: Option<String>,
638    /// Short summary text for the remote object.
639    #[serde(skip_serializing_if = "Option::is_none")]
640    pub summary: Option<String>,
641    /// Icon associated with the remote object (often labels the kind of
642    /// external target, e.g. "Confluence Page").
643    #[serde(skip_serializing_if = "Option::is_none")]
644    pub icon: Option<JiraRemoteIssueLinkIcon>,
645}
646
647/// Icon metadata for a [`JiraRemoteIssueLinkObject`]. Mirrors the upstream
648/// `object.icon` shape, with JIRA's `url16x16` flattened to `url`.
649#[derive(Debug, Clone, Serialize)]
650pub struct JiraRemoteIssueLinkIcon {
651    /// Icon URL (from JIRA's `url16x16`).
652    #[serde(skip_serializing_if = "Option::is_none")]
653    pub url: Option<String>,
654    /// Icon title — typically the label of the external target kind.
655    #[serde(skip_serializing_if = "Option::is_none")]
656    pub title: Option<String>,
657}
658
659/// A JIRA issue attachment, embedded in the `attachment` field of
660/// `GET /rest/api/3/issue/{key}`.
661///
662/// Uploaded via `POST /rest/api/3/issue/{key}/attachments` and removed via
663/// `DELETE /rest/api/3/attachment/{id}`.
664#[derive(Debug, Clone, Serialize)]
665pub struct JiraAttachment {
666    /// Attachment ID.
667    pub id: String,
668    /// File name.
669    pub filename: String,
670    /// MIME type (e.g., "image/png", "application/pdf").
671    pub mime_type: String,
672    /// File size in bytes.
673    pub size: u64,
674    /// Download URL.
675    pub content_url: String,
676}
677
678impl From<JiraAttachmentEntry> for JiraAttachment {
679    fn from(entry: JiraAttachmentEntry) -> Self {
680        Self {
681            id: entry.id,
682            filename: entry.filename,
683            mime_type: entry.mime_type,
684            size: entry.size,
685            content_url: entry.content,
686        }
687    }
688}
689
690/// A JIRA workflow transition returned by
691/// `GET /rest/api/3/issue/{key}/transitions`. Executed via
692/// `POST /rest/api/3/issue/{key}/transitions`.
693#[derive(Debug, Clone, Serialize)]
694pub struct JiraTransition {
695    /// Transition ID.
696    pub id: String,
697    /// Transition name (e.g., "In Progress", "Done").
698    pub name: String,
699    /// Status the transition moves the issue into.
700    #[serde(skip_serializing_if = "Option::is_none")]
701    pub to_status: Option<JiraTransitionToStatus>,
702    /// Whether executing the transition triggers a screen requiring extra fields.
703    #[serde(skip_serializing_if = "Option::is_none")]
704    pub has_screen: Option<bool>,
705}
706
707/// Destination status of a JIRA workflow transition, embedded in
708/// [`JiraTransition::to_status`].
709#[derive(Debug, Clone, Serialize)]
710pub struct JiraTransitionToStatus {
711    /// Status ID.
712    pub id: String,
713    /// Status name (e.g., "In Progress", "Done").
714    pub name: String,
715    /// Status category key (e.g., "new", "indeterminate", "done").
716    #[serde(skip_serializing_if = "Option::is_none")]
717    pub category: Option<String>,
718}
719
720/// A pull request entry from Jira's DevStatus detail endpoint
721/// (`GET /rest/dev-status/1.0/issue/detail?issueId={id}&applicationType=…&dataType=pullrequest`).
722#[derive(Debug, Clone, Serialize)]
723pub struct JiraDevPullRequest {
724    /// PR identifier (e.g., "#2174").
725    pub id: String,
726    /// PR title.
727    pub name: String,
728    /// Status (e.g., "OPEN", "MERGED", "DECLINED").
729    pub status: String,
730    /// URL to the pull request.
731    pub url: String,
732    /// Repository name (e.g., "org/repo").
733    pub repository_name: String,
734    /// Source branch name.
735    pub source_branch: String,
736    /// Destination branch name.
737    pub destination_branch: String,
738    /// PR author name.
739    #[serde(skip_serializing_if = "Option::is_none")]
740    pub author: Option<String>,
741    /// Reviewer names.
742    #[serde(skip_serializing_if = "Vec::is_empty")]
743    pub reviewers: Vec<String>,
744    /// Number of comments on the PR.
745    #[serde(skip_serializing_if = "Option::is_none")]
746    pub comment_count: Option<u32>,
747    /// Last update timestamp (ISO 8601).
748    #[serde(skip_serializing_if = "Option::is_none")]
749    pub last_update: Option<String>,
750}
751
752/// A commit entry from Jira's DevStatus detail endpoint
753/// (`dataType=repository`), embedded in [`JiraDevRepository::commits`] and
754/// [`JiraDevBranch::last_commit`].
755#[derive(Debug, Clone, Serialize)]
756pub struct JiraDevCommit {
757    /// Full commit SHA.
758    pub id: String,
759    /// Short commit SHA.
760    pub display_id: String,
761    /// Commit message.
762    pub message: String,
763    /// Commit author name.
764    #[serde(skip_serializing_if = "Option::is_none")]
765    pub author: Option<String>,
766    /// Author timestamp (ISO 8601).
767    #[serde(skip_serializing_if = "Option::is_none")]
768    pub timestamp: Option<String>,
769    /// URL to the commit.
770    pub url: String,
771    /// Number of files changed.
772    pub file_count: u32,
773    /// Whether this is a merge commit.
774    pub merge: bool,
775}
776
777/// A branch entry from Jira's DevStatus detail endpoint
778/// (`dataType=branch`).
779#[derive(Debug, Clone, Serialize)]
780pub struct JiraDevBranch {
781    /// Branch name.
782    pub name: String,
783    /// URL to the branch.
784    pub url: String,
785    /// Repository name (e.g., "org/repo").
786    pub repository_name: String,
787    /// URL to create a pull request from this branch.
788    #[serde(skip_serializing_if = "Option::is_none")]
789    pub create_pr_url: Option<String>,
790    /// Most recent commit on this branch.
791    #[serde(skip_serializing_if = "Option::is_none")]
792    pub last_commit: Option<JiraDevCommit>,
793}
794
795/// A repository entry from Jira's DevStatus detail endpoint
796/// (`dataType=repository`).
797#[derive(Debug, Clone, Serialize)]
798pub struct JiraDevRepository {
799    /// Repository name (e.g., "org/repo").
800    pub name: String,
801    /// URL to the repository.
802    pub url: String,
803    /// Commits linked to this issue in the repository.
804    #[serde(skip_serializing_if = "Vec::is_empty")]
805    pub commits: Vec<JiraDevCommit>,
806}
807
808/// Aggregated development data for a Jira issue, assembled from the DevStatus
809/// detail endpoint (`GET /rest/dev-status/1.0/issue/detail`) across the PR,
810/// branch, and repository data types.
811///
812/// See [`JiraDevStatusSummary`] for the high-level count-only summary.
813#[derive(Debug, Clone, Serialize)]
814pub struct JiraDevStatus {
815    /// Linked pull requests.
816    #[serde(skip_serializing_if = "Vec::is_empty")]
817    pub pull_requests: Vec<JiraDevPullRequest>,
818    /// Linked branches.
819    #[serde(skip_serializing_if = "Vec::is_empty")]
820    pub branches: Vec<JiraDevBranch>,
821    /// Linked repositories.
822    #[serde(skip_serializing_if = "Vec::is_empty")]
823    pub repositories: Vec<JiraDevRepository>,
824}
825
826/// Per-category count from Jira's DevStatus summary endpoint
827/// (`GET /rest/dev-status/1.0/issue/summary?issueId={id}`). Embedded in
828/// [`JiraDevStatusSummary`].
829#[derive(Debug, Clone, Serialize)]
830pub struct JiraDevStatusCount {
831    /// Number of items.
832    pub count: u32,
833    /// Providers that have data for this category.
834    pub providers: Vec<JiraDevProvider>,
835}
836
837/// A development-info provider that has data for a JIRA issue, as reported by
838/// the DevStatus summary endpoint's `byInstanceType` map.
839#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
840pub struct JiraDevProvider {
841    /// Instance-type identifier — the value the detail endpoint expects as
842    /// `applicationType` (e.g. "stash", "bitbucket", "GitHub"). This is the
843    /// round-trip key; do not substitute the display name here.
844    pub instance_type: String,
845    /// Human-readable display name (e.g. "Bitbucket Server", "GitHub").
846    pub name: String,
847}
848
849/// High-level dev-status summary from
850/// `GET /rest/dev-status/1.0/issue/summary?issueId={id}`. Count-only — use
851/// [`JiraDevStatus`] when the individual PRs / branches / repos are needed.
852#[derive(Debug, Clone, Serialize)]
853pub struct JiraDevStatusSummary {
854    /// Pull request summary.
855    pub pullrequest: JiraDevStatusCount,
856    /// Branch summary.
857    pub branch: JiraDevStatusCount,
858    /// Repository summary.
859    pub repository: JiraDevStatusCount,
860}
861
862/// A JIRA issue worklog entry returned by
863/// `GET /rest/api/3/issue/{key}/worklog`.
864#[derive(Debug, Clone, Serialize)]
865pub struct JiraWorklog {
866    /// Worklog ID.
867    pub id: String,
868    /// Author display name.
869    pub author: String,
870    /// Time spent in human-readable format (e.g., "2h 30m").
871    pub time_spent: String,
872    /// Time spent in seconds.
873    pub time_spent_seconds: u64,
874    /// ISO 8601 timestamp when the work was started.
875    pub started: String,
876    /// Comment text (plain text, extracted from ADF).
877    #[serde(skip_serializing_if = "Option::is_none")]
878    pub comment: Option<String>,
879}
880
881/// Paginated wrapper around [`JiraWorklog`] entries from
882/// `GET /rest/api/3/issue/{key}/worklog`.
883#[derive(Debug, Clone, Serialize)]
884pub struct JiraWorklogList {
885    /// Worklog entries.
886    pub worklogs: Vec<JiraWorklog>,
887    /// Total number of worklogs.
888    pub total: u32,
889}
890
891// ── Internal API response structs ───────────────────────────────────
892
893#[derive(Deserialize)]
894pub(crate) struct JiraIssueResponse {
895    pub(crate) key: String,
896    pub(crate) fields: JiraIssueFields,
897}
898
899/// Flexible deserialization target for `GET /rest/api/3/issue/{key}` that
900/// retains every field value as raw JSON so custom fields can be extracted.
901#[derive(Deserialize)]
902pub(crate) struct JiraIssueEnvelope {
903    pub(crate) key: String,
904    #[serde(default)]
905    pub(crate) fields: std::collections::BTreeMap<String, serde_json::Value>,
906    #[serde(default)]
907    pub(crate) names: std::collections::BTreeMap<String, String>,
908}
909
910impl JiraIssueEnvelope {
911    pub(crate) fn into_issue(self, selection: &FieldSelection) -> JiraIssue {
912        let Self {
913            key,
914            mut fields,
915            names,
916        } = self;
917
918        let description_adf = fields.remove("description").filter(|v| !v.is_null());
919        let summary = fields
920            .remove("summary")
921            .and_then(|v| v.as_str().map(str::to_string))
922            .unwrap_or_default();
923        let status = extract_named_field(fields.remove("status"));
924        let issue_type = extract_named_field(fields.remove("issuetype"));
925        let assignee = extract_display_name(fields.remove("assignee"));
926        let priority = extract_named_field(fields.remove("priority"));
927        let labels = fields
928            .remove("labels")
929            .and_then(|v| serde_json::from_value::<Vec<String>>(v).ok())
930            .unwrap_or_default();
931
932        let collect_customs = !matches!(selection, FieldSelection::Standard);
933        let custom_fields = if collect_customs {
934            fields
935                .into_iter()
936                .filter(|(_, value)| !value.is_null())
937                .map(|(id, value)| {
938                    let name = names.get(&id).cloned().unwrap_or_else(|| id.clone());
939                    JiraCustomField { id, name, value }
940                })
941                .collect()
942        } else {
943            Vec::new()
944        };
945
946        JiraIssue {
947            key,
948            summary,
949            description_adf,
950            status,
951            issue_type,
952            assignee,
953            priority,
954            labels,
955            custom_fields,
956        }
957    }
958}
959
960fn extract_named_field(value: Option<serde_json::Value>) -> Option<String> {
961    value
962        .and_then(|v| v.get("name").cloned())
963        .and_then(|n| n.as_str().map(str::to_string))
964}
965
966fn extract_display_name(value: Option<serde_json::Value>) -> Option<String> {
967    value
968        .and_then(|v| v.get("displayName").cloned())
969        .and_then(|n| n.as_str().map(str::to_string))
970}
971
972#[derive(Deserialize)]
973pub(crate) struct JiraEditMetaResponse {
974    #[serde(default)]
975    pub(crate) fields: std::collections::BTreeMap<String, JiraEditMetaField>,
976}
977
978#[derive(Deserialize)]
979pub(crate) struct JiraEditMetaField {
980    #[serde(default)]
981    pub(crate) name: Option<String>,
982    #[serde(default)]
983    pub(crate) schema: Option<JiraEditMetaSchemaRaw>,
984    #[serde(rename = "allowedValues", default)]
985    pub(crate) allowed_values: Vec<JiraAllowedValueRaw>,
986}
987
988impl JiraEditMetaField {
989    /// Flattens the raw `allowedValues` to their display `value`s (falling back
990    /// to `name`) for option validation. Cascading `children` are ignored —
991    /// `--set-field` targets only the top-level option.
992    pub(crate) fn allowed_value_strings(&self) -> Vec<String> {
993        self.allowed_values
994            .iter()
995            .filter_map(|v| v.value.clone().or_else(|| v.name.clone()))
996            .collect()
997    }
998}
999
1000#[derive(Deserialize)]
1001pub(crate) struct JiraEditMetaSchemaRaw {
1002    #[serde(rename = "type", default)]
1003    pub(crate) kind: Option<String>,
1004    #[serde(default)]
1005    pub(crate) custom: Option<String>,
1006    #[serde(default)]
1007    pub(crate) items: Option<String>,
1008    #[serde(default)]
1009    pub(crate) system: Option<String>,
1010}
1011
1012impl From<Option<JiraEditMetaSchemaRaw>> for EditMetaSchema {
1013    fn from(raw: Option<JiraEditMetaSchemaRaw>) -> Self {
1014        raw.map_or_else(Self::default, |s| Self {
1015            kind: s.kind.unwrap_or_default(),
1016            custom: s.custom,
1017            items: s.items,
1018            system: s.system,
1019        })
1020    }
1021}
1022
1023#[derive(Deserialize)]
1024pub(crate) struct JiraCreateMetaResponse {
1025    #[serde(default)]
1026    pub(crate) projects: Vec<JiraCreateMetaProject>,
1027}
1028
1029#[derive(Deserialize)]
1030pub(crate) struct JiraCreateMetaProject {
1031    #[serde(default)]
1032    pub(crate) issuetypes: Vec<JiraCreateMetaIssueType>,
1033}
1034
1035#[derive(Deserialize)]
1036pub(crate) struct JiraCreateMetaIssueType {
1037    #[serde(default)]
1038    pub(crate) fields: std::collections::BTreeMap<String, JiraEditMetaField>,
1039}
1040
1041/// Full createmeta response, parsed for the richer `create-meta` introspection
1042/// (keeps `required`, `allowedValues`, and `defaultValue`, which the lean
1043/// `JiraCreateMeta*` path above discards).
1044#[derive(Deserialize)]
1045pub(crate) struct JiraCreateMetaFullResponse {
1046    #[serde(default)]
1047    pub(crate) projects: Vec<JiraCreateMetaFullProject>,
1048}
1049
1050#[derive(Deserialize)]
1051pub(crate) struct JiraCreateMetaFullProject {
1052    #[serde(default)]
1053    pub(crate) issuetypes: Vec<JiraCreateMetaFullIssueType>,
1054}
1055
1056#[derive(Deserialize)]
1057pub(crate) struct JiraCreateMetaFullIssueType {
1058    #[serde(default)]
1059    pub(crate) fields: std::collections::BTreeMap<String, JiraCreateMetaFieldRaw>,
1060}
1061
1062#[derive(Deserialize)]
1063pub(crate) struct JiraCreateMetaFieldRaw {
1064    #[serde(default)]
1065    pub(crate) name: Option<String>,
1066    #[serde(default)]
1067    pub(crate) required: bool,
1068    #[serde(default)]
1069    pub(crate) schema: Option<JiraCreateMetaSchemaRaw>,
1070    #[serde(rename = "allowedValues", default)]
1071    pub(crate) allowed_values: Vec<JiraAllowedValueRaw>,
1072    #[serde(rename = "defaultValue", default)]
1073    pub(crate) default_value: Option<serde_json::Value>,
1074}
1075
1076#[derive(Deserialize)]
1077pub(crate) struct JiraCreateMetaSchemaRaw {
1078    #[serde(rename = "type", default)]
1079    pub(crate) kind: Option<String>,
1080    #[serde(default)]
1081    pub(crate) items: Option<String>,
1082    #[serde(default)]
1083    pub(crate) custom: Option<String>,
1084}
1085
1086#[derive(Deserialize)]
1087pub(crate) struct JiraAllowedValueRaw {
1088    #[serde(default)]
1089    pub(crate) id: Option<String>,
1090    #[serde(default)]
1091    pub(crate) value: Option<String>,
1092    #[serde(default)]
1093    pub(crate) name: Option<String>,
1094    #[serde(default)]
1095    pub(crate) children: Vec<Self>,
1096}
1097
1098impl JiraAllowedValueRaw {
1099    /// Normalizes one raw allowed value: display value is `value` falling back
1100    /// to `name`; children recurse.
1101    pub(crate) fn into_allowed_value(self) -> CreateMetaAllowedValue {
1102        CreateMetaAllowedValue {
1103            id: self.id,
1104            value: self.value.or(self.name),
1105            children: self
1106                .children
1107                .into_iter()
1108                .map(Self::into_allowed_value)
1109                .collect(),
1110        }
1111    }
1112}
1113
1114#[derive(Deserialize)]
1115pub(crate) struct JiraIssueFields {
1116    pub(crate) summary: Option<String>,
1117    pub(crate) description: Option<serde_json::Value>,
1118    pub(crate) status: Option<JiraNameField>,
1119    pub(crate) issuetype: Option<JiraNameField>,
1120    pub(crate) assignee: Option<JiraAssigneeField>,
1121    pub(crate) priority: Option<JiraNameField>,
1122    #[serde(default)]
1123    pub(crate) labels: Vec<String>,
1124}
1125
1126#[derive(Deserialize)]
1127pub(crate) struct JiraNameField {
1128    pub(crate) name: Option<String>,
1129}
1130
1131#[derive(Deserialize)]
1132pub(crate) struct JiraAssigneeField {
1133    #[serde(rename = "displayName")]
1134    pub(crate) display_name: Option<String>,
1135}
1136
1137#[derive(Deserialize)]
1138#[allow(dead_code)]
1139pub(crate) struct JiraSearchResponse {
1140    pub(crate) issues: Vec<JiraIssueResponse>,
1141    #[serde(default)]
1142    pub(crate) total: u32,
1143    #[serde(rename = "nextPageToken", default)]
1144    pub(crate) next_page_token: Option<String>,
1145}
1146
1147#[derive(Deserialize)]
1148pub(crate) struct JiraTransitionsResponse {
1149    pub(crate) transitions: Vec<JiraTransitionEntry>,
1150}
1151
1152#[derive(Deserialize)]
1153pub(crate) struct JiraTransitionEntry {
1154    pub(crate) id: String,
1155    pub(crate) name: String,
1156    #[serde(default)]
1157    pub(crate) to: Option<JiraTransitionToEntry>,
1158    #[serde(rename = "hasScreen", default)]
1159    pub(crate) has_screen: Option<bool>,
1160    /// Transition-screen field metadata, populated only when the request adds
1161    /// `expand=transitions.fields`. Same shape as the editmeta fields map, so it
1162    /// reuses [`JiraEditMetaField`]. Empty for the lean list path.
1163    #[serde(default)]
1164    pub(crate) fields: std::collections::BTreeMap<String, JiraEditMetaField>,
1165}
1166
1167#[derive(Deserialize)]
1168pub(crate) struct JiraTransitionToEntry {
1169    pub(crate) id: String,
1170    pub(crate) name: String,
1171    #[serde(rename = "statusCategory", default)]
1172    pub(crate) status_category: Option<JiraStatusCategoryEntry>,
1173}
1174
1175#[derive(Deserialize)]
1176pub(crate) struct JiraStatusCategoryEntry {
1177    #[serde(default)]
1178    pub(crate) key: Option<String>,
1179}
1180
1181#[derive(Deserialize)]
1182pub(crate) struct JiraCommentsResponse {
1183    #[serde(default)]
1184    pub(crate) comments: Vec<JiraCommentEntry>,
1185    #[serde(default)]
1186    pub(crate) total: u32,
1187    #[serde(rename = "startAt", default)]
1188    pub(crate) start_at: u32,
1189    #[serde(rename = "maxResults", default)]
1190    #[allow(dead_code)]
1191    pub(crate) max_results: u32,
1192}
1193
1194#[derive(Deserialize)]
1195pub(crate) struct JiraCommentEntry {
1196    pub(crate) id: String,
1197    pub(crate) author: Option<JiraCommentAuthor>,
1198    pub(crate) body: Option<serde_json::Value>,
1199    pub(crate) created: Option<String>,
1200    #[serde(default)]
1201    pub(crate) updated: Option<String>,
1202}
1203
1204#[derive(Deserialize)]
1205pub(crate) struct JiraCommentAuthor {
1206    #[serde(rename = "displayName")]
1207    pub(crate) display_name: Option<String>,
1208}
1209
1210#[derive(Deserialize)]
1211pub(crate) struct JiraWorklogResponse {
1212    #[serde(default)]
1213    pub(crate) worklogs: Vec<JiraWorklogEntry>,
1214    #[serde(default)]
1215    pub(crate) total: u32,
1216}
1217
1218#[derive(Deserialize)]
1219pub(crate) struct JiraWorklogEntry {
1220    pub(crate) id: String,
1221    pub(crate) author: Option<JiraCommentAuthor>,
1222    #[serde(rename = "timeSpent")]
1223    pub(crate) time_spent: Option<String>,
1224    #[serde(rename = "timeSpentSeconds", default)]
1225    pub(crate) time_spent_seconds: u64,
1226    pub(crate) started: Option<String>,
1227    pub(crate) comment: Option<serde_json::Value>,
1228}
1229
1230// ── JIRA user search API response struct ──────────────────────────
1231
1232#[derive(Deserialize)]
1233pub(crate) struct JiraUserSearchEntry {
1234    #[serde(rename = "accountId")]
1235    pub(crate) account_id: String,
1236    #[serde(rename = "displayName", default)]
1237    pub(crate) display_name: Option<String>,
1238    #[serde(rename = "emailAddress", default)]
1239    pub(crate) email_address: Option<String>,
1240    #[serde(default)]
1241    pub(crate) active: bool,
1242    #[serde(rename = "accountType", default)]
1243    pub(crate) account_type: Option<String>,
1244}
1245
1246// ── Agile API response structs ─────────────────────────────────────
1247
1248#[derive(Deserialize)]
1249#[allow(dead_code)]
1250pub(crate) struct AgileBoardListResponse {
1251    pub(crate) values: Vec<AgileBoardEntry>,
1252    #[serde(default)]
1253    pub(crate) total: u32,
1254    #[serde(rename = "isLast", default)]
1255    pub(crate) is_last: bool,
1256}
1257
1258#[derive(Deserialize)]
1259pub(crate) struct AgileBoardEntry {
1260    pub(crate) id: u64,
1261    pub(crate) name: String,
1262    #[serde(rename = "type")]
1263    pub(crate) board_type: String,
1264    pub(crate) location: Option<AgileBoardLocation>,
1265}
1266
1267#[derive(Deserialize)]
1268pub(crate) struct AgileBoardLocation {
1269    #[serde(rename = "projectKey")]
1270    pub(crate) project_key: Option<String>,
1271}
1272
1273#[derive(Deserialize)]
1274#[allow(dead_code)]
1275pub(crate) struct AgileIssueListResponse {
1276    pub(crate) issues: Vec<JiraIssueResponse>,
1277    #[serde(default)]
1278    pub(crate) total: u32,
1279    #[serde(rename = "isLast", default)]
1280    pub(crate) is_last: bool,
1281}
1282
1283#[derive(Deserialize)]
1284#[allow(dead_code)]
1285pub(crate) struct AgileSprintListResponse {
1286    pub(crate) values: Vec<AgileSprintEntry>,
1287    #[serde(default)]
1288    pub(crate) total: u32,
1289    #[serde(rename = "isLast", default)]
1290    pub(crate) is_last: bool,
1291}
1292
1293#[derive(Deserialize)]
1294pub(crate) struct AgileSprintEntry {
1295    pub(crate) id: u64,
1296    pub(crate) name: String,
1297    pub(crate) state: String,
1298    #[serde(rename = "startDate")]
1299    pub(crate) start_date: Option<String>,
1300    #[serde(rename = "endDate")]
1301    pub(crate) end_date: Option<String>,
1302    pub(crate) goal: Option<String>,
1303}
1304
1305#[derive(Deserialize)]
1306pub(crate) struct JiraProjectVersionEntry {
1307    pub(crate) id: String,
1308    pub(crate) name: String,
1309    #[serde(default)]
1310    pub(crate) description: Option<String>,
1311    #[serde(default)]
1312    pub(crate) released: bool,
1313    #[serde(default)]
1314    pub(crate) archived: bool,
1315    #[serde(rename = "releaseDate", default)]
1316    pub(crate) release_date: Option<String>,
1317    #[serde(rename = "startDate", default)]
1318    pub(crate) start_date: Option<String>,
1319}
1320
1321#[derive(Deserialize)]
1322pub(crate) struct JiraIssueLinksResponse {
1323    pub(crate) fields: JiraIssueLinksFields,
1324}
1325
1326#[derive(Deserialize)]
1327pub(crate) struct JiraIssueLinksFields {
1328    #[serde(default)]
1329    pub(crate) issuelinks: Vec<JiraIssueLinkEntry>,
1330}
1331
1332#[derive(Deserialize)]
1333pub(crate) struct JiraIssueLinkEntry {
1334    pub(crate) id: String,
1335    #[serde(rename = "type")]
1336    pub(crate) link_type: JiraIssueLinkType,
1337    #[serde(rename = "inwardIssue")]
1338    pub(crate) inward_issue: Option<JiraIssueLinkIssue>,
1339    #[serde(rename = "outwardIssue")]
1340    pub(crate) outward_issue: Option<JiraIssueLinkIssue>,
1341}
1342
1343#[derive(Deserialize)]
1344pub(crate) struct JiraIssueLinkType {
1345    pub(crate) name: String,
1346}
1347
1348#[derive(Deserialize)]
1349pub(crate) struct JiraIssueLinkIssue {
1350    pub(crate) key: String,
1351    pub(crate) fields: Option<JiraIssueLinkIssueFields>,
1352}
1353
1354#[derive(Deserialize)]
1355pub(crate) struct JiraIssueLinkIssueFields {
1356    pub(crate) summary: Option<String>,
1357}
1358
1359#[derive(Deserialize)]
1360pub(crate) struct JiraRemoteIssueLinkEntry {
1361    pub(crate) id: serde_json::Value,
1362    #[serde(rename = "globalId", default)]
1363    pub(crate) global_id: Option<String>,
1364    #[serde(default)]
1365    pub(crate) relationship: Option<String>,
1366    pub(crate) object: JiraRemoteIssueLinkObjectEntry,
1367}
1368
1369#[derive(Deserialize)]
1370pub(crate) struct JiraRemoteIssueLinkObjectEntry {
1371    pub(crate) url: String,
1372    #[serde(default)]
1373    pub(crate) title: Option<String>,
1374    #[serde(default)]
1375    pub(crate) summary: Option<String>,
1376    #[serde(default)]
1377    pub(crate) icon: Option<JiraRemoteIssueLinkIconEntry>,
1378}
1379
1380#[derive(Deserialize)]
1381pub(crate) struct JiraRemoteIssueLinkIconEntry {
1382    #[serde(rename = "url16x16", default)]
1383    pub(crate) url: Option<String>,
1384    #[serde(default)]
1385    pub(crate) title: Option<String>,
1386}
1387
1388#[derive(Deserialize)]
1389pub(crate) struct JiraLinkTypesResponse {
1390    #[serde(rename = "issueLinkTypes")]
1391    pub(crate) issue_link_types: Vec<JiraLinkTypeEntry>,
1392}
1393
1394#[derive(Deserialize)]
1395pub(crate) struct JiraLinkTypeEntry {
1396    pub(crate) id: String,
1397    pub(crate) name: String,
1398    pub(crate) inward: String,
1399    pub(crate) outward: String,
1400}
1401
1402#[derive(Deserialize)]
1403pub(crate) struct JiraAttachmentIssueResponse {
1404    pub(crate) fields: JiraAttachmentFields,
1405}
1406
1407#[derive(Deserialize)]
1408pub(crate) struct JiraAttachmentFields {
1409    #[serde(default)]
1410    pub(crate) attachment: Vec<JiraAttachmentEntry>,
1411}
1412
1413#[derive(Deserialize)]
1414pub(crate) struct JiraAttachmentEntry {
1415    pub(crate) id: String,
1416    pub(crate) filename: String,
1417    #[serde(rename = "mimeType")]
1418    pub(crate) mime_type: String,
1419    pub(crate) size: u64,
1420    pub(crate) content: String,
1421}
1422
1423#[derive(Deserialize)]
1424#[allow(dead_code)]
1425pub(crate) struct JiraChangelogResponse {
1426    pub(crate) values: Vec<JiraChangelogEntryResponse>,
1427    #[serde(default)]
1428    pub(crate) total: u32,
1429    #[serde(rename = "isLast", default)]
1430    pub(crate) is_last: bool,
1431}
1432
1433#[derive(Deserialize)]
1434pub(crate) struct JiraChangelogEntryResponse {
1435    pub(crate) id: String,
1436    pub(crate) author: Option<JiraCommentAuthor>,
1437    pub(crate) created: Option<String>,
1438    #[serde(default)]
1439    pub(crate) items: Vec<JiraChangelogItemResponse>,
1440}
1441
1442#[derive(Deserialize)]
1443pub(crate) struct JiraChangelogItemResponse {
1444    pub(crate) field: String,
1445    #[serde(rename = "fromString")]
1446    pub(crate) from_string: Option<String>,
1447    #[serde(rename = "toString")]
1448    pub(crate) to_string: Option<String>,
1449}
1450
1451#[derive(Deserialize)]
1452pub(crate) struct JiraFieldEntry {
1453    pub(crate) id: String,
1454    pub(crate) name: String,
1455    #[serde(default)]
1456    pub(crate) custom: bool,
1457    pub(crate) schema: Option<JiraFieldSchema>,
1458}
1459
1460#[derive(Deserialize)]
1461pub(crate) struct JiraFieldSchema {
1462    #[serde(rename = "type")]
1463    pub(crate) schema_type: Option<String>,
1464    pub(crate) custom: Option<String>,
1465}
1466
1467#[derive(Deserialize)]
1468pub(crate) struct JiraFieldContextsResponse {
1469    pub(crate) values: Vec<JiraFieldContextEntry>,
1470}
1471
1472#[derive(Deserialize)]
1473pub(crate) struct JiraFieldContextEntry {
1474    pub(crate) id: String,
1475}
1476
1477#[derive(Deserialize)]
1478pub(crate) struct JiraFieldOptionsResponse {
1479    pub(crate) values: Vec<JiraFieldOptionEntry>,
1480}
1481
1482#[derive(Deserialize)]
1483pub(crate) struct JiraFieldOptionEntry {
1484    pub(crate) id: String,
1485    pub(crate) value: String,
1486}
1487
1488#[derive(Deserialize)]
1489#[allow(dead_code)]
1490pub(crate) struct JiraProjectSearchResponse {
1491    pub(crate) values: Vec<JiraProjectEntry>,
1492    pub(crate) total: u32,
1493    #[serde(rename = "isLast", default)]
1494    pub(crate) is_last: bool,
1495}
1496
1497#[derive(Deserialize)]
1498pub(crate) struct JiraProjectEntry {
1499    pub(crate) id: String,
1500    pub(crate) key: String,
1501    pub(crate) name: String,
1502    #[serde(rename = "projectTypeKey")]
1503    pub(crate) project_type_key: Option<String>,
1504    pub(crate) lead: Option<JiraProjectLead>,
1505}
1506
1507#[derive(Deserialize)]
1508pub(crate) struct JiraProjectLead {
1509    #[serde(rename = "displayName")]
1510    pub(crate) display_name: Option<String>,
1511}
1512
1513#[derive(Deserialize)]
1514pub(crate) struct JiraCreateResponse {
1515    pub(crate) key: String,
1516    pub(crate) id: String,
1517    #[serde(rename = "self")]
1518    pub(crate) self_url: String,
1519}
1520
1521// ── DevStatus API response structs ─────────────────────────────────
1522
1523/// Minimal response for resolving an issue key to its numeric ID.
1524#[derive(Deserialize)]
1525pub(crate) struct JiraIssueIdResponse {
1526    pub(crate) id: String,
1527}
1528
1529#[derive(Deserialize)]
1530pub(crate) struct DevStatusResponse {
1531    #[serde(default)]
1532    pub(crate) detail: Vec<DevStatusDetail>,
1533}
1534
1535#[derive(Deserialize)]
1536pub(crate) struct DevStatusDetail {
1537    #[serde(rename = "pullRequests", default)]
1538    pub(crate) pull_requests: Vec<DevStatusPullRequest>,
1539    #[serde(default)]
1540    pub(crate) branches: Vec<DevStatusBranch>,
1541    #[serde(default)]
1542    pub(crate) repositories: Vec<DevStatusRepositoryEntry>,
1543}
1544
1545#[derive(Deserialize)]
1546pub(crate) struct DevStatusPullRequest {
1547    #[serde(default)]
1548    pub(crate) id: String,
1549    #[serde(default)]
1550    pub(crate) name: String,
1551    #[serde(default)]
1552    pub(crate) status: String,
1553    #[serde(default)]
1554    pub(crate) url: String,
1555    #[serde(rename = "repositoryName", default)]
1556    pub(crate) repository_name: String,
1557    #[serde(default)]
1558    pub(crate) source: Option<DevStatusBranchRef>,
1559    #[serde(default)]
1560    pub(crate) destination: Option<DevStatusBranchRef>,
1561    #[serde(default)]
1562    pub(crate) author: Option<DevStatusAuthor>,
1563    #[serde(default)]
1564    pub(crate) reviewers: Vec<DevStatusReviewer>,
1565    #[serde(rename = "commentCount", default)]
1566    pub(crate) comment_count: Option<u32>,
1567    #[serde(rename = "lastUpdate", default)]
1568    pub(crate) last_update: Option<String>,
1569}
1570
1571#[derive(Deserialize)]
1572pub(crate) struct DevStatusBranchRef {
1573    #[serde(default)]
1574    pub(crate) branch: String,
1575}
1576
1577#[derive(Deserialize)]
1578pub(crate) struct DevStatusAuthor {
1579    #[serde(default)]
1580    pub(crate) name: String,
1581}
1582
1583#[derive(Deserialize)]
1584pub(crate) struct DevStatusReviewer {
1585    #[serde(default)]
1586    pub(crate) name: String,
1587}
1588
1589#[derive(Deserialize)]
1590pub(crate) struct DevStatusCommit {
1591    #[serde(default)]
1592    pub(crate) id: String,
1593    #[serde(rename = "displayId", default)]
1594    pub(crate) display_id: String,
1595    #[serde(default)]
1596    pub(crate) message: String,
1597    #[serde(default)]
1598    pub(crate) author: Option<DevStatusAuthor>,
1599    #[serde(rename = "authorTimestamp", default)]
1600    pub(crate) author_timestamp: Option<String>,
1601    #[serde(default)]
1602    pub(crate) url: String,
1603    #[serde(rename = "fileCount", default)]
1604    pub(crate) file_count: u32,
1605    #[serde(default)]
1606    pub(crate) merge: bool,
1607}
1608
1609#[derive(Deserialize)]
1610pub(crate) struct DevStatusBranch {
1611    #[serde(default)]
1612    pub(crate) name: String,
1613    #[serde(default)]
1614    pub(crate) url: String,
1615    #[serde(rename = "repositoryName", default)]
1616    pub(crate) repository_name: String,
1617    #[serde(rename = "createPullRequestUrl", default)]
1618    pub(crate) create_pr_url: Option<String>,
1619    #[serde(rename = "lastCommit", default)]
1620    pub(crate) last_commit: Option<DevStatusCommit>,
1621}
1622
1623#[derive(Deserialize)]
1624pub(crate) struct DevStatusRepositoryEntry {
1625    #[serde(default)]
1626    pub(crate) name: String,
1627    #[serde(default)]
1628    pub(crate) url: String,
1629    #[serde(default)]
1630    pub(crate) commits: Vec<DevStatusCommit>,
1631}
1632
1633// ── DevStatus summary response structs ────────────────────────────
1634
1635#[derive(Deserialize)]
1636pub(crate) struct DevStatusSummaryResponse {
1637    #[serde(default)]
1638    pub(crate) summary: DevStatusSummaryData,
1639}
1640
1641#[derive(Deserialize, Default)]
1642pub(crate) struct DevStatusSummaryData {
1643    #[serde(default)]
1644    pub(crate) pullrequest: Option<DevStatusSummaryCategory>,
1645    #[serde(default)]
1646    pub(crate) branch: Option<DevStatusSummaryCategory>,
1647    #[serde(default)]
1648    pub(crate) repository: Option<DevStatusSummaryCategory>,
1649}
1650
1651#[derive(Deserialize)]
1652pub(crate) struct DevStatusSummaryCategory {
1653    pub(crate) overall: Option<DevStatusSummaryOverall>,
1654    // Keyed by instance-type identifier (e.g. "github", "stash"); only the
1655    // keys are needed for provider discovery, so the values are ignored.
1656    #[serde(rename = "byInstanceType", default)]
1657    pub(crate) by_instance_type: HashMap<String, serde_json::Value>,
1658}
1659
1660#[derive(Deserialize)]
1661pub(crate) struct DevStatusSummaryOverall {
1662    #[serde(default)]
1663    pub(crate) count: u32,
1664}