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
237#[derive(Debug, Deserialize)]
239#[serde(rename_all = "camelCase")]
240pub struct BoardSearchResponse {
241 pub values: Vec<Board>,
242 pub is_last: bool,
243 #[serde(default)]
244 pub start_at: usize,
245 pub total: usize,
246}
247
248#[derive(Debug, Deserialize, Serialize, Clone)]
250#[serde(rename_all = "camelCase")]
251pub struct Sprint {
252 pub id: u64,
253 pub name: String,
254 pub state: String,
255 pub start_date: Option<String>,
256 pub end_date: Option<String>,
257 pub complete_date: Option<String>,
258 pub origin_board_id: Option<u64>,
259}
260
261#[derive(Debug, Deserialize)]
263#[serde(rename_all = "camelCase")]
264pub struct SprintSearchResponse {
265 pub values: Vec<Sprint>,
266 pub is_last: bool,
267 #[serde(default)]
268 pub start_at: usize,
269}
270
271#[derive(Debug, Deserialize, Serialize, Clone)]
273pub struct Field {
274 pub id: String,
275 pub name: String,
276 #[serde(default)]
277 pub custom: bool,
278 pub schema: Option<FieldSchema>,
279}
280
281#[derive(Debug, Deserialize, Serialize, Clone)]
283pub struct FieldSchema {
284 #[serde(rename = "type")]
285 pub field_type: String,
286 pub items: Option<String>,
287 pub system: Option<String>,
288 pub custom: Option<String>,
289}
290
291#[derive(Debug, Deserialize, Serialize, Clone)]
293pub struct Project {
294 pub id: String,
295 pub key: String,
296 pub name: String,
297 #[serde(rename = "projectTypeKey")]
298 pub project_type: Option<String>,
299}
300
301#[derive(Debug, Deserialize)]
303#[serde(rename_all = "camelCase")]
304pub struct ProjectSearchResponse {
305 pub values: Vec<Project>,
306 pub total: usize,
307 #[serde(default)]
308 pub start_at: usize,
309 #[serde(default)]
310 pub max_results: usize,
311 pub is_last: bool,
312}
313
314#[derive(Debug, Deserialize, Serialize, Clone)]
316pub struct Transition {
317 pub id: String,
318 pub name: String,
319 pub to: Option<TransitionTo>,
321}
322
323#[derive(Debug, Deserialize, Serialize, Clone)]
325#[serde(rename_all = "camelCase")]
326pub struct TransitionTo {
327 pub name: String,
328 pub status_category: Option<StatusCategory>,
329}
330
331#[derive(Debug, Deserialize, Serialize, Clone)]
333pub struct StatusCategory {
334 pub key: String,
335 pub name: String,
336}
337
338#[derive(Debug, Deserialize)]
343#[serde(rename_all = "camelCase")]
344pub struct SearchJqlPage {
345 pub issues: Vec<Issue>,
346 #[serde(default)]
347 pub is_last: bool,
348 #[serde(default)]
349 pub next_page_token: Option<String>,
350}
351
352#[derive(Debug, Deserialize)]
357#[serde(rename_all = "camelCase")]
358pub struct SearchJqlSkipPage {
359 #[serde(default)]
360 pub issues: Vec<serde_json::Value>,
361 #[serde(default)]
362 pub is_last: bool,
363 #[serde(default)]
364 pub next_page_token: Option<String>,
365}
366
367#[derive(Debug, Deserialize, Serialize)]
373pub struct SearchResponse {
374 pub issues: Vec<Issue>,
375 pub total: Option<usize>,
376 #[serde(rename = "startAt")]
377 pub start_at: usize,
378 #[serde(rename = "maxResults")]
379 pub max_results: usize,
380 #[serde(rename = "isLast", default)]
381 pub is_last: bool,
382}
383
384#[derive(Debug, Deserialize, Serialize)]
386pub struct TransitionsResponse {
387 pub transitions: Vec<Transition>,
388}
389
390#[derive(Debug, Deserialize, Serialize, Clone)]
392#[serde(rename_all = "camelCase")]
393pub struct WorklogEntry {
394 pub id: String,
395 pub author: UserField,
396 pub time_spent: String,
397 pub time_spent_seconds: u64,
398 pub started: String,
399 pub created: String,
400}
401
402#[derive(Debug, Deserialize, Serialize)]
404pub struct CreateIssueResponse {
405 pub id: String,
406 pub key: String,
407 #[serde(rename = "self")]
408 pub url: String,
409}
410
411#[derive(Debug, Deserialize)]
417#[serde(rename_all = "camelCase")]
418pub struct Myself {
419 #[serde(alias = "name")]
421 pub account_id: String,
422 pub display_name: String,
423 pub email_address: Option<String>,
426}
427
428pub struct IssueDraft<'a> {
433 pub project_key: &'a str,
434 pub issue_type: &'a str,
435 pub summary: &'a str,
436 pub description: Option<&'a str>,
437 pub priority: Option<&'a str>,
438 pub labels: Option<&'a [&'a str]>,
439 pub components: Option<&'a [&'a str]>,
440 pub fix_versions: Option<&'a [&'a str]>,
441 pub assignee: Option<&'a str>,
442 pub parent: Option<&'a str>,
443}
444
445#[derive(Default)]
451pub struct IssueUpdate<'a> {
452 pub summary: Option<&'a str>,
453 pub description: Option<&'a str>,
454 pub priority: Option<&'a str>,
455 pub components: Option<&'a [&'a str]>,
456 pub fix_versions: Option<&'a [&'a str]>,
457 pub labels: Option<&'a [&'a str]>,
458 pub assignee: Option<Option<&'a str>>,
467}
468
469pub fn text_to_adf(text: &str) -> serde_json::Value {
475 let paragraphs: Vec<serde_json::Value> = text
476 .split('\n')
477 .map(|line| {
478 if line.is_empty() {
479 serde_json::json!({ "type": "paragraph", "content": [] })
480 } else {
481 serde_json::json!({
482 "type": "paragraph",
483 "content": [{"type": "text", "text": line}]
484 })
485 }
486 })
487 .collect();
488
489 serde_json::json!({
490 "type": "doc",
491 "version": 1,
492 "content": paragraphs
493 })
494}
495
496pub fn extract_adf_text(node: &serde_json::Value) -> String {
502 if let Some(s) = node.as_str() {
503 return s.to_string();
504 }
505 let mut buf = String::new();
506 collect_text(node, &mut buf);
507 buf.trim().to_string()
508}
509
510fn collect_text(node: &serde_json::Value, buf: &mut String) {
511 let node_type = node.get("type").and_then(|v| v.as_str()).unwrap_or("");
512
513 if node_type == "text" {
514 if let Some(text) = node.get("text").and_then(|v| v.as_str()) {
515 buf.push_str(text);
516 }
517 return;
518 }
519
520 if node_type == "hardBreak" {
521 buf.push('\n');
522 return;
523 }
524
525 if let Some(content) = node.get("content").and_then(|v| v.as_array()) {
526 for child in content {
527 collect_text(child, buf);
528 }
529 }
530
531 if matches!(
533 node_type,
534 "paragraph"
535 | "heading"
536 | "bulletList"
537 | "orderedList"
538 | "listItem"
539 | "codeBlock"
540 | "blockquote"
541 | "rule"
542 ) && !buf.ends_with('\n')
543 {
544 buf.push('\n');
545 }
546}
547
548pub fn escape_jql(value: &str) -> String {
552 value.replace('\\', "\\\\").replace('"', "\\\"")
553}
554
555fn attachment_id<'de, D: serde::Deserializer<'de>>(de: D) -> Result<String, D::Error> {
558 match serde_json::Value::deserialize(de)? {
559 serde_json::Value::String(s) => Ok(s),
560 serde_json::Value::Number(n) => Ok(n.to_string()),
561 other => Err(serde::de::Error::custom(format!(
562 "expected attachment id as string or number, got {other}"
563 ))),
564 }
565}
566
567#[cfg(test)]
568mod tests {
569 use super::*;
570
571 #[test]
572 fn extract_simple_paragraph() {
573 let doc = serde_json::json!({
574 "type": "doc",
575 "version": 1,
576 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Hello world"}]}]
577 });
578 assert_eq!(extract_adf_text(&doc), "Hello world");
579 }
580
581 #[test]
582 fn extract_multiple_paragraphs() {
583 let doc = serde_json::json!({
584 "type": "doc",
585 "version": 1,
586 "content": [
587 {"type": "paragraph", "content": [{"type": "text", "text": "First"}]},
588 {"type": "paragraph", "content": [{"type": "text", "text": "Second"}]}
589 ]
590 });
591 let text = extract_adf_text(&doc);
592 assert!(text.contains("First"));
593 assert!(text.contains("Second"));
594 }
595
596 #[test]
597 fn text_to_adf_preserves_newlines() {
598 let original = "Line one\nLine two\nLine three";
599 let adf = text_to_adf(original);
600 let extracted = extract_adf_text(&adf);
601 assert!(extracted.contains("Line one"));
602 assert!(extracted.contains("Line two"));
603 assert!(extracted.contains("Line three"));
604 }
605
606 #[test]
607 fn text_to_adf_single_line_roundtrip() {
608 let original = "My description text";
609 let adf = text_to_adf(original);
610 let extracted = extract_adf_text(&adf);
611 assert_eq!(extracted, original);
612 }
613
614 #[test]
615 fn text_to_adf_blank_line_produces_empty_paragraph() {
616 let adf = text_to_adf("First\n\nThird");
617 let content = adf["content"].as_array().unwrap();
618 assert_eq!(content.len(), 3);
619 let blank_paragraph = &content[1];
622 assert_eq!(blank_paragraph["type"], "paragraph");
623 let blank_content = blank_paragraph["content"].as_array().unwrap();
624 assert!(blank_content.is_empty());
625 }
626
627 #[test]
628 fn escape_jql_double_quotes() {
629 assert_eq!(escape_jql(r#"say "hello""#), r#"say \"hello\""#);
630 }
631
632 #[test]
633 fn escape_jql_clean_input() {
634 assert_eq!(escape_jql("In Progress"), "In Progress");
635 }
636
637 #[test]
638 fn escape_jql_backslash() {
639 assert_eq!(escape_jql(r"foo\bar"), r"foo\\bar");
640 }
641}