1use crate::mapping::{JiraIssue, issue_to_ticket};
11use async_trait::async_trait;
12use gonzalo_domain::{StateCategory, Ticket};
13use gonzalo_ticket::{
14 Capabilities, Cursor, Page, Result, SourceError, StateMapping, TicketSource, provider_error,
15};
16use serde::Deserialize;
17
18fn target_status_category(target: StateCategory) -> &'static str {
20 match target {
21 StateCategory::InProgress | StateCategory::Pending => "indeterminate",
22 StateCategory::Done | StateCategory::Canceled => "done",
23 _ => "new",
25 }
26}
27
28#[derive(Debug, Deserialize)]
29struct TransitionsResponse {
30 #[serde(default)]
31 transitions: Vec<Transition>,
32}
33
34#[derive(Debug, Deserialize)]
35struct Transition {
36 id: String,
37 to: TransitionTo,
38}
39
40#[derive(Debug, Deserialize)]
41struct TransitionTo {
42 #[serde(default)]
43 name: String,
44 #[serde(rename = "statusCategory")]
45 status_category: StatusCategoryRef,
46}
47
48#[derive(Debug, Deserialize)]
49struct StatusCategoryRef {
50 key: String,
51}
52
53fn is_cancel_intent(status_name: &str) -> bool {
58 let name = status_name.to_ascii_lowercase();
59 const NEEDLES: &[&str] = &[
62 "cancel",
63 "won't do",
64 "wont do",
65 "will not do",
66 "reject",
67 "abandon",
68 "discard",
69 ];
70 NEEDLES.iter().any(|needle| name.contains(needle))
71}
72
73fn choose_transition(transitions: &[Transition], target: StateCategory) -> Option<&Transition> {
82 let want = target_status_category(target);
83 let candidates: Vec<&Transition> = transitions
84 .iter()
85 .filter(|t| t.to.status_category.key == want)
86 .collect();
87 let first = *candidates.first()?;
88 let preferred = match target {
89 StateCategory::Canceled => candidates
90 .iter()
91 .copied()
92 .find(|t| is_cancel_intent(&t.to.name)),
93 StateCategory::Done => candidates
94 .iter()
95 .copied()
96 .find(|t| !is_cancel_intent(&t.to.name)),
97 _ => None,
98 };
99 Some(preferred.unwrap_or(first))
100}
101
102const FIELDS: &[&str] = &[
103 "summary",
104 "description",
105 "status",
106 "issuetype",
107 "priority",
108 "assignee",
109 "reporter",
110 "labels",
111 "project",
112 "resolution",
113];
114
115#[derive(Debug, Deserialize)]
116struct SearchResponse {
117 #[serde(default)]
118 issues: Vec<JiraIssue>,
119 #[serde(rename = "nextPageToken", default)]
120 next_page_token: Option<String>,
121}
122
123pub struct JiraSource {
125 client: reqwest::Client,
126 base: reqwest::Url,
127 email: String,
128 token: String,
129 mapping: Option<StateMapping>,
130 jql: String,
131}
132
133impl JiraSource {
134 pub fn new(site: &str, email: impl Into<String>, token: impl Into<String>) -> Result<Self> {
137 let base = reqwest::Url::parse(site).map_err(be)?;
138 let client = reqwest::Client::builder()
139 .user_agent("gonzalo-ticket-jira")
140 .build()
141 .map_err(be)?;
142 Ok(Self {
143 client,
144 base,
145 email: email.into(),
146 token: token.into(),
147 mapping: None,
148 jql: "order by updated asc".to_string(),
149 })
150 }
151
152 #[must_use]
155 pub fn with_mapping(mut self, mapping: StateMapping) -> Self {
156 self.mapping = Some(mapping);
157 self
158 }
159
160 #[must_use]
163 pub fn with_jql(mut self, jql: impl Into<String>) -> Self {
164 self.jql = jql.into();
165 self
166 }
167
168 fn url(&self, segments: &[&str]) -> Result<reqwest::Url> {
169 let mut url = self.base.clone();
170 url.path_segments_mut()
171 .map_err(|_| SourceError::Backend("site URL cannot be a base".into()))?
172 .extend(segments);
173 Ok(url)
174 }
175
176 fn auth(&self, rb: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
177 rb.basic_auth(&self.email, Some(&self.token))
178 }
179}
180
181#[async_trait]
182impl TicketSource for JiraSource {
183 fn capabilities(&self) -> Capabilities {
184 Capabilities {
187 push: true,
188 comments: true,
189 transitions_required: true,
190 ..Capabilities::default()
191 }
192 }
193
194 async fn fetch_changed(&self, cursor: &Cursor) -> Result<Page> {
195 let url = self.url(&["rest", "api", "3", "search", "jql"])?;
196 let mut body = serde_json::json!({
197 "jql": self.jql,
198 "fields": FIELDS,
199 "maxResults": 100,
200 });
201 if let Some(token) = &cursor.0 {
202 body["nextPageToken"] = serde_json::json!(token);
203 }
204 let resp = self
205 .auth(self.client.post(url).json(&body))
206 .send()
207 .await
208 .map_err(be)?
209 .check_status()
210 .await?;
211 let search: SearchResponse = resp.json().await.map_err(be)?;
212 let tickets = search
213 .issues
214 .iter()
215 .map(|i| issue_to_ticket(i, self.mapping.as_ref()))
216 .collect();
217 Ok(Page {
218 tickets,
219 next: Cursor(search.next_page_token),
220 })
221 }
222
223 async fn get(&self, uid: &str) -> Result<Ticket> {
224 let mut url = self.url(&["rest", "api", "3", "issue", uid])?;
225 url.query_pairs_mut()
226 .append_pair("fields", &FIELDS.join(","));
227 let resp = self
228 .auth(self.client.get(url))
229 .send()
230 .await
231 .map_err(be)?
232 .check_status()
233 .await?;
234 let issue: JiraIssue = resp.json().await.map_err(be)?;
235 Ok(issue_to_ticket(&issue, self.mapping.as_ref()))
236 }
237
238 async fn set_state(&self, uid: &str, target: StateCategory) -> Result<()> {
239 let list_url = self.url(&["rest", "api", "3", "issue", uid, "transitions"])?;
242 let resp = self
243 .auth(self.client.get(list_url))
244 .send()
245 .await
246 .map_err(be)?
247 .check_status()
248 .await?;
249 let available: TransitionsResponse = resp.json().await.map_err(be)?;
250
251 let transition = choose_transition(&available.transitions, target).ok_or_else(|| {
252 let want = target_status_category(target);
253 SourceError::Backend(format!(
254 "no available transition into statusCategory '{want}' for {uid}"
255 ))
256 })?;
257
258 let post_url = self.url(&["rest", "api", "3", "issue", uid, "transitions"])?;
259 self.auth(
260 self.client
261 .post(post_url)
262 .json(&serde_json::json!({ "transition": { "id": transition.id } })),
263 )
264 .send()
265 .await
266 .map_err(be)?
267 .check_status()
268 .await?;
269 Ok(())
270 }
271
272 async fn comment(&self, uid: &str, body: &str) -> Result<()> {
273 let adf = serde_json::json!({
275 "body": {
276 "type": "doc",
277 "version": 1,
278 "content": [{
279 "type": "paragraph",
280 "content": [{ "type": "text", "text": body }]
281 }]
282 }
283 });
284 let url = self.url(&["rest", "api", "3", "issue", uid, "comment"])?;
285 self.auth(self.client.post(url).json(&adf))
286 .send()
287 .await
288 .map_err(be)?
289 .check_status()
290 .await?;
291 Ok(())
292 }
293}
294
295fn be<E: std::fmt::Display>(e: E) -> SourceError {
296 SourceError::Backend(e.to_string())
297}
298
299trait CheckStatus: Sized {
308 async fn check_status(self) -> Result<Self>;
309}
310
311impl CheckStatus for reqwest::Response {
312 async fn check_status(self) -> Result<Self> {
313 let status = self.status();
314 if status.is_success() {
315 return Ok(self);
316 }
317 let body = self.text().await.unwrap_or_default();
319 Err(provider_error(status.as_u16(), &body))
320 }
321}
322
323#[cfg(test)]
324mod tests {
325 use super::*;
326
327 #[test]
328 fn builds_search_and_issue_urls() {
329 let src = JiraSource::new("https://acme.atlassian.net", "me@acme.co", "tok").unwrap();
330 assert_eq!(
331 src.url(&["rest", "api", "3", "search", "jql"])
332 .unwrap()
333 .as_str(),
334 "https://acme.atlassian.net/rest/api/3/search/jql"
335 );
336 assert_eq!(
337 src.url(&["rest", "api", "3", "issue", "ENG-42"])
338 .unwrap()
339 .as_str(),
340 "https://acme.atlassian.net/rest/api/3/issue/ENG-42"
341 );
342 }
343
344 #[test]
345 fn capabilities_flag_transition_gating() {
346 let src = JiraSource::new("https://acme.atlassian.net", "me@acme.co", "tok").unwrap();
347 let caps = src.capabilities();
348 assert!(caps.transitions_required);
349 assert!(caps.push);
350 assert!(caps.comments);
351 }
352
353 #[test]
354 fn rejects_bad_site_url() {
355 assert!(JiraSource::new("not a url", "e", "t").is_err());
356 }
357
358 fn transition(id: &str, name: &str, category: &str) -> Transition {
359 Transition {
360 id: id.to_string(),
361 to: TransitionTo {
362 name: name.to_string(),
363 status_category: StatusCategoryRef {
364 key: category.to_string(),
365 },
366 },
367 }
368 }
369
370 #[test]
371 fn cancel_intent_recognizes_wont_do_variants() {
372 for name in [
373 "Cancel",
374 "Canceled",
375 "Cancelled",
376 "Won't Do",
377 "Wont Do",
378 "Will Not Do",
379 "Rejected",
380 "Abandoned",
381 "Discarded",
382 ] {
383 assert!(
384 is_cancel_intent(name),
385 "expected cancel-intent for {name:?}"
386 );
387 }
388 for name in ["Done", "Closed", "Resolved", "Completed"] {
389 assert!(!is_cancel_intent(name), "expected non-cancel for {name:?}");
390 }
391 }
392
393 #[test]
394 fn canceled_prefers_wont_do_over_done() {
395 let transitions = vec![
398 transition("11", "Done", "done"),
399 transition("21", "Won't Do", "done"),
400 ];
401
402 let canceled = choose_transition(&transitions, StateCategory::Canceled).unwrap();
403 assert_eq!(canceled.id, "21");
404 assert_eq!(canceled.to.name, "Won't Do");
405
406 let done = choose_transition(&transitions, StateCategory::Done).unwrap();
407 assert_eq!(done.id, "11");
408 assert_eq!(done.to.name, "Done");
409 }
410
411 #[test]
412 fn canceled_falls_back_to_first_done_when_no_cancel_status() {
413 let transitions = vec![transition("11", "Done", "done")];
416 let chosen = choose_transition(&transitions, StateCategory::Canceled).unwrap();
417 assert_eq!(chosen.id, "11");
418 }
419
420 #[test]
421 fn done_falls_back_to_cancel_status_when_no_plain_done() {
422 let transitions = vec![transition("21", "Won't Do", "done")];
425 let chosen = choose_transition(&transitions, StateCategory::Done).unwrap();
426 assert_eq!(chosen.id, "21");
427 }
428
429 #[test]
430 fn in_progress_takes_first_indeterminate() {
431 let transitions = vec![
432 transition("31", "In Review", "indeterminate"),
433 transition("32", "In Progress", "indeterminate"),
434 ];
435 let chosen = choose_transition(&transitions, StateCategory::InProgress).unwrap();
436 assert_eq!(chosen.id, "31");
437 }
438
439 #[test]
440 fn no_matching_category_yields_none() {
441 let transitions = vec![transition("11", "Done", "done")];
442 assert!(choose_transition(&transitions, StateCategory::InProgress).is_none());
443 }
444}