Skip to main content

gonzalo_ticket/
ingest.rs

1//! The ingest engine: pull a [`TicketSource`] and persist each [`Ticket`] as a
2//! `Record`, using optimistic concurrency (ADR 0005). Re-sync is idempotent —
3//! unchanged tickets (same body hash) are skipped, so a full board re-scan is
4//! cheap. Depends only on the trait + `Store`, never on a concrete connector.
5
6use crate::{TicketSource, record_key};
7use gonzalo_core::{ContentHash, Identity, Meta, PutResult, Record, Revision, Store};
8use gonzalo_domain::{RecordCodec, Ticket};
9
10/// How many tickets a sync created, updated, or left untouched.
11#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
12pub struct IngestSummary {
13    pub imported: usize,
14    pub updated: usize,
15    pub unchanged: usize,
16}
17
18/// Failures during ingest.
19#[derive(Debug, thiserror::Error)]
20pub enum IngestError {
21    #[error("ticket source: {0}")]
22    Source(#[from] crate::SourceError),
23    #[error("store: {0}")]
24    Store(#[from] gonzalo_core::CoreError),
25    /// Boxed because inlining a `RecordKey` and two revisions made every
26    /// `Result<_, IngestError>` in this module 136 bytes wide on its error
27    /// path (clippy 1.99's `result_large_err`). `PutResult::Conflict` already
28    /// hands back a `Box<Conflict>`, so carrying it through costs nothing and
29    /// gives callers the whole conflicting record rather than its revision
30    /// alone. The message still prints only the revision — a `Record`'s
31    /// `Debug` would include its body.
32    #[error("write conflict on {}: expected {:?}, store has {:?}", .0.key, .0.expected, .0.current.revision)]
33    Conflict(Box<gonzalo_core::Conflict>),
34}
35
36/// Pull all changed tickets from `source` and upsert them into `store`,
37/// attributing writes to `author`.
38///
39/// `scope` is the board/connection discriminator woven into each record key
40/// (see [`crate::record_key`]): pass the connection name for board sources so
41/// the same external item on two boards lands in two distinct records (#159),
42/// or `None` for a single-board / board-agnostic source.
43pub async fn ingest(
44    source: &dyn TicketSource,
45    store: &dyn Store,
46    author: &str,
47    scope: Option<&str>,
48) -> Result<IngestSummary, IngestError> {
49    let mut summary = IngestSummary::default();
50    let mut cursor = crate::Cursor::default();
51    loop {
52        let page = source.fetch_changed(&cursor).await?;
53        for ticket in &page.tickets {
54            match upsert(store, ticket, author, scope).await? {
55                Outcome::Imported => summary.imported += 1,
56                Outcome::Updated => summary.updated += 1,
57                Outcome::Unchanged => summary.unchanged += 1,
58            }
59        }
60        if page.next.0.is_none() || page.next == cursor {
61            break;
62        }
63        cursor = page.next;
64    }
65    Ok(summary)
66}
67
68/// Per-ticket result of an upsert, tallied into [`IngestSummary`].
69enum Outcome {
70    Imported,
71    Updated,
72    Unchanged,
73}
74
75async fn upsert(
76    store: &dyn Store,
77    ticket: &Ticket,
78    author: &str,
79    scope: Option<&str>,
80) -> Result<Outcome, IngestError> {
81    let key = record_key(ticket, scope);
82    let body = ticket.to_body()?;
83    let new_hash = ContentHash::of(body.bytes());
84
85    let existing = store.get(&key).await?;
86    if let Some(rec) = &existing
87        && rec.revision.hash == new_hash
88    {
89        return Ok(Outcome::Unchanged);
90    }
91
92    let expected: Option<Revision> = existing.as_ref().map(|r| r.revision.clone());
93    let meta = Meta::new(Identity::new(author), "ticket-ingest");
94    let record = match &existing {
95        Some(current) => current.update(body, meta),
96        None => Record::create(key.clone(), Ticket::KIND, body, meta),
97    };
98    match store.put(record, expected).await? {
99        PutResult::Committed(_) => Ok(if existing.is_some() {
100            Outcome::Updated
101        } else {
102            Outcome::Imported
103        }),
104        PutResult::Conflict(c) => Err(IngestError::Conflict(c)),
105    }
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111    use crate::InMemorySource;
112    use gonzalo_domain::{BodyFormat, Provider, State, StateCategory, TicketBody};
113    use gonzalo_store_fs::FsStore;
114    use std::collections::BTreeMap;
115
116    fn ticket(uid: &str, title: &str) -> Ticket {
117        Ticket {
118            provider: Provider::GitHub,
119            uid: uid.into(),
120            display: "#1".into(),
121            item_type: "issue".into(),
122            title: title.into(),
123            state: State {
124                category: StateCategory::Open,
125                resolution: None,
126                raw_name: "Todo".into(),
127                raw_id: None,
128            },
129            priority: None,
130            actors: vec![],
131            labels: vec![],
132            containers: vec![],
133            links: vec![],
134            body: TicketBody {
135                markdown: String::new(),
136                format: BodyFormat::Markdown,
137                raw: None,
138            },
139            fields: BTreeMap::new(),
140        }
141    }
142
143    #[tokio::test]
144    async fn imports_then_is_idempotent_then_updates() {
145        let dir = tempfile::tempdir().unwrap();
146        let store = FsStore::new(dir.path());
147
148        let src = InMemorySource::new(vec![ticket("a", "A"), ticket("b", "B")]);
149        let s1 = ingest(&src, &store, "tester", None).await.unwrap();
150        assert_eq!(
151            s1,
152            IngestSummary {
153                imported: 2,
154                updated: 0,
155                unchanged: 0
156            }
157        );
158
159        let s2 = ingest(&src, &store, "tester", None).await.unwrap();
160        assert_eq!(
161            s2,
162            IngestSummary {
163                imported: 0,
164                updated: 0,
165                unchanged: 2
166            }
167        );
168
169        let src2 = InMemorySource::new(vec![ticket("a", "A2"), ticket("b", "B")]);
170        let s3 = ingest(&src2, &store, "tester", None).await.unwrap();
171        assert_eq!(
172            s3,
173            IngestSummary {
174                imported: 0,
175                updated: 1,
176                unchanged: 1
177            }
178        );
179    }
180
181    /// A source that returns two pages, driving the pagination loop across the
182    /// cursor boundary. Stateless: it branches on the incoming cursor value.
183    struct PagedSource;
184
185    #[async_trait::async_trait]
186    impl crate::TicketSource for PagedSource {
187        fn capabilities(&self) -> crate::Capabilities {
188            crate::Capabilities::default()
189        }
190
191        async fn fetch_changed(&self, cursor: &crate::Cursor) -> crate::Result<crate::Page> {
192            match cursor.0.as_deref() {
193                None => Ok(crate::Page {
194                    tickets: vec![ticket("p1", "P1")],
195                    next: crate::Cursor(Some("p2".into())),
196                }),
197                Some("p2") => Ok(crate::Page {
198                    tickets: vec![ticket("p2", "P2")],
199                    next: crate::Cursor::default(),
200                }),
201                Some(other) => Err(crate::SourceError::Backend(format!(
202                    "unexpected cursor: {other}"
203                ))),
204            }
205        }
206
207        async fn get(&self, uid: &str) -> crate::Result<Ticket> {
208            Ok(ticket(uid, uid))
209        }
210    }
211
212    #[tokio::test]
213    async fn paginates_across_multiple_pages() {
214        let dir = tempfile::tempdir().unwrap();
215        let store = FsStore::new(dir.path());
216
217        let summary = ingest(&PagedSource, &store, "tester", None).await.unwrap();
218        assert_eq!(summary.imported, 2);
219    }
220
221    #[tokio::test]
222    async fn same_issue_on_two_boards_does_not_thrash_one_record() {
223        // #159: the same uid imported under two connections must yield two
224        // distinct records (one per board), not alternating overwrites of one.
225        let dir = tempfile::tempdir().unwrap();
226        let store = FsStore::new(dir.path());
227
228        // Board A puts the card in "Todo"; board B in "Done" — same uid.
229        let mut on_b = ticket("caliban-ai/gonzalo#15", "shared");
230        on_b.state.raw_name = "Done".into();
231        on_b.state.category = StateCategory::Done;
232        let src_a = InMemorySource::new(vec![ticket("caliban-ai/gonzalo#15", "shared")]);
233        let src_b = InMemorySource::new(vec![on_b]);
234
235        // First import on each board is a fresh record — no collision.
236        let a1 = ingest(&src_a, &store, "tester", Some("board-a"))
237            .await
238            .unwrap();
239        let b1 = ingest(&src_b, &store, "tester", Some("board-b"))
240            .await
241            .unwrap();
242        assert_eq!(a1.imported, 1);
243        assert_eq!(b1.imported, 1);
244
245        // Re-syncing each is idempotent: neither disturbs the other's record,
246        // which is exactly the thrash the shared key used to cause.
247        let a2 = ingest(&src_a, &store, "tester", Some("board-a"))
248            .await
249            .unwrap();
250        let b2 = ingest(&src_b, &store, "tester", Some("board-b"))
251            .await
252            .unwrap();
253        assert_eq!(a2.unchanged, 1, "board-a stayed put: {a2:?}");
254        assert_eq!(b2.unchanged, 1, "board-b stayed put: {b2:?}");
255
256        // Both records exist side by side under distinct keys.
257        let ka = record_key(&ticket("caliban-ai/gonzalo#15", "shared"), Some("board-a"));
258        let kb = record_key(&ticket("caliban-ai/gonzalo#15", "shared"), Some("board-b"));
259        assert_ne!(ka, kb);
260        assert!(store.get(&ka).await.unwrap().is_some());
261        assert!(store.get(&kb).await.unwrap().is_some());
262    }
263
264    /// A store whose `put` always conflicts, to exercise the conflict arm.
265    struct ConflictStore;
266
267    #[async_trait::async_trait]
268    impl gonzalo_core::Store for ConflictStore {
269        async fn get(
270            &self,
271            _key: &gonzalo_core::RecordKey,
272        ) -> gonzalo_core::Result<Option<Record>> {
273            Ok(None)
274        }
275
276        async fn put(
277            &self,
278            record: Record,
279            expected: Option<Revision>,
280        ) -> gonzalo_core::Result<PutResult> {
281            Ok(PutResult::Conflict(Box::new(gonzalo_core::Conflict {
282                key: record.key.clone(),
283                expected,
284                current: record,
285            })))
286        }
287
288        async fn list(
289            &self,
290            _prefix: &gonzalo_core::KeyPrefix,
291        ) -> gonzalo_core::Result<Vec<gonzalo_core::RecordKey>> {
292            Ok(vec![])
293        }
294
295        async fn delete_as(
296            &self,
297            _key: &gonzalo_core::RecordKey,
298            _expected: Option<Revision>,
299            _author: Option<Identity>,
300        ) -> gonzalo_core::Result<gonzalo_core::DeleteResult> {
301            Ok(gonzalo_core::DeleteResult::Deleted)
302        }
303
304        async fn put_raw(
305            &self,
306            record: Record,
307            expected: Option<Revision>,
308        ) -> gonzalo_core::Result<PutResult> {
309            <Self as gonzalo_core::Store>::put(self, record, expected).await
310        }
311
312        async fn get_raw(
313            &self,
314            _key: &gonzalo_core::RecordKey,
315        ) -> gonzalo_core::Result<Option<Record>> {
316            Ok(None)
317        }
318
319        async fn list_raw(
320            &self,
321            _prefix: &gonzalo_core::KeyPrefix,
322        ) -> gonzalo_core::Result<Vec<gonzalo_core::RecordKey>> {
323            Ok(vec![])
324        }
325
326        async fn purge(
327            &self,
328            _key: &gonzalo_core::RecordKey,
329            _expected: Revision,
330        ) -> gonzalo_core::Result<gonzalo_core::DeleteResult> {
331            Ok(gonzalo_core::DeleteResult::Deleted)
332        }
333    }
334
335    #[tokio::test]
336    async fn surfaces_store_conflict() {
337        let src = InMemorySource::new(vec![ticket("a", "A")]);
338        let err = ingest(&src, &ConflictStore, "tester", None)
339            .await
340            .unwrap_err();
341        assert!(matches!(err, IngestError::Conflict(_)));
342    }
343}