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