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
678/// A JIRA workflow transition returned by
679/// `GET /rest/api/3/issue/{key}/transitions`. Executed via
680/// `POST /rest/api/3/issue/{key}/transitions`.
681#[derive(Debug, Clone, Serialize)]
682pub struct JiraTransition {
683    /// Transition ID.
684    pub id: String,
685    /// Transition name (e.g., "In Progress", "Done").
686    pub name: String,
687    /// Status the transition moves the issue into.
688    #[serde(skip_serializing_if = "Option::is_none")]
689    pub to_status: Option<JiraTransitionToStatus>,
690    /// Whether executing the transition triggers a screen requiring extra fields.
691    #[serde(skip_serializing_if = "Option::is_none")]
692    pub has_screen: Option<bool>,
693}
694
695/// Destination status of a JIRA workflow transition, embedded in
696/// [`JiraTransition::to_status`].
697#[derive(Debug, Clone, Serialize)]
698pub struct JiraTransitionToStatus {
699    /// Status ID.
700    pub id: String,
701    /// Status name (e.g., "In Progress", "Done").
702    pub name: String,
703    /// Status category key (e.g., "new", "indeterminate", "done").
704    #[serde(skip_serializing_if = "Option::is_none")]
705    pub category: Option<String>,
706}
707
708/// A pull request entry from Jira's DevStatus detail endpoint
709/// (`GET /rest/dev-status/1.0/issue/detail?issueId={id}&applicationType=…&dataType=pullrequest`).
710#[derive(Debug, Clone, Serialize)]
711pub struct JiraDevPullRequest {
712    /// PR identifier (e.g., "#2174").
713    pub id: String,
714    /// PR title.
715    pub name: String,
716    /// Status (e.g., "OPEN", "MERGED", "DECLINED").
717    pub status: String,
718    /// URL to the pull request.
719    pub url: String,
720    /// Repository name (e.g., "org/repo").
721    pub repository_name: String,
722    /// Source branch name.
723    pub source_branch: String,
724    /// Destination branch name.
725    pub destination_branch: String,
726    /// PR author name.
727    #[serde(skip_serializing_if = "Option::is_none")]
728    pub author: Option<String>,
729    /// Reviewer names.
730    #[serde(skip_serializing_if = "Vec::is_empty")]
731    pub reviewers: Vec<String>,
732    /// Number of comments on the PR.
733    #[serde(skip_serializing_if = "Option::is_none")]
734    pub comment_count: Option<u32>,
735    /// Last update timestamp (ISO 8601).
736    #[serde(skip_serializing_if = "Option::is_none")]
737    pub last_update: Option<String>,
738}
739
740/// A commit entry from Jira's DevStatus detail endpoint
741/// (`dataType=repository`), embedded in [`JiraDevRepository::commits`] and
742/// [`JiraDevBranch::last_commit`].
743#[derive(Debug, Clone, Serialize)]
744pub struct JiraDevCommit {
745    /// Full commit SHA.
746    pub id: String,
747    /// Short commit SHA.
748    pub display_id: String,
749    /// Commit message.
750    pub message: String,
751    /// Commit author name.
752    #[serde(skip_serializing_if = "Option::is_none")]
753    pub author: Option<String>,
754    /// Author timestamp (ISO 8601).
755    #[serde(skip_serializing_if = "Option::is_none")]
756    pub timestamp: Option<String>,
757    /// URL to the commit.
758    pub url: String,
759    /// Number of files changed.
760    pub file_count: u32,
761    /// Whether this is a merge commit.
762    pub merge: bool,
763}
764
765/// A branch entry from Jira's DevStatus detail endpoint
766/// (`dataType=branch`).
767#[derive(Debug, Clone, Serialize)]
768pub struct JiraDevBranch {
769    /// Branch name.
770    pub name: String,
771    /// URL to the branch.
772    pub url: String,
773    /// Repository name (e.g., "org/repo").
774    pub repository_name: String,
775    /// URL to create a pull request from this branch.
776    #[serde(skip_serializing_if = "Option::is_none")]
777    pub create_pr_url: Option<String>,
778    /// Most recent commit on this branch.
779    #[serde(skip_serializing_if = "Option::is_none")]
780    pub last_commit: Option<JiraDevCommit>,
781}
782
783/// A repository entry from Jira's DevStatus detail endpoint
784/// (`dataType=repository`).
785#[derive(Debug, Clone, Serialize)]
786pub struct JiraDevRepository {
787    /// Repository name (e.g., "org/repo").
788    pub name: String,
789    /// URL to the repository.
790    pub url: String,
791    /// Commits linked to this issue in the repository.
792    #[serde(skip_serializing_if = "Vec::is_empty")]
793    pub commits: Vec<JiraDevCommit>,
794}
795
796/// Aggregated development data for a Jira issue, assembled from the DevStatus
797/// detail endpoint (`GET /rest/dev-status/1.0/issue/detail`) across the PR,
798/// branch, and repository data types.
799///
800/// See [`JiraDevStatusSummary`] for the high-level count-only summary.
801#[derive(Debug, Clone, Serialize)]
802pub struct JiraDevStatus {
803    /// Linked pull requests.
804    #[serde(skip_serializing_if = "Vec::is_empty")]
805    pub pull_requests: Vec<JiraDevPullRequest>,
806    /// Linked branches.
807    #[serde(skip_serializing_if = "Vec::is_empty")]
808    pub branches: Vec<JiraDevBranch>,
809    /// Linked repositories.
810    #[serde(skip_serializing_if = "Vec::is_empty")]
811    pub repositories: Vec<JiraDevRepository>,
812}
813
814/// Per-category count from Jira's DevStatus summary endpoint
815/// (`GET /rest/dev-status/1.0/issue/summary?issueId={id}`). Embedded in
816/// [`JiraDevStatusSummary`].
817#[derive(Debug, Clone, Serialize)]
818pub struct JiraDevStatusCount {
819    /// Number of items.
820    pub count: u32,
821    /// Providers that have data for this category.
822    pub providers: Vec<JiraDevProvider>,
823}
824
825/// A development-info provider that has data for a JIRA issue, as reported by
826/// the DevStatus summary endpoint's `byInstanceType` map.
827#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
828pub struct JiraDevProvider {
829    /// Instance-type identifier — the value the detail endpoint expects as
830    /// `applicationType` (e.g. "stash", "bitbucket", "GitHub"). This is the
831    /// round-trip key; do not substitute the display name here.
832    pub instance_type: String,
833    /// Human-readable display name (e.g. "Bitbucket Server", "GitHub").
834    pub name: String,
835}
836
837/// High-level dev-status summary from
838/// `GET /rest/dev-status/1.0/issue/summary?issueId={id}`. Count-only — use
839/// [`JiraDevStatus`] when the individual PRs / branches / repos are needed.
840#[derive(Debug, Clone, Serialize)]
841pub struct JiraDevStatusSummary {
842    /// Pull request summary.
843    pub pullrequest: JiraDevStatusCount,
844    /// Branch summary.
845    pub branch: JiraDevStatusCount,
846    /// Repository summary.
847    pub repository: JiraDevStatusCount,
848}
849
850/// A JIRA issue worklog entry returned by
851/// `GET /rest/api/3/issue/{key}/worklog`.
852#[derive(Debug, Clone, Serialize)]
853pub struct JiraWorklog {
854    /// Worklog ID.
855    pub id: String,
856    /// Author display name.
857    pub author: String,
858    /// Time spent in human-readable format (e.g., "2h 30m").
859    pub time_spent: String,
860    /// Time spent in seconds.
861    pub time_spent_seconds: u64,
862    /// ISO 8601 timestamp when the work was started.
863    pub started: String,
864    /// Comment text (plain text, extracted from ADF).
865    #[serde(skip_serializing_if = "Option::is_none")]
866    pub comment: Option<String>,
867}
868
869/// Paginated wrapper around [`JiraWorklog`] entries from
870/// `GET /rest/api/3/issue/{key}/worklog`.
871#[derive(Debug, Clone, Serialize)]
872pub struct JiraWorklogList {
873    /// Worklog entries.
874    pub worklogs: Vec<JiraWorklog>,
875    /// Total number of worklogs.
876    pub total: u32,
877}
878
879// ── Internal API response structs ───────────────────────────────────
880
881#[derive(Deserialize)]
882pub(crate) struct JiraIssueResponse {
883    pub(crate) key: String,
884    pub(crate) fields: JiraIssueFields,
885}
886
887/// Flexible deserialization target for `GET /rest/api/3/issue/{key}` that
888/// retains every field value as raw JSON so custom fields can be extracted.
889#[derive(Deserialize)]
890pub(crate) struct JiraIssueEnvelope {
891    pub(crate) key: String,
892    #[serde(default)]
893    pub(crate) fields: std::collections::BTreeMap<String, serde_json::Value>,
894    #[serde(default)]
895    pub(crate) names: std::collections::BTreeMap<String, String>,
896}
897
898impl JiraIssueEnvelope {
899    pub(crate) fn into_issue(self, selection: &FieldSelection) -> JiraIssue {
900        let Self {
901            key,
902            mut fields,
903            names,
904        } = self;
905
906        let description_adf = fields.remove("description").filter(|v| !v.is_null());
907        let summary = fields
908            .remove("summary")
909            .and_then(|v| v.as_str().map(str::to_string))
910            .unwrap_or_default();
911        let status = extract_named_field(fields.remove("status"));
912        let issue_type = extract_named_field(fields.remove("issuetype"));
913        let assignee = extract_display_name(fields.remove("assignee"));
914        let priority = extract_named_field(fields.remove("priority"));
915        let labels = fields
916            .remove("labels")
917            .and_then(|v| serde_json::from_value::<Vec<String>>(v).ok())
918            .unwrap_or_default();
919
920        let collect_customs = !matches!(selection, FieldSelection::Standard);
921        let custom_fields = if collect_customs {
922            fields
923                .into_iter()
924                .filter(|(_, value)| !value.is_null())
925                .map(|(id, value)| {
926                    let name = names.get(&id).cloned().unwrap_or_else(|| id.clone());
927                    JiraCustomField { id, name, value }
928                })
929                .collect()
930        } else {
931            Vec::new()
932        };
933
934        JiraIssue {
935            key,
936            summary,
937            description_adf,
938            status,
939            issue_type,
940            assignee,
941            priority,
942            labels,
943            custom_fields,
944        }
945    }
946}
947
948fn extract_named_field(value: Option<serde_json::Value>) -> Option<String> {
949    value
950        .and_then(|v| v.get("name").cloned())
951        .and_then(|n| n.as_str().map(str::to_string))
952}
953
954fn extract_display_name(value: Option<serde_json::Value>) -> Option<String> {
955    value
956        .and_then(|v| v.get("displayName").cloned())
957        .and_then(|n| n.as_str().map(str::to_string))
958}
959
960#[derive(Deserialize)]
961pub(crate) struct JiraEditMetaResponse {
962    #[serde(default)]
963    pub(crate) fields: std::collections::BTreeMap<String, JiraEditMetaField>,
964}
965
966#[derive(Deserialize)]
967pub(crate) struct JiraEditMetaField {
968    #[serde(default)]
969    pub(crate) name: Option<String>,
970    #[serde(default)]
971    pub(crate) schema: Option<JiraEditMetaSchemaRaw>,
972    #[serde(rename = "allowedValues", default)]
973    pub(crate) allowed_values: Vec<JiraAllowedValueRaw>,
974}
975
976impl JiraEditMetaField {
977    /// Flattens the raw `allowedValues` to their display `value`s (falling back
978    /// to `name`) for option validation. Cascading `children` are ignored —
979    /// `--set-field` targets only the top-level option.
980    pub(crate) fn allowed_value_strings(&self) -> Vec<String> {
981        self.allowed_values
982            .iter()
983            .filter_map(|v| v.value.clone().or_else(|| v.name.clone()))
984            .collect()
985    }
986}
987
988#[derive(Deserialize)]
989pub(crate) struct JiraEditMetaSchemaRaw {
990    #[serde(rename = "type", default)]
991    pub(crate) kind: Option<String>,
992    #[serde(default)]
993    pub(crate) custom: Option<String>,
994    #[serde(default)]
995    pub(crate) items: Option<String>,
996    #[serde(default)]
997    pub(crate) system: Option<String>,
998}
999
1000impl From<Option<JiraEditMetaSchemaRaw>> for EditMetaSchema {
1001    fn from(raw: Option<JiraEditMetaSchemaRaw>) -> Self {
1002        raw.map_or_else(Self::default, |s| Self {
1003            kind: s.kind.unwrap_or_default(),
1004            custom: s.custom,
1005            items: s.items,
1006            system: s.system,
1007        })
1008    }
1009}
1010
1011#[derive(Deserialize)]
1012pub(crate) struct JiraCreateMetaResponse {
1013    #[serde(default)]
1014    pub(crate) projects: Vec<JiraCreateMetaProject>,
1015}
1016
1017#[derive(Deserialize)]
1018pub(crate) struct JiraCreateMetaProject {
1019    #[serde(default)]
1020    pub(crate) issuetypes: Vec<JiraCreateMetaIssueType>,
1021}
1022
1023#[derive(Deserialize)]
1024pub(crate) struct JiraCreateMetaIssueType {
1025    #[serde(default)]
1026    pub(crate) fields: std::collections::BTreeMap<String, JiraEditMetaField>,
1027}
1028
1029/// Full createmeta response, parsed for the richer `create-meta` introspection
1030/// (keeps `required`, `allowedValues`, and `defaultValue`, which the lean
1031/// `JiraCreateMeta*` path above discards).
1032#[derive(Deserialize)]
1033pub(crate) struct JiraCreateMetaFullResponse {
1034    #[serde(default)]
1035    pub(crate) projects: Vec<JiraCreateMetaFullProject>,
1036}
1037
1038#[derive(Deserialize)]
1039pub(crate) struct JiraCreateMetaFullProject {
1040    #[serde(default)]
1041    pub(crate) issuetypes: Vec<JiraCreateMetaFullIssueType>,
1042}
1043
1044#[derive(Deserialize)]
1045pub(crate) struct JiraCreateMetaFullIssueType {
1046    #[serde(default)]
1047    pub(crate) fields: std::collections::BTreeMap<String, JiraCreateMetaFieldRaw>,
1048}
1049
1050#[derive(Deserialize)]
1051pub(crate) struct JiraCreateMetaFieldRaw {
1052    #[serde(default)]
1053    pub(crate) name: Option<String>,
1054    #[serde(default)]
1055    pub(crate) required: bool,
1056    #[serde(default)]
1057    pub(crate) schema: Option<JiraCreateMetaSchemaRaw>,
1058    #[serde(rename = "allowedValues", default)]
1059    pub(crate) allowed_values: Vec<JiraAllowedValueRaw>,
1060    #[serde(rename = "defaultValue", default)]
1061    pub(crate) default_value: Option<serde_json::Value>,
1062}
1063
1064#[derive(Deserialize)]
1065pub(crate) struct JiraCreateMetaSchemaRaw {
1066    #[serde(rename = "type", default)]
1067    pub(crate) kind: Option<String>,
1068    #[serde(default)]
1069    pub(crate) items: Option<String>,
1070    #[serde(default)]
1071    pub(crate) custom: Option<String>,
1072}
1073
1074#[derive(Deserialize)]
1075pub(crate) struct JiraAllowedValueRaw {
1076    #[serde(default)]
1077    pub(crate) id: Option<String>,
1078    #[serde(default)]
1079    pub(crate) value: Option<String>,
1080    #[serde(default)]
1081    pub(crate) name: Option<String>,
1082    #[serde(default)]
1083    pub(crate) children: Vec<Self>,
1084}
1085
1086impl JiraAllowedValueRaw {
1087    /// Normalizes one raw allowed value: display value is `value` falling back
1088    /// to `name`; children recurse.
1089    pub(crate) fn into_allowed_value(self) -> CreateMetaAllowedValue {
1090        CreateMetaAllowedValue {
1091            id: self.id,
1092            value: self.value.or(self.name),
1093            children: self
1094                .children
1095                .into_iter()
1096                .map(Self::into_allowed_value)
1097                .collect(),
1098        }
1099    }
1100}
1101
1102#[derive(Deserialize)]
1103pub(crate) struct JiraIssueFields {
1104    pub(crate) summary: Option<String>,
1105    pub(crate) description: Option<serde_json::Value>,
1106    pub(crate) status: Option<JiraNameField>,
1107    pub(crate) issuetype: Option<JiraNameField>,
1108    pub(crate) assignee: Option<JiraAssigneeField>,
1109    pub(crate) priority: Option<JiraNameField>,
1110    #[serde(default)]
1111    pub(crate) labels: Vec<String>,
1112}
1113
1114#[derive(Deserialize)]
1115pub(crate) struct JiraNameField {
1116    pub(crate) name: Option<String>,
1117}
1118
1119#[derive(Deserialize)]
1120pub(crate) struct JiraAssigneeField {
1121    #[serde(rename = "displayName")]
1122    pub(crate) display_name: Option<String>,
1123}
1124
1125#[derive(Deserialize)]
1126#[allow(dead_code)]
1127pub(crate) struct JiraSearchResponse {
1128    pub(crate) issues: Vec<JiraIssueResponse>,
1129    #[serde(default)]
1130    pub(crate) total: u32,
1131    #[serde(rename = "nextPageToken", default)]
1132    pub(crate) next_page_token: Option<String>,
1133}
1134
1135#[derive(Deserialize)]
1136pub(crate) struct JiraTransitionsResponse {
1137    pub(crate) transitions: Vec<JiraTransitionEntry>,
1138}
1139
1140#[derive(Deserialize)]
1141pub(crate) struct JiraTransitionEntry {
1142    pub(crate) id: String,
1143    pub(crate) name: String,
1144    #[serde(default)]
1145    pub(crate) to: Option<JiraTransitionToEntry>,
1146    #[serde(rename = "hasScreen", default)]
1147    pub(crate) has_screen: Option<bool>,
1148}
1149
1150#[derive(Deserialize)]
1151pub(crate) struct JiraTransitionToEntry {
1152    pub(crate) id: String,
1153    pub(crate) name: String,
1154    #[serde(rename = "statusCategory", default)]
1155    pub(crate) status_category: Option<JiraStatusCategoryEntry>,
1156}
1157
1158#[derive(Deserialize)]
1159pub(crate) struct JiraStatusCategoryEntry {
1160    #[serde(default)]
1161    pub(crate) key: Option<String>,
1162}
1163
1164#[derive(Deserialize)]
1165pub(crate) struct JiraCommentsResponse {
1166    #[serde(default)]
1167    pub(crate) comments: Vec<JiraCommentEntry>,
1168    #[serde(default)]
1169    pub(crate) total: u32,
1170    #[serde(rename = "startAt", default)]
1171    pub(crate) start_at: u32,
1172    #[serde(rename = "maxResults", default)]
1173    #[allow(dead_code)]
1174    pub(crate) max_results: u32,
1175}
1176
1177#[derive(Deserialize)]
1178pub(crate) struct JiraCommentEntry {
1179    pub(crate) id: String,
1180    pub(crate) author: Option<JiraCommentAuthor>,
1181    pub(crate) body: Option<serde_json::Value>,
1182    pub(crate) created: Option<String>,
1183    #[serde(default)]
1184    pub(crate) updated: Option<String>,
1185}
1186
1187#[derive(Deserialize)]
1188pub(crate) struct JiraCommentAuthor {
1189    #[serde(rename = "displayName")]
1190    pub(crate) display_name: Option<String>,
1191}
1192
1193#[derive(Deserialize)]
1194pub(crate) struct JiraWorklogResponse {
1195    #[serde(default)]
1196    pub(crate) worklogs: Vec<JiraWorklogEntry>,
1197    #[serde(default)]
1198    pub(crate) total: u32,
1199}
1200
1201#[derive(Deserialize)]
1202pub(crate) struct JiraWorklogEntry {
1203    pub(crate) id: String,
1204    pub(crate) author: Option<JiraCommentAuthor>,
1205    #[serde(rename = "timeSpent")]
1206    pub(crate) time_spent: Option<String>,
1207    #[serde(rename = "timeSpentSeconds", default)]
1208    pub(crate) time_spent_seconds: u64,
1209    pub(crate) started: Option<String>,
1210    pub(crate) comment: Option<serde_json::Value>,
1211}
1212
1213// ── JIRA user search API response struct ──────────────────────────
1214
1215#[derive(Deserialize)]
1216pub(crate) struct JiraUserSearchEntry {
1217    #[serde(rename = "accountId")]
1218    pub(crate) account_id: String,
1219    #[serde(rename = "displayName", default)]
1220    pub(crate) display_name: Option<String>,
1221    #[serde(rename = "emailAddress", default)]
1222    pub(crate) email_address: Option<String>,
1223    #[serde(default)]
1224    pub(crate) active: bool,
1225    #[serde(rename = "accountType", default)]
1226    pub(crate) account_type: Option<String>,
1227}
1228
1229// ── Agile API response structs ─────────────────────────────────────
1230
1231#[derive(Deserialize)]
1232#[allow(dead_code)]
1233pub(crate) struct AgileBoardListResponse {
1234    pub(crate) values: Vec<AgileBoardEntry>,
1235    #[serde(default)]
1236    pub(crate) total: u32,
1237    #[serde(rename = "isLast", default)]
1238    pub(crate) is_last: bool,
1239}
1240
1241#[derive(Deserialize)]
1242pub(crate) struct AgileBoardEntry {
1243    pub(crate) id: u64,
1244    pub(crate) name: String,
1245    #[serde(rename = "type")]
1246    pub(crate) board_type: String,
1247    pub(crate) location: Option<AgileBoardLocation>,
1248}
1249
1250#[derive(Deserialize)]
1251pub(crate) struct AgileBoardLocation {
1252    #[serde(rename = "projectKey")]
1253    pub(crate) project_key: Option<String>,
1254}
1255
1256#[derive(Deserialize)]
1257#[allow(dead_code)]
1258pub(crate) struct AgileIssueListResponse {
1259    pub(crate) issues: Vec<JiraIssueResponse>,
1260    #[serde(default)]
1261    pub(crate) total: u32,
1262    #[serde(rename = "isLast", default)]
1263    pub(crate) is_last: bool,
1264}
1265
1266#[derive(Deserialize)]
1267#[allow(dead_code)]
1268pub(crate) struct AgileSprintListResponse {
1269    pub(crate) values: Vec<AgileSprintEntry>,
1270    #[serde(default)]
1271    pub(crate) total: u32,
1272    #[serde(rename = "isLast", default)]
1273    pub(crate) is_last: bool,
1274}
1275
1276#[derive(Deserialize)]
1277pub(crate) struct AgileSprintEntry {
1278    pub(crate) id: u64,
1279    pub(crate) name: String,
1280    pub(crate) state: String,
1281    #[serde(rename = "startDate")]
1282    pub(crate) start_date: Option<String>,
1283    #[serde(rename = "endDate")]
1284    pub(crate) end_date: Option<String>,
1285    pub(crate) goal: Option<String>,
1286}
1287
1288#[derive(Deserialize)]
1289pub(crate) struct JiraProjectVersionEntry {
1290    pub(crate) id: String,
1291    pub(crate) name: String,
1292    #[serde(default)]
1293    pub(crate) description: Option<String>,
1294    #[serde(default)]
1295    pub(crate) released: bool,
1296    #[serde(default)]
1297    pub(crate) archived: bool,
1298    #[serde(rename = "releaseDate", default)]
1299    pub(crate) release_date: Option<String>,
1300    #[serde(rename = "startDate", default)]
1301    pub(crate) start_date: Option<String>,
1302}
1303
1304#[derive(Deserialize)]
1305pub(crate) struct JiraIssueLinksResponse {
1306    pub(crate) fields: JiraIssueLinksFields,
1307}
1308
1309#[derive(Deserialize)]
1310pub(crate) struct JiraIssueLinksFields {
1311    #[serde(default)]
1312    pub(crate) issuelinks: Vec<JiraIssueLinkEntry>,
1313}
1314
1315#[derive(Deserialize)]
1316pub(crate) struct JiraIssueLinkEntry {
1317    pub(crate) id: String,
1318    #[serde(rename = "type")]
1319    pub(crate) link_type: JiraIssueLinkType,
1320    #[serde(rename = "inwardIssue")]
1321    pub(crate) inward_issue: Option<JiraIssueLinkIssue>,
1322    #[serde(rename = "outwardIssue")]
1323    pub(crate) outward_issue: Option<JiraIssueLinkIssue>,
1324}
1325
1326#[derive(Deserialize)]
1327pub(crate) struct JiraIssueLinkType {
1328    pub(crate) name: String,
1329}
1330
1331#[derive(Deserialize)]
1332pub(crate) struct JiraIssueLinkIssue {
1333    pub(crate) key: String,
1334    pub(crate) fields: Option<JiraIssueLinkIssueFields>,
1335}
1336
1337#[derive(Deserialize)]
1338pub(crate) struct JiraIssueLinkIssueFields {
1339    pub(crate) summary: Option<String>,
1340}
1341
1342#[derive(Deserialize)]
1343pub(crate) struct JiraRemoteIssueLinkEntry {
1344    pub(crate) id: serde_json::Value,
1345    #[serde(rename = "globalId", default)]
1346    pub(crate) global_id: Option<String>,
1347    #[serde(default)]
1348    pub(crate) relationship: Option<String>,
1349    pub(crate) object: JiraRemoteIssueLinkObjectEntry,
1350}
1351
1352#[derive(Deserialize)]
1353pub(crate) struct JiraRemoteIssueLinkObjectEntry {
1354    pub(crate) url: String,
1355    #[serde(default)]
1356    pub(crate) title: Option<String>,
1357    #[serde(default)]
1358    pub(crate) summary: Option<String>,
1359    #[serde(default)]
1360    pub(crate) icon: Option<JiraRemoteIssueLinkIconEntry>,
1361}
1362
1363#[derive(Deserialize)]
1364pub(crate) struct JiraRemoteIssueLinkIconEntry {
1365    #[serde(rename = "url16x16", default)]
1366    pub(crate) url: Option<String>,
1367    #[serde(default)]
1368    pub(crate) title: Option<String>,
1369}
1370
1371#[derive(Deserialize)]
1372pub(crate) struct JiraLinkTypesResponse {
1373    #[serde(rename = "issueLinkTypes")]
1374    pub(crate) issue_link_types: Vec<JiraLinkTypeEntry>,
1375}
1376
1377#[derive(Deserialize)]
1378pub(crate) struct JiraLinkTypeEntry {
1379    pub(crate) id: String,
1380    pub(crate) name: String,
1381    pub(crate) inward: String,
1382    pub(crate) outward: String,
1383}
1384
1385#[derive(Deserialize)]
1386pub(crate) struct JiraAttachmentIssueResponse {
1387    pub(crate) fields: JiraAttachmentFields,
1388}
1389
1390#[derive(Deserialize)]
1391pub(crate) struct JiraAttachmentFields {
1392    #[serde(default)]
1393    pub(crate) attachment: Vec<JiraAttachmentEntry>,
1394}
1395
1396#[derive(Deserialize)]
1397pub(crate) struct JiraAttachmentEntry {
1398    pub(crate) id: String,
1399    pub(crate) filename: String,
1400    #[serde(rename = "mimeType")]
1401    pub(crate) mime_type: String,
1402    pub(crate) size: u64,
1403    pub(crate) content: String,
1404}
1405
1406#[derive(Deserialize)]
1407#[allow(dead_code)]
1408pub(crate) struct JiraChangelogResponse {
1409    pub(crate) values: Vec<JiraChangelogEntryResponse>,
1410    #[serde(default)]
1411    pub(crate) total: u32,
1412    #[serde(rename = "isLast", default)]
1413    pub(crate) is_last: bool,
1414}
1415
1416#[derive(Deserialize)]
1417pub(crate) struct JiraChangelogEntryResponse {
1418    pub(crate) id: String,
1419    pub(crate) author: Option<JiraCommentAuthor>,
1420    pub(crate) created: Option<String>,
1421    #[serde(default)]
1422    pub(crate) items: Vec<JiraChangelogItemResponse>,
1423}
1424
1425#[derive(Deserialize)]
1426pub(crate) struct JiraChangelogItemResponse {
1427    pub(crate) field: String,
1428    #[serde(rename = "fromString")]
1429    pub(crate) from_string: Option<String>,
1430    #[serde(rename = "toString")]
1431    pub(crate) to_string: Option<String>,
1432}
1433
1434#[derive(Deserialize)]
1435pub(crate) struct JiraFieldEntry {
1436    pub(crate) id: String,
1437    pub(crate) name: String,
1438    #[serde(default)]
1439    pub(crate) custom: bool,
1440    pub(crate) schema: Option<JiraFieldSchema>,
1441}
1442
1443#[derive(Deserialize)]
1444pub(crate) struct JiraFieldSchema {
1445    #[serde(rename = "type")]
1446    pub(crate) schema_type: Option<String>,
1447    pub(crate) custom: Option<String>,
1448}
1449
1450#[derive(Deserialize)]
1451pub(crate) struct JiraFieldContextsResponse {
1452    pub(crate) values: Vec<JiraFieldContextEntry>,
1453}
1454
1455#[derive(Deserialize)]
1456pub(crate) struct JiraFieldContextEntry {
1457    pub(crate) id: String,
1458}
1459
1460#[derive(Deserialize)]
1461pub(crate) struct JiraFieldOptionsResponse {
1462    pub(crate) values: Vec<JiraFieldOptionEntry>,
1463}
1464
1465#[derive(Deserialize)]
1466pub(crate) struct JiraFieldOptionEntry {
1467    pub(crate) id: String,
1468    pub(crate) value: String,
1469}
1470
1471#[derive(Deserialize)]
1472#[allow(dead_code)]
1473pub(crate) struct JiraProjectSearchResponse {
1474    pub(crate) values: Vec<JiraProjectEntry>,
1475    pub(crate) total: u32,
1476    #[serde(rename = "isLast", default)]
1477    pub(crate) is_last: bool,
1478}
1479
1480#[derive(Deserialize)]
1481pub(crate) struct JiraProjectEntry {
1482    pub(crate) id: String,
1483    pub(crate) key: String,
1484    pub(crate) name: String,
1485    #[serde(rename = "projectTypeKey")]
1486    pub(crate) project_type_key: Option<String>,
1487    pub(crate) lead: Option<JiraProjectLead>,
1488}
1489
1490#[derive(Deserialize)]
1491pub(crate) struct JiraProjectLead {
1492    #[serde(rename = "displayName")]
1493    pub(crate) display_name: Option<String>,
1494}
1495
1496#[derive(Deserialize)]
1497pub(crate) struct JiraCreateResponse {
1498    pub(crate) key: String,
1499    pub(crate) id: String,
1500    #[serde(rename = "self")]
1501    pub(crate) self_url: String,
1502}
1503
1504// ── DevStatus API response structs ─────────────────────────────────
1505
1506/// Minimal response for resolving an issue key to its numeric ID.
1507#[derive(Deserialize)]
1508pub(crate) struct JiraIssueIdResponse {
1509    pub(crate) id: String,
1510}
1511
1512#[derive(Deserialize)]
1513pub(crate) struct DevStatusResponse {
1514    #[serde(default)]
1515    pub(crate) detail: Vec<DevStatusDetail>,
1516}
1517
1518#[derive(Deserialize)]
1519pub(crate) struct DevStatusDetail {
1520    #[serde(rename = "pullRequests", default)]
1521    pub(crate) pull_requests: Vec<DevStatusPullRequest>,
1522    #[serde(default)]
1523    pub(crate) branches: Vec<DevStatusBranch>,
1524    #[serde(default)]
1525    pub(crate) repositories: Vec<DevStatusRepositoryEntry>,
1526}
1527
1528#[derive(Deserialize)]
1529pub(crate) struct DevStatusPullRequest {
1530    #[serde(default)]
1531    pub(crate) id: String,
1532    #[serde(default)]
1533    pub(crate) name: String,
1534    #[serde(default)]
1535    pub(crate) status: String,
1536    #[serde(default)]
1537    pub(crate) url: String,
1538    #[serde(rename = "repositoryName", default)]
1539    pub(crate) repository_name: String,
1540    #[serde(default)]
1541    pub(crate) source: Option<DevStatusBranchRef>,
1542    #[serde(default)]
1543    pub(crate) destination: Option<DevStatusBranchRef>,
1544    #[serde(default)]
1545    pub(crate) author: Option<DevStatusAuthor>,
1546    #[serde(default)]
1547    pub(crate) reviewers: Vec<DevStatusReviewer>,
1548    #[serde(rename = "commentCount", default)]
1549    pub(crate) comment_count: Option<u32>,
1550    #[serde(rename = "lastUpdate", default)]
1551    pub(crate) last_update: Option<String>,
1552}
1553
1554#[derive(Deserialize)]
1555pub(crate) struct DevStatusBranchRef {
1556    #[serde(default)]
1557    pub(crate) branch: String,
1558}
1559
1560#[derive(Deserialize)]
1561pub(crate) struct DevStatusAuthor {
1562    #[serde(default)]
1563    pub(crate) name: String,
1564}
1565
1566#[derive(Deserialize)]
1567pub(crate) struct DevStatusReviewer {
1568    #[serde(default)]
1569    pub(crate) name: String,
1570}
1571
1572#[derive(Deserialize)]
1573pub(crate) struct DevStatusCommit {
1574    #[serde(default)]
1575    pub(crate) id: String,
1576    #[serde(rename = "displayId", default)]
1577    pub(crate) display_id: String,
1578    #[serde(default)]
1579    pub(crate) message: String,
1580    #[serde(default)]
1581    pub(crate) author: Option<DevStatusAuthor>,
1582    #[serde(rename = "authorTimestamp", default)]
1583    pub(crate) author_timestamp: Option<String>,
1584    #[serde(default)]
1585    pub(crate) url: String,
1586    #[serde(rename = "fileCount", default)]
1587    pub(crate) file_count: u32,
1588    #[serde(default)]
1589    pub(crate) merge: bool,
1590}
1591
1592#[derive(Deserialize)]
1593pub(crate) struct DevStatusBranch {
1594    #[serde(default)]
1595    pub(crate) name: String,
1596    #[serde(default)]
1597    pub(crate) url: String,
1598    #[serde(rename = "repositoryName", default)]
1599    pub(crate) repository_name: String,
1600    #[serde(rename = "createPullRequestUrl", default)]
1601    pub(crate) create_pr_url: Option<String>,
1602    #[serde(rename = "lastCommit", default)]
1603    pub(crate) last_commit: Option<DevStatusCommit>,
1604}
1605
1606#[derive(Deserialize)]
1607pub(crate) struct DevStatusRepositoryEntry {
1608    #[serde(default)]
1609    pub(crate) name: String,
1610    #[serde(default)]
1611    pub(crate) url: String,
1612    #[serde(default)]
1613    pub(crate) commits: Vec<DevStatusCommit>,
1614}
1615
1616// ── DevStatus summary response structs ────────────────────────────
1617
1618#[derive(Deserialize)]
1619pub(crate) struct DevStatusSummaryResponse {
1620    #[serde(default)]
1621    pub(crate) summary: DevStatusSummaryData,
1622}
1623
1624#[derive(Deserialize, Default)]
1625pub(crate) struct DevStatusSummaryData {
1626    #[serde(default)]
1627    pub(crate) pullrequest: Option<DevStatusSummaryCategory>,
1628    #[serde(default)]
1629    pub(crate) branch: Option<DevStatusSummaryCategory>,
1630    #[serde(default)]
1631    pub(crate) repository: Option<DevStatusSummaryCategory>,
1632}
1633
1634#[derive(Deserialize)]
1635pub(crate) struct DevStatusSummaryCategory {
1636    pub(crate) overall: Option<DevStatusSummaryOverall>,
1637    // Keyed by instance-type identifier (e.g. "github", "stash"); only the
1638    // keys are needed for provider discovery, so the values are ignored.
1639    #[serde(rename = "byInstanceType", default)]
1640    pub(crate) by_instance_type: HashMap<String, serde_json::Value>,
1641}
1642
1643#[derive(Deserialize)]
1644pub(crate) struct DevStatusSummaryOverall {
1645    #[serde(default)]
1646    pub(crate) count: u32,
1647}