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::{
14    Capabilities, Cursor, Page, Result, SourceError, StateMapping, TicketSource, provider_error,
15};
16use serde::Deserialize;
17
18/// The statusCategory key a target [`StateCategory`] should land in.
19fn target_status_category(target: StateCategory) -> &'static str {
20    match target {
21        StateCategory::InProgress | StateCategory::Pending => "indeterminate",
22        StateCategory::Done | StateCategory::Canceled => "done",
23        // Triage / Backlog / Open
24        _ => "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
53/// Does a target status name read as a cancellation / won't-do outcome rather
54/// than a genuine completion? Matched case-insensitively against a set of
55/// cancel-intent words so that `set_state(Canceled)` never lands on a plain
56/// "Done" (see #138).
57fn is_cancel_intent(status_name: &str) -> bool {
58    let name = status_name.to_ascii_lowercase();
59    // `cancel` covers cancel/canceled/cancelled; `reject` covers rejected;
60    // `abandon`/`discard` cover their -ed forms.
61    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
73/// Select the workflow transition to apply for a desired [`StateCategory`].
74///
75/// Jira collapses both completion and cancellation into statusCategory `done`,
76/// so a first-match on category alone routes a cancel to whatever done-status
77/// comes first (often "Done"). This prefers a cancel-intent status when the
78/// target is [`StateCategory::Canceled`], and a non-cancel status when the
79/// target is [`StateCategory::Done`], falling back to the first category match
80/// when no better candidate exists.
81fn 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
123/// Imports issues from a Jira Cloud site.
124pub 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    /// Connect to `site` (e.g. `https://acme.atlassian.net`) with an Atlassian
135    /// account email and API token.
136    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    /// Apply a per-connection [`StateMapping`] for status-name → category
153    /// overrides (falls back to Jira's `statusCategory`).
154    #[must_use]
155    pub fn with_mapping(mut self, mapping: StateMapping) -> Self {
156        self.mapping = Some(mapping);
157        self
158    }
159
160    /// Restrict the import to issues matching `jql` (default: all, ordered by
161    /// `updated` ascending).
162    #[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        // `transitions_required` records that set_state moves state via a
185        // workflow transition, not a field set.
186        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        // Jira state moves through workflow transitions: list the available
240        // ones for this issue and pick one landing in the target statusCategory.
241        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        // v3 comments are ADF; wrap the text in a minimal document.
274        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
299/// Fail with the provider's own message instead of reqwest's generic one.
300///
301/// reqwest's `error_for_status()` discards the response body, which is exactly
302/// where a ticket provider puts the reason — expired token, missing scope,
303/// malformed query, rate-limit window. Swapping it for `check_status()` keeps
304/// the reason (#240). The message format is shared via
305/// [`provider_error`](gonzalo_ticket::provider_error) so the connectors cannot
306/// drift apart.
307trait 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        // Read the body only on the failure path; the caller decodes success.
318        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        // Both land in statusCategory "done"; #138 required Canceled to pick the
396        // won't-do transition rather than the first done-category one.
397        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        // Only a plain "Done" is available; a cancel request should still move
414        // the issue (best-effort) rather than fail.
415        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        // Only a won't-do transition exists in the done category; Done has no
423        // better option and falls back to the first candidate.
424        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}