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}