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
237impl Board {
238    /// Unknown board types may support sprints; let the Agile API decide.
239    pub(crate) fn may_support_sprints(&self) -> bool {
240        !self.board_type.eq_ignore_ascii_case("kanban")
241    }
242}
243
244/// Paginated board response from the Agile API.
245#[derive(Debug, Deserialize)]
246#[serde(rename_all = "camelCase")]
247pub struct BoardSearchResponse {
248    pub values: Vec<Board>,
249    pub is_last: bool,
250    #[serde(default)]
251    pub start_at: usize,
252}
253
254/// A Jira sprint.
255#[derive(Debug, Deserialize, Serialize, Clone)]
256#[serde(rename_all = "camelCase")]
257pub struct Sprint {
258    pub id: u64,
259    pub name: String,
260    pub state: String,
261    pub start_date: Option<String>,
262    pub end_date: Option<String>,
263    pub complete_date: Option<String>,
264    pub origin_board_id: Option<u64>,
265}
266
267/// Paginated sprint response from the Agile API.
268#[derive(Debug, Deserialize)]
269#[serde(rename_all = "camelCase")]
270pub struct SprintSearchResponse {
271    pub values: Vec<Sprint>,
272    pub is_last: bool,
273    #[serde(default)]
274    pub start_at: usize,
275}
276
277/// A Jira field (system or custom).
278#[derive(Debug, Deserialize, Serialize, Clone)]
279pub struct Field {
280    pub id: String,
281    pub name: String,
282    #[serde(default)]
283    pub custom: bool,
284    pub schema: Option<FieldSchema>,
285}
286
287/// The schema of a field, describing its type.
288#[derive(Debug, Deserialize, Serialize, Clone)]
289pub struct FieldSchema {
290    #[serde(rename = "type")]
291    pub field_type: String,
292    pub items: Option<String>,
293    pub system: Option<String>,
294    pub custom: Option<String>,
295}
296
297/// Jira project.
298#[derive(Debug, Deserialize, Serialize, Clone)]
299pub struct Project {
300    pub id: String,
301    pub key: String,
302    pub name: String,
303    #[serde(rename = "projectTypeKey")]
304    pub project_type: Option<String>,
305}
306
307/// Response from the paginated project search endpoint.
308#[derive(Debug, Deserialize)]
309#[serde(rename_all = "camelCase")]
310pub struct ProjectSearchResponse {
311    pub values: Vec<Project>,
312    pub total: usize,
313    #[serde(default)]
314    pub start_at: usize,
315    pub is_last: bool,
316}
317
318/// A single issue transition (workflow action).
319#[derive(Debug, Deserialize, Serialize, Clone)]
320pub struct Transition {
321    pub id: String,
322    pub name: String,
323    /// The status this transition leads to, including its workflow category.
324    pub to: Option<TransitionTo>,
325}
326
327/// The target status of a transition.
328#[derive(Debug, Deserialize, Serialize, Clone)]
329#[serde(rename_all = "camelCase")]
330pub struct TransitionTo {
331    pub name: String,
332    pub status_category: Option<StatusCategory>,
333}
334
335/// Workflow category for a status (e.g. "new", "indeterminate", "done").
336#[derive(Debug, Deserialize, Serialize, Clone)]
337pub struct StatusCategory {
338    pub key: String,
339    pub name: String,
340}
341
342/// Raw page from the Jira Cloud `/rest/api/3/search/jql` endpoint.
343///
344/// Cursor-based. `is_last` is authoritative for end-of-results;
345/// `next_page_token` may be absent or null on the final page.
346#[derive(Debug, Deserialize)]
347#[serde(rename_all = "camelCase")]
348pub struct SearchJqlPage {
349    pub issues: Vec<Issue>,
350    #[serde(default)]
351    pub is_last: bool,
352    #[serde(default)]
353    pub next_page_token: Option<String>,
354}
355
356/// Lightweight page response used when walking the cursor forward with
357/// `fields=["id"]`. The returned issue objects then lack a `fields`
358/// sub-object, so the regular `Issue` deserialization would fail - we only
359/// need the issue count and the next cursor here.
360#[derive(Debug, Deserialize)]
361#[serde(rename_all = "camelCase")]
362pub struct SearchJqlSkipPage {
363    /// Required, like the field of the same name on `SearchJqlPage`. The count
364    /// is what advances the cursor towards the requested offset, so an absent
365    /// array read as a page of zero would end the walk and report the results
366    /// so far as complete.
367    pub issues: Vec<serde_json::Value>,
368    #[serde(default)]
369    pub is_last: bool,
370    #[serde(default)]
371    pub next_page_token: Option<String>,
372}
373
374/// Response from the Jira search endpoint.
375///
376/// `total` is `None` on Jira Cloud (API v3): the new `/search/jql` endpoint
377/// no longer returns an exact total. `is_last` is authoritative - use it to
378/// decide whether more pages exist.
379#[derive(Debug, Deserialize, Serialize)]
380pub struct SearchResponse {
381    pub issues: Vec<Issue>,
382    pub total: Option<usize>,
383    #[serde(rename = "startAt")]
384    pub start_at: usize,
385    #[serde(rename = "maxResults")]
386    pub max_results: usize,
387    #[serde(rename = "isLast", default)]
388    pub is_last: bool,
389}
390
391/// Response from the transitions endpoint.
392#[derive(Debug, Deserialize, Serialize)]
393pub struct TransitionsResponse {
394    pub transitions: Vec<Transition>,
395}
396
397/// A single worklog entry on an issue.
398#[derive(Debug, Deserialize, Serialize, Clone)]
399#[serde(rename_all = "camelCase")]
400pub struct WorklogEntry {
401    pub id: String,
402    pub author: UserField,
403    pub time_spent: String,
404    pub time_spent_seconds: u64,
405    pub started: String,
406    pub created: String,
407}
408
409/// Response from creating an issue.
410#[derive(Debug, Deserialize, Serialize)]
411pub struct CreateIssueResponse {
412    pub id: String,
413    pub key: String,
414    #[serde(rename = "self")]
415    pub url: String,
416}
417
418/// Current authenticated user.
419///
420/// Jira Cloud (API v3) identifies users by `accountId`.
421/// Jira Data Center / Server (API v2) identifies users by `name` (username).
422/// Both forms deserialize into `account_id` so callers can use it uniformly.
423#[derive(Debug, Deserialize)]
424#[serde(rename_all = "camelCase")]
425pub struct Myself {
426    /// Cloud: `accountId`. DC/Server: `name` (username).
427    #[serde(alias = "name")]
428    pub account_id: String,
429    pub display_name: String,
430    /// Absent when the account's email is private, which Jira Cloud applies by
431    /// default. A missing field deserializes to `None`, never an empty string.
432    pub email_address: Option<String>,
433}
434
435/// Fields for creating a new issue.
436///
437/// `project_key`, `issue_type`, and `summary` are required by the Jira API.
438/// All other fields are optional; pass `None` to omit them from the create payload.
439pub struct IssueDraft<'a> {
440    pub project_key: &'a str,
441    pub issue_type: &'a str,
442    pub summary: &'a str,
443    pub description: Option<&'a str>,
444    pub priority: Option<&'a str>,
445    pub labels: Option<&'a [&'a str]>,
446    pub components: Option<&'a [&'a str]>,
447    pub fix_versions: Option<&'a [&'a str]>,
448    /// None keeps Jira's default; Some(None) creates an unassigned issue.
449    pub assignee: Option<Option<&'a str>>,
450    pub parent: Option<&'a str>,
451    /// Epic membership, resolved using project metadata (not a subtask parent).
452    pub epic: Option<&'a str>,
453}
454
455/// Fields to update on an existing issue.
456///
457/// All fields are optional. `components`, `fix_versions`, and `labels` are three-state:
458/// `None` leaves the field untouched, `Some(&[])` clears it, `Some(&[..])` replaces it.
459/// `assignee` is also three-state: `None` = untouched, `Some(None)` = unassign, `Some(Some(id))` = set.
460#[derive(Default)]
461pub struct IssueUpdate<'a> {
462    pub summary: Option<&'a str>,
463    pub description: Option<&'a str>,
464    pub priority: Option<&'a str>,
465    pub epic: Option<&'a str>,
466    /// Remove epic membership; conflicts with `epic`.
467    pub clear_epic: bool,
468    pub components: Option<&'a [&'a str]>,
469    pub fix_versions: Option<&'a [&'a str]>,
470    pub labels: Option<&'a [&'a str]>,
471    /// Three-state assignee:
472    /// - `None` - leave untouched (assignee key absent from PUT body)
473    /// - `Some(None)` - unassign (PUT sends `"assignee": null`)
474    /// - `Some(Some(id))` - set to account ID (PUT sends `{"accountId": id}` on v3, `{"name": id}` on v2)
475    ///
476    /// Sentinels (`"none"`, `"me"`) are CLI-layer concepts only. By the time
477    /// a value reaches `IssueUpdate.assignee` it must already be resolved:
478    /// `"me"` → resolved account ID, `"none"` → `Some(None)`.
479    pub assignee: Option<Option<&'a str>>,
480}
481
482/// Build an Atlassian Document Format document from plain text.
483///
484/// Each newline-separated line becomes a separate ADF paragraph node.
485/// Blank lines produce empty paragraphs (no content array items), which is the
486/// correct ADF representation accepted by Jira Cloud.
487pub fn text_to_adf(text: &str) -> serde_json::Value {
488    let paragraphs: Vec<serde_json::Value> = text
489        .split('\n')
490        .map(|line| {
491            if line.is_empty() {
492                serde_json::json!({ "type": "paragraph", "content": [] })
493            } else {
494                serde_json::json!({
495                    "type": "paragraph",
496                    "content": [{"type": "text", "text": line}]
497                })
498            }
499        })
500        .collect();
501
502    serde_json::json!({
503        "type": "doc",
504        "version": 1,
505        "content": paragraphs
506    })
507}
508
509/// Extract plain text from an ADF node or a plain string value.
510///
511/// API v2 (Jira Data Center / Server) returns descriptions and comment bodies
512/// as plain JSON strings. API v3 (Jira Cloud) uses Atlassian Document Format.
513/// Both forms are handled here so the same display path works for both versions.
514pub fn extract_adf_text(node: &serde_json::Value) -> String {
515    if let Some(s) = node.as_str() {
516        return s.to_string();
517    }
518    let mut buf = String::new();
519    collect_text(node, &mut buf);
520    buf.trim().to_string()
521}
522
523fn collect_text(node: &serde_json::Value, buf: &mut String) {
524    let node_type = node.get("type").and_then(|v| v.as_str()).unwrap_or("");
525
526    if node_type == "text" {
527        if let Some(text) = node.get("text").and_then(|v| v.as_str()) {
528            buf.push_str(text);
529        }
530        return;
531    }
532
533    if node_type == "hardBreak" {
534        buf.push('\n');
535        return;
536    }
537
538    if let Some(content) = node.get("content").and_then(|v| v.as_array()) {
539        for child in content {
540            collect_text(child, buf);
541        }
542    }
543
544    // Block-level nodes get a trailing newline
545    if matches!(
546        node_type,
547        "paragraph"
548            | "heading"
549            | "bulletList"
550            | "orderedList"
551            | "listItem"
552            | "codeBlock"
553            | "blockquote"
554            | "rule"
555    ) && !buf.ends_with('\n')
556    {
557        buf.push('\n');
558    }
559}
560
561/// Escape a value for use inside a JQL double-quoted string literal.
562///
563/// JQL escapes double quotes as `\"` inside a quoted string.
564pub fn escape_jql(value: &str) -> String {
565    value.replace('\\', "\\\\").replace('"', "\\\"")
566}
567
568/// Read an attachment ID as a string: the `attachment/{id}` endpoint reports it
569/// as a number, the `attachment` field of an issue reports it as a string.
570fn attachment_id<'de, D: serde::Deserializer<'de>>(de: D) -> Result<String, D::Error> {
571    match serde_json::Value::deserialize(de)? {
572        serde_json::Value::String(s) => Ok(s),
573        serde_json::Value::Number(n) => Ok(n.to_string()),
574        other => Err(serde::de::Error::custom(format!(
575            "expected attachment id as string or number, got {other}"
576        ))),
577    }
578}
579
580#[cfg(test)]
581mod tests {
582    use super::*;
583
584    #[test]
585    fn extract_simple_paragraph() {
586        let doc = serde_json::json!({
587            "type": "doc",
588            "version": 1,
589            "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Hello world"}]}]
590        });
591        assert_eq!(extract_adf_text(&doc), "Hello world");
592    }
593
594    #[test]
595    fn extract_multiple_paragraphs() {
596        let doc = serde_json::json!({
597            "type": "doc",
598            "version": 1,
599            "content": [
600                {"type": "paragraph", "content": [{"type": "text", "text": "First"}]},
601                {"type": "paragraph", "content": [{"type": "text", "text": "Second"}]}
602            ]
603        });
604        let text = extract_adf_text(&doc);
605        assert!(text.contains("First"));
606        assert!(text.contains("Second"));
607    }
608
609    #[test]
610    fn text_to_adf_preserves_newlines() {
611        let original = "Line one\nLine two\nLine three";
612        let adf = text_to_adf(original);
613        let extracted = extract_adf_text(&adf);
614        assert!(extracted.contains("Line one"));
615        assert!(extracted.contains("Line two"));
616        assert!(extracted.contains("Line three"));
617    }
618
619    #[test]
620    fn text_to_adf_single_line_roundtrip() {
621        let original = "My description text";
622        let adf = text_to_adf(original);
623        let extracted = extract_adf_text(&adf);
624        assert_eq!(extracted, original);
625    }
626
627    #[test]
628    fn text_to_adf_blank_line_produces_empty_paragraph() {
629        let adf = text_to_adf("First\n\nThird");
630        let content = adf["content"].as_array().unwrap();
631        assert_eq!(content.len(), 3);
632        // The blank middle line must produce an empty content array, not a text node
633        // with an empty string - the latter is rejected by some Jira Cloud instances.
634        let blank_paragraph = &content[1];
635        assert_eq!(blank_paragraph["type"], "paragraph");
636        let blank_content = blank_paragraph["content"].as_array().unwrap();
637        assert!(blank_content.is_empty());
638    }
639
640    #[test]
641    fn escape_jql_double_quotes() {
642        assert_eq!(escape_jql(r#"say "hello""#), r#"say \"hello\""#);
643    }
644
645    #[test]
646    fn escape_jql_clean_input() {
647        assert_eq!(escape_jql("In Progress"), "In Progress");
648    }
649
650    #[test]
651    fn escape_jql_backslash() {
652        assert_eq!(escape_jql(r"foo\bar"), r"foo\\bar");
653    }
654}