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}
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 #[serde(default)]
141 pub start_at: usize,
142 #[serde(default)]
143 pub max_results: usize,
144 pub is_last: bool,
145}
146
147#[derive(Debug, Deserialize, Serialize, Clone)]
149pub struct Transition {
150 pub id: String,
151 pub name: String,
152 pub to: Option<TransitionTo>,
154}
155
156#[derive(Debug, Deserialize, Serialize, Clone)]
158#[serde(rename_all = "camelCase")]
159pub struct TransitionTo {
160 pub name: String,
161 pub status_category: Option<StatusCategory>,
162}
163
164#[derive(Debug, Deserialize, Serialize, Clone)]
166pub struct StatusCategory {
167 pub key: String,
168 pub name: String,
169}
170
171#[derive(Debug, Deserialize, Serialize)]
173pub struct SearchResponse {
174 pub issues: Vec<Issue>,
175 pub total: usize,
176 #[serde(rename = "startAt")]
177 pub start_at: usize,
178 #[serde(rename = "maxResults")]
179 pub max_results: usize,
180}
181
182#[derive(Debug, Deserialize, Serialize)]
184pub struct TransitionsResponse {
185 pub transitions: Vec<Transition>,
186}
187
188#[derive(Debug, Deserialize, Serialize)]
190pub struct CreateIssueResponse {
191 pub id: String,
192 pub key: String,
193 #[serde(rename = "self")]
194 pub url: String,
195}
196
197#[derive(Debug, Deserialize)]
199#[serde(rename_all = "camelCase")]
200pub struct Myself {
201 pub account_id: String,
202 pub display_name: String,
203}
204
205pub fn text_to_adf(text: &str) -> serde_json::Value {
211 let paragraphs: Vec<serde_json::Value> = text
212 .split('\n')
213 .map(|line| {
214 if line.is_empty() {
215 serde_json::json!({ "type": "paragraph", "content": [] })
216 } else {
217 serde_json::json!({
218 "type": "paragraph",
219 "content": [{"type": "text", "text": line}]
220 })
221 }
222 })
223 .collect();
224
225 serde_json::json!({
226 "type": "doc",
227 "version": 1,
228 "content": paragraphs
229 })
230}
231
232pub fn extract_adf_text(node: &serde_json::Value) -> String {
234 let mut buf = String::new();
235 collect_text(node, &mut buf);
236 buf.trim().to_string()
237}
238
239fn collect_text(node: &serde_json::Value, buf: &mut String) {
240 let node_type = node.get("type").and_then(|v| v.as_str()).unwrap_or("");
241
242 if node_type == "text" {
243 if let Some(text) = node.get("text").and_then(|v| v.as_str()) {
244 buf.push_str(text);
245 }
246 return;
247 }
248
249 if node_type == "hardBreak" {
250 buf.push('\n');
251 return;
252 }
253
254 if let Some(content) = node.get("content").and_then(|v| v.as_array()) {
255 for child in content {
256 collect_text(child, buf);
257 }
258 }
259
260 if matches!(
262 node_type,
263 "paragraph"
264 | "heading"
265 | "bulletList"
266 | "orderedList"
267 | "listItem"
268 | "codeBlock"
269 | "blockquote"
270 | "rule"
271 ) && !buf.ends_with('\n')
272 {
273 buf.push('\n');
274 }
275}
276
277pub fn escape_jql(value: &str) -> String {
281 value.replace('\\', "\\\\").replace('"', "\\\"")
282}
283
284#[cfg(test)]
285mod tests {
286 use super::*;
287
288 #[test]
289 fn extract_simple_paragraph() {
290 let doc = serde_json::json!({
291 "type": "doc",
292 "version": 1,
293 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Hello world"}]}]
294 });
295 assert_eq!(extract_adf_text(&doc), "Hello world");
296 }
297
298 #[test]
299 fn extract_multiple_paragraphs() {
300 let doc = serde_json::json!({
301 "type": "doc",
302 "version": 1,
303 "content": [
304 {"type": "paragraph", "content": [{"type": "text", "text": "First"}]},
305 {"type": "paragraph", "content": [{"type": "text", "text": "Second"}]}
306 ]
307 });
308 let text = extract_adf_text(&doc);
309 assert!(text.contains("First"));
310 assert!(text.contains("Second"));
311 }
312
313 #[test]
314 fn text_to_adf_preserves_newlines() {
315 let original = "Line one\nLine two\nLine three";
316 let adf = text_to_adf(original);
317 let extracted = extract_adf_text(&adf);
318 assert!(extracted.contains("Line one"));
319 assert!(extracted.contains("Line two"));
320 assert!(extracted.contains("Line three"));
321 }
322
323 #[test]
324 fn text_to_adf_single_line_roundtrip() {
325 let original = "My description text";
326 let adf = text_to_adf(original);
327 let extracted = extract_adf_text(&adf);
328 assert_eq!(extracted, original);
329 }
330
331 #[test]
332 fn text_to_adf_blank_line_produces_empty_paragraph() {
333 let adf = text_to_adf("First\n\nThird");
334 let content = adf["content"].as_array().unwrap();
335 assert_eq!(content.len(), 3);
336 let blank_paragraph = &content[1];
339 assert_eq!(blank_paragraph["type"], "paragraph");
340 let blank_content = blank_paragraph["content"].as_array().unwrap();
341 assert!(blank_content.is_empty());
342 }
343
344 #[test]
345 fn escape_jql_double_quotes() {
346 assert_eq!(escape_jql(r#"say "hello""#), r#"say \"hello\""#);
347 }
348
349 #[test]
350 fn escape_jql_clean_input() {
351 assert_eq!(escape_jql("In Progress"), "In Progress");
352 }
353
354 #[test]
355 fn escape_jql_backslash() {
356 assert_eq!(escape_jql(r"foo\bar"), r"foo\\bar");
357 }
358}