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
56#[derive(Debug, Deserialize, Serialize, Clone)]
57pub struct IssueFields {
58 pub summary: String,
59 pub status: StatusField,
60 pub assignee: Option<UserField>,
61 pub reporter: Option<UserField>,
62 pub priority: Option<PriorityField>,
63 pub issuetype: IssueTypeField,
64 pub description: Option<serde_json::Value>,
65 pub labels: Option<Vec<String>>,
66 pub created: Option<String>,
67 pub updated: Option<String>,
68 pub comment: Option<CommentList>,
69 #[serde(rename = "issuelinks")]
70 pub issue_links: Option<Vec<IssueLink>>,
71}
72
73#[derive(Debug, Deserialize, Serialize, Clone)]
74pub struct StatusField {
75 pub name: String,
76}
77
78#[derive(Debug, Deserialize, Serialize, Clone)]
79#[serde(rename_all = "camelCase")]
80pub struct UserField {
81 pub display_name: String,
82 pub email_address: Option<String>,
83 #[serde(alias = "name")]
85 pub account_id: Option<String>,
86}
87
88#[derive(Debug, Deserialize, Serialize, Clone)]
89pub struct PriorityField {
90 pub name: String,
91}
92
93#[derive(Debug, Deserialize, Serialize, Clone)]
94pub struct IssueTypeField {
95 pub name: String,
96}
97
98#[derive(Debug, Deserialize, Serialize, Clone)]
99#[serde(rename_all = "camelCase")]
100pub struct CommentList {
101 pub comments: Vec<Comment>,
102 pub total: usize,
103 #[serde(default)]
104 pub start_at: usize,
105 #[serde(default)]
106 pub max_results: usize,
107}
108
109#[derive(Debug, Deserialize, Serialize, Clone)]
110#[serde(rename_all = "camelCase")]
111pub struct Comment {
112 pub id: String,
113 pub author: UserField,
114 pub body: Option<serde_json::Value>,
115 pub created: String,
116 pub updated: Option<String>,
117}
118
119impl Comment {
120 pub fn body_text(&self) -> String {
121 match &self.body {
122 Some(doc) => extract_adf_text(doc),
123 None => String::new(),
124 }
125 }
126}
127
128#[derive(Debug, Deserialize, Serialize, Clone)]
130#[serde(rename_all = "camelCase")]
131pub struct User {
132 #[serde(alias = "name")]
134 pub account_id: String,
135 pub display_name: String,
136 pub email_address: Option<String>,
137}
138
139#[derive(Debug, Deserialize, Serialize, Clone)]
141#[serde(rename_all = "camelCase")]
142pub struct IssueLink {
143 pub id: String,
144 #[serde(rename = "type")]
145 pub link_type: IssueLinkType,
146 pub outward_issue: Option<LinkedIssue>,
147 pub inward_issue: Option<LinkedIssue>,
148}
149
150#[derive(Debug, Deserialize, Serialize, Clone)]
152pub struct IssueLinkType {
153 pub id: String,
154 pub name: String,
155 pub inward: String,
156 pub outward: String,
157}
158
159#[derive(Debug, Deserialize, Serialize, Clone)]
161pub struct LinkedIssue {
162 pub key: String,
163 pub fields: LinkedIssueFields,
164}
165
166#[derive(Debug, Deserialize, Serialize, Clone)]
167pub struct LinkedIssueFields {
168 pub summary: String,
169 pub status: StatusField,
170}
171
172#[derive(Debug, Deserialize, Serialize, Clone)]
174#[serde(rename_all = "camelCase")]
175pub struct Board {
176 pub id: u64,
177 pub name: String,
178 #[serde(rename = "type")]
179 pub board_type: String,
180}
181
182#[derive(Debug, Deserialize)]
184#[serde(rename_all = "camelCase")]
185pub struct BoardSearchResponse {
186 pub values: Vec<Board>,
187 pub is_last: bool,
188 #[serde(default)]
189 pub start_at: usize,
190 pub total: usize,
191}
192
193#[derive(Debug, Deserialize, Serialize, Clone)]
195#[serde(rename_all = "camelCase")]
196pub struct Sprint {
197 pub id: u64,
198 pub name: String,
199 pub state: String,
200 pub start_date: Option<String>,
201 pub end_date: Option<String>,
202 pub complete_date: Option<String>,
203 pub origin_board_id: Option<u64>,
204}
205
206#[derive(Debug, Deserialize)]
208#[serde(rename_all = "camelCase")]
209pub struct SprintSearchResponse {
210 pub values: Vec<Sprint>,
211 pub is_last: bool,
212 #[serde(default)]
213 pub start_at: usize,
214}
215
216#[derive(Debug, Deserialize, Serialize, Clone)]
218pub struct Field {
219 pub id: String,
220 pub name: String,
221 #[serde(default)]
222 pub custom: bool,
223 pub schema: Option<FieldSchema>,
224}
225
226#[derive(Debug, Deserialize, Serialize, Clone)]
228pub struct FieldSchema {
229 #[serde(rename = "type")]
230 pub field_type: String,
231 pub items: Option<String>,
232 pub system: Option<String>,
233 pub custom: Option<String>,
234}
235
236#[derive(Debug, Deserialize, Serialize, Clone)]
238pub struct Project {
239 pub id: String,
240 pub key: String,
241 pub name: String,
242 #[serde(rename = "projectTypeKey")]
243 pub project_type: Option<String>,
244}
245
246#[derive(Debug, Deserialize)]
248#[serde(rename_all = "camelCase")]
249pub struct ProjectSearchResponse {
250 pub values: Vec<Project>,
251 pub total: usize,
252 #[serde(default)]
253 pub start_at: usize,
254 #[serde(default)]
255 pub max_results: usize,
256 pub is_last: bool,
257}
258
259#[derive(Debug, Deserialize, Serialize, Clone)]
261pub struct Transition {
262 pub id: String,
263 pub name: String,
264 pub to: Option<TransitionTo>,
266}
267
268#[derive(Debug, Deserialize, Serialize, Clone)]
270#[serde(rename_all = "camelCase")]
271pub struct TransitionTo {
272 pub name: String,
273 pub status_category: Option<StatusCategory>,
274}
275
276#[derive(Debug, Deserialize, Serialize, Clone)]
278pub struct StatusCategory {
279 pub key: String,
280 pub name: String,
281}
282
283#[derive(Debug, Deserialize)]
288#[serde(rename_all = "camelCase")]
289pub struct SearchJqlPage {
290 pub issues: Vec<Issue>,
291 #[serde(default)]
292 pub is_last: bool,
293 #[serde(default)]
294 pub next_page_token: Option<String>,
295}
296
297#[derive(Debug, Deserialize)]
302#[serde(rename_all = "camelCase")]
303pub struct SearchJqlSkipPage {
304 #[serde(default)]
305 pub issues: Vec<serde_json::Value>,
306 #[serde(default)]
307 pub is_last: bool,
308 #[serde(default)]
309 pub next_page_token: Option<String>,
310}
311
312#[derive(Debug, Deserialize, Serialize)]
318pub struct SearchResponse {
319 pub issues: Vec<Issue>,
320 pub total: Option<usize>,
321 #[serde(rename = "startAt")]
322 pub start_at: usize,
323 #[serde(rename = "maxResults")]
324 pub max_results: usize,
325 #[serde(rename = "isLast", default)]
326 pub is_last: bool,
327}
328
329#[derive(Debug, Deserialize, Serialize)]
331pub struct TransitionsResponse {
332 pub transitions: Vec<Transition>,
333}
334
335#[derive(Debug, Deserialize, Serialize, Clone)]
337#[serde(rename_all = "camelCase")]
338pub struct WorklogEntry {
339 pub id: String,
340 pub author: UserField,
341 pub time_spent: String,
342 pub time_spent_seconds: u64,
343 pub started: String,
344 pub created: String,
345}
346
347#[derive(Debug, Deserialize, Serialize)]
349pub struct CreateIssueResponse {
350 pub id: String,
351 pub key: String,
352 #[serde(rename = "self")]
353 pub url: String,
354}
355
356#[derive(Debug, Deserialize)]
362#[serde(rename_all = "camelCase")]
363pub struct Myself {
364 #[serde(alias = "name")]
366 pub account_id: String,
367 pub display_name: String,
368}
369
370pub fn text_to_adf(text: &str) -> serde_json::Value {
376 let paragraphs: Vec<serde_json::Value> = text
377 .split('\n')
378 .map(|line| {
379 if line.is_empty() {
380 serde_json::json!({ "type": "paragraph", "content": [] })
381 } else {
382 serde_json::json!({
383 "type": "paragraph",
384 "content": [{"type": "text", "text": line}]
385 })
386 }
387 })
388 .collect();
389
390 serde_json::json!({
391 "type": "doc",
392 "version": 1,
393 "content": paragraphs
394 })
395}
396
397pub fn extract_adf_text(node: &serde_json::Value) -> String {
403 if let Some(s) = node.as_str() {
404 return s.to_string();
405 }
406 let mut buf = String::new();
407 collect_text(node, &mut buf);
408 buf.trim().to_string()
409}
410
411fn collect_text(node: &serde_json::Value, buf: &mut String) {
412 let node_type = node.get("type").and_then(|v| v.as_str()).unwrap_or("");
413
414 if node_type == "text" {
415 if let Some(text) = node.get("text").and_then(|v| v.as_str()) {
416 buf.push_str(text);
417 }
418 return;
419 }
420
421 if node_type == "hardBreak" {
422 buf.push('\n');
423 return;
424 }
425
426 if let Some(content) = node.get("content").and_then(|v| v.as_array()) {
427 for child in content {
428 collect_text(child, buf);
429 }
430 }
431
432 if matches!(
434 node_type,
435 "paragraph"
436 | "heading"
437 | "bulletList"
438 | "orderedList"
439 | "listItem"
440 | "codeBlock"
441 | "blockquote"
442 | "rule"
443 ) && !buf.ends_with('\n')
444 {
445 buf.push('\n');
446 }
447}
448
449pub fn escape_jql(value: &str) -> String {
453 value.replace('\\', "\\\\").replace('"', "\\\"")
454}
455
456#[cfg(test)]
457mod tests {
458 use super::*;
459
460 #[test]
461 fn extract_simple_paragraph() {
462 let doc = serde_json::json!({
463 "type": "doc",
464 "version": 1,
465 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Hello world"}]}]
466 });
467 assert_eq!(extract_adf_text(&doc), "Hello world");
468 }
469
470 #[test]
471 fn extract_multiple_paragraphs() {
472 let doc = serde_json::json!({
473 "type": "doc",
474 "version": 1,
475 "content": [
476 {"type": "paragraph", "content": [{"type": "text", "text": "First"}]},
477 {"type": "paragraph", "content": [{"type": "text", "text": "Second"}]}
478 ]
479 });
480 let text = extract_adf_text(&doc);
481 assert!(text.contains("First"));
482 assert!(text.contains("Second"));
483 }
484
485 #[test]
486 fn text_to_adf_preserves_newlines() {
487 let original = "Line one\nLine two\nLine three";
488 let adf = text_to_adf(original);
489 let extracted = extract_adf_text(&adf);
490 assert!(extracted.contains("Line one"));
491 assert!(extracted.contains("Line two"));
492 assert!(extracted.contains("Line three"));
493 }
494
495 #[test]
496 fn text_to_adf_single_line_roundtrip() {
497 let original = "My description text";
498 let adf = text_to_adf(original);
499 let extracted = extract_adf_text(&adf);
500 assert_eq!(extracted, original);
501 }
502
503 #[test]
504 fn text_to_adf_blank_line_produces_empty_paragraph() {
505 let adf = text_to_adf("First\n\nThird");
506 let content = adf["content"].as_array().unwrap();
507 assert_eq!(content.len(), 3);
508 let blank_paragraph = &content[1];
511 assert_eq!(blank_paragraph["type"], "paragraph");
512 let blank_content = blank_paragraph["content"].as_array().unwrap();
513 assert!(blank_content.is_empty());
514 }
515
516 #[test]
517 fn escape_jql_double_quotes() {
518 assert_eq!(escape_jql(r#"say "hello""#), r#"say \"hello\""#);
519 }
520
521 #[test]
522 fn escape_jql_clean_input() {
523 assert_eq!(escape_jql("In Progress"), "In Progress");
524 }
525
526 #[test]
527 fn escape_jql_backslash() {
528 assert_eq!(escape_jql(r"foo\bar"), r"foo\\bar");
529 }
530}