Skip to main content

jira_cli/api/
types.rs

1use serde::{Deserialize, Serialize};
2
3/// Jira issue as returned by the search and issue endpoints.
4#[derive(Debug, Deserialize, Serialize, Clone)]
5pub struct Issue {
6    pub id: String,
7    pub key: String,
8    #[serde(rename = "self")]
9    pub url: Option<String>,
10    pub fields: IssueFields,
11    /// Key of the epic this issue belongs to directly. Filled in by the client
12    /// after fetching: from `parent` on Cloud, from the Epic Link field on
13    /// Data Center / Server.
14    #[serde(skip)]
15    pub epic: Option<String>,
16}
17
18impl Issue {
19    pub fn summary(&self) -> &str {
20        &self.fields.summary
21    }
22
23    pub fn status(&self) -> &str {
24        &self.fields.status.name
25    }
26
27    pub fn assignee(&self) -> &str {
28        self.fields
29            .assignee
30            .as_ref()
31            .map(|a| a.display_name.as_str())
32            .unwrap_or("-")
33    }
34
35    pub fn priority(&self) -> &str {
36        self.fields
37            .priority
38            .as_ref()
39            .map(|p| p.name.as_str())
40            .unwrap_or("-")
41    }
42
43    pub fn issue_type(&self) -> &str {
44        &self.fields.issuetype.name
45    }
46
47    /// Extract plain text from the Atlassian Document Format description.
48    pub fn description_text(&self) -> String {
49        match &self.fields.description {
50            Some(doc) => extract_adf_text(doc),
51            None => String::new(),
52        }
53    }
54
55    /// Construct the browser URL from the site base URL.
56    pub fn browser_url(&self, site_url: &str) -> String {
57        format!("{site_url}/browse/{}", self.key)
58    }
59
60    pub fn components(&self) -> &[Component] {
61        self.fields.components.as_deref().unwrap_or(&[])
62    }
63}
64
65#[derive(Debug, Deserialize, Serialize, Clone)]
66pub struct IssueFields {
67    pub summary: String,
68    pub status: StatusField,
69    pub assignee: Option<UserField>,
70    pub reporter: Option<UserField>,
71    pub priority: Option<PriorityField>,
72    pub issuetype: IssueTypeField,
73    pub description: Option<serde_json::Value>,
74    pub labels: Option<Vec<String>>,
75    pub components: Option<Vec<Component>>,
76    #[serde(rename = "fixVersions")]
77    pub fix_versions: Option<Vec<Version>>,
78    /// Affected versions (API field name: `versions`).
79    pub versions: Option<Vec<Version>>,
80    pub created: Option<String>,
81    pub updated: Option<String>,
82    pub comment: Option<CommentList>,
83    #[serde(rename = "issuelinks")]
84    pub issue_links: Option<Vec<IssueLink>>,
85    pub parent: Option<ParentIssue>,
86    /// Requested fields without a typed slot (the Data Center Epic Link custom
87    /// field, whose ID differs per instance).
88    #[serde(flatten, skip_serializing)]
89    pub extra: serde_json::Map<String, serde_json::Value>,
90}
91
92/// The `parent` field of an issue: a subtask's parent, or on Cloud also the
93/// epic above a standard issue.
94#[derive(Debug, Deserialize, Serialize, Clone)]
95pub struct ParentIssue {
96    pub key: String,
97    #[serde(default)]
98    pub fields: Option<ParentIssueFields>,
99}
100
101#[derive(Debug, Deserialize, Serialize, Clone)]
102pub struct ParentIssueFields {
103    pub summary: Option<String>,
104    pub issuetype: Option<IssueTypeField>,
105}
106
107#[derive(Debug, Deserialize, Serialize, Clone)]
108pub struct StatusField {
109    pub name: String,
110}
111
112#[derive(Debug, Deserialize, Serialize, Clone)]
113#[serde(rename_all = "camelCase")]
114pub struct UserField {
115    pub display_name: String,
116    pub email_address: Option<String>,
117    /// Cloud: `accountId`. DC/Server: `name` (username).
118    #[serde(alias = "name")]
119    pub account_id: Option<String>,
120}
121
122#[derive(Debug, Deserialize, Serialize, Clone)]
123pub struct PriorityField {
124    pub name: String,
125}
126
127#[derive(Debug, Deserialize, Serialize, Clone)]
128#[serde(rename_all = "camelCase")]
129pub struct IssueTypeField {
130    pub name: String,
131    /// Cloud only; `1` is the epic level.
132    #[serde(default, skip_serializing_if = "Option::is_none")]
133    pub hierarchy_level: Option<i64>,
134}
135
136#[derive(Debug, Deserialize, Serialize, Clone)]
137#[serde(rename_all = "camelCase")]
138pub struct CommentList {
139    pub comments: Vec<Comment>,
140    pub total: usize,
141    #[serde(default)]
142    pub start_at: usize,
143    #[serde(default)]
144    pub max_results: usize,
145}
146
147#[derive(Debug, Deserialize, Serialize, Clone)]
148#[serde(rename_all = "camelCase")]
149pub struct Comment {
150    pub id: String,
151    pub author: UserField,
152    pub body: Option<serde_json::Value>,
153    pub created: String,
154    pub updated: Option<String>,
155}
156
157impl Comment {
158    pub fn body_text(&self) -> String {
159        match &self.body {
160            Some(doc) => extract_adf_text(doc),
161            None => String::new(),
162        }
163    }
164}
165
166/// A file attached to an issue.
167#[derive(Debug, Deserialize, Serialize, Clone)]
168#[serde(rename_all = "camelCase")]
169pub struct Attachment {
170    #[serde(deserialize_with = "attachment_id")]
171    pub id: String,
172    pub filename: String,
173    pub size: u64,
174    pub mime_type: Option<String>,
175    pub author: Option<UserField>,
176    pub created: String,
177}
178
179impl Attachment {
180    pub fn mime_type(&self) -> &str {
181        self.mime_type.as_deref().unwrap_or("-")
182    }
183
184    pub fn author(&self) -> &str {
185        self.author
186            .as_ref()
187            .map(|a| a.display_name.as_str())
188            .unwrap_or("-")
189    }
190}
191
192/// A Jira user returned from the user search endpoint.
193#[derive(Debug, Deserialize, Serialize, Clone)]
194#[serde(rename_all = "camelCase")]
195pub struct User {
196    /// Cloud: `accountId`. DC/Server: `name` (username).
197    #[serde(alias = "name")]
198    pub account_id: String,
199    pub display_name: String,
200    pub email_address: Option<String>,
201}
202
203/// An issue link (relationship between two issues).
204#[derive(Debug, Deserialize, Serialize, Clone)]
205#[serde(rename_all = "camelCase")]
206pub struct IssueLink {
207    pub id: String,
208    #[serde(rename = "type")]
209    pub link_type: IssueLinkType,
210    pub outward_issue: Option<LinkedIssue>,
211    pub inward_issue: Option<LinkedIssue>,
212}
213
214/// The type of an issue link (e.g. "Blocks", "Duplicate").
215#[derive(Debug, Deserialize, Serialize, Clone)]
216pub struct IssueLinkType {
217    pub id: String,
218    pub name: String,
219    pub inward: String,
220    pub outward: String,
221}
222
223/// A summary view of an issue referenced in a link.
224#[derive(Debug, Deserialize, Serialize, Clone)]
225pub struct LinkedIssue {
226    pub key: String,
227    pub fields: LinkedIssueFields,
228}
229
230#[derive(Debug, Deserialize, Serialize, Clone)]
231pub struct LinkedIssueFields {
232    pub summary: String,
233    pub status: StatusField,
234}
235
236/// A Jira project component (a sub-grouping of issues within a project).
237#[derive(Debug, Deserialize, Serialize, Clone)]
238pub struct Component {
239    pub id: String,
240    pub name: String,
241    pub description: Option<String>,
242}
243
244/// A Jira project version (a release milestone; also used for affectedVersions).
245#[derive(Debug, Deserialize, Serialize, Clone)]
246#[serde(rename_all = "camelCase")]
247pub struct Version {
248    pub id: String,
249    pub name: String,
250    pub description: Option<String>,
251    pub released: Option<bool>,
252    pub archived: Option<bool>,
253    pub release_date: Option<String>,
254}
255
256/// A Jira Agile board.
257#[derive(Debug, Deserialize, Serialize, Clone)]
258#[serde(rename_all = "camelCase")]
259pub struct Board {
260    pub id: u64,
261    pub name: String,
262    #[serde(rename = "type")]
263    pub board_type: String,
264}
265
266impl Board {
267    /// Unknown board types may support sprints; let the Agile API decide.
268    pub(crate) fn may_support_sprints(&self) -> bool {
269        !self.board_type.eq_ignore_ascii_case("kanban")
270    }
271}
272
273/// Paginated board response from the Agile API.
274#[derive(Debug, Deserialize)]
275#[serde(rename_all = "camelCase")]
276pub struct BoardSearchResponse {
277    pub values: Vec<Board>,
278    pub is_last: bool,
279    #[serde(default)]
280    pub start_at: usize,
281}
282
283/// A Jira sprint.
284#[derive(Debug, Deserialize, Serialize, Clone)]
285#[serde(rename_all = "camelCase")]
286pub struct Sprint {
287    pub id: u64,
288    pub name: String,
289    pub state: String,
290    pub start_date: Option<String>,
291    pub end_date: Option<String>,
292    pub complete_date: Option<String>,
293    pub origin_board_id: Option<u64>,
294}
295
296/// Paginated sprint response from the Agile API.
297#[derive(Debug, Deserialize)]
298#[serde(rename_all = "camelCase")]
299pub struct SprintSearchResponse {
300    pub values: Vec<Sprint>,
301    pub is_last: bool,
302    #[serde(default)]
303    pub start_at: usize,
304}
305
306/// A Jira field (system or custom).
307#[derive(Debug, Deserialize, Serialize, Clone)]
308pub struct Field {
309    pub id: String,
310    pub name: String,
311    #[serde(default)]
312    pub custom: bool,
313    pub schema: Option<FieldSchema>,
314}
315
316/// The schema of a field, describing its type.
317#[derive(Debug, Deserialize, Serialize, Clone)]
318pub struct FieldSchema {
319    #[serde(rename = "type")]
320    pub field_type: String,
321    pub items: Option<String>,
322    pub system: Option<String>,
323    pub custom: Option<String>,
324}
325
326/// Jira project.
327#[derive(Debug, Deserialize, Serialize, Clone)]
328pub struct Project {
329    pub id: String,
330    pub key: String,
331    pub name: String,
332    #[serde(rename = "projectTypeKey")]
333    pub project_type: Option<String>,
334}
335
336/// Response from the paginated project search endpoint.
337#[derive(Debug, Deserialize)]
338#[serde(rename_all = "camelCase")]
339pub struct ProjectSearchResponse {
340    pub values: Vec<Project>,
341    pub total: usize,
342    #[serde(default)]
343    pub start_at: usize,
344    pub is_last: bool,
345}
346
347/// A single issue transition (workflow action).
348#[derive(Debug, Deserialize, Serialize, Clone)]
349pub struct Transition {
350    pub id: String,
351    pub name: String,
352    /// The status this transition leads to, including its workflow category.
353    pub to: Option<TransitionTo>,
354}
355
356/// The target status of a transition.
357#[derive(Debug, Deserialize, Serialize, Clone)]
358#[serde(rename_all = "camelCase")]
359pub struct TransitionTo {
360    pub name: String,
361    pub status_category: Option<StatusCategory>,
362}
363
364/// Workflow category for a status (e.g. "new", "indeterminate", "done").
365#[derive(Debug, Deserialize, Serialize, Clone)]
366pub struct StatusCategory {
367    pub key: String,
368    pub name: String,
369}
370
371/// Raw page from the Jira Cloud `/rest/api/3/search/jql` endpoint.
372///
373/// Cursor-based. `is_last` is authoritative for end-of-results;
374/// `next_page_token` may be absent or null on the final page.
375#[derive(Debug, Deserialize)]
376#[serde(rename_all = "camelCase")]
377pub struct SearchJqlPage {
378    pub issues: Vec<Issue>,
379    #[serde(default)]
380    pub is_last: bool,
381    #[serde(default)]
382    pub next_page_token: Option<String>,
383}
384
385/// Lightweight page response used when walking the cursor forward with
386/// `fields=["id"]`. The returned issue objects then lack a `fields`
387/// sub-object, so the regular `Issue` deserialization would fail - we only
388/// need the issue count and the next cursor here.
389#[derive(Debug, Deserialize)]
390#[serde(rename_all = "camelCase")]
391pub struct SearchJqlSkipPage {
392    /// Required, like the field of the same name on `SearchJqlPage`. The count
393    /// is what advances the cursor towards the requested offset, so an absent
394    /// array read as a page of zero would end the walk and report the results
395    /// so far as complete.
396    pub issues: Vec<serde_json::Value>,
397    #[serde(default)]
398    pub is_last: bool,
399    #[serde(default)]
400    pub next_page_token: Option<String>,
401}
402
403/// Response from the Jira search endpoint.
404///
405/// `total` is `None` on Jira Cloud (API v3): the new `/search/jql` endpoint
406/// no longer returns an exact total. `is_last` is authoritative - use it to
407/// decide whether more pages exist.
408#[derive(Debug, Deserialize, Serialize)]
409pub struct SearchResponse {
410    pub issues: Vec<Issue>,
411    pub total: Option<usize>,
412    #[serde(rename = "startAt")]
413    pub start_at: usize,
414    #[serde(rename = "maxResults")]
415    pub max_results: usize,
416    #[serde(rename = "isLast", default)]
417    pub is_last: bool,
418}
419
420/// Response from the transitions endpoint.
421#[derive(Debug, Deserialize, Serialize)]
422pub struct TransitionsResponse {
423    pub transitions: Vec<Transition>,
424}
425
426/// A single worklog entry on an issue.
427#[derive(Debug, Deserialize, Serialize, Clone)]
428#[serde(rename_all = "camelCase")]
429pub struct WorklogEntry {
430    pub id: String,
431    pub author: UserField,
432    pub time_spent: String,
433    pub time_spent_seconds: u64,
434    pub started: String,
435    pub created: String,
436}
437
438/// Response from creating an issue.
439#[derive(Debug, Deserialize, Serialize)]
440pub struct CreateIssueResponse {
441    pub id: String,
442    pub key: String,
443    #[serde(rename = "self")]
444    pub url: String,
445}
446
447/// Current authenticated user.
448///
449/// Jira Cloud (API v3) identifies users by `accountId`.
450/// Jira Data Center / Server (API v2) identifies users by `name` (username).
451/// Both forms deserialize into `account_id` so callers can use it uniformly.
452#[derive(Debug, Deserialize)]
453#[serde(rename_all = "camelCase")]
454pub struct Myself {
455    /// Cloud: `accountId`. DC/Server: `name` (username).
456    #[serde(alias = "name")]
457    pub account_id: String,
458    pub display_name: String,
459    /// Absent when the account's email is private, which Jira Cloud applies by
460    /// default. A missing field deserializes to `None`, never an empty string.
461    pub email_address: Option<String>,
462}
463
464/// Fields for creating a new issue.
465///
466/// `project_key`, `issue_type`, and `summary` are required by the Jira API.
467/// All other fields are optional; pass `None` to omit them from the create payload.
468pub struct IssueDraft<'a> {
469    pub project_key: &'a str,
470    pub issue_type: &'a str,
471    pub summary: &'a str,
472    pub description: Option<&'a str>,
473    pub priority: Option<&'a str>,
474    pub labels: Option<&'a [&'a str]>,
475    pub components: Option<&'a [&'a str]>,
476    pub fix_versions: Option<&'a [&'a str]>,
477    /// None keeps Jira's default; Some(None) creates an unassigned issue.
478    pub assignee: Option<Option<&'a str>>,
479    pub parent: Option<&'a str>,
480    /// Epic membership, resolved using project metadata (not a subtask parent).
481    pub epic: Option<&'a str>,
482}
483
484/// Fields to update on an existing issue.
485///
486/// All fields are optional. `components`, `fix_versions`, and `labels` are three-state:
487/// `None` leaves the field untouched, `Some(&[])` clears it, `Some(&[..])` replaces it.
488/// `assignee` is also three-state: `None` = untouched, `Some(None)` = unassign, `Some(Some(id))` = set.
489#[derive(Default)]
490pub struct IssueUpdate<'a> {
491    pub summary: Option<&'a str>,
492    pub description: Option<&'a str>,
493    pub priority: Option<&'a str>,
494    /// Issue type name or ID to switch to, within the same hierarchy level.
495    pub issue_type: Option<&'a str>,
496    pub epic: Option<&'a str>,
497    /// Remove epic membership; conflicts with `epic`.
498    pub clear_epic: bool,
499    pub components: Option<&'a [&'a str]>,
500    pub fix_versions: Option<&'a [&'a str]>,
501    pub labels: Option<&'a [&'a str]>,
502    /// Three-state assignee:
503    /// - `None` - leave untouched (assignee key absent from PUT body)
504    /// - `Some(None)` - unassign (PUT sends `"assignee": null`)
505    /// - `Some(Some(id))` - set to account ID (PUT sends `{"accountId": id}` on v3, `{"name": id}` on v2)
506    ///
507    /// Sentinels (`"none"`, `"me"`) are CLI-layer concepts only. By the time
508    /// a value reaches `IssueUpdate.assignee` it must already be resolved:
509    /// `"me"` → resolved account ID, `"none"` → `Some(None)`.
510    pub assignee: Option<Option<&'a str>>,
511}
512
513/// Build an Atlassian Document Format document from plain text.
514///
515/// Each newline-separated line becomes a separate ADF paragraph node.
516/// Blank lines produce empty paragraphs (no content array items), which is the
517/// correct ADF representation accepted by Jira Cloud.
518pub fn text_to_adf(text: &str) -> serde_json::Value {
519    let paragraphs: Vec<serde_json::Value> = text
520        .split('\n')
521        .map(|line| {
522            if line.is_empty() {
523                serde_json::json!({ "type": "paragraph", "content": [] })
524            } else {
525                serde_json::json!({
526                    "type": "paragraph",
527                    "content": [{"type": "text", "text": line}]
528                })
529            }
530        })
531        .collect();
532
533    serde_json::json!({
534        "type": "doc",
535        "version": 1,
536        "content": paragraphs
537    })
538}
539
540/// Extract plain text from an ADF node or a plain string value.
541///
542/// API v2 (Jira Data Center / Server) returns descriptions and comment bodies
543/// as plain JSON strings. API v3 (Jira Cloud) uses Atlassian Document Format.
544/// Both forms are handled here so the same display path works for both versions.
545pub fn extract_adf_text(node: &serde_json::Value) -> String {
546    if let Some(s) = node.as_str() {
547        return s.to_string();
548    }
549    let mut buf = String::new();
550    collect_text(node, &mut buf);
551    buf.trim().to_string()
552}
553
554fn collect_text(node: &serde_json::Value, buf: &mut String) {
555    let node_type = node.get("type").and_then(|v| v.as_str()).unwrap_or("");
556
557    if node_type == "text" {
558        if let Some(text) = node.get("text").and_then(|v| v.as_str()) {
559            buf.push_str(text);
560        }
561        return;
562    }
563
564    if node_type == "hardBreak" {
565        buf.push('\n');
566        return;
567    }
568
569    if let Some(content) = node.get("content").and_then(|v| v.as_array()) {
570        for child in content {
571            collect_text(child, buf);
572        }
573    }
574
575    // Block-level nodes get a trailing newline
576    if matches!(
577        node_type,
578        "paragraph"
579            | "heading"
580            | "bulletList"
581            | "orderedList"
582            | "listItem"
583            | "codeBlock"
584            | "blockquote"
585            | "rule"
586    ) && !buf.ends_with('\n')
587    {
588        buf.push('\n');
589    }
590}
591
592/// Escape a value for use inside a JQL double-quoted string literal.
593///
594/// JQL escapes double quotes as `\"` inside a quoted string.
595pub fn escape_jql(value: &str) -> String {
596    value.replace('\\', "\\\\").replace('"', "\\\"")
597}
598
599/// Read an attachment ID as a string: the `attachment/{id}` endpoint reports it
600/// as a number, the `attachment` field of an issue reports it as a string.
601fn attachment_id<'de, D: serde::Deserializer<'de>>(de: D) -> Result<String, D::Error> {
602    match serde_json::Value::deserialize(de)? {
603        serde_json::Value::String(s) => Ok(s),
604        serde_json::Value::Number(n) => Ok(n.to_string()),
605        other => Err(serde::de::Error::custom(format!(
606            "expected attachment id as string or number, got {other}"
607        ))),
608    }
609}
610
611#[cfg(test)]
612mod tests {
613    use super::*;
614
615    #[test]
616    fn extract_simple_paragraph() {
617        let doc = serde_json::json!({
618            "type": "doc",
619            "version": 1,
620            "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Hello world"}]}]
621        });
622        assert_eq!(extract_adf_text(&doc), "Hello world");
623    }
624
625    #[test]
626    fn extract_multiple_paragraphs() {
627        let doc = serde_json::json!({
628            "type": "doc",
629            "version": 1,
630            "content": [
631                {"type": "paragraph", "content": [{"type": "text", "text": "First"}]},
632                {"type": "paragraph", "content": [{"type": "text", "text": "Second"}]}
633            ]
634        });
635        let text = extract_adf_text(&doc);
636        assert!(text.contains("First"));
637        assert!(text.contains("Second"));
638    }
639
640    #[test]
641    fn text_to_adf_preserves_newlines() {
642        let original = "Line one\nLine two\nLine three";
643        let adf = text_to_adf(original);
644        let extracted = extract_adf_text(&adf);
645        assert!(extracted.contains("Line one"));
646        assert!(extracted.contains("Line two"));
647        assert!(extracted.contains("Line three"));
648    }
649
650    #[test]
651    fn text_to_adf_single_line_roundtrip() {
652        let original = "My description text";
653        let adf = text_to_adf(original);
654        let extracted = extract_adf_text(&adf);
655        assert_eq!(extracted, original);
656    }
657
658    #[test]
659    fn text_to_adf_blank_line_produces_empty_paragraph() {
660        let adf = text_to_adf("First\n\nThird");
661        let content = adf["content"].as_array().unwrap();
662        assert_eq!(content.len(), 3);
663        // The blank middle line must produce an empty content array, not a text node
664        // with an empty string - the latter is rejected by some Jira Cloud instances.
665        let blank_paragraph = &content[1];
666        assert_eq!(blank_paragraph["type"], "paragraph");
667        let blank_content = blank_paragraph["content"].as_array().unwrap();
668        assert!(blank_content.is_empty());
669    }
670
671    #[test]
672    fn escape_jql_double_quotes() {
673        assert_eq!(escape_jql(r#"say "hello""#), r#"say \"hello\""#);
674    }
675
676    #[test]
677    fn escape_jql_clean_input() {
678        assert_eq!(escape_jql("In Progress"), "In Progress");
679    }
680
681    #[test]
682    fn escape_jql_backslash() {
683        assert_eq!(escape_jql(r"foo\bar"), r"foo\\bar");
684    }
685}