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}
1161
1162#[derive(Deserialize)]
1163pub(crate) struct JiraTransitionToEntry {
1164    pub(crate) id: String,
1165    pub(crate) name: String,
1166    #[serde(rename = "statusCategory", default)]
1167    pub(crate) status_category: Option<JiraStatusCategoryEntry>,
1168}
1169
1170#[derive(Deserialize)]
1171pub(crate) struct JiraStatusCategoryEntry {
1172    #[serde(default)]
1173    pub(crate) key: Option<String>,
1174}
1175
1176#[derive(Deserialize)]
1177pub(crate) struct JiraCommentsResponse {
1178    #[serde(default)]
1179    pub(crate) comments: Vec<JiraCommentEntry>,
1180    #[serde(default)]
1181    pub(crate) total: u32,
1182    #[serde(rename = "startAt", default)]
1183    pub(crate) start_at: u32,
1184    #[serde(rename = "maxResults", default)]
1185    #[allow(dead_code)]
1186    pub(crate) max_results: u32,
1187}
1188
1189#[derive(Deserialize)]
1190pub(crate) struct JiraCommentEntry {
1191    pub(crate) id: String,
1192    pub(crate) author: Option<JiraCommentAuthor>,
1193    pub(crate) body: Option<serde_json::Value>,
1194    pub(crate) created: Option<String>,
1195    #[serde(default)]
1196    pub(crate) updated: Option<String>,
1197}
1198
1199#[derive(Deserialize)]
1200pub(crate) struct JiraCommentAuthor {
1201    #[serde(rename = "displayName")]
1202    pub(crate) display_name: Option<String>,
1203}
1204
1205#[derive(Deserialize)]
1206pub(crate) struct JiraWorklogResponse {
1207    #[serde(default)]
1208    pub(crate) worklogs: Vec<JiraWorklogEntry>,
1209    #[serde(default)]
1210    pub(crate) total: u32,
1211}
1212
1213#[derive(Deserialize)]
1214pub(crate) struct JiraWorklogEntry {
1215    pub(crate) id: String,
1216    pub(crate) author: Option<JiraCommentAuthor>,
1217    #[serde(rename = "timeSpent")]
1218    pub(crate) time_spent: Option<String>,
1219    #[serde(rename = "timeSpentSeconds", default)]
1220    pub(crate) time_spent_seconds: u64,
1221    pub(crate) started: Option<String>,
1222    pub(crate) comment: Option<serde_json::Value>,
1223}
1224
1225// ── JIRA user search API response struct ──────────────────────────
1226
1227#[derive(Deserialize)]
1228pub(crate) struct JiraUserSearchEntry {
1229    #[serde(rename = "accountId")]
1230    pub(crate) account_id: String,
1231    #[serde(rename = "displayName", default)]
1232    pub(crate) display_name: Option<String>,
1233    #[serde(rename = "emailAddress", default)]
1234    pub(crate) email_address: Option<String>,
1235    #[serde(default)]
1236    pub(crate) active: bool,
1237    #[serde(rename = "accountType", default)]
1238    pub(crate) account_type: Option<String>,
1239}
1240
1241// ── Agile API response structs ─────────────────────────────────────
1242
1243#[derive(Deserialize)]
1244#[allow(dead_code)]
1245pub(crate) struct AgileBoardListResponse {
1246    pub(crate) values: Vec<AgileBoardEntry>,
1247    #[serde(default)]
1248    pub(crate) total: u32,
1249    #[serde(rename = "isLast", default)]
1250    pub(crate) is_last: bool,
1251}
1252
1253#[derive(Deserialize)]
1254pub(crate) struct AgileBoardEntry {
1255    pub(crate) id: u64,
1256    pub(crate) name: String,
1257    #[serde(rename = "type")]
1258    pub(crate) board_type: String,
1259    pub(crate) location: Option<AgileBoardLocation>,
1260}
1261
1262#[derive(Deserialize)]
1263pub(crate) struct AgileBoardLocation {
1264    #[serde(rename = "projectKey")]
1265    pub(crate) project_key: Option<String>,
1266}
1267
1268#[derive(Deserialize)]
1269#[allow(dead_code)]
1270pub(crate) struct AgileIssueListResponse {
1271    pub(crate) issues: Vec<JiraIssueResponse>,
1272    #[serde(default)]
1273    pub(crate) total: u32,
1274    #[serde(rename = "isLast", default)]
1275    pub(crate) is_last: bool,
1276}
1277
1278#[derive(Deserialize)]
1279#[allow(dead_code)]
1280pub(crate) struct AgileSprintListResponse {
1281    pub(crate) values: Vec<AgileSprintEntry>,
1282    #[serde(default)]
1283    pub(crate) total: u32,
1284    #[serde(rename = "isLast", default)]
1285    pub(crate) is_last: bool,
1286}
1287
1288#[derive(Deserialize)]
1289pub(crate) struct AgileSprintEntry {
1290    pub(crate) id: u64,
1291    pub(crate) name: String,
1292    pub(crate) state: String,
1293    #[serde(rename = "startDate")]
1294    pub(crate) start_date: Option<String>,
1295    #[serde(rename = "endDate")]
1296    pub(crate) end_date: Option<String>,
1297    pub(crate) goal: Option<String>,
1298}
1299
1300#[derive(Deserialize)]
1301pub(crate) struct JiraProjectVersionEntry {
1302    pub(crate) id: String,
1303    pub(crate) name: String,
1304    #[serde(default)]
1305    pub(crate) description: Option<String>,
1306    #[serde(default)]
1307    pub(crate) released: bool,
1308    #[serde(default)]
1309    pub(crate) archived: bool,
1310    #[serde(rename = "releaseDate", default)]
1311    pub(crate) release_date: Option<String>,
1312    #[serde(rename = "startDate", default)]
1313    pub(crate) start_date: Option<String>,
1314}
1315
1316#[derive(Deserialize)]
1317pub(crate) struct JiraIssueLinksResponse {
1318    pub(crate) fields: JiraIssueLinksFields,
1319}
1320
1321#[derive(Deserialize)]
1322pub(crate) struct JiraIssueLinksFields {
1323    #[serde(default)]
1324    pub(crate) issuelinks: Vec<JiraIssueLinkEntry>,
1325}
1326
1327#[derive(Deserialize)]
1328pub(crate) struct JiraIssueLinkEntry {
1329    pub(crate) id: String,
1330    #[serde(rename = "type")]
1331    pub(crate) link_type: JiraIssueLinkType,
1332    #[serde(rename = "inwardIssue")]
1333    pub(crate) inward_issue: Option<JiraIssueLinkIssue>,
1334    #[serde(rename = "outwardIssue")]
1335    pub(crate) outward_issue: Option<JiraIssueLinkIssue>,
1336}
1337
1338#[derive(Deserialize)]
1339pub(crate) struct JiraIssueLinkType {
1340    pub(crate) name: String,
1341}
1342
1343#[derive(Deserialize)]
1344pub(crate) struct JiraIssueLinkIssue {
1345    pub(crate) key: String,
1346    pub(crate) fields: Option<JiraIssueLinkIssueFields>,
1347}
1348
1349#[derive(Deserialize)]
1350pub(crate) struct JiraIssueLinkIssueFields {
1351    pub(crate) summary: Option<String>,
1352}
1353
1354#[derive(Deserialize)]
1355pub(crate) struct JiraRemoteIssueLinkEntry {
1356    pub(crate) id: serde_json::Value,
1357    #[serde(rename = "globalId", default)]
1358    pub(crate) global_id: Option<String>,
1359    #[serde(default)]
1360    pub(crate) relationship: Option<String>,
1361    pub(crate) object: JiraRemoteIssueLinkObjectEntry,
1362}
1363
1364#[derive(Deserialize)]
1365pub(crate) struct JiraRemoteIssueLinkObjectEntry {
1366    pub(crate) url: String,
1367    #[serde(default)]
1368    pub(crate) title: Option<String>,
1369    #[serde(default)]
1370    pub(crate) summary: Option<String>,
1371    #[serde(default)]
1372    pub(crate) icon: Option<JiraRemoteIssueLinkIconEntry>,
1373}
1374
1375#[derive(Deserialize)]
1376pub(crate) struct JiraRemoteIssueLinkIconEntry {
1377    #[serde(rename = "url16x16", default)]
1378    pub(crate) url: Option<String>,
1379    #[serde(default)]
1380    pub(crate) title: Option<String>,
1381}
1382
1383#[derive(Deserialize)]
1384pub(crate) struct JiraLinkTypesResponse {
1385    #[serde(rename = "issueLinkTypes")]
1386    pub(crate) issue_link_types: Vec<JiraLinkTypeEntry>,
1387}
1388
1389#[derive(Deserialize)]
1390pub(crate) struct JiraLinkTypeEntry {
1391    pub(crate) id: String,
1392    pub(crate) name: String,
1393    pub(crate) inward: String,
1394    pub(crate) outward: String,
1395}
1396
1397#[derive(Deserialize)]
1398pub(crate) struct JiraAttachmentIssueResponse {
1399    pub(crate) fields: JiraAttachmentFields,
1400}
1401
1402#[derive(Deserialize)]
1403pub(crate) struct JiraAttachmentFields {
1404    #[serde(default)]
1405    pub(crate) attachment: Vec<JiraAttachmentEntry>,
1406}
1407
1408#[derive(Deserialize)]
1409pub(crate) struct JiraAttachmentEntry {
1410    pub(crate) id: String,
1411    pub(crate) filename: String,
1412    #[serde(rename = "mimeType")]
1413    pub(crate) mime_type: String,
1414    pub(crate) size: u64,
1415    pub(crate) content: String,
1416}
1417
1418#[derive(Deserialize)]
1419#[allow(dead_code)]
1420pub(crate) struct JiraChangelogResponse {
1421    pub(crate) values: Vec<JiraChangelogEntryResponse>,
1422    #[serde(default)]
1423    pub(crate) total: u32,
1424    #[serde(rename = "isLast", default)]
1425    pub(crate) is_last: bool,
1426}
1427
1428#[derive(Deserialize)]
1429pub(crate) struct JiraChangelogEntryResponse {
1430    pub(crate) id: String,
1431    pub(crate) author: Option<JiraCommentAuthor>,
1432    pub(crate) created: Option<String>,
1433    #[serde(default)]
1434    pub(crate) items: Vec<JiraChangelogItemResponse>,
1435}
1436
1437#[derive(Deserialize)]
1438pub(crate) struct JiraChangelogItemResponse {
1439    pub(crate) field: String,
1440    #[serde(rename = "fromString")]
1441    pub(crate) from_string: Option<String>,
1442    #[serde(rename = "toString")]
1443    pub(crate) to_string: Option<String>,
1444}
1445
1446#[derive(Deserialize)]
1447pub(crate) struct JiraFieldEntry {
1448    pub(crate) id: String,
1449    pub(crate) name: String,
1450    #[serde(default)]
1451    pub(crate) custom: bool,
1452    pub(crate) schema: Option<JiraFieldSchema>,
1453}
1454
1455#[derive(Deserialize)]
1456pub(crate) struct JiraFieldSchema {
1457    #[serde(rename = "type")]
1458    pub(crate) schema_type: Option<String>,
1459    pub(crate) custom: Option<String>,
1460}
1461
1462#[derive(Deserialize)]
1463pub(crate) struct JiraFieldContextsResponse {
1464    pub(crate) values: Vec<JiraFieldContextEntry>,
1465}
1466
1467#[derive(Deserialize)]
1468pub(crate) struct JiraFieldContextEntry {
1469    pub(crate) id: String,
1470}
1471
1472#[derive(Deserialize)]
1473pub(crate) struct JiraFieldOptionsResponse {
1474    pub(crate) values: Vec<JiraFieldOptionEntry>,
1475}
1476
1477#[derive(Deserialize)]
1478pub(crate) struct JiraFieldOptionEntry {
1479    pub(crate) id: String,
1480    pub(crate) value: String,
1481}
1482
1483#[derive(Deserialize)]
1484#[allow(dead_code)]
1485pub(crate) struct JiraProjectSearchResponse {
1486    pub(crate) values: Vec<JiraProjectEntry>,
1487    pub(crate) total: u32,
1488    #[serde(rename = "isLast", default)]
1489    pub(crate) is_last: bool,
1490}
1491
1492#[derive(Deserialize)]
1493pub(crate) struct JiraProjectEntry {
1494    pub(crate) id: String,
1495    pub(crate) key: String,
1496    pub(crate) name: String,
1497    #[serde(rename = "projectTypeKey")]
1498    pub(crate) project_type_key: Option<String>,
1499    pub(crate) lead: Option<JiraProjectLead>,
1500}
1501
1502#[derive(Deserialize)]
1503pub(crate) struct JiraProjectLead {
1504    #[serde(rename = "displayName")]
1505    pub(crate) display_name: Option<String>,
1506}
1507
1508#[derive(Deserialize)]
1509pub(crate) struct JiraCreateResponse {
1510    pub(crate) key: String,
1511    pub(crate) id: String,
1512    #[serde(rename = "self")]
1513    pub(crate) self_url: String,
1514}
1515
1516// ── DevStatus API response structs ─────────────────────────────────
1517
1518/// Minimal response for resolving an issue key to its numeric ID.
1519#[derive(Deserialize)]
1520pub(crate) struct JiraIssueIdResponse {
1521    pub(crate) id: String,
1522}
1523
1524#[derive(Deserialize)]
1525pub(crate) struct DevStatusResponse {
1526    #[serde(default)]
1527    pub(crate) detail: Vec<DevStatusDetail>,
1528}
1529
1530#[derive(Deserialize)]
1531pub(crate) struct DevStatusDetail {
1532    #[serde(rename = "pullRequests", default)]
1533    pub(crate) pull_requests: Vec<DevStatusPullRequest>,
1534    #[serde(default)]
1535    pub(crate) branches: Vec<DevStatusBranch>,
1536    #[serde(default)]
1537    pub(crate) repositories: Vec<DevStatusRepositoryEntry>,
1538}
1539
1540#[derive(Deserialize)]
1541pub(crate) struct DevStatusPullRequest {
1542    #[serde(default)]
1543    pub(crate) id: String,
1544    #[serde(default)]
1545    pub(crate) name: String,
1546    #[serde(default)]
1547    pub(crate) status: String,
1548    #[serde(default)]
1549    pub(crate) url: String,
1550    #[serde(rename = "repositoryName", default)]
1551    pub(crate) repository_name: String,
1552    #[serde(default)]
1553    pub(crate) source: Option<DevStatusBranchRef>,
1554    #[serde(default)]
1555    pub(crate) destination: Option<DevStatusBranchRef>,
1556    #[serde(default)]
1557    pub(crate) author: Option<DevStatusAuthor>,
1558    #[serde(default)]
1559    pub(crate) reviewers: Vec<DevStatusReviewer>,
1560    #[serde(rename = "commentCount", default)]
1561    pub(crate) comment_count: Option<u32>,
1562    #[serde(rename = "lastUpdate", default)]
1563    pub(crate) last_update: Option<String>,
1564}
1565
1566#[derive(Deserialize)]
1567pub(crate) struct DevStatusBranchRef {
1568    #[serde(default)]
1569    pub(crate) branch: String,
1570}
1571
1572#[derive(Deserialize)]
1573pub(crate) struct DevStatusAuthor {
1574    #[serde(default)]
1575    pub(crate) name: String,
1576}
1577
1578#[derive(Deserialize)]
1579pub(crate) struct DevStatusReviewer {
1580    #[serde(default)]
1581    pub(crate) name: String,
1582}
1583
1584#[derive(Deserialize)]
1585pub(crate) struct DevStatusCommit {
1586    #[serde(default)]
1587    pub(crate) id: String,
1588    #[serde(rename = "displayId", default)]
1589    pub(crate) display_id: String,
1590    #[serde(default)]
1591    pub(crate) message: String,
1592    #[serde(default)]
1593    pub(crate) author: Option<DevStatusAuthor>,
1594    #[serde(rename = "authorTimestamp", default)]
1595    pub(crate) author_timestamp: Option<String>,
1596    #[serde(default)]
1597    pub(crate) url: String,
1598    #[serde(rename = "fileCount", default)]
1599    pub(crate) file_count: u32,
1600    #[serde(default)]
1601    pub(crate) merge: bool,
1602}
1603
1604#[derive(Deserialize)]
1605pub(crate) struct DevStatusBranch {
1606    #[serde(default)]
1607    pub(crate) name: String,
1608    #[serde(default)]
1609    pub(crate) url: String,
1610    #[serde(rename = "repositoryName", default)]
1611    pub(crate) repository_name: String,
1612    #[serde(rename = "createPullRequestUrl", default)]
1613    pub(crate) create_pr_url: Option<String>,
1614    #[serde(rename = "lastCommit", default)]
1615    pub(crate) last_commit: Option<DevStatusCommit>,
1616}
1617
1618#[derive(Deserialize)]
1619pub(crate) struct DevStatusRepositoryEntry {
1620    #[serde(default)]
1621    pub(crate) name: String,
1622    #[serde(default)]
1623    pub(crate) url: String,
1624    #[serde(default)]
1625    pub(crate) commits: Vec<DevStatusCommit>,
1626}
1627
1628// ── DevStatus summary response structs ────────────────────────────
1629
1630#[derive(Deserialize)]
1631pub(crate) struct DevStatusSummaryResponse {
1632    #[serde(default)]
1633    pub(crate) summary: DevStatusSummaryData,
1634}
1635
1636#[derive(Deserialize, Default)]
1637pub(crate) struct DevStatusSummaryData {
1638    #[serde(default)]
1639    pub(crate) pullrequest: Option<DevStatusSummaryCategory>,
1640    #[serde(default)]
1641    pub(crate) branch: Option<DevStatusSummaryCategory>,
1642    #[serde(default)]
1643    pub(crate) repository: Option<DevStatusSummaryCategory>,
1644}
1645
1646#[derive(Deserialize)]
1647pub(crate) struct DevStatusSummaryCategory {
1648    pub(crate) overall: Option<DevStatusSummaryOverall>,
1649    // Keyed by instance-type identifier (e.g. "github", "stash"); only the
1650    // keys are needed for provider discovery, so the values are ignored.
1651    #[serde(rename = "byInstanceType", default)]
1652    pub(crate) by_instance_type: HashMap<String, serde_json::Value>,
1653}
1654
1655#[derive(Deserialize)]
1656pub(crate) struct DevStatusSummaryOverall {
1657    #[serde(default)]
1658    pub(crate) count: u32,
1659}