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, Clone)]
302#[serde(rename_all = "camelCase")]
303pub struct WorklogEntry {
304 pub id: String,
305 pub author: UserField,
306 pub time_spent: String,
307 pub time_spent_seconds: u64,
308 pub started: String,
309 pub created: String,
310}
311
312#[derive(Debug, Deserialize, Serialize)]
314pub struct CreateIssueResponse {
315 pub id: String,
316 pub key: String,
317 #[serde(rename = "self")]
318 pub url: String,
319}
320
321#[derive(Debug, Deserialize)]
327#[serde(rename_all = "camelCase")]
328pub struct Myself {
329 #[serde(alias = "name")]
331 pub account_id: String,
332 pub display_name: String,
333}
334
335pub fn text_to_adf(text: &str) -> serde_json::Value {
341 let paragraphs: Vec<serde_json::Value> = text
342 .split('\n')
343 .map(|line| {
344 if line.is_empty() {
345 serde_json::json!({ "type": "paragraph", "content": [] })
346 } else {
347 serde_json::json!({
348 "type": "paragraph",
349 "content": [{"type": "text", "text": line}]
350 })
351 }
352 })
353 .collect();
354
355 serde_json::json!({
356 "type": "doc",
357 "version": 1,
358 "content": paragraphs
359 })
360}
361
362pub fn extract_adf_text(node: &serde_json::Value) -> String {
368 if let Some(s) = node.as_str() {
369 return s.to_string();
370 }
371 let mut buf = String::new();
372 collect_text(node, &mut buf);
373 buf.trim().to_string()
374}
375
376fn collect_text(node: &serde_json::Value, buf: &mut String) {
377 let node_type = node.get("type").and_then(|v| v.as_str()).unwrap_or("");
378
379 if node_type == "text" {
380 if let Some(text) = node.get("text").and_then(|v| v.as_str()) {
381 buf.push_str(text);
382 }
383 return;
384 }
385
386 if node_type == "hardBreak" {
387 buf.push('\n');
388 return;
389 }
390
391 if let Some(content) = node.get("content").and_then(|v| v.as_array()) {
392 for child in content {
393 collect_text(child, buf);
394 }
395 }
396
397 if matches!(
399 node_type,
400 "paragraph"
401 | "heading"
402 | "bulletList"
403 | "orderedList"
404 | "listItem"
405 | "codeBlock"
406 | "blockquote"
407 | "rule"
408 ) && !buf.ends_with('\n')
409 {
410 buf.push('\n');
411 }
412}
413
414pub fn escape_jql(value: &str) -> String {
418 value.replace('\\', "\\\\").replace('"', "\\\"")
419}
420
421#[cfg(test)]
422mod tests {
423 use super::*;
424
425 #[test]
426 fn extract_simple_paragraph() {
427 let doc = serde_json::json!({
428 "type": "doc",
429 "version": 1,
430 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Hello world"}]}]
431 });
432 assert_eq!(extract_adf_text(&doc), "Hello world");
433 }
434
435 #[test]
436 fn extract_multiple_paragraphs() {
437 let doc = serde_json::json!({
438 "type": "doc",
439 "version": 1,
440 "content": [
441 {"type": "paragraph", "content": [{"type": "text", "text": "First"}]},
442 {"type": "paragraph", "content": [{"type": "text", "text": "Second"}]}
443 ]
444 });
445 let text = extract_adf_text(&doc);
446 assert!(text.contains("First"));
447 assert!(text.contains("Second"));
448 }
449
450 #[test]
451 fn text_to_adf_preserves_newlines() {
452 let original = "Line one\nLine two\nLine three";
453 let adf = text_to_adf(original);
454 let extracted = extract_adf_text(&adf);
455 assert!(extracted.contains("Line one"));
456 assert!(extracted.contains("Line two"));
457 assert!(extracted.contains("Line three"));
458 }
459
460 #[test]
461 fn text_to_adf_single_line_roundtrip() {
462 let original = "My description text";
463 let adf = text_to_adf(original);
464 let extracted = extract_adf_text(&adf);
465 assert_eq!(extracted, original);
466 }
467
468 #[test]
469 fn text_to_adf_blank_line_produces_empty_paragraph() {
470 let adf = text_to_adf("First\n\nThird");
471 let content = adf["content"].as_array().unwrap();
472 assert_eq!(content.len(), 3);
473 let blank_paragraph = &content[1];
476 assert_eq!(blank_paragraph["type"], "paragraph");
477 let blank_content = blank_paragraph["content"].as_array().unwrap();
478 assert!(blank_content.is_empty());
479 }
480
481 #[test]
482 fn escape_jql_double_quotes() {
483 assert_eq!(escape_jql(r#"say "hello""#), r#"say \"hello\""#);
484 }
485
486 #[test]
487 fn escape_jql_clean_input() {
488 assert_eq!(escape_jql("In Progress"), "In Progress");
489 }
490
491 #[test]
492 fn escape_jql_backslash() {
493 assert_eq!(escape_jql(r"foo\bar"), r"foo\\bar");
494 }
495}