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