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