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/// A single worklog entry on an issue.
301#[derive(Debug, Deserialize, Serialize, Clone)]
302#[serde(rename_all = "camelCase")]
303pub struct WorklogEntry {
304    pub id: String,
305    pub author: UserField,
306    pub time_spent: String,
307    pub time_spent_seconds: u64,
308    pub started: String,
309    pub created: String,
310}
311
312/// Response from creating an issue.
313#[derive(Debug, Deserialize, Serialize)]
314pub struct CreateIssueResponse {
315    pub id: String,
316    pub key: String,
317    #[serde(rename = "self")]
318    pub url: String,
319}
320
321/// Current authenticated user.
322///
323/// Jira Cloud (API v3) identifies users by `accountId`.
324/// Jira Data Center / Server (API v2) identifies users by `name` (username).
325/// Both forms deserialize into `account_id` so callers can use it uniformly.
326#[derive(Debug, Deserialize)]
327#[serde(rename_all = "camelCase")]
328pub struct Myself {
329    /// Cloud: `accountId`. DC/Server: `name` (username).
330    #[serde(alias = "name")]
331    pub account_id: String,
332    pub display_name: String,
333}
334
335/// Build an Atlassian Document Format document from plain text.
336///
337/// Each newline-separated line becomes a separate ADF paragraph node.
338/// Blank lines produce empty paragraphs (no content array items), which is the
339/// correct ADF representation accepted by Jira Cloud.
340pub fn text_to_adf(text: &str) -> serde_json::Value {
341    let paragraphs: Vec<serde_json::Value> = text
342        .split('\n')
343        .map(|line| {
344            if line.is_empty() {
345                serde_json::json!({ "type": "paragraph", "content": [] })
346            } else {
347                serde_json::json!({
348                    "type": "paragraph",
349                    "content": [{"type": "text", "text": line}]
350                })
351            }
352        })
353        .collect();
354
355    serde_json::json!({
356        "type": "doc",
357        "version": 1,
358        "content": paragraphs
359    })
360}
361
362/// Extract plain text from an ADF node or a plain string value.
363///
364/// API v2 (Jira Data Center / Server) returns descriptions and comment bodies
365/// as plain JSON strings. API v3 (Jira Cloud) uses Atlassian Document Format.
366/// Both forms are handled here so the same display path works for both versions.
367pub fn extract_adf_text(node: &serde_json::Value) -> String {
368    if let Some(s) = node.as_str() {
369        return s.to_string();
370    }
371    let mut buf = String::new();
372    collect_text(node, &mut buf);
373    buf.trim().to_string()
374}
375
376fn collect_text(node: &serde_json::Value, buf: &mut String) {
377    let node_type = node.get("type").and_then(|v| v.as_str()).unwrap_or("");
378
379    if node_type == "text" {
380        if let Some(text) = node.get("text").and_then(|v| v.as_str()) {
381            buf.push_str(text);
382        }
383        return;
384    }
385
386    if node_type == "hardBreak" {
387        buf.push('\n');
388        return;
389    }
390
391    if let Some(content) = node.get("content").and_then(|v| v.as_array()) {
392        for child in content {
393            collect_text(child, buf);
394        }
395    }
396
397    // Block-level nodes get a trailing newline
398    if matches!(
399        node_type,
400        "paragraph"
401            | "heading"
402            | "bulletList"
403            | "orderedList"
404            | "listItem"
405            | "codeBlock"
406            | "blockquote"
407            | "rule"
408    ) && !buf.ends_with('\n')
409    {
410        buf.push('\n');
411    }
412}
413
414/// Escape a value for use inside a JQL double-quoted string literal.
415///
416/// JQL escapes double quotes as `\"` inside a quoted string.
417pub fn escape_jql(value: &str) -> String {
418    value.replace('\\', "\\\\").replace('"', "\\\"")
419}
420
421#[cfg(test)]
422mod tests {
423    use super::*;
424
425    #[test]
426    fn extract_simple_paragraph() {
427        let doc = serde_json::json!({
428            "type": "doc",
429            "version": 1,
430            "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Hello world"}]}]
431        });
432        assert_eq!(extract_adf_text(&doc), "Hello world");
433    }
434
435    #[test]
436    fn extract_multiple_paragraphs() {
437        let doc = serde_json::json!({
438            "type": "doc",
439            "version": 1,
440            "content": [
441                {"type": "paragraph", "content": [{"type": "text", "text": "First"}]},
442                {"type": "paragraph", "content": [{"type": "text", "text": "Second"}]}
443            ]
444        });
445        let text = extract_adf_text(&doc);
446        assert!(text.contains("First"));
447        assert!(text.contains("Second"));
448    }
449
450    #[test]
451    fn text_to_adf_preserves_newlines() {
452        let original = "Line one\nLine two\nLine three";
453        let adf = text_to_adf(original);
454        let extracted = extract_adf_text(&adf);
455        assert!(extracted.contains("Line one"));
456        assert!(extracted.contains("Line two"));
457        assert!(extracted.contains("Line three"));
458    }
459
460    #[test]
461    fn text_to_adf_single_line_roundtrip() {
462        let original = "My description text";
463        let adf = text_to_adf(original);
464        let extracted = extract_adf_text(&adf);
465        assert_eq!(extracted, original);
466    }
467
468    #[test]
469    fn text_to_adf_blank_line_produces_empty_paragraph() {
470        let adf = text_to_adf("First\n\nThird");
471        let content = adf["content"].as_array().unwrap();
472        assert_eq!(content.len(), 3);
473        // The blank middle line must produce an empty content array, not a text node
474        // with an empty string — the latter is rejected by some Jira Cloud instances.
475        let blank_paragraph = &content[1];
476        assert_eq!(blank_paragraph["type"], "paragraph");
477        let blank_content = blank_paragraph["content"].as_array().unwrap();
478        assert!(blank_content.is_empty());
479    }
480
481    #[test]
482    fn escape_jql_double_quotes() {
483        assert_eq!(escape_jql(r#"say "hello""#), r#"say \"hello\""#);
484    }
485
486    #[test]
487    fn escape_jql_clean_input() {
488        assert_eq!(escape_jql("In Progress"), "In Progress");
489    }
490
491    #[test]
492    fn escape_jql_backslash() {
493        assert_eq!(escape_jql(r"foo\bar"), r"foo\\bar");
494    }
495}