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