1use anyhow::Context;
10use forge_primitives::review::{
11 ReviewAuthor, ReviewComment, ReviewSet, ReviewStatus, ReviewThread,
12};
13use redb::{ReadableTable, TableDefinition};
14
15use super::Db;
16
17const REVIEW_THREADS: TableDefinition<(&str, &str), &[u8]> = TableDefinition::new("review_threads");
18const REVIEWS: TableDefinition<(&str, &str), &[u8]> = TableDefinition::new("reviews");
22
23pub fn load(db: &Db, project: &str, branch: &str) -> anyhow::Result<Vec<ReviewThread>> {
26 let txn = db.database().begin_read()?;
27 let table = match txn.open_table(REVIEW_THREADS) {
28 Ok(t) => t,
29 Err(redb::TableError::TableDoesNotExist(_)) => return Ok(Vec::new()),
30 Err(e) => return Err(e.into()),
31 };
32 match table.get((project, branch))? {
33 Some(value) => decode(value.value(), project, branch),
34 None => Ok(Vec::new()),
35 }
36}
37
38pub fn save(db: &Db, project: &str, branch: &str, threads: &[ReviewThread]) -> anyhow::Result<()> {
41 if threads.is_empty() {
42 delete(db, project, branch)?;
43 return Ok(());
44 }
45 let value = serde_json::to_vec(threads).context("serialize review threads")?;
46 let txn = db.database().begin_write()?;
47 {
48 let mut table = txn.open_table(REVIEW_THREADS)?;
49 table.insert((project, branch), value.as_slice())?;
50 }
51 txn.commit()?;
52 Ok(())
53}
54
55pub fn upsert(
61 db: &Db,
62 project: &str,
63 branch: &str,
64 mut thread: ReviewThread,
65) -> anyhow::Result<()> {
66 let now = rfc3339_now();
67 thread.updated_at.clone_from(&now);
68 for comment in &mut thread.comments {
71 if comment.at.is_empty() {
72 comment.at.clone_from(&now);
73 }
74 }
75 let txn = db.database().begin_write()?;
76 {
77 let mut table = txn.open_table(REVIEW_THREADS)?;
78 let mut threads = match table.get((project, branch))? {
79 Some(value) => decode(value.value(), project, branch)?,
80 None => Vec::new(),
81 };
82 if let Some(existing) = threads.iter_mut().find(|t| t.id == thread.id) {
83 thread.created_at.clone_from(&existing.created_at);
84 *existing = thread;
85 } else {
86 if thread.created_at.is_empty() {
87 thread.created_at = now;
88 }
89 threads.push(thread);
90 }
91 let value = serde_json::to_vec(&threads).context("serialize review threads")?;
92 table.insert((project, branch), value.as_slice())?;
93 }
94 txn.commit()?;
95 Ok(())
96}
97
98pub fn remove_thread(db: &Db, project: &str, branch: &str, id: &str) -> anyhow::Result<bool> {
103 let txn = db.database().begin_write()?;
104 let removed = {
105 let mut table = txn.open_table(REVIEW_THREADS)?;
106 let mut threads = match table.get((project, branch))? {
107 Some(value) => decode(value.value(), project, branch)?,
108 None => Vec::new(),
109 };
110 let before = threads.len();
111 threads.retain(|t| t.id != id);
112 let removed = threads.len() != before;
113 if removed {
114 if threads.is_empty() {
115 table.remove((project, branch))?;
116 } else {
117 let value = serde_json::to_vec(&threads).context("serialize review threads")?;
118 table.insert((project, branch), value.as_slice())?;
119 }
120 }
121 removed
122 };
123 txn.commit()?;
124 Ok(removed)
125}
126
127pub fn set_status(
130 db: &Db,
131 project: &str,
132 branch: &str,
133 id: &str,
134 status: ReviewStatus,
135) -> anyhow::Result<bool> {
136 let txn = db.database().begin_write()?;
137 let found = {
138 let mut table = txn.open_table(REVIEW_THREADS)?;
139 let mut threads = match table.get((project, branch))? {
140 Some(value) => decode(value.value(), project, branch)?,
141 None => Vec::new(),
142 };
143 match threads.iter_mut().find(|t| t.id == id) {
144 Some(thread) => {
145 thread.status = status;
146 thread.updated_at = rfc3339_now();
147 let encoded = serde_json::to_vec(&threads).context("serialize review threads")?;
148 table.insert((project, branch), encoded.as_slice())?;
149 true
150 }
151 None => false,
152 }
153 };
154 txn.commit()?;
155 Ok(found)
156}
157
158pub fn find_thread_by_id(
162 db: &Db,
163 project: &str,
164 branch: &str,
165 id: &str,
166) -> anyhow::Result<Option<ReviewThread>> {
167 Ok(load(db, project, branch)?.into_iter().find(|t| t.id == id))
168}
169
170pub fn append_reply(
178 db: &Db,
179 project: &str,
180 branch: &str,
181 id: &str,
182 author_label: &str,
183 text: &str,
184 at: &str,
185) -> anyhow::Result<ReviewStatus> {
186 let stamp = if at.is_empty() { rfc3339_now() } else { at.to_owned() };
187 let txn = db.database().begin_write()?;
188 let status = {
189 let mut table = txn.open_table(REVIEW_THREADS)?;
190 let mut threads = match table.get((project, branch))? {
191 Some(value) => decode(value.value(), project, branch)?,
192 None => Vec::new(),
193 };
194 let thread = threads
195 .iter_mut()
196 .find(|t| t.id == id)
197 .with_context(|| format!("no review thread {id} in ({project}, {branch})"))?;
198 thread.comments.push(ReviewComment {
199 author: ReviewAuthor::Agent { label: author_label.to_owned() },
200 text: text.to_owned(),
201 at: stamp.clone(),
202 review_id: None,
203 });
204 if thread.status == ReviewStatus::Open {
205 thread.status = ReviewStatus::Addressed;
206 }
207 thread.updated_at = stamp;
208 let status = thread.status;
209 let encoded = serde_json::to_vec(&threads).context("serialize review threads")?;
210 table.insert((project, branch), encoded.as_slice())?;
211 status
212 };
213 txn.commit()?;
214 Ok(status)
215}
216
217pub fn delete(db: &Db, project: &str, branch: &str) -> anyhow::Result<bool> {
220 let txn = db.database().begin_write()?;
221 let existed = {
222 let mut table = txn.open_table(REVIEW_THREADS)?;
223 table.remove((project, branch))?.is_some()
224 };
225 txn.commit()?;
226 Ok(existed)
227}
228
229pub fn load_reviews(db: &Db, project: &str, branch: &str) -> anyhow::Result<Vec<ReviewSet>> {
232 let txn = db.database().begin_read()?;
233 let table = match txn.open_table(REVIEWS) {
234 Ok(t) => t,
235 Err(redb::TableError::TableDoesNotExist(_)) => return Ok(Vec::new()),
236 Err(e) => return Err(e.into()),
237 };
238 match table.get((project, branch))? {
239 Some(value) => decode_reviews(value.value(), project, branch),
240 None => Ok(Vec::new()),
241 }
242}
243
244pub fn review_branches(db: &Db, project: &str) -> anyhow::Result<Vec<String>> {
249 let txn = db.database().begin_read()?;
250 let table = match txn.open_table(REVIEWS) {
251 Ok(t) => t,
252 Err(redb::TableError::TableDoesNotExist(_)) => return Ok(Vec::new()),
253 Err(e) => return Err(e.into()),
254 };
255 let mut branches = Vec::new();
256 for row in table.range((project, "")..)? {
257 let (key, _) = row?;
258 let (row_project, branch) = key.value();
259 if row_project != project {
260 break;
261 }
262 branches.push(branch.to_owned());
263 }
264 Ok(branches)
265}
266
267pub fn stored_branches(db: &Db, project: &str) -> anyhow::Result<Vec<String>> {
273 let txn = db.database().begin_read()?;
274 let mut branches = std::collections::BTreeSet::new();
275 for definition in [REVIEW_THREADS, REVIEWS] {
276 let table = match txn.open_table(definition) {
277 Ok(t) => t,
278 Err(redb::TableError::TableDoesNotExist(_)) => continue,
279 Err(e) => return Err(e.into()),
280 };
281 for row in table.range((project, "")..)? {
282 let (key, _) = row?;
283 let (row_project, branch) = key.value();
284 if row_project != project {
285 break;
286 }
287 branches.insert(branch.to_owned());
288 }
289 }
290 Ok(branches.into_iter().collect())
291}
292
293pub fn delete_branch_state(db: &Db, project: &str, branch: &str) -> anyhow::Result<()> {
300 let txn = db.database().begin_write()?;
301 {
302 txn.open_table(REVIEW_THREADS)?.remove((project, branch))?;
303 txn.open_table(REVIEWS)?.remove((project, branch))?;
304 }
305 txn.commit()?;
306 Ok(())
307}
308
309#[cfg(test)]
316pub(crate) fn save_reviews(
317 db: &Db,
318 project: &str,
319 branch: &str,
320 reviews: &[ReviewSet],
321) -> anyhow::Result<()> {
322 let txn = db.database().begin_write()?;
323 {
324 let mut table = txn.open_table(REVIEWS)?;
325 if reviews.is_empty() {
326 table.remove((project, branch))?;
327 } else {
328 let value = serde_json::to_vec(reviews).context("serialize reviews")?;
329 table.insert((project, branch), value.as_slice())?;
330 }
331 }
332 txn.commit()?;
333 Ok(())
334}
335
336pub fn submit_review(
347 db: &Db,
348 project: &str,
349 branch: &str,
350 summary: Option<String>,
351 thread_ids: &[String],
352) -> anyhow::Result<ReviewSet> {
353 let now = rfc3339_now();
354 let txn = db.database().begin_write()?;
355 let review = {
356 let mut reviews_table = txn.open_table(REVIEWS)?;
357 let mut reviews = match reviews_table.get((project, branch))? {
358 Some(value) => decode_reviews(value.value(), project, branch)?,
359 None => Vec::new(),
360 };
361 let number = u32::try_from(reviews.len()).unwrap_or(u32::MAX).saturating_add(1);
362 let review =
363 ReviewSet { id: uuid::Uuid::new_v4().to_string(), number, summary, created_at: now };
364 {
365 let mut threads_table = txn.open_table(REVIEW_THREADS)?;
366 let existing = match threads_table.get((project, branch))? {
369 Some(value) => Some(decode(value.value(), project, branch)?),
370 None => None,
371 };
372 if let Some(mut threads) = existing {
373 let mut changed = false;
374 for thread in threads.iter_mut().filter(|t| thread_ids.contains(&t.id)) {
375 let mut sealed = false;
376 for turn in thread
377 .comments
378 .iter_mut()
379 .filter(|c| matches!(c.author, ReviewAuthor::User) && c.review_id.is_none())
380 {
381 turn.review_id = Some(review.id.clone());
382 sealed = true;
383 }
384 if sealed && thread.status == ReviewStatus::Addressed {
385 thread.status = ReviewStatus::Open;
386 }
387 changed |= sealed;
388 }
389 if changed {
390 let encoded =
391 serde_json::to_vec(&threads).context("serialize review threads")?;
392 threads_table.insert((project, branch), encoded.as_slice())?;
393 }
394 }
395 }
396 reviews.push(review.clone());
397 let encoded = serde_json::to_vec(&reviews).context("serialize reviews")?;
398 reviews_table.insert((project, branch), encoded.as_slice())?;
399 review
400 };
401 txn.commit()?;
402 Ok(review)
403}
404
405fn decode(bytes: &[u8], project: &str, branch: &str) -> anyhow::Result<Vec<ReviewThread>> {
410 let mut threads: Vec<ReviewThread> = serde_json::from_slice(bytes)
411 .with_context(|| format!("decode review threads for ({project}, {branch})"))?;
412 lift_thread_membership(bytes, &mut threads);
413 Ok(threads)
414}
415
416#[derive(serde::Deserialize)]
419struct ThreadMembership {
420 #[serde(default)]
421 review_id: Option<String>,
422}
423
424fn lift_thread_membership(bytes: &[u8], threads: &mut [ReviewThread]) {
431 let Ok(stored) = serde_json::from_slice::<Vec<ThreadMembership>>(bytes) else {
432 return;
433 };
434 for (thread, legacy) in threads.iter_mut().zip(stored) {
435 let Some(review_id) = legacy.review_id else { continue };
436 if thread.comments.iter().any(|c| c.review_id.is_some()) {
437 continue;
438 }
439 for turn in thread.comments.iter_mut().filter(|c| matches!(c.author, ReviewAuthor::User)) {
440 turn.review_id = Some(review_id.clone());
441 }
442 }
443}
444
445fn decode_reviews(bytes: &[u8], project: &str, branch: &str) -> anyhow::Result<Vec<ReviewSet>> {
448 serde_json::from_slice(bytes)
449 .with_context(|| format!("decode reviews for ({project}, {branch})"))
450}
451
452fn rfc3339_now() -> String {
455 use time::OffsetDateTime;
456 use time::format_description::well_known::Rfc3339;
457 OffsetDateTime::now_utc().format(&Rfc3339).unwrap_or_else(|err| {
458 tracing::warn!(error = %err, "rfc3339 format failed; emitting epoch sentinel");
459 "1970-01-01T00:00:00Z".to_owned()
460 })
461}
462
463#[cfg(feature = "test-helpers")]
467pub fn write_corrupt_row_for_test(db: &Db, project: &str, branch: &str) -> anyhow::Result<()> {
468 let txn = db.database().begin_write()?;
469 {
470 let mut table = txn.open_table(REVIEW_THREADS)?;
471 table.insert((project, branch), b"not json".as_slice())?;
472 }
473 txn.commit()?;
474 Ok(())
475}
476
477#[cfg(feature = "test-helpers")]
480pub fn write_corrupt_reviews_row_for_test(
481 db: &Db,
482 project: &str,
483 branch: &str,
484) -> anyhow::Result<()> {
485 let txn = db.database().begin_write()?;
486 {
487 let mut table = txn.open_table(REVIEWS)?;
488 table.insert((project, branch), b"not json".as_slice())?;
489 }
490 txn.commit()?;
491 Ok(())
492}
493
494#[cfg(test)]
495mod tests {
496 use super::*;
497 use forge_primitives::review::{
498 ReviewAnchor, ReviewAuthor, ReviewComment, ReviewSet, ReviewSide,
499 };
500 use tempfile::tempdir;
501
502 fn thread(id: &str, line: u32) -> ReviewThread {
503 ReviewThread {
504 id: id.to_owned(),
505 anchor: ReviewAnchor {
506 path: "src/x.rs".to_owned(),
507 side: ReviewSide::New,
508 line,
509 content_hash: u64::from(line),
510 context: vec!["ctx".to_owned()],
511 base_ref: "main".to_owned(),
512 },
513 comments: vec![ReviewComment {
514 author: ReviewAuthor::User,
515 text: format!("comment {id}"),
516 at: "2026-07-19T10:00:00Z".to_owned(),
517 review_id: None,
518 }],
519 status: ReviewStatus::Open,
520 created_at: "2026-07-19T10:00:00Z".to_owned(),
521 updated_at: "2026-07-19T10:00:00Z".to_owned(),
522 commit: None,
523 }
524 }
525
526 fn reply(db: &Db, id: &str, text: &str) {
529 let mut thread =
530 find_thread_by_id(db, "forge", "feat", id).expect("load").expect("thread for reply");
531 thread.comments.push(ReviewComment {
532 author: ReviewAuthor::User,
533 text: text.to_owned(),
534 at: String::new(),
535 review_id: None,
536 });
537 upsert(db, "forge", "feat", thread).expect("upsert reply");
538 }
539
540 fn open_db() -> (tempfile::TempDir, Db) {
541 let dir = tempdir().expect("tempdir");
542 let db = Db::open(&dir.path().join("db.redb")).expect("open db");
543 (dir, db)
544 }
545
546 #[test]
547 fn save_then_load_round_trips() {
548 let (_dir, db) = open_db();
549 let threads = vec![thread("a", 10), thread("b", 20)];
550 save(&db, "forge", "feat", &threads).expect("save");
551 assert_eq!(load(&db, "forge", "feat").expect("load"), threads);
552 }
553
554 #[test]
559 fn a_doc_scoped_thread_set_round_trips_under_its_own_scope() {
560 let (_dir, db) = open_db();
561 let scope = forge_primitives::review::doc_scope_ref("/Users/me/proj/docs/plan.md");
562 let mut doc = thread("d", 4);
563 doc.anchor.path = "/Users/me/proj/docs/plan.md".to_owned();
564 doc.anchor.base_ref = scope.clone();
565 save(&db, "forge", &scope, &[doc.clone()]).expect("save");
566 save(&db, "forge", "feat", &[thread("a", 10)]).expect("save a branch too");
567
568 assert_eq!(load(&db, "forge", &scope).expect("load"), vec![doc]);
569 assert_eq!(load(&db, "forge", "feat").expect("load").len(), 1, "the rows stay apart");
570 }
571
572 #[test]
573 fn load_is_empty_on_miss() {
574 let (_dir, db) = open_db();
575 assert!(load(&db, "forge", "nope").expect("load").is_empty());
576 }
577
578 #[test]
579 fn upsert_replaces_by_id_and_appends_new() {
580 let (_dir, db) = open_db();
581 upsert(&db, "forge", "feat", thread("a", 10)).expect("upsert a");
582 upsert(&db, "forge", "feat", thread("b", 20)).expect("upsert b");
583 let mut a2 = thread("a", 10);
585 a2.anchor.line = 99;
586 upsert(&db, "forge", "feat", a2).expect("re-upsert a");
587 let loaded = load(&db, "forge", "feat").expect("load");
588 assert_eq!(loaded.len(), 2, "re-upsert replaced, no duplicate");
589 assert_eq!(loaded.iter().find(|t| t.id == "a").expect("a present").anchor.line, 99);
590 }
591
592 #[test]
593 fn set_status_mutates_one_and_reports_missing() {
594 let (_dir, db) = open_db();
595 save(&db, "forge", "feat", &[thread("a", 10), thread("b", 20)]).expect("save");
596 assert!(set_status(&db, "forge", "feat", "a", ReviewStatus::Resolved).expect("set"));
597 let loaded = load(&db, "forge", "feat").expect("load");
598 assert_eq!(loaded.iter().find(|t| t.id == "a").expect("a").status, ReviewStatus::Resolved);
599 assert_eq!(loaded.iter().find(|t| t.id == "b").expect("b").status, ReviewStatus::Open);
600 assert!(
601 !set_status(&db, "forge", "feat", "missing", ReviewStatus::Resolved).expect("set"),
602 "an unknown id reports not-found",
603 );
604 }
605
606 #[test]
607 fn upsert_stamps_an_empty_comment_at() {
608 let (_dir, db) = open_db();
609 let mut t = thread("a", 10);
610 t.comments[0].at = String::new();
611 upsert(&db, "forge", "feat", t).expect("upsert");
612 let loaded = load(&db, "forge", "feat").expect("load");
613 assert!(!loaded[0].comments[0].at.is_empty(), "upsert stamps an empty comment `at`");
614 }
615
616 #[test]
617 fn remove_thread_drops_one_and_reports_missing() {
618 let (_dir, db) = open_db();
619 save(&db, "forge", "feat", &[thread("a", 10), thread("b", 20)]).expect("save");
620 assert!(remove_thread(&db, "forge", "feat", "a").expect("remove a"));
621 let loaded = load(&db, "forge", "feat").expect("load");
622 assert_eq!(loaded.len(), 1);
623 assert_eq!(loaded[0].id, "b");
624 assert!(!remove_thread(&db, "forge", "feat", "a").expect("remove again"), "already gone");
625 assert!(remove_thread(&db, "forge", "feat", "b").expect("remove b"));
627 assert!(load(&db, "forge", "feat").expect("load").is_empty());
628 }
629
630 #[test]
631 fn delete_removes_the_branch_set() {
632 let (_dir, db) = open_db();
633 save(&db, "forge", "feat", &[thread("a", 10)]).expect("save");
634 assert!(delete(&db, "forge", "feat").expect("delete"), "existing row deletes to true");
635 assert!(load(&db, "forge", "feat").expect("load").is_empty());
636 assert!(!delete(&db, "forge", "feat").expect("delete"), "absent row deletes to false");
637 }
638
639 #[test]
640 fn distinct_project_branch_keys_do_not_collide() {
641 let (_dir, db) = open_db();
642 save(&db, "forge", "feat", &[thread("a", 10)]).expect("save feat");
643 save(&db, "forge", "other", &[thread("b", 20), thread("c", 30)]).expect("save other");
644 save(&db, "elsewhere", "feat", &[thread("d", 40)]).expect("save elsewhere");
645 assert_eq!(load(&db, "forge", "feat").expect("load").len(), 1);
646 assert_eq!(load(&db, "forge", "other").expect("load").len(), 2);
647 assert_eq!(load(&db, "elsewhere", "feat").expect("load").len(), 1);
648 delete(&db, "forge", "feat").expect("delete");
650 assert!(load(&db, "forge", "feat").expect("load").is_empty());
651 assert_eq!(load(&db, "forge", "other").expect("load").len(), 2);
652 assert_eq!(load(&db, "elsewhere", "feat").expect("load").len(), 1);
653 }
654
655 #[test]
656 fn corrupt_row_surfaces_error_and_is_never_clobbered() {
657 let (_dir, db) = open_db();
658 {
660 let txn = db.database().begin_write().expect("begin");
661 {
662 let mut table = txn.open_table(REVIEW_THREADS).expect("open");
663 table.insert(("forge", "feat"), b"not json".as_slice()).expect("insert");
664 }
665 txn.commit().expect("commit");
666 }
667 assert!(load(&db, "forge", "feat").is_err(), "a corrupt row is an error, not empty");
669 assert!(upsert(&db, "forge", "feat", thread("a", 1)).is_err(), "upsert aborts");
672 assert!(
673 set_status(&db, "forge", "feat", "a", ReviewStatus::Resolved).is_err(),
674 "set_status aborts",
675 );
676 assert!(remove_thread(&db, "forge", "feat", "a").is_err(), "remove aborts");
677 let txn = db.database().begin_read().expect("begin read");
678 let table = txn.open_table(REVIEW_THREADS).expect("open");
679 let raw = table.get(("forge", "feat")).expect("get").expect("row present");
680 assert_eq!(raw.value(), b"not json".as_slice(), "the corrupt bytes were never overwritten");
681 }
682
683 #[test]
684 fn save_empty_slice_clears_the_row() {
685 let (_dir, db) = open_db();
686 save(&db, "forge", "feat", &[thread("a", 10)]).expect("save");
687 save(&db, "forge", "feat", &[]).expect("save empty");
688 assert!(load(&db, "forge", "feat").expect("load").is_empty());
689 }
690
691 #[test]
692 fn append_reply_appends_agent_comment_and_flips_open_to_addressed() {
693 let (_dir, db) = open_db();
694 save(&db, "forge", "feat", &[thread("a", 10)]).expect("save");
695 let status = append_reply(
696 &db,
697 "forge",
698 "feat",
699 "a",
700 "implementer",
701 "fixed it",
702 "2026-07-23T11:00:00Z",
703 )
704 .expect("append");
705 assert_eq!(status, ReviewStatus::Addressed, "an Open thread flips to Addressed on reply");
706 let loaded = load(&db, "forge", "feat").expect("load");
707 let a = loaded.iter().find(|t| t.id == "a").expect("a present");
708 assert_eq!(a.comments.len(), 2, "the agent reply appends to the thread");
709 assert_eq!(a.comments[1].author, ReviewAuthor::Agent { label: "implementer".to_owned() });
710 assert_eq!(a.comments[1].text, "fixed it");
711 assert_eq!(a.comments[1].at, "2026-07-23T11:00:00Z");
712 assert_eq!(a.status, ReviewStatus::Addressed);
713 }
714
715 #[test]
716 fn append_reply_on_resolved_thread_appends_but_keeps_resolved() {
717 let (_dir, db) = open_db();
718 let mut t = thread("a", 10);
719 t.status = ReviewStatus::Resolved;
720 save(&db, "forge", "feat", &[t]).expect("save");
721 let status =
722 append_reply(&db, "forge", "feat", "a", "impl", "more", "2026-07-23T11:00:00Z")
723 .expect("append");
724 assert_eq!(status, ReviewStatus::Resolved, "a resolved thread stays resolved");
725 let loaded = load(&db, "forge", "feat").expect("load");
726 let a = loaded.iter().find(|t| t.id == "a").expect("a");
727 assert_eq!(a.comments.len(), 2, "the reply still appends");
728 assert_eq!(a.status, ReviewStatus::Resolved);
729 }
730
731 #[test]
732 fn append_reply_unknown_id_errors() {
733 let (_dir, db) = open_db();
734 save(&db, "forge", "feat", &[thread("a", 10)]).expect("save");
735 assert!(
736 append_reply(&db, "forge", "feat", "missing", "impl", "x", "2026-07-23T11:00:00Z")
737 .is_err(),
738 "an unknown comment_id is an error, not a silent no-op",
739 );
740 }
741
742 #[test]
743 fn find_thread_by_id_returns_the_thread_or_none() {
744 let (_dir, db) = open_db();
745 save(&db, "forge", "feat", &[thread("a", 10), thread("b", 20)]).expect("save");
746 let found = find_thread_by_id(&db, "forge", "feat", "b").expect("lookup");
747 assert_eq!(found.map(|t| t.id), Some("b".to_owned()));
748 assert!(
749 find_thread_by_id(&db, "forge", "feat", "missing").expect("lookup").is_none(),
750 "an unknown id resolves to None",
751 );
752 }
753
754 fn review(number: u32, summary: Option<&str>) -> ReviewSet {
755 ReviewSet {
756 id: format!("review-{number}"),
757 number,
758 summary: summary.map(str::to_owned),
759 created_at: "2026-07-23T10:00:00Z".to_owned(),
760 }
761 }
762
763 #[test]
764 fn reviews_save_then_load_round_trips() {
765 let (_dir, db) = open_db();
766 let reviews = vec![review(1, Some("first")), review(2, None)];
767 save_reviews(&db, "forge", "feat", &reviews).expect("save");
768 assert_eq!(load_reviews(&db, "forge", "feat").expect("load"), reviews);
769 }
770
771 #[test]
772 fn load_reviews_is_empty_on_miss() {
773 let (_dir, db) = open_db();
774 assert!(load_reviews(&db, "forge", "nope").expect("load").is_empty());
775 }
776
777 #[test]
778 fn review_branches_lists_only_the_projects_own_branches() {
779 let (_dir, db) = open_db();
780 save_reviews(&db, "forge", "feat/a", &[review(1, None)]).expect("save a");
781 save_reviews(&db, "forge", "feat/b", &[review(1, None)]).expect("save b");
782 save_reviews(&db, "elsewhere", "feat/c", &[review(1, None)]).expect("save c");
783 save(&db, "forge", "drafts-only", &[thread("a", 10)]).expect("save threads");
786 assert_eq!(
787 review_branches(&db, "forge").expect("branches"),
788 vec!["feat/a".to_owned(), "feat/b".to_owned()],
789 );
790 assert_eq!(review_branches(&db, "nosuch").expect("branches"), Vec::<String>::new());
791 }
792
793 #[test]
796 fn review_branches_does_not_bleed_across_adjacent_projects() {
797 let (_dir, db) = open_db();
798 save_reviews(&db, "aaa", "feat/x", &[review(1, None)]).expect("save aaa");
799 save_reviews(&db, "aaa-suffix", "feat/y", &[review(1, None)]).expect("save suffix");
800 save_reviews(&db, "bbb", "feat/z", &[review(1, None)]).expect("save bbb");
801 assert_eq!(review_branches(&db, "aaa").expect("branches"), vec!["feat/x".to_owned()]);
802 assert_eq!(
803 review_branches(&db, "aaa-suffix").expect("branches"),
804 vec!["feat/y".to_owned()]
805 );
806 }
807
808 #[test]
813 fn delete_branch_state_clears_both_tables_and_spares_other_branches() {
814 let (_dir, db) = open_db();
815 save(&db, "forge", "feat", &[thread("a", 10)]).expect("save threads");
816 save_reviews(&db, "forge", "feat", &[review(1, None)]).expect("save reviews");
817 save(&db, "forge", "other", &[thread("b", 20)]).expect("save other threads");
818 save_reviews(&db, "forge", "other", &[review(1, None)]).expect("save other reviews");
819
820 delete_branch_state(&db, "forge", "feat").expect("delete");
821
822 assert!(load(&db, "forge", "feat").expect("load").is_empty(), "threads gone");
823 assert!(load_reviews(&db, "forge", "feat").expect("load").is_empty(), "reviews gone too");
824 assert_eq!(load(&db, "forge", "other").expect("load").len(), 1, "scoped to its branch");
825 assert_eq!(load_reviews(&db, "forge", "other").expect("load").len(), 1);
826 delete_branch_state(&db, "forge", "absent").expect("absent branch deletes cleanly");
828 }
829
830 #[test]
834 fn stored_branches_unions_both_tables_without_bleeding_across_projects() {
835 let (_dir, db) = open_db();
836 save(&db, "aaa", "drafts-only", &[thread("a", 10)]).expect("save threads");
837 save_reviews(&db, "aaa", "both", &[review(1, None)]).expect("save reviews");
838 save(&db, "aaa", "both", &[thread("b", 20)]).expect("save threads");
839 save_reviews(&db, "aaa", "reviews-only", &[review(1, None)]).expect("save reviews");
840 save(&db, "aaa-suffix", "theirs", &[thread("c", 30)]).expect("save suffix");
842 save_reviews(&db, "bbb", "theirs", &[review(1, None)]).expect("save bbb");
843
844 assert_eq!(
845 stored_branches(&db, "aaa").expect("branches"),
846 vec!["both".to_owned(), "drafts-only".to_owned(), "reviews-only".to_owned()],
847 "every branch this project holds state on, from either table, once",
848 );
849 assert_eq!(
850 stored_branches(&db, "aaa-suffix").expect("branches"),
851 vec!["theirs".to_owned()],
852 );
853 assert!(stored_branches(&db, "nosuch").expect("branches").is_empty());
854 }
855
856 #[test]
857 fn save_reviews_empty_slice_clears_the_row() {
858 let (_dir, db) = open_db();
859 save_reviews(&db, "forge", "feat", &[review(1, None)]).expect("save");
860 save_reviews(&db, "forge", "feat", &[]).expect("save empty");
861 assert!(load_reviews(&db, "forge", "feat").expect("load").is_empty());
862 }
863
864 #[test]
865 fn submit_review_mints_number_and_files_listed_unfiled_threads() {
866 let (_dir, db) = open_db();
867 save(&db, "forge", "feat", &[thread("a", 10), thread("b", 20), thread("c", 30)])
868 .expect("save threads");
869
870 let filed = |id: &str| {
871 load(&db, "forge", "feat")
872 .expect("load")
873 .into_iter()
874 .find(|t| t.id == id)
875 .expect("thread")
876 .origin_review()
877 .map(str::to_owned)
878 };
879
880 let r1 = submit_review(
881 &db,
882 "forge",
883 "feat",
884 Some("first pass".to_owned()),
885 &["a".to_owned(), "b".to_owned()],
886 )
887 .expect("submit");
888 assert_eq!(r1.number, 1);
889 assert_eq!(r1.summary.as_deref(), Some("first pass"));
890 assert_eq!(filed("a"), Some(r1.id.clone()), "a filed into r1");
891 assert_eq!(filed("b"), Some(r1.id.clone()), "b filed into r1");
892 assert_eq!(filed("c"), None, "c not listed, stays unfiled");
893 assert_eq!(load_reviews(&db, "forge", "feat").expect("load").len(), 1);
894
895 let r2 = submit_review(&db, "forge", "feat", None, &["c".to_owned()]).expect("submit 2");
897 assert_eq!(r2.number, 2);
898 assert_ne!(r2.id, r1.id, "each review has a distinct id");
899 assert_eq!(filed("a"), Some(r1.id.clone()), "a stays in r1");
900 assert_eq!(filed("c"), Some(r2.id.clone()), "c filed into r2");
901 assert_eq!(load_reviews(&db, "forge", "feat").expect("load").len(), 2);
902 }
903
904 #[test]
905 fn submit_review_leaves_an_already_filed_turn_untouched() {
906 let (_dir, db) = open_db();
907 let mut a = thread("a", 10);
908 a.comments[0].review_id = Some("prior".to_owned());
909 save(&db, "forge", "feat", &[a]).expect("save");
910 let r = submit_review(&db, "forge", "feat", None, &["a".to_owned()]).expect("submit");
911 let stored = load(&db, "forge", "feat").expect("load");
912 assert_eq!(
913 stored[0].comments[0].review_id,
914 Some("prior".to_owned()),
915 "a sealed turn never moves to a later review",
916 );
917 assert!(!stored[0].is_in_review(&r.id), "with nothing new to seal it joins no review");
918 assert_eq!(r.number, 1, "the review still mints even if it files nothing new");
919 }
920
921 #[test]
922 fn a_reply_on_a_filed_thread_joins_the_next_review() {
923 let (_dir, db) = open_db();
924 save(&db, "forge", "feat", &[thread("a", 10)]).expect("save");
925 let r1 = submit_review(&db, "forge", "feat", None, &["a".to_owned()]).expect("submit 1");
926 append_reply(&db, "forge", "feat", "a", "implementer", "done", "").expect("agent reply");
927 reply(&db, "a", "not quite");
928 let r2 = submit_review(&db, "forge", "feat", None, &["a".to_owned()]).expect("submit 2");
929
930 let stored = load(&db, "forge", "feat").expect("load").remove(0);
931 assert!(stored.is_in_review(&r1.id), "the first round stays attributed to r1");
932 assert!(stored.is_in_review(&r2.id), "the reply puts the thread in r2 as well");
933 assert_eq!(
934 stored.comments.iter().map(|c| c.review_id.clone()).collect::<Vec<_>>(),
935 vec![Some(r1.id.clone()), None, Some(r2.id.clone())],
936 "turns are attributed per round; the agent reply carries no membership",
937 );
938 assert_eq!(stored.origin_review(), Some(r1.id.as_str()));
939 assert_eq!(stored.latest_review(), Some(r2.id.as_str()));
940 }
941
942 #[test]
943 fn sealing_a_new_turn_reopens_an_addressed_thread() {
944 let (_dir, db) = open_db();
945 save(&db, "forge", "feat", &[thread("a", 10)]).expect("save");
946 submit_review(&db, "forge", "feat", None, &["a".to_owned()]).expect("submit 1");
947 let status =
948 append_reply(&db, "forge", "feat", "a", "implementer", "done", "").expect("reply");
949 assert_eq!(status, ReviewStatus::Addressed, "the agent's answer addressed it");
950 reply(&db, "a", "not quite");
951 assert_eq!(
952 find_thread_by_id(&db, "forge", "feat", "a").expect("load").expect("thread").status,
953 ReviewStatus::Addressed,
954 "typing a reply alone does not change state",
955 );
956 submit_review(&db, "forge", "feat", None, &["a".to_owned()]).expect("submit 2");
957 assert_eq!(
958 find_thread_by_id(&db, "forge", "feat", "a").expect("load").expect("thread").status,
959 ReviewStatus::Open,
960 "sealing the reply hands the thread back to the agent",
961 );
962 }
963
964 #[test]
965 fn submitting_without_a_new_turn_leaves_an_addressed_thread_addressed() {
966 let (_dir, db) = open_db();
967 save(&db, "forge", "feat", &[thread("a", 10), thread("b", 20)]).expect("save");
968 submit_review(&db, "forge", "feat", None, &["a".to_owned()]).expect("submit 1");
969 append_reply(&db, "forge", "feat", "a", "implementer", "done", "").expect("reply");
970 submit_review(&db, "forge", "feat", None, &["a".to_owned(), "b".to_owned()])
973 .expect("submit 2");
974 assert_eq!(
975 find_thread_by_id(&db, "forge", "feat", "a").expect("load").expect("thread").status,
976 ReviewStatus::Addressed,
977 "no new turn on a, so its state is untouched",
978 );
979 }
980
981 #[test]
982 fn sealing_does_not_reopen_a_resolved_thread() {
983 let (_dir, db) = open_db();
984 save(&db, "forge", "feat", &[thread("a", 10)]).expect("save");
985 submit_review(&db, "forge", "feat", None, &["a".to_owned()]).expect("submit 1");
986 reply(&db, "a", "one more thought");
987 set_status(&db, "forge", "feat", "a", ReviewStatus::Resolved).expect("resolve");
988 submit_review(&db, "forge", "feat", None, &["a".to_owned()]).expect("submit 2");
989 assert_eq!(
990 find_thread_by_id(&db, "forge", "feat", "a").expect("load").expect("thread").status,
991 ReviewStatus::Resolved,
992 "the reviewer's own resolve outranks the reopen nudge",
993 );
994 }
995
996 #[test]
997 fn a_thread_filed_before_per_turn_membership_keeps_its_history() {
998 let (_dir, db) = open_db();
1002 let stored = serde_json::json!([{
1003 "id": "a",
1004 "anchor": {
1005 "path": "src/x.rs",
1006 "side": "New",
1007 "line": 10,
1008 "content_hash": 10,
1009 "context": ["ctx"],
1010 "base_ref": "main"
1011 },
1012 "comments": [
1013 { "author": "User", "text": "why?", "at": "2026-07-19T10:00:00Z" },
1014 {
1015 "author": { "Agent": { "label": "implementer" } },
1016 "text": "because",
1017 "at": "2026-07-19T10:05:00Z"
1018 }
1019 ],
1020 "status": "Addressed",
1021 "created_at": "2026-07-19T10:00:00Z",
1022 "updated_at": "2026-07-19T10:05:00Z",
1023 "review_id": "review-1"
1024 }]);
1025 let txn = db.database().begin_write().expect("txn");
1026 {
1027 let mut table = txn.open_table(REVIEW_THREADS).expect("table");
1028 let bytes = serde_json::to_vec(&stored).expect("encode");
1029 table.insert(("forge", "feat"), bytes.as_slice()).expect("insert");
1030 }
1031 txn.commit().expect("commit");
1032
1033 let loaded = load(&db, "forge", "feat").expect("load").remove(0);
1034 assert!(loaded.is_in_review("review-1"), "the thread still belongs to its review");
1035 assert_eq!(loaded.comments[0].review_id.as_deref(), Some("review-1"));
1036 assert_eq!(loaded.comments[1].review_id, None, "the agent reply gains no membership");
1037 assert!(!loaded.has_unfiled_user_turn(), "filed history does not read as pending work");
1038 }
1039}