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, host: &str) -> String {
52 format!("https://{host}/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}
70
71#[derive(Debug, Deserialize, Serialize, Clone)]
72pub struct StatusField {
73 pub name: String,
74}
75
76#[derive(Debug, Deserialize, Serialize, Clone)]
77#[serde(rename_all = "camelCase")]
78pub struct UserField {
79 pub display_name: String,
80 pub email_address: Option<String>,
81 pub account_id: Option<String>,
82}
83
84#[derive(Debug, Deserialize, Serialize, Clone)]
85pub struct PriorityField {
86 pub name: String,
87}
88
89#[derive(Debug, Deserialize, Serialize, Clone)]
90pub struct IssueTypeField {
91 pub name: String,
92}
93
94#[derive(Debug, Deserialize, Serialize, Clone)]
95#[serde(rename_all = "camelCase")]
96pub struct CommentList {
97 pub comments: Vec<Comment>,
98 pub total: usize,
99 #[serde(default)]
100 pub start_at: usize,
101 #[serde(default)]
102 pub max_results: usize,
103}
104
105#[derive(Debug, Deserialize, Serialize, Clone)]
106#[serde(rename_all = "camelCase")]
107pub struct Comment {
108 pub id: String,
109 pub author: UserField,
110 pub body: Option<serde_json::Value>,
111 pub created: String,
112 pub updated: Option<String>,
113}
114
115impl Comment {
116 pub fn body_text(&self) -> String {
117 match &self.body {
118 Some(doc) => extract_adf_text(doc),
119 None => String::new(),
120 }
121 }
122}
123
124#[derive(Debug, Deserialize, Serialize, Clone)]
126pub struct Project {
127 pub id: String,
128 pub key: String,
129 pub name: String,
130 #[serde(rename = "projectTypeKey")]
131 pub project_type: Option<String>,
132}
133
134#[derive(Debug, Deserialize)]
136#[serde(rename_all = "camelCase")]
137pub struct ProjectSearchResponse {
138 pub values: Vec<Project>,
139 pub total: usize,
140 pub is_last: bool,
141}
142
143#[derive(Debug, Deserialize, Serialize, Clone)]
145pub struct Transition {
146 pub id: String,
147 pub name: String,
148 pub to: Option<TransitionTo>,
150}
151
152#[derive(Debug, Deserialize, Serialize, Clone)]
154#[serde(rename_all = "camelCase")]
155pub struct TransitionTo {
156 pub name: String,
157 pub status_category: Option<StatusCategory>,
158}
159
160#[derive(Debug, Deserialize, Serialize, Clone)]
162pub struct StatusCategory {
163 pub key: String,
164 pub name: String,
165}
166
167#[derive(Debug, Deserialize, Serialize)]
169pub struct SearchResponse {
170 pub issues: Vec<Issue>,
171 pub total: usize,
172 #[serde(rename = "startAt")]
173 pub start_at: usize,
174 #[serde(rename = "maxResults")]
175 pub max_results: usize,
176}
177
178#[derive(Debug, Deserialize, Serialize)]
180pub struct TransitionsResponse {
181 pub transitions: Vec<Transition>,
182}
183
184#[derive(Debug, Deserialize, Serialize)]
186pub struct CreateIssueResponse {
187 pub id: String,
188 pub key: String,
189 #[serde(rename = "self")]
190 pub url: String,
191}
192
193#[derive(Debug, Deserialize)]
195#[serde(rename_all = "camelCase")]
196pub struct Myself {
197 pub account_id: String,
198 pub display_name: String,
199}
200
201pub fn text_to_adf(text: &str) -> serde_json::Value {
207 let paragraphs: Vec<serde_json::Value> = text
208 .split('\n')
209 .map(|line| {
210 if line.is_empty() {
211 serde_json::json!({ "type": "paragraph", "content": [] })
212 } else {
213 serde_json::json!({
214 "type": "paragraph",
215 "content": [{"type": "text", "text": line}]
216 })
217 }
218 })
219 .collect();
220
221 serde_json::json!({
222 "type": "doc",
223 "version": 1,
224 "content": paragraphs
225 })
226}
227
228pub fn extract_adf_text(node: &serde_json::Value) -> String {
230 let mut buf = String::new();
231 collect_text(node, &mut buf);
232 buf.trim().to_string()
233}
234
235fn collect_text(node: &serde_json::Value, buf: &mut String) {
236 let node_type = node.get("type").and_then(|v| v.as_str()).unwrap_or("");
237
238 if node_type == "text" {
239 if let Some(text) = node.get("text").and_then(|v| v.as_str()) {
240 buf.push_str(text);
241 }
242 return;
243 }
244
245 if node_type == "hardBreak" {
246 buf.push('\n');
247 return;
248 }
249
250 if let Some(content) = node.get("content").and_then(|v| v.as_array()) {
251 for child in content {
252 collect_text(child, buf);
253 }
254 }
255
256 if matches!(
258 node_type,
259 "paragraph" | "heading" | "bulletList" | "orderedList" | "listItem" | "codeBlock" | "blockquote" | "rule"
260 ) && !buf.ends_with('\n')
261 {
262 buf.push('\n');
263 }
264}
265
266pub fn escape_jql(value: &str) -> String {
270 value.replace('\\', "\\\\").replace('"', "\\\"")
271}
272
273#[cfg(test)]
274mod tests {
275 use super::*;
276
277 #[test]
278 fn extract_simple_paragraph() {
279 let doc = serde_json::json!({
280 "type": "doc",
281 "version": 1,
282 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Hello world"}]}]
283 });
284 assert_eq!(extract_adf_text(&doc), "Hello world");
285 }
286
287 #[test]
288 fn extract_multiple_paragraphs() {
289 let doc = serde_json::json!({
290 "type": "doc",
291 "version": 1,
292 "content": [
293 {"type": "paragraph", "content": [{"type": "text", "text": "First"}]},
294 {"type": "paragraph", "content": [{"type": "text", "text": "Second"}]}
295 ]
296 });
297 let text = extract_adf_text(&doc);
298 assert!(text.contains("First"));
299 assert!(text.contains("Second"));
300 }
301
302 #[test]
303 fn text_to_adf_preserves_newlines() {
304 let original = "Line one\nLine two\nLine three";
305 let adf = text_to_adf(original);
306 let extracted = extract_adf_text(&adf);
307 assert!(extracted.contains("Line one"));
308 assert!(extracted.contains("Line two"));
309 assert!(extracted.contains("Line three"));
310 }
311
312 #[test]
313 fn text_to_adf_single_line_roundtrip() {
314 let original = "My description text";
315 let adf = text_to_adf(original);
316 let extracted = extract_adf_text(&adf);
317 assert_eq!(extracted, original);
318 }
319
320 #[test]
321 fn text_to_adf_blank_line_produces_empty_paragraph() {
322 let adf = text_to_adf("First\n\nThird");
323 let content = adf["content"].as_array().unwrap();
324 assert_eq!(content.len(), 3);
325 let blank_paragraph = &content[1];
328 assert_eq!(blank_paragraph["type"], "paragraph");
329 let blank_content = blank_paragraph["content"].as_array().unwrap();
330 assert!(blank_content.is_empty());
331 }
332
333 #[test]
334 fn escape_jql_double_quotes() {
335 assert_eq!(escape_jql(r#"say "hello""#), r#"say \"hello\""#);
336 }
337
338 #[test]
339 fn escape_jql_clean_input() {
340 assert_eq!(escape_jql("In Progress"), "In Progress");
341 }
342
343 #[test]
344 fn escape_jql_backslash() {
345 assert_eq!(escape_jql(r"foo\bar"), r"foo\\bar");
346 }
347}