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}
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 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 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 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 #[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#[derive(Debug, Deserialize, Serialize, Clone)]
139#[serde(rename_all = "camelCase")]
140pub struct Attachment {
141 #[serde(deserialize_with = "attachment_id")]
142 pub id: String,
143 pub filename: String,
144 pub size: u64,
145 pub mime_type: Option<String>,
146 pub author: Option<UserField>,
147 pub created: String,
148}
149
150impl Attachment {
151 pub fn mime_type(&self) -> &str {
152 self.mime_type.as_deref().unwrap_or("-")
153 }
154
155 pub fn author(&self) -> &str {
156 self.author
157 .as_ref()
158 .map(|a| a.display_name.as_str())
159 .unwrap_or("-")
160 }
161}
162
163#[derive(Debug, Deserialize, Serialize, Clone)]
165#[serde(rename_all = "camelCase")]
166pub struct User {
167 #[serde(alias = "name")]
169 pub account_id: String,
170 pub display_name: String,
171 pub email_address: Option<String>,
172}
173
174#[derive(Debug, Deserialize, Serialize, Clone)]
176#[serde(rename_all = "camelCase")]
177pub struct IssueLink {
178 pub id: String,
179 #[serde(rename = "type")]
180 pub link_type: IssueLinkType,
181 pub outward_issue: Option<LinkedIssue>,
182 pub inward_issue: Option<LinkedIssue>,
183}
184
185#[derive(Debug, Deserialize, Serialize, Clone)]
187pub struct IssueLinkType {
188 pub id: String,
189 pub name: String,
190 pub inward: String,
191 pub outward: String,
192}
193
194#[derive(Debug, Deserialize, Serialize, Clone)]
196pub struct LinkedIssue {
197 pub key: String,
198 pub fields: LinkedIssueFields,
199}
200
201#[derive(Debug, Deserialize, Serialize, Clone)]
202pub struct LinkedIssueFields {
203 pub summary: String,
204 pub status: StatusField,
205}
206
207#[derive(Debug, Deserialize, Serialize, Clone)]
209pub struct Component {
210 pub id: String,
211 pub name: String,
212 pub description: Option<String>,
213}
214
215#[derive(Debug, Deserialize, Serialize, Clone)]
217#[serde(rename_all = "camelCase")]
218pub struct Version {
219 pub id: String,
220 pub name: String,
221 pub description: Option<String>,
222 pub released: Option<bool>,
223 pub archived: Option<bool>,
224 pub release_date: Option<String>,
225}
226
227#[derive(Debug, Deserialize, Serialize, Clone)]
229#[serde(rename_all = "camelCase")]
230pub struct Board {
231 pub id: u64,
232 pub name: String,
233 #[serde(rename = "type")]
234 pub board_type: String,
235}
236
237impl Board {
238 pub(crate) fn may_support_sprints(&self) -> bool {
240 !self.board_type.eq_ignore_ascii_case("kanban")
241 }
242}
243
244#[derive(Debug, Deserialize)]
246#[serde(rename_all = "camelCase")]
247pub struct BoardSearchResponse {
248 pub values: Vec<Board>,
249 pub is_last: bool,
250 #[serde(default)]
251 pub start_at: usize,
252}
253
254#[derive(Debug, Deserialize, Serialize, Clone)]
256#[serde(rename_all = "camelCase")]
257pub struct Sprint {
258 pub id: u64,
259 pub name: String,
260 pub state: String,
261 pub start_date: Option<String>,
262 pub end_date: Option<String>,
263 pub complete_date: Option<String>,
264 pub origin_board_id: Option<u64>,
265}
266
267#[derive(Debug, Deserialize)]
269#[serde(rename_all = "camelCase")]
270pub struct SprintSearchResponse {
271 pub values: Vec<Sprint>,
272 pub is_last: bool,
273 #[serde(default)]
274 pub start_at: usize,
275}
276
277#[derive(Debug, Deserialize, Serialize, Clone)]
279pub struct Field {
280 pub id: String,
281 pub name: String,
282 #[serde(default)]
283 pub custom: bool,
284 pub schema: Option<FieldSchema>,
285}
286
287#[derive(Debug, Deserialize, Serialize, Clone)]
289pub struct FieldSchema {
290 #[serde(rename = "type")]
291 pub field_type: String,
292 pub items: Option<String>,
293 pub system: Option<String>,
294 pub custom: Option<String>,
295}
296
297#[derive(Debug, Deserialize, Serialize, Clone)]
299pub struct Project {
300 pub id: String,
301 pub key: String,
302 pub name: String,
303 #[serde(rename = "projectTypeKey")]
304 pub project_type: Option<String>,
305}
306
307#[derive(Debug, Deserialize)]
309#[serde(rename_all = "camelCase")]
310pub struct ProjectSearchResponse {
311 pub values: Vec<Project>,
312 pub total: usize,
313 #[serde(default)]
314 pub start_at: usize,
315 pub is_last: bool,
316}
317
318#[derive(Debug, Deserialize, Serialize, Clone)]
320pub struct Transition {
321 pub id: String,
322 pub name: String,
323 pub to: Option<TransitionTo>,
325}
326
327#[derive(Debug, Deserialize, Serialize, Clone)]
329#[serde(rename_all = "camelCase")]
330pub struct TransitionTo {
331 pub name: String,
332 pub status_category: Option<StatusCategory>,
333}
334
335#[derive(Debug, Deserialize, Serialize, Clone)]
337pub struct StatusCategory {
338 pub key: String,
339 pub name: String,
340}
341
342#[derive(Debug, Deserialize)]
347#[serde(rename_all = "camelCase")]
348pub struct SearchJqlPage {
349 pub issues: Vec<Issue>,
350 #[serde(default)]
351 pub is_last: bool,
352 #[serde(default)]
353 pub next_page_token: Option<String>,
354}
355
356#[derive(Debug, Deserialize)]
361#[serde(rename_all = "camelCase")]
362pub struct SearchJqlSkipPage {
363 pub issues: Vec<serde_json::Value>,
368 #[serde(default)]
369 pub is_last: bool,
370 #[serde(default)]
371 pub next_page_token: Option<String>,
372}
373
374#[derive(Debug, Deserialize, Serialize)]
380pub struct SearchResponse {
381 pub issues: Vec<Issue>,
382 pub total: Option<usize>,
383 #[serde(rename = "startAt")]
384 pub start_at: usize,
385 #[serde(rename = "maxResults")]
386 pub max_results: usize,
387 #[serde(rename = "isLast", default)]
388 pub is_last: bool,
389}
390
391#[derive(Debug, Deserialize, Serialize)]
393pub struct TransitionsResponse {
394 pub transitions: Vec<Transition>,
395}
396
397#[derive(Debug, Deserialize, Serialize, Clone)]
399#[serde(rename_all = "camelCase")]
400pub struct WorklogEntry {
401 pub id: String,
402 pub author: UserField,
403 pub time_spent: String,
404 pub time_spent_seconds: u64,
405 pub started: String,
406 pub created: String,
407}
408
409#[derive(Debug, Deserialize, Serialize)]
411pub struct CreateIssueResponse {
412 pub id: String,
413 pub key: String,
414 #[serde(rename = "self")]
415 pub url: String,
416}
417
418#[derive(Debug, Deserialize)]
424#[serde(rename_all = "camelCase")]
425pub struct Myself {
426 #[serde(alias = "name")]
428 pub account_id: String,
429 pub display_name: String,
430 pub email_address: Option<String>,
433}
434
435pub struct IssueDraft<'a> {
440 pub project_key: &'a str,
441 pub issue_type: &'a str,
442 pub summary: &'a str,
443 pub description: Option<&'a str>,
444 pub priority: Option<&'a str>,
445 pub labels: Option<&'a [&'a str]>,
446 pub components: Option<&'a [&'a str]>,
447 pub fix_versions: Option<&'a [&'a str]>,
448 pub assignee: Option<Option<&'a str>>,
450 pub parent: Option<&'a str>,
451 pub epic: Option<&'a str>,
453}
454
455#[derive(Default)]
461pub struct IssueUpdate<'a> {
462 pub summary: Option<&'a str>,
463 pub description: Option<&'a str>,
464 pub priority: Option<&'a str>,
465 pub epic: Option<&'a str>,
466 pub clear_epic: bool,
468 pub components: Option<&'a [&'a str]>,
469 pub fix_versions: Option<&'a [&'a str]>,
470 pub labels: Option<&'a [&'a str]>,
471 pub assignee: Option<Option<&'a str>>,
480}
481
482pub fn text_to_adf(text: &str) -> serde_json::Value {
488 let paragraphs: Vec<serde_json::Value> = text
489 .split('\n')
490 .map(|line| {
491 if line.is_empty() {
492 serde_json::json!({ "type": "paragraph", "content": [] })
493 } else {
494 serde_json::json!({
495 "type": "paragraph",
496 "content": [{"type": "text", "text": line}]
497 })
498 }
499 })
500 .collect();
501
502 serde_json::json!({
503 "type": "doc",
504 "version": 1,
505 "content": paragraphs
506 })
507}
508
509pub fn extract_adf_text(node: &serde_json::Value) -> String {
515 if let Some(s) = node.as_str() {
516 return s.to_string();
517 }
518 let mut buf = String::new();
519 collect_text(node, &mut buf);
520 buf.trim().to_string()
521}
522
523fn collect_text(node: &serde_json::Value, buf: &mut String) {
524 let node_type = node.get("type").and_then(|v| v.as_str()).unwrap_or("");
525
526 if node_type == "text" {
527 if let Some(text) = node.get("text").and_then(|v| v.as_str()) {
528 buf.push_str(text);
529 }
530 return;
531 }
532
533 if node_type == "hardBreak" {
534 buf.push('\n');
535 return;
536 }
537
538 if let Some(content) = node.get("content").and_then(|v| v.as_array()) {
539 for child in content {
540 collect_text(child, buf);
541 }
542 }
543
544 if matches!(
546 node_type,
547 "paragraph"
548 | "heading"
549 | "bulletList"
550 | "orderedList"
551 | "listItem"
552 | "codeBlock"
553 | "blockquote"
554 | "rule"
555 ) && !buf.ends_with('\n')
556 {
557 buf.push('\n');
558 }
559}
560
561pub fn escape_jql(value: &str) -> String {
565 value.replace('\\', "\\\\").replace('"', "\\\"")
566}
567
568fn attachment_id<'de, D: serde::Deserializer<'de>>(de: D) -> Result<String, D::Error> {
571 match serde_json::Value::deserialize(de)? {
572 serde_json::Value::String(s) => Ok(s),
573 serde_json::Value::Number(n) => Ok(n.to_string()),
574 other => Err(serde::de::Error::custom(format!(
575 "expected attachment id as string or number, got {other}"
576 ))),
577 }
578}
579
580#[cfg(test)]
581mod tests {
582 use super::*;
583
584 #[test]
585 fn extract_simple_paragraph() {
586 let doc = serde_json::json!({
587 "type": "doc",
588 "version": 1,
589 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Hello world"}]}]
590 });
591 assert_eq!(extract_adf_text(&doc), "Hello world");
592 }
593
594 #[test]
595 fn extract_multiple_paragraphs() {
596 let doc = serde_json::json!({
597 "type": "doc",
598 "version": 1,
599 "content": [
600 {"type": "paragraph", "content": [{"type": "text", "text": "First"}]},
601 {"type": "paragraph", "content": [{"type": "text", "text": "Second"}]}
602 ]
603 });
604 let text = extract_adf_text(&doc);
605 assert!(text.contains("First"));
606 assert!(text.contains("Second"));
607 }
608
609 #[test]
610 fn text_to_adf_preserves_newlines() {
611 let original = "Line one\nLine two\nLine three";
612 let adf = text_to_adf(original);
613 let extracted = extract_adf_text(&adf);
614 assert!(extracted.contains("Line one"));
615 assert!(extracted.contains("Line two"));
616 assert!(extracted.contains("Line three"));
617 }
618
619 #[test]
620 fn text_to_adf_single_line_roundtrip() {
621 let original = "My description text";
622 let adf = text_to_adf(original);
623 let extracted = extract_adf_text(&adf);
624 assert_eq!(extracted, original);
625 }
626
627 #[test]
628 fn text_to_adf_blank_line_produces_empty_paragraph() {
629 let adf = text_to_adf("First\n\nThird");
630 let content = adf["content"].as_array().unwrap();
631 assert_eq!(content.len(), 3);
632 let blank_paragraph = &content[1];
635 assert_eq!(blank_paragraph["type"], "paragraph");
636 let blank_content = blank_paragraph["content"].as_array().unwrap();
637 assert!(blank_content.is_empty());
638 }
639
640 #[test]
641 fn escape_jql_double_quotes() {
642 assert_eq!(escape_jql(r#"say "hello""#), r#"say \"hello\""#);
643 }
644
645 #[test]
646 fn escape_jql_clean_input() {
647 assert_eq!(escape_jql("In Progress"), "In Progress");
648 }
649
650 #[test]
651 fn escape_jql_backslash() {
652 assert_eq!(escape_jql(r"foo\bar"), r"foo\\bar");
653 }
654}