Skip to main content

gonzalo_ticket/
source.rs

1//! The provider boundary: [`TicketSource`] (ADR 0010).
2//!
3//! `TicketSource` is the ticket analogue of `gonzalo_vector::Embedder` — it
4//! keeps gonzalo provider-agnostic about *where* tickets come from. Phase 1 is
5//! read-only (`fetch_changed` / `get`); write-back (`set_state`, `comment`) is
6//! capability-gated and defaults to `Unsupported`, so a read-only mirror need
7//! implement only the two readers.
8
9use async_trait::async_trait;
10use gonzalo_domain::{StateCategory, Ticket};
11use thiserror::Error;
12
13/// An opaque, per-source incremental-sync cursor — a timestamp, a JQL bound, a
14/// GraphQL page cursor, or an event sync token, depending on the provider.
15/// Deliberately **not** gonzalo's `Revision`: the external system owns its own
16/// change watermark.
17#[derive(Debug, Clone, PartialEq, Eq, Default)]
18pub struct Cursor(pub Option<String>);
19
20/// What a source supports, negotiated up front rather than discovered at
21/// runtime — this is what keeps the trait free of `if provider == …` branches.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
23pub struct Capabilities {
24    pub push: bool,
25    pub transitions_required: bool,
26    pub custom_fields: bool,
27    pub single_assignee: bool,
28    pub hierarchy: bool,
29    pub relations: bool,
30    pub comments: bool,
31}
32
33/// A page of changed tickets plus the cursor to resume from. Does not derive
34/// `Eq` because [`Ticket`] does not (its `fields` hold `serde_json::Value`).
35#[derive(Debug, Clone, PartialEq)]
36pub struct Page {
37    pub tickets: Vec<Ticket>,
38    pub next: Cursor,
39}
40
41/// Errors a source can surface.
42#[derive(Debug, Error)]
43pub enum SourceError {
44    /// A capability the source does not provide was requested.
45    #[error("operation not supported by this source: {0}")]
46    Unsupported(&'static str),
47    /// A transport / backend failure, carrying the provider's message.
48    #[error("ticket source backend error: {0}")]
49    Backend(String),
50}
51
52pub type Result<T> = std::result::Result<T, SourceError>;
53
54/// Longest provider error body echoed into a [`SourceError::Backend`] message.
55/// A provider error is normally a short JSON object, but nothing guarantees it
56/// and an error string is a poor place for a megabyte.
57const MAX_ERROR_BODY: usize = 512;
58
59/// Build the error for a non-success HTTP response from a ticket provider.
60///
61/// [`SourceError::Backend`] is documented as "carrying the provider's message",
62/// but every connector reached it through reqwest's `error_for_status()`, which
63/// **discards the response body** — so the message was reqwest's generic
64/// "HTTP status client error … for url …" and the provider's actual reason was
65/// thrown away. That reason is the whole value: an expired token, a missing
66/// scope, a malformed query, a rate-limit window with a retry hint (#240).
67///
68/// Shared here, rather than per connector, so five providers cannot drift into
69/// five ways of describing the same failure.
70pub fn provider_error(status: u16, body: &str) -> SourceError {
71    let body = body.trim();
72    if body.is_empty() {
73        return SourceError::Backend(format!("provider returned {status}"));
74    }
75    // Truncate on a char boundary; a provider may answer in any encoding.
76    let shown: String = match body.char_indices().nth(MAX_ERROR_BODY) {
77        Some((cut, _)) => format!("{}…", &body[..cut]),
78        None => body.to_string(),
79    };
80    SourceError::Backend(format!("provider returned {status}: {shown}"))
81}
82
83/// A source of tickets from an external platform.
84///
85/// Requires `Send + Sync` (like [`gonzalo_core::Store`]) so a
86/// `Box<dyn TicketSource>` can be driven across threads — the daemon ingests
87/// over `Send` futures on the gRPC and HTTP transports.
88#[async_trait]
89pub trait TicketSource: Send + Sync {
90    /// What this source supports. Callers consult this before attempting writes.
91    fn capabilities(&self) -> Capabilities;
92
93    /// Tickets changed since `cursor` (or all tickets, if the cursor is empty),
94    /// plus the cursor to resume incremental sync from.
95    async fn fetch_changed(&self, cursor: &Cursor) -> Result<Page>;
96
97    /// Fetch a single ticket by its stable provider `uid`.
98    async fn get(&self, uid: &str) -> Result<Ticket>;
99
100    /// Move a ticket to a normalized [`StateCategory`]. The source resolves this
101    /// to its native mechanism (a Jira transition, a GitLab label swap, an Asana
102    /// section move). Capability-gated: defaults to `Unsupported`.
103    async fn set_state(&self, _uid: &str, _target: StateCategory) -> Result<()> {
104        Err(SourceError::Unsupported("set_state"))
105    }
106
107    /// Append a comment to a ticket. Capability-gated: defaults to `Unsupported`.
108    async fn comment(&self, _uid: &str, _body: &str) -> Result<()> {
109        Err(SourceError::Unsupported("comment"))
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116
117    /// The point of #240: the provider's reason survives, where
118    /// `error_for_status()` threw it away and left reqwest's generic message.
119    #[test]
120    fn the_provider_reason_survives() {
121        let err = provider_error(
122            403,
123            r#"{"message":"Resource not accessible by integration"}"#,
124        );
125        let SourceError::Backend(msg) = err else {
126            panic!("provider failures are Backend errors");
127        };
128        assert!(msg.contains("403"), "{msg}");
129        assert!(
130            msg.contains("Resource not accessible by integration"),
131            "{msg}"
132        );
133    }
134
135    #[test]
136    fn an_empty_body_still_names_the_status() {
137        // Some providers answer 5xx with nothing at all; the status is then the
138        // whole signal and the message must not trail a bare colon.
139        let msg = provider_error(502, "   ").to_string();
140        assert!(msg.contains("502"), "{msg}");
141        assert!(
142            !msg.contains("502: "),
143            "no dangling separator after the status: {msg}"
144        );
145    }
146
147    #[test]
148    fn a_long_body_is_truncated() {
149        // An error string is a poor place for an unbounded response.
150        let msg = provider_error(400, &"x".repeat(MAX_ERROR_BODY * 4)).to_string();
151        assert!(
152            msg.len() < MAX_ERROR_BODY * 2,
153            "still bounded: {}",
154            msg.len()
155        );
156        assert!(msg.ends_with('…'), "truncation is visible: {msg}");
157    }
158
159    #[test]
160    fn truncation_respects_char_boundaries() {
161        // A provider may answer in any encoding; slicing mid-codepoint panics.
162        let body = "é".repeat(MAX_ERROR_BODY * 2);
163        let msg = provider_error(400, &body).to_string();
164        assert!(msg.contains("400"), "{msg}");
165    }
166}