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/// A source of tickets from an external platform.
55///
56/// Requires `Send + Sync` (like [`gonzalo_core::Store`]) so a
57/// `Box<dyn TicketSource>` can be driven across threads — the daemon ingests
58/// over `Send` futures on the gRPC and HTTP transports.
59#[async_trait]
60pub trait TicketSource: Send + Sync {
61    /// What this source supports. Callers consult this before attempting writes.
62    fn capabilities(&self) -> Capabilities;
63
64    /// Tickets changed since `cursor` (or all tickets, if the cursor is empty),
65    /// plus the cursor to resume incremental sync from.
66    async fn fetch_changed(&self, cursor: &Cursor) -> Result<Page>;
67
68    /// Fetch a single ticket by its stable provider `uid`.
69    async fn get(&self, uid: &str) -> Result<Ticket>;
70
71    /// Move a ticket to a normalized [`StateCategory`]. The source resolves this
72    /// to its native mechanism (a Jira transition, a GitLab label swap, an Asana
73    /// section move). Capability-gated: defaults to `Unsupported`.
74    async fn set_state(&self, _uid: &str, _target: StateCategory) -> Result<()> {
75        Err(SourceError::Unsupported("set_state"))
76    }
77
78    /// Append a comment to a ticket. Capability-gated: defaults to `Unsupported`.
79    async fn comment(&self, _uid: &str, _body: &str) -> Result<()> {
80        Err(SourceError::Unsupported("comment"))
81    }
82}