Skip to main content

gonzalo_ticket_jira/
source.rs

1//! A read-only [`TicketSource`] backed by the Jira Cloud REST v3 API.
2//!
3//! Authenticates with Atlassian basic auth (`email` + API token). Phase 1
4//! imports via `fetch_changed` (enhanced `/search/jql`, token-paginated) and
5//! `get`; `capabilities` reports `transitions_required` so a future write-back
6//! knows Jira state changes go through transitions, not field sets. HTTP-level
7//! behavior is exercised by the conformance suite's fixtures (#20); the mapping
8//! is unit-tested in [`crate::mapping`].
9
10use 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
16/// The statusCategory key a target [`StateCategory`] should land in.
17fn target_status_category(target: StateCategory) -> &'static str {
18    match target {
19        StateCategory::InProgress | StateCategory::Pending => "indeterminate",
20        StateCategory::Done | StateCategory::Canceled => "done",
21        // Triage / Backlog / Open
22        _ => "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
51/// Does a target status name read as a cancellation / won't-do outcome rather
52/// than a genuine completion? Matched case-insensitively against a set of
53/// cancel-intent words so that `set_state(Canceled)` never lands on a plain
54/// "Done" (see #138).
55fn is_cancel_intent(status_name: &str) -> bool {
56    let name = status_name.to_ascii_lowercase();
57    // `cancel` covers cancel/canceled/cancelled; `reject` covers rejected;
58    // `abandon`/`discard` cover their -ed forms.
59    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
71/// Select the workflow transition to apply for a desired [`StateCategory`].
72///
73/// Jira collapses both completion and cancellation into statusCategory `done`,
74/// so a first-match on category alone routes a cancel to whatever done-status
75/// comes first (often "Done"). This prefers a cancel-intent status when the
76/// target is [`StateCategory::Canceled`], and a non-cancel status when the
77/// target is [`StateCategory::Done`], falling back to the first category match
78/// when no better candidate exists.
79fn 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
121/// Imports issues from a Jira Cloud site.
122pub 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    /// Connect to `site` (e.g. `https://acme.atlassian.net`) with an Atlassian
133    /// account email and API token.
134    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    /// Apply a per-connection [`StateMapping`] for status-name → category
151    /// overrides (falls back to Jira's `statusCategory`).
152    #[must_use]
153    pub fn with_mapping(mut self, mapping: StateMapping) -> Self {
154        self.mapping = Some(mapping);
155        self
156    }
157
158    /// Restrict the import to issues matching `jql` (default: all, ordered by
159    /// `updated` ascending).
160    #[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        // `transitions_required` records that set_state moves state via a
183        // workflow transition, not a field set.
184        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        // Jira state moves through workflow transitions: list the available
238        // ones for this issue and pick one landing in the target statusCategory.
239        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        // v3 comments are ADF; wrap the text in a minimal document.
272        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        // Both land in statusCategory "done"; #138 required Canceled to pick the
370        // won't-do transition rather than the first done-category one.
371        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        // Only a plain "Done" is available; a cancel request should still move
388        // the issue (best-effort) rather than fail.
389        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        // Only a won't-do transition exists in the done category; Done has no
397        // better option and falls back to the first candidate.
398        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}