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, Serialize)]
285pub struct SearchResponse {
286 pub issues: Vec<Issue>,
287 pub total: usize,
288 #[serde(rename = "startAt")]
289 pub start_at: usize,
290 #[serde(rename = "maxResults")]
291 pub max_results: usize,
292}
293
294#[derive(Debug, Deserialize, Serialize)]
296pub struct TransitionsResponse {
297 pub transitions: Vec<Transition>,
298}
299
300#[derive(Debug, Deserialize, Serialize)]
302pub struct CreateIssueResponse {
303 pub id: String,
304 pub key: String,
305 #[serde(rename = "self")]
306 pub url: String,
307}
308
309#[derive(Debug, Deserialize)]
315#[serde(rename_all = "camelCase")]
316pub struct Myself {
317 #[serde(alias = "name")]
319 pub account_id: String,
320 pub display_name: String,
321}
322
323pub fn text_to_adf(text: &str) -> serde_json::Value {
329 let paragraphs: Vec<serde_json::Value> = text
330 .split('\n')
331 .map(|line| {
332 if line.is_empty() {
333 serde_json::json!({ "type": "paragraph", "content": [] })
334 } else {
335 serde_json::json!({
336 "type": "paragraph",
337 "content": [{"type": "text", "text": line}]
338 })
339 }
340 })
341 .collect();
342
343 serde_json::json!({
344 "type": "doc",
345 "version": 1,
346 "content": paragraphs
347 })
348}
349
350pub fn extract_adf_text(node: &serde_json::Value) -> String {
356 if let Some(s) = node.as_str() {
357 return s.to_string();
358 }
359 let mut buf = String::new();
360 collect_text(node, &mut buf);
361 buf.trim().to_string()
362}
363
364fn collect_text(node: &serde_json::Value, buf: &mut String) {
365 let node_type = node.get("type").and_then(|v| v.as_str()).unwrap_or("");
366
367 if node_type == "text" {
368 if let Some(text) = node.get("text").and_then(|v| v.as_str()) {
369 buf.push_str(text);
370 }
371 return;
372 }
373
374 if node_type == "hardBreak" {
375 buf.push('\n');
376 return;
377 }
378
379 if let Some(content) = node.get("content").and_then(|v| v.as_array()) {
380 for child in content {
381 collect_text(child, buf);
382 }
383 }
384
385 if matches!(
387 node_type,
388 "paragraph"
389 | "heading"
390 | "bulletList"
391 | "orderedList"
392 | "listItem"
393 | "codeBlock"
394 | "blockquote"
395 | "rule"
396 ) && !buf.ends_with('\n')
397 {
398 buf.push('\n');
399 }
400}
401
402pub fn escape_jql(value: &str) -> String {
406 value.replace('\\', "\\\\").replace('"', "\\\"")
407}
408
409#[cfg(test)]
410mod tests {
411 use super::*;
412
413 #[test]
414 fn extract_simple_paragraph() {
415 let doc = serde_json::json!({
416 "type": "doc",
417 "version": 1,
418 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Hello world"}]}]
419 });
420 assert_eq!(extract_adf_text(&doc), "Hello world");
421 }
422
423 #[test]
424 fn extract_multiple_paragraphs() {
425 let doc = serde_json::json!({
426 "type": "doc",
427 "version": 1,
428 "content": [
429 {"type": "paragraph", "content": [{"type": "text", "text": "First"}]},
430 {"type": "paragraph", "content": [{"type": "text", "text": "Second"}]}
431 ]
432 });
433 let text = extract_adf_text(&doc);
434 assert!(text.contains("First"));
435 assert!(text.contains("Second"));
436 }
437
438 #[test]
439 fn text_to_adf_preserves_newlines() {
440 let original = "Line one\nLine two\nLine three";
441 let adf = text_to_adf(original);
442 let extracted = extract_adf_text(&adf);
443 assert!(extracted.contains("Line one"));
444 assert!(extracted.contains("Line two"));
445 assert!(extracted.contains("Line three"));
446 }
447
448 #[test]
449 fn text_to_adf_single_line_roundtrip() {
450 let original = "My description text";
451 let adf = text_to_adf(original);
452 let extracted = extract_adf_text(&adf);
453 assert_eq!(extracted, original);
454 }
455
456 #[test]
457 fn text_to_adf_blank_line_produces_empty_paragraph() {
458 let adf = text_to_adf("First\n\nThird");
459 let content = adf["content"].as_array().unwrap();
460 assert_eq!(content.len(), 3);
461 let blank_paragraph = &content[1];
464 assert_eq!(blank_paragraph["type"], "paragraph");
465 let blank_content = blank_paragraph["content"].as_array().unwrap();
466 assert!(blank_content.is_empty());
467 }
468
469 #[test]
470 fn escape_jql_double_quotes() {
471 assert_eq!(escape_jql(r#"say "hello""#), r#"say \"hello\""#);
472 }
473
474 #[test]
475 fn escape_jql_clean_input() {
476 assert_eq!(escape_jql("In Progress"), "In Progress");
477 }
478
479 #[test]
480 fn escape_jql_backslash() {
481 assert_eq!(escape_jql(r"foo\bar"), r"foo\\bar");
482 }
483}