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 User {
141 #[serde(alias = "name")]
143 pub account_id: String,
144 pub display_name: String,
145 pub email_address: Option<String>,
146}
147
148#[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#[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#[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#[derive(Debug, Deserialize, Serialize, Clone)]
183pub struct Component {
184 pub id: String,
185 pub name: String,
186 pub description: Option<String>,
187}
188
189#[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#[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#[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#[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#[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#[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#[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#[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#[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#[derive(Debug, Deserialize, Serialize, Clone)]
290pub struct Transition {
291 pub id: String,
292 pub name: String,
293 pub to: Option<TransitionTo>,
295}
296
297#[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#[derive(Debug, Deserialize, Serialize, Clone)]
307pub struct StatusCategory {
308 pub key: String,
309 pub name: String,
310}
311
312#[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#[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#[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#[derive(Debug, Deserialize, Serialize)]
360pub struct TransitionsResponse {
361 pub transitions: Vec<Transition>,
362}
363
364#[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#[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#[derive(Debug, Deserialize)]
391#[serde(rename_all = "camelCase")]
392pub struct Myself {
393 #[serde(alias = "name")]
395 pub account_id: String,
396 pub display_name: String,
397}
398
399pub 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#[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 pub assignee: Option<Option<&'a str>>,
438}
439
440pub 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
467pub 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 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
519pub 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 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}