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