1use crate::{TicketSource, record_key};
7use gonzalo_core::{ContentHash, Identity, Meta, PutResult, Record, Revision, Store};
8use gonzalo_domain::{RecordCodec, Ticket};
9
10#[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#[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 #[error("write conflict on {}: expected {:?}, store has {:?}", .0.key, .0.expected, .0.current.revision)]
33 Conflict(Box<gonzalo_core::Conflict>),
34}
35
36pub 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
68enum 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 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 let dir = tempfile::tempdir().unwrap();
226 let store = FsStore::new(dir.path());
227
228 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 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 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 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 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}