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/// Raw page from the Jira Cloud `/rest/api/3/search/jql` endpoint.
284///
285/// Cursor-based. `is_last` is authoritative for end-of-results;
286/// `next_page_token` may be absent or null on the final page.
287#[derive(Debug, Deserialize)]
288#[serde(rename_all = "camelCase")]
289pub struct SearchJqlPage {
290    pub issues: Vec<Issue>,
291    #[serde(default)]
292    pub is_last: bool,
293    #[serde(default)]
294    pub next_page_token: Option<String>,
295}
296
297/// Lightweight page response used when walking the cursor forward with
298/// `fields=["id"]`. The returned issue objects then lack a `fields`
299/// sub-object, so the regular `Issue` deserialization would fail — we only
300/// need the issue count and the next cursor here.
301#[derive(Debug, Deserialize)]
302#[serde(rename_all = "camelCase")]
303pub struct SearchJqlSkipPage {
304    #[serde(default)]
305    pub issues: Vec<serde_json::Value>,
306    #[serde(default)]
307    pub is_last: bool,
308    #[serde(default)]
309    pub next_page_token: Option<String>,
310}
311
312/// Response from the Jira search endpoint.
313///
314/// `total` is `None` on Jira Cloud (API v3): the new `/search/jql` endpoint
315/// no longer returns an exact total. `is_last` is authoritative — use it to
316/// decide whether more pages exist.
317#[derive(Debug, Deserialize, Serialize)]
318pub struct SearchResponse {
319    pub issues: Vec<Issue>,
320    pub total: Option<usize>,
321    #[serde(rename = "startAt")]
322    pub start_at: usize,
323    #[serde(rename = "maxResults")]
324    pub max_results: usize,
325    #[serde(rename = "isLast", default)]
326    pub is_last: bool,
327}
328
329/// Response from the transitions endpoint.
330#[derive(Debug, Deserialize, Serialize)]
331pub struct TransitionsResponse {
332    pub transitions: Vec<Transition>,
333}
334
335/// A single worklog entry on an issue.
336#[derive(Debug, Deserialize, Serialize, Clone)]
337#[serde(rename_all = "camelCase")]
338pub struct WorklogEntry {
339    pub id: String,
340    pub author: UserField,
341    pub time_spent: String,
342    pub time_spent_seconds: u64,
343    pub started: String,
344    pub created: String,
345}
346
347/// Response from creating an issue.
348#[derive(Debug, Deserialize, Serialize)]
349pub struct CreateIssueResponse {
350    pub id: String,
351    pub key: String,
352    #[serde(rename = "self")]
353    pub url: String,
354}
355
356/// Current authenticated user.
357///
358/// Jira Cloud (API v3) identifies users by `accountId`.
359/// Jira Data Center / Server (API v2) identifies users by `name` (username).
360/// Both forms deserialize into `account_id` so callers can use it uniformly.
361#[derive(Debug, Deserialize)]
362#[serde(rename_all = "camelCase")]
363pub struct Myself {
364    /// Cloud: `accountId`. DC/Server: `name` (username).
365    #[serde(alias = "name")]
366    pub account_id: String,
367    pub display_name: String,
368}
369
370/// Build an Atlassian Document Format document from plain text.
371///
372/// Each newline-separated line becomes a separate ADF paragraph node.
373/// Blank lines produce empty paragraphs (no content array items), which is the
374/// correct ADF representation accepted by Jira Cloud.
375pub fn text_to_adf(text: &str) -> serde_json::Value {
376    let paragraphs: Vec<serde_json::Value> = text
377        .split('\n')
378        .map(|line| {
379            if line.is_empty() {
380                serde_json::json!({ "type": "paragraph", "content": [] })
381            } else {
382                serde_json::json!({
383                    "type": "paragraph",
384                    "content": [{"type": "text", "text": line}]
385                })
386            }
387        })
388        .collect();
389
390    serde_json::json!({
391        "type": "doc",
392        "version": 1,
393        "content": paragraphs
394    })
395}
396
397/// Extract plain text from an ADF node or a plain string value.
398///
399/// API v2 (Jira Data Center / Server) returns descriptions and comment bodies
400/// as plain JSON strings. API v3 (Jira Cloud) uses Atlassian Document Format.
401/// Both forms are handled here so the same display path works for both versions.
402pub fn extract_adf_text(node: &serde_json::Value) -> String {
403    if let Some(s) = node.as_str() {
404        return s.to_string();
405    }
406    let mut buf = String::new();
407    collect_text(node, &mut buf);
408    buf.trim().to_string()
409}
410
411fn collect_text(node: &serde_json::Value, buf: &mut String) {
412    let node_type = node.get("type").and_then(|v| v.as_str()).unwrap_or("");
413
414    if node_type == "text" {
415        if let Some(text) = node.get("text").and_then(|v| v.as_str()) {
416            buf.push_str(text);
417        }
418        return;
419    }
420
421    if node_type == "hardBreak" {
422        buf.push('\n');
423        return;
424    }
425
426    if let Some(content) = node.get("content").and_then(|v| v.as_array()) {
427        for child in content {
428            collect_text(child, buf);
429        }
430    }
431
432    // Block-level nodes get a trailing newline
433    if matches!(
434        node_type,
435        "paragraph"
436            | "heading"
437            | "bulletList"
438            | "orderedList"
439            | "listItem"
440            | "codeBlock"
441            | "blockquote"
442            | "rule"
443    ) && !buf.ends_with('\n')
444    {
445        buf.push('\n');
446    }
447}
448
449/// Escape a value for use inside a JQL double-quoted string literal.
450///
451/// JQL escapes double quotes as `\"` inside a quoted string.
452pub fn escape_jql(value: &str) -> String {
453    value.replace('\\', "\\\\").replace('"', "\\\"")
454}
455
456#[cfg(test)]
457mod tests {
458    use super::*;
459
460    #[test]
461    fn extract_simple_paragraph() {
462        let doc = serde_json::json!({
463            "type": "doc",
464            "version": 1,
465            "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Hello world"}]}]
466        });
467        assert_eq!(extract_adf_text(&doc), "Hello world");
468    }
469
470    #[test]
471    fn extract_multiple_paragraphs() {
472        let doc = serde_json::json!({
473            "type": "doc",
474            "version": 1,
475            "content": [
476                {"type": "paragraph", "content": [{"type": "text", "text": "First"}]},
477                {"type": "paragraph", "content": [{"type": "text", "text": "Second"}]}
478            ]
479        });
480        let text = extract_adf_text(&doc);
481        assert!(text.contains("First"));
482        assert!(text.contains("Second"));
483    }
484
485    #[test]
486    fn text_to_adf_preserves_newlines() {
487        let original = "Line one\nLine two\nLine three";
488        let adf = text_to_adf(original);
489        let extracted = extract_adf_text(&adf);
490        assert!(extracted.contains("Line one"));
491        assert!(extracted.contains("Line two"));
492        assert!(extracted.contains("Line three"));
493    }
494
495    #[test]
496    fn text_to_adf_single_line_roundtrip() {
497        let original = "My description text";
498        let adf = text_to_adf(original);
499        let extracted = extract_adf_text(&adf);
500        assert_eq!(extracted, original);
501    }
502
503    #[test]
504    fn text_to_adf_blank_line_produces_empty_paragraph() {
505        let adf = text_to_adf("First\n\nThird");
506        let content = adf["content"].as_array().unwrap();
507        assert_eq!(content.len(), 3);
508        // The blank middle line must produce an empty content array, not a text node
509        // with an empty string — the latter is rejected by some Jira Cloud instances.
510        let blank_paragraph = &content[1];
511        assert_eq!(blank_paragraph["type"], "paragraph");
512        let blank_content = blank_paragraph["content"].as_array().unwrap();
513        assert!(blank_content.is_empty());
514    }
515
516    #[test]
517    fn escape_jql_double_quotes() {
518        assert_eq!(escape_jql(r#"say "hello""#), r#"say \"hello\""#);
519    }
520
521    #[test]
522    fn escape_jql_clean_input() {
523        assert_eq!(escape_jql("In Progress"), "In Progress");
524    }
525
526    #[test]
527    fn escape_jql_backslash() {
528        assert_eq!(escape_jql(r"foo\bar"), r"foo\\bar");
529    }
530}