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