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 pub created: Option<String>,
72 pub updated: Option<String>,
73 pub comment: Option<CommentList>,
74 #[serde(rename = "issuelinks")]
75 pub issue_links: Option<Vec<IssueLink>>,
76}
77
78#[derive(Debug, Deserialize, Serialize, Clone)]
79pub struct StatusField {
80 pub name: String,
81}
82
83#[derive(Debug, Deserialize, Serialize, Clone)]
84#[serde(rename_all = "camelCase")]
85pub struct UserField {
86 pub display_name: String,
87 pub email_address: Option<String>,
88 #[serde(alias = "name")]
90 pub account_id: Option<String>,
91}
92
93#[derive(Debug, Deserialize, Serialize, Clone)]
94pub struct PriorityField {
95 pub name: String,
96}
97
98#[derive(Debug, Deserialize, Serialize, Clone)]
99pub struct IssueTypeField {
100 pub name: String,
101}
102
103#[derive(Debug, Deserialize, Serialize, Clone)]
104#[serde(rename_all = "camelCase")]
105pub struct CommentList {
106 pub comments: Vec<Comment>,
107 pub total: usize,
108 #[serde(default)]
109 pub start_at: usize,
110 #[serde(default)]
111 pub max_results: usize,
112}
113
114#[derive(Debug, Deserialize, Serialize, Clone)]
115#[serde(rename_all = "camelCase")]
116pub struct Comment {
117 pub id: String,
118 pub author: UserField,
119 pub body: Option<serde_json::Value>,
120 pub created: String,
121 pub updated: Option<String>,
122}
123
124impl Comment {
125 pub fn body_text(&self) -> String {
126 match &self.body {
127 Some(doc) => extract_adf_text(doc),
128 None => String::new(),
129 }
130 }
131}
132
133#[derive(Debug, Deserialize, Serialize, Clone)]
135#[serde(rename_all = "camelCase")]
136pub struct User {
137 #[serde(alias = "name")]
139 pub account_id: String,
140 pub display_name: String,
141 pub email_address: Option<String>,
142}
143
144#[derive(Debug, Deserialize, Serialize, Clone)]
146#[serde(rename_all = "camelCase")]
147pub struct IssueLink {
148 pub id: String,
149 #[serde(rename = "type")]
150 pub link_type: IssueLinkType,
151 pub outward_issue: Option<LinkedIssue>,
152 pub inward_issue: Option<LinkedIssue>,
153}
154
155#[derive(Debug, Deserialize, Serialize, Clone)]
157pub struct IssueLinkType {
158 pub id: String,
159 pub name: String,
160 pub inward: String,
161 pub outward: String,
162}
163
164#[derive(Debug, Deserialize, Serialize, Clone)]
166pub struct LinkedIssue {
167 pub key: String,
168 pub fields: LinkedIssueFields,
169}
170
171#[derive(Debug, Deserialize, Serialize, Clone)]
172pub struct LinkedIssueFields {
173 pub summary: String,
174 pub status: StatusField,
175}
176
177#[derive(Debug, Deserialize, Serialize, Clone)]
179pub struct Component {
180 pub id: String,
181 pub name: String,
182 pub description: Option<String>,
183}
184
185#[derive(Debug, Deserialize, Serialize, Clone)]
187#[serde(rename_all = "camelCase")]
188pub struct Board {
189 pub id: u64,
190 pub name: String,
191 #[serde(rename = "type")]
192 pub board_type: String,
193}
194
195#[derive(Debug, Deserialize)]
197#[serde(rename_all = "camelCase")]
198pub struct BoardSearchResponse {
199 pub values: Vec<Board>,
200 pub is_last: bool,
201 #[serde(default)]
202 pub start_at: usize,
203 pub total: usize,
204}
205
206#[derive(Debug, Deserialize, Serialize, Clone)]
208#[serde(rename_all = "camelCase")]
209pub struct Sprint {
210 pub id: u64,
211 pub name: String,
212 pub state: String,
213 pub start_date: Option<String>,
214 pub end_date: Option<String>,
215 pub complete_date: Option<String>,
216 pub origin_board_id: Option<u64>,
217}
218
219#[derive(Debug, Deserialize)]
221#[serde(rename_all = "camelCase")]
222pub struct SprintSearchResponse {
223 pub values: Vec<Sprint>,
224 pub is_last: bool,
225 #[serde(default)]
226 pub start_at: usize,
227}
228
229#[derive(Debug, Deserialize, Serialize, Clone)]
231pub struct Field {
232 pub id: String,
233 pub name: String,
234 #[serde(default)]
235 pub custom: bool,
236 pub schema: Option<FieldSchema>,
237}
238
239#[derive(Debug, Deserialize, Serialize, Clone)]
241pub struct FieldSchema {
242 #[serde(rename = "type")]
243 pub field_type: String,
244 pub items: Option<String>,
245 pub system: Option<String>,
246 pub custom: Option<String>,
247}
248
249#[derive(Debug, Deserialize, Serialize, Clone)]
251pub struct Project {
252 pub id: String,
253 pub key: String,
254 pub name: String,
255 #[serde(rename = "projectTypeKey")]
256 pub project_type: Option<String>,
257}
258
259#[derive(Debug, Deserialize)]
261#[serde(rename_all = "camelCase")]
262pub struct ProjectSearchResponse {
263 pub values: Vec<Project>,
264 pub total: usize,
265 #[serde(default)]
266 pub start_at: usize,
267 #[serde(default)]
268 pub max_results: usize,
269 pub is_last: bool,
270}
271
272#[derive(Debug, Deserialize, Serialize, Clone)]
274pub struct Transition {
275 pub id: String,
276 pub name: String,
277 pub to: Option<TransitionTo>,
279}
280
281#[derive(Debug, Deserialize, Serialize, Clone)]
283#[serde(rename_all = "camelCase")]
284pub struct TransitionTo {
285 pub name: String,
286 pub status_category: Option<StatusCategory>,
287}
288
289#[derive(Debug, Deserialize, Serialize, Clone)]
291pub struct StatusCategory {
292 pub key: String,
293 pub name: String,
294}
295
296#[derive(Debug, Deserialize)]
301#[serde(rename_all = "camelCase")]
302pub struct SearchJqlPage {
303 pub issues: Vec<Issue>,
304 #[serde(default)]
305 pub is_last: bool,
306 #[serde(default)]
307 pub next_page_token: Option<String>,
308}
309
310#[derive(Debug, Deserialize)]
315#[serde(rename_all = "camelCase")]
316pub struct SearchJqlSkipPage {
317 #[serde(default)]
318 pub issues: Vec<serde_json::Value>,
319 #[serde(default)]
320 pub is_last: bool,
321 #[serde(default)]
322 pub next_page_token: Option<String>,
323}
324
325#[derive(Debug, Deserialize, Serialize)]
331pub struct SearchResponse {
332 pub issues: Vec<Issue>,
333 pub total: Option<usize>,
334 #[serde(rename = "startAt")]
335 pub start_at: usize,
336 #[serde(rename = "maxResults")]
337 pub max_results: usize,
338 #[serde(rename = "isLast", default)]
339 pub is_last: bool,
340}
341
342#[derive(Debug, Deserialize, Serialize)]
344pub struct TransitionsResponse {
345 pub transitions: Vec<Transition>,
346}
347
348#[derive(Debug, Deserialize, Serialize, Clone)]
350#[serde(rename_all = "camelCase")]
351pub struct WorklogEntry {
352 pub id: String,
353 pub author: UserField,
354 pub time_spent: String,
355 pub time_spent_seconds: u64,
356 pub started: String,
357 pub created: String,
358}
359
360#[derive(Debug, Deserialize, Serialize)]
362pub struct CreateIssueResponse {
363 pub id: String,
364 pub key: String,
365 #[serde(rename = "self")]
366 pub url: String,
367}
368
369#[derive(Debug, Deserialize)]
375#[serde(rename_all = "camelCase")]
376pub struct Myself {
377 #[serde(alias = "name")]
379 pub account_id: String,
380 pub display_name: String,
381}
382
383pub struct IssueDraft<'a> {
388 pub project_key: &'a str,
389 pub issue_type: &'a str,
390 pub summary: &'a str,
391 pub description: Option<&'a str>,
392 pub priority: Option<&'a str>,
393 pub labels: Option<&'a [&'a str]>,
394 pub components: Option<&'a [&'a str]>,
395 pub assignee: Option<&'a str>,
396 pub parent: Option<&'a str>,
397}
398
399#[derive(Default)]
404pub struct IssueUpdate<'a> {
405 pub summary: Option<&'a str>,
406 pub description: Option<&'a str>,
407 pub priority: Option<&'a str>,
408 pub components: Option<&'a [&'a str]>,
409}
410
411pub fn text_to_adf(text: &str) -> serde_json::Value {
417 let paragraphs: Vec<serde_json::Value> = text
418 .split('\n')
419 .map(|line| {
420 if line.is_empty() {
421 serde_json::json!({ "type": "paragraph", "content": [] })
422 } else {
423 serde_json::json!({
424 "type": "paragraph",
425 "content": [{"type": "text", "text": line}]
426 })
427 }
428 })
429 .collect();
430
431 serde_json::json!({
432 "type": "doc",
433 "version": 1,
434 "content": paragraphs
435 })
436}
437
438pub fn extract_adf_text(node: &serde_json::Value) -> String {
444 if let Some(s) = node.as_str() {
445 return s.to_string();
446 }
447 let mut buf = String::new();
448 collect_text(node, &mut buf);
449 buf.trim().to_string()
450}
451
452fn collect_text(node: &serde_json::Value, buf: &mut String) {
453 let node_type = node.get("type").and_then(|v| v.as_str()).unwrap_or("");
454
455 if node_type == "text" {
456 if let Some(text) = node.get("text").and_then(|v| v.as_str()) {
457 buf.push_str(text);
458 }
459 return;
460 }
461
462 if node_type == "hardBreak" {
463 buf.push('\n');
464 return;
465 }
466
467 if let Some(content) = node.get("content").and_then(|v| v.as_array()) {
468 for child in content {
469 collect_text(child, buf);
470 }
471 }
472
473 if matches!(
475 node_type,
476 "paragraph"
477 | "heading"
478 | "bulletList"
479 | "orderedList"
480 | "listItem"
481 | "codeBlock"
482 | "blockquote"
483 | "rule"
484 ) && !buf.ends_with('\n')
485 {
486 buf.push('\n');
487 }
488}
489
490pub fn escape_jql(value: &str) -> String {
494 value.replace('\\', "\\\\").replace('"', "\\\"")
495}
496
497#[cfg(test)]
498mod tests {
499 use super::*;
500
501 #[test]
502 fn extract_simple_paragraph() {
503 let doc = serde_json::json!({
504 "type": "doc",
505 "version": 1,
506 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Hello world"}]}]
507 });
508 assert_eq!(extract_adf_text(&doc), "Hello world");
509 }
510
511 #[test]
512 fn extract_multiple_paragraphs() {
513 let doc = serde_json::json!({
514 "type": "doc",
515 "version": 1,
516 "content": [
517 {"type": "paragraph", "content": [{"type": "text", "text": "First"}]},
518 {"type": "paragraph", "content": [{"type": "text", "text": "Second"}]}
519 ]
520 });
521 let text = extract_adf_text(&doc);
522 assert!(text.contains("First"));
523 assert!(text.contains("Second"));
524 }
525
526 #[test]
527 fn text_to_adf_preserves_newlines() {
528 let original = "Line one\nLine two\nLine three";
529 let adf = text_to_adf(original);
530 let extracted = extract_adf_text(&adf);
531 assert!(extracted.contains("Line one"));
532 assert!(extracted.contains("Line two"));
533 assert!(extracted.contains("Line three"));
534 }
535
536 #[test]
537 fn text_to_adf_single_line_roundtrip() {
538 let original = "My description text";
539 let adf = text_to_adf(original);
540 let extracted = extract_adf_text(&adf);
541 assert_eq!(extracted, original);
542 }
543
544 #[test]
545 fn text_to_adf_blank_line_produces_empty_paragraph() {
546 let adf = text_to_adf("First\n\nThird");
547 let content = adf["content"].as_array().unwrap();
548 assert_eq!(content.len(), 3);
549 let blank_paragraph = &content[1];
552 assert_eq!(blank_paragraph["type"], "paragraph");
553 let blank_content = blank_paragraph["content"].as_array().unwrap();
554 assert!(blank_content.is_empty());
555 }
556
557 #[test]
558 fn escape_jql_double_quotes() {
559 assert_eq!(escape_jql(r#"say "hello""#), r#"say \"hello\""#);
560 }
561
562 #[test]
563 fn escape_jql_clean_input() {
564 assert_eq!(escape_jql("In Progress"), "In Progress");
565 }
566
567 #[test]
568 fn escape_jql_backslash() {
569 assert_eq!(escape_jql(r"foo\bar"), r"foo\\bar");
570 }
571}