1use serde::{Deserialize, Serialize};
2
3#[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 #[serde(skip)]
15 pub epic: Option<String>,
16}
17
18impl Issue {
19 pub fn summary(&self) -> &str {
20 &self.fields.summary
21 }
22
23 pub fn status(&self) -> &str {
24 &self.fields.status.name
25 }
26
27 pub fn assignee(&self) -> &str {
28 self.fields
29 .assignee
30 .as_ref()
31 .map(|a| a.display_name.as_str())
32 .unwrap_or("-")
33 }
34
35 pub fn priority(&self) -> &str {
36 self.fields
37 .priority
38 .as_ref()
39 .map(|p| p.name.as_str())
40 .unwrap_or("-")
41 }
42
43 pub fn issue_type(&self) -> &str {
44 &self.fields.issuetype.name
45 }
46
47 pub fn description_text(&self) -> String {
49 match &self.fields.description {
50 Some(doc) => extract_adf_text(doc),
51 None => String::new(),
52 }
53 }
54
55 pub fn browser_url(&self, site_url: &str) -> String {
57 format!("{site_url}/browse/{}", self.key)
58 }
59
60 pub fn components(&self) -> &[Component] {
61 self.fields.components.as_deref().unwrap_or(&[])
62 }
63}
64
65#[derive(Debug, Deserialize, Serialize, Clone)]
66pub struct IssueFields {
67 pub summary: String,
68 pub status: StatusField,
69 pub assignee: Option<UserField>,
70 pub reporter: Option<UserField>,
71 pub priority: Option<PriorityField>,
72 pub issuetype: IssueTypeField,
73 pub description: Option<serde_json::Value>,
74 pub labels: Option<Vec<String>>,
75 pub components: Option<Vec<Component>>,
76 #[serde(rename = "fixVersions")]
77 pub fix_versions: Option<Vec<Version>>,
78 pub versions: Option<Vec<Version>>,
80 pub created: Option<String>,
81 pub updated: Option<String>,
82 pub comment: Option<CommentList>,
83 #[serde(rename = "issuelinks")]
84 pub issue_links: Option<Vec<IssueLink>>,
85 pub parent: Option<ParentIssue>,
86 #[serde(flatten, skip_serializing)]
89 pub extra: serde_json::Map<String, serde_json::Value>,
90}
91
92#[derive(Debug, Deserialize, Serialize, Clone)]
95pub struct ParentIssue {
96 pub key: String,
97 #[serde(default)]
98 pub fields: Option<ParentIssueFields>,
99}
100
101#[derive(Debug, Deserialize, Serialize, Clone)]
102pub struct ParentIssueFields {
103 pub summary: Option<String>,
104 pub issuetype: Option<IssueTypeField>,
105}
106
107#[derive(Debug, Deserialize, Serialize, Clone)]
108pub struct StatusField {
109 pub name: String,
110}
111
112#[derive(Debug, Deserialize, Serialize, Clone)]
113#[serde(rename_all = "camelCase")]
114pub struct UserField {
115 pub display_name: String,
116 pub email_address: Option<String>,
117 #[serde(alias = "name")]
119 pub account_id: Option<String>,
120}
121
122#[derive(Debug, Deserialize, Serialize, Clone)]
123pub struct PriorityField {
124 pub name: String,
125}
126
127#[derive(Debug, Deserialize, Serialize, Clone)]
128#[serde(rename_all = "camelCase")]
129pub struct IssueTypeField {
130 pub name: String,
131 #[serde(default, skip_serializing_if = "Option::is_none")]
133 pub hierarchy_level: Option<i64>,
134}
135
136#[derive(Debug, Deserialize, Serialize, Clone)]
137#[serde(rename_all = "camelCase")]
138pub struct CommentList {
139 pub comments: Vec<Comment>,
140 pub total: usize,
141 #[serde(default)]
142 pub start_at: usize,
143 #[serde(default)]
144 pub max_results: usize,
145}
146
147#[derive(Debug, Deserialize, Serialize, Clone)]
148#[serde(rename_all = "camelCase")]
149pub struct Comment {
150 pub id: String,
151 pub author: UserField,
152 pub body: Option<serde_json::Value>,
153 pub created: String,
154 pub updated: Option<String>,
155}
156
157impl Comment {
158 pub fn body_text(&self) -> String {
159 match &self.body {
160 Some(doc) => extract_adf_text(doc),
161 None => String::new(),
162 }
163 }
164}
165
166#[derive(Debug, Deserialize, Serialize, Clone)]
168#[serde(rename_all = "camelCase")]
169pub struct Attachment {
170 #[serde(deserialize_with = "attachment_id")]
171 pub id: String,
172 pub filename: String,
173 pub size: u64,
174 pub mime_type: Option<String>,
175 pub author: Option<UserField>,
176 pub created: String,
177}
178
179impl Attachment {
180 pub fn mime_type(&self) -> &str {
181 self.mime_type.as_deref().unwrap_or("-")
182 }
183
184 pub fn author(&self) -> &str {
185 self.author
186 .as_ref()
187 .map(|a| a.display_name.as_str())
188 .unwrap_or("-")
189 }
190}
191
192#[derive(Debug, Deserialize, Serialize, Clone)]
194#[serde(rename_all = "camelCase")]
195pub struct User {
196 #[serde(alias = "name")]
198 pub account_id: String,
199 pub display_name: String,
200 pub email_address: Option<String>,
201}
202
203#[derive(Debug, Deserialize, Serialize, Clone)]
205#[serde(rename_all = "camelCase")]
206pub struct IssueLink {
207 pub id: String,
208 #[serde(rename = "type")]
209 pub link_type: IssueLinkType,
210 pub outward_issue: Option<LinkedIssue>,
211 pub inward_issue: Option<LinkedIssue>,
212}
213
214#[derive(Debug, Deserialize, Serialize, Clone)]
216pub struct IssueLinkType {
217 pub id: String,
218 pub name: String,
219 pub inward: String,
220 pub outward: String,
221}
222
223#[derive(Debug, Deserialize, Serialize, Clone)]
225pub struct LinkedIssue {
226 pub key: String,
227 pub fields: LinkedIssueFields,
228}
229
230#[derive(Debug, Deserialize, Serialize, Clone)]
231pub struct LinkedIssueFields {
232 pub summary: String,
233 pub status: StatusField,
234}
235
236#[derive(Debug, Deserialize, Serialize, Clone)]
238pub struct Component {
239 pub id: String,
240 pub name: String,
241 pub description: Option<String>,
242}
243
244#[derive(Debug, Deserialize, Serialize, Clone)]
246#[serde(rename_all = "camelCase")]
247pub struct Version {
248 pub id: String,
249 pub name: String,
250 pub description: Option<String>,
251 pub released: Option<bool>,
252 pub archived: Option<bool>,
253 pub release_date: Option<String>,
254}
255
256#[derive(Debug, Deserialize, Serialize, Clone)]
258#[serde(rename_all = "camelCase")]
259pub struct Board {
260 pub id: u64,
261 pub name: String,
262 #[serde(rename = "type")]
263 pub board_type: String,
264}
265
266impl Board {
267 pub(crate) fn may_support_sprints(&self) -> bool {
269 !self.board_type.eq_ignore_ascii_case("kanban")
270 }
271}
272
273#[derive(Debug, Deserialize)]
275#[serde(rename_all = "camelCase")]
276pub struct BoardSearchResponse {
277 pub values: Vec<Board>,
278 pub is_last: bool,
279 #[serde(default)]
280 pub start_at: usize,
281}
282
283#[derive(Debug, Deserialize, Serialize, Clone)]
285#[serde(rename_all = "camelCase")]
286pub struct Sprint {
287 pub id: u64,
288 pub name: String,
289 pub state: String,
290 pub start_date: Option<String>,
291 pub end_date: Option<String>,
292 pub complete_date: Option<String>,
293 pub origin_board_id: Option<u64>,
294}
295
296#[derive(Debug, Deserialize)]
298#[serde(rename_all = "camelCase")]
299pub struct SprintSearchResponse {
300 pub values: Vec<Sprint>,
301 pub is_last: bool,
302 #[serde(default)]
303 pub start_at: usize,
304}
305
306#[derive(Debug, Deserialize, Serialize, Clone)]
308pub struct Field {
309 pub id: String,
310 pub name: String,
311 #[serde(default)]
312 pub custom: bool,
313 pub schema: Option<FieldSchema>,
314}
315
316#[derive(Debug, Deserialize, Serialize, Clone)]
318pub struct FieldSchema {
319 #[serde(rename = "type")]
320 pub field_type: String,
321 pub items: Option<String>,
322 pub system: Option<String>,
323 pub custom: Option<String>,
324}
325
326#[derive(Debug, Deserialize, Serialize, Clone)]
328pub struct Project {
329 pub id: String,
330 pub key: String,
331 pub name: String,
332 #[serde(rename = "projectTypeKey")]
333 pub project_type: Option<String>,
334}
335
336#[derive(Debug, Deserialize)]
338#[serde(rename_all = "camelCase")]
339pub struct ProjectSearchResponse {
340 pub values: Vec<Project>,
341 pub total: usize,
342 #[serde(default)]
343 pub start_at: usize,
344 pub is_last: bool,
345}
346
347#[derive(Debug, Deserialize, Serialize, Clone)]
349pub struct Transition {
350 pub id: String,
351 pub name: String,
352 pub to: Option<TransitionTo>,
354}
355
356#[derive(Debug, Deserialize, Serialize, Clone)]
358#[serde(rename_all = "camelCase")]
359pub struct TransitionTo {
360 pub name: String,
361 pub status_category: Option<StatusCategory>,
362}
363
364#[derive(Debug, Deserialize, Serialize, Clone)]
366pub struct StatusCategory {
367 pub key: String,
368 pub name: String,
369}
370
371#[derive(Debug, Deserialize)]
376#[serde(rename_all = "camelCase")]
377pub struct SearchJqlPage {
378 pub issues: Vec<Issue>,
379 #[serde(default)]
380 pub is_last: bool,
381 #[serde(default)]
382 pub next_page_token: Option<String>,
383}
384
385#[derive(Debug, Deserialize)]
390#[serde(rename_all = "camelCase")]
391pub struct SearchJqlSkipPage {
392 pub issues: Vec<serde_json::Value>,
397 #[serde(default)]
398 pub is_last: bool,
399 #[serde(default)]
400 pub next_page_token: Option<String>,
401}
402
403#[derive(Debug, Deserialize, Serialize)]
409pub struct SearchResponse {
410 pub issues: Vec<Issue>,
411 pub total: Option<usize>,
412 #[serde(rename = "startAt")]
413 pub start_at: usize,
414 #[serde(rename = "maxResults")]
415 pub max_results: usize,
416 #[serde(rename = "isLast", default)]
417 pub is_last: bool,
418}
419
420#[derive(Debug, Deserialize, Serialize)]
422pub struct TransitionsResponse {
423 pub transitions: Vec<Transition>,
424}
425
426#[derive(Debug, Deserialize, Serialize, Clone)]
428#[serde(rename_all = "camelCase")]
429pub struct WorklogEntry {
430 pub id: String,
431 pub author: UserField,
432 pub time_spent: String,
433 pub time_spent_seconds: u64,
434 pub started: String,
435 pub created: String,
436}
437
438#[derive(Debug, Deserialize, Serialize)]
440pub struct CreateIssueResponse {
441 pub id: String,
442 pub key: String,
443 #[serde(rename = "self")]
444 pub url: String,
445}
446
447#[derive(Debug, Deserialize)]
453#[serde(rename_all = "camelCase")]
454pub struct Myself {
455 #[serde(alias = "name")]
457 pub account_id: String,
458 pub display_name: String,
459 pub email_address: Option<String>,
462}
463
464pub struct IssueDraft<'a> {
469 pub project_key: &'a str,
470 pub issue_type: &'a str,
471 pub summary: &'a str,
472 pub description: Option<&'a str>,
473 pub priority: Option<&'a str>,
474 pub labels: Option<&'a [&'a str]>,
475 pub components: Option<&'a [&'a str]>,
476 pub fix_versions: Option<&'a [&'a str]>,
477 pub assignee: Option<Option<&'a str>>,
479 pub parent: Option<&'a str>,
480 pub epic: Option<&'a str>,
482}
483
484#[derive(Default)]
490pub struct IssueUpdate<'a> {
491 pub summary: Option<&'a str>,
492 pub description: Option<&'a str>,
493 pub priority: Option<&'a str>,
494 pub issue_type: Option<&'a str>,
496 pub epic: Option<&'a str>,
497 pub clear_epic: bool,
499 pub components: Option<&'a [&'a str]>,
500 pub fix_versions: Option<&'a [&'a str]>,
501 pub labels: Option<&'a [&'a str]>,
502 pub assignee: Option<Option<&'a str>>,
511}
512
513pub fn text_to_adf(text: &str) -> serde_json::Value {
519 let paragraphs: Vec<serde_json::Value> = text
520 .split('\n')
521 .map(|line| {
522 if line.is_empty() {
523 serde_json::json!({ "type": "paragraph", "content": [] })
524 } else {
525 serde_json::json!({
526 "type": "paragraph",
527 "content": [{"type": "text", "text": line}]
528 })
529 }
530 })
531 .collect();
532
533 serde_json::json!({
534 "type": "doc",
535 "version": 1,
536 "content": paragraphs
537 })
538}
539
540pub fn extract_adf_text(node: &serde_json::Value) -> String {
546 if let Some(s) = node.as_str() {
547 return s.to_string();
548 }
549 let mut buf = String::new();
550 collect_text(node, &mut buf);
551 buf.trim().to_string()
552}
553
554fn collect_text(node: &serde_json::Value, buf: &mut String) {
555 let node_type = node.get("type").and_then(|v| v.as_str()).unwrap_or("");
556
557 if node_type == "text" {
558 if let Some(text) = node.get("text").and_then(|v| v.as_str()) {
559 buf.push_str(text);
560 }
561 return;
562 }
563
564 if node_type == "hardBreak" {
565 buf.push('\n');
566 return;
567 }
568
569 if let Some(content) = node.get("content").and_then(|v| v.as_array()) {
570 for child in content {
571 collect_text(child, buf);
572 }
573 }
574
575 if matches!(
577 node_type,
578 "paragraph"
579 | "heading"
580 | "bulletList"
581 | "orderedList"
582 | "listItem"
583 | "codeBlock"
584 | "blockquote"
585 | "rule"
586 ) && !buf.ends_with('\n')
587 {
588 buf.push('\n');
589 }
590}
591
592pub fn escape_jql(value: &str) -> String {
596 value.replace('\\', "\\\\").replace('"', "\\\"")
597}
598
599fn attachment_id<'de, D: serde::Deserializer<'de>>(de: D) -> Result<String, D::Error> {
602 match serde_json::Value::deserialize(de)? {
603 serde_json::Value::String(s) => Ok(s),
604 serde_json::Value::Number(n) => Ok(n.to_string()),
605 other => Err(serde::de::Error::custom(format!(
606 "expected attachment id as string or number, got {other}"
607 ))),
608 }
609}
610
611#[cfg(test)]
612mod tests {
613 use super::*;
614
615 #[test]
616 fn extract_simple_paragraph() {
617 let doc = serde_json::json!({
618 "type": "doc",
619 "version": 1,
620 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Hello world"}]}]
621 });
622 assert_eq!(extract_adf_text(&doc), "Hello world");
623 }
624
625 #[test]
626 fn extract_multiple_paragraphs() {
627 let doc = serde_json::json!({
628 "type": "doc",
629 "version": 1,
630 "content": [
631 {"type": "paragraph", "content": [{"type": "text", "text": "First"}]},
632 {"type": "paragraph", "content": [{"type": "text", "text": "Second"}]}
633 ]
634 });
635 let text = extract_adf_text(&doc);
636 assert!(text.contains("First"));
637 assert!(text.contains("Second"));
638 }
639
640 #[test]
641 fn text_to_adf_preserves_newlines() {
642 let original = "Line one\nLine two\nLine three";
643 let adf = text_to_adf(original);
644 let extracted = extract_adf_text(&adf);
645 assert!(extracted.contains("Line one"));
646 assert!(extracted.contains("Line two"));
647 assert!(extracted.contains("Line three"));
648 }
649
650 #[test]
651 fn text_to_adf_single_line_roundtrip() {
652 let original = "My description text";
653 let adf = text_to_adf(original);
654 let extracted = extract_adf_text(&adf);
655 assert_eq!(extracted, original);
656 }
657
658 #[test]
659 fn text_to_adf_blank_line_produces_empty_paragraph() {
660 let adf = text_to_adf("First\n\nThird");
661 let content = adf["content"].as_array().unwrap();
662 assert_eq!(content.len(), 3);
663 let blank_paragraph = &content[1];
666 assert_eq!(blank_paragraph["type"], "paragraph");
667 let blank_content = blank_paragraph["content"].as_array().unwrap();
668 assert!(blank_content.is_empty());
669 }
670
671 #[test]
672 fn escape_jql_double_quotes() {
673 assert_eq!(escape_jql(r#"say "hello""#), r#"say \"hello\""#);
674 }
675
676 #[test]
677 fn escape_jql_clean_input() {
678 assert_eq!(escape_jql("In Progress"), "In Progress");
679 }
680
681 #[test]
682 fn escape_jql_backslash() {
683 assert_eq!(escape_jql(r"foo\bar"), r"foo\\bar");
684 }
685}