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