1use 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#[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#[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
34pub 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
66enum 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 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 let dir = tempfile::tempdir().unwrap();
241 let store = FsStore::new(dir.path());
242
243 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 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 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 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 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}