Skip to main content

forge_workspace/store/
review.rs

1//! Review-thread persistence on the redb `review_threads` table.
2//!
3//! The `/diff` overlay's line comments persist here per `(project,
4//! branch)` so a review round survives closing the overlay and forge
5//! restarts. Keyed by `(project, branch)` - the whole set for a branch
6//! is one serde-json blob. `project` is the forge.toml project NAME
7//! (worktree-agnostic), never the directory-hash key.
8
9use 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");
18/// Submitted reviews per `(project, branch)` - the sealed groupings a
19/// thread's turns point into. Sibling of [`REVIEW_THREADS`]; the whole set
20/// for a branch is one serde-json blob.
21const REVIEWS: TableDefinition<(&str, &str), &[u8]> = TableDefinition::new("reviews");
22
23/// Load every thread for `(project, branch)`. Empty when the branch has
24/// no row (or the table doesn't exist yet).
25pub 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
38/// Overwrite the whole thread set for `(project, branch)`. An empty
39/// slice removes the row rather than storing an empty blob.
40pub 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
55/// Insert `thread`, replacing any existing thread with the same id in
56/// the branch's set; appends when the id is new. Stamps `updated_at` to
57/// now and fills `created_at` (preserved from the existing row on
58/// replace, set to now when the caller left it empty) so a view need
59/// not carry a clock.
60pub 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    // Stamp any comment the caller left unstamped (a view carries no
69    // clock), so a persisted comment's `at` is a real rfc3339 time.
70    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
98/// Remove the single thread `id` from `(project, branch)`, dropping the
99/// row entirely when it was the last one. Returns whether a thread was
100/// removed. Used when the user clears a reopened comment's text to
101/// delete it, so it doesn't resurrect on the next hydrate.
102pub 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
127/// Set the status of the thread `id` in `(project, branch)`, bumping its
128/// `updated_at`. Returns whether a matching thread was found.
129pub 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
158/// Look up one thread by its globally-unique `id` in `(project, branch)`.
159/// `None` when the branch has no such thread (or no row). `comment_id` in
160/// the review MCP maps to this `id`.
161pub 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
170/// Append an agent reply to the thread `id` in `(project, branch)`: push a
171/// `ReviewComment` authored by `Agent { label: author_label }`, flip an
172/// `Open` thread to `Addressed` (a Resolved / Outdated / already-Addressed
173/// thread keeps its state), bump `updated_at`, and persist in one write
174/// transaction. Returns the thread's status after the append. Errors when
175/// no thread carries `id`, so a stale comment_id surfaces rather than
176/// silently no-op'ing. An empty `at` is stamped with the current instant.
177pub 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
217/// Delete the whole thread set for `(project, branch)`. Returns whether
218/// a row existed.
219pub 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
229/// Load every submitted review for `(project, branch)`, oldest first.
230/// Empty when the branch has no row (or the table doesn't exist yet).
231pub 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
244/// The branches under `project` that hold at least one submitted review,
245/// in key order. Seeks straight to the project's first key and stops at
246/// its last, so the `review__list` empty path can ask "is this review
247/// filed against another branch?" without scanning the table.
248pub 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
267/// Every branch under `project` holding review state of either kind,
268/// deduped, in key order. Same per-project seek as [`review_branches`],
269/// run over both tables: a branch can carry drafted threads and no
270/// submitted review, so a sweep consulting only the reviews table would
271/// leave its threads row behind.
272pub 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
293/// Drop both of `(project, branch)`'s rows in one write transaction.
294///
295/// One transaction because the two are halves of one fact. Deleting them
296/// separately leaves a window where a crash strands a reviews row whose
297/// threads are gone - `review__get` would then resolve a review whose
298/// comments no longer exist.
299pub 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/// Overwrite the whole review set for `(project, branch)`. An empty slice
310/// removes the row rather than storing an empty blob.
311///
312/// Test-only: production writes reviews exclusively through
313/// [`submit_review`]; this exists so tests can seed the store directly
314/// (simulating the post-restart shape without a recorded submit origin).
315#[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
336/// Seal a new review for `(project, branch)`: mint its number (existing
337/// count + 1), stamp every still-unfiled user turn on each listed thread
338/// with the new review id, and append the [`ReviewSet`]. Turns already
339/// filed never move, so a thread whose earlier turns went into review 1
340/// and whose reply goes into review 2 belongs to both. A thread that
341/// gains a turn this way is `Addressed` no longer - the agent owes an
342/// answer again - so it flips back to `Open`; a `Resolved` / `Outdated`
343/// thread keeps its state. Agent replies are answers to a round rather
344/// than part of one and are never stamped. One write transaction spans
345/// both tables so the stamp and the append land together.
346pub 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            // Decode into an owned value so the read guard's borrow ends
367            // before the insert below.
368            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
405/// Decode a stored blob into threads, mapping a decode failure to an
406/// error tagged with the owning `(project, branch)` so a corrupt row is
407/// diagnosable rather than a bare serde message. Lifts membership off any
408/// row still carrying it on the thread (see [`lift_thread_membership`]).
409fn 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/// Membership a row carries on the thread rather than its turns - the
417/// shape stored while a thread could only belong to one review.
418#[derive(serde::Deserialize)]
419struct ThreadMembership {
420    #[serde(default)]
421    review_id: Option<String>,
422}
423
424/// Attribute a pre-per-turn row's history: a thread filed as a whole has
425/// its user turns stamped with that review, so they read as the round they
426/// were actually part of instead of as unfiled. Positional - the mirror
427/// decode ignores every other key, so it aligns with `threads`. Only
428/// applies where no turn is filed yet, leaving a re-saved row's own
429/// membership alone; the new shape persists on the thread's next write.
430fn 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
445/// Decode a stored blob into reviews, tagged with the owning `(project,
446/// branch)` on failure so a corrupt row is diagnosable.
447fn 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
452/// RFC3339 timestamp for the current instant, matching the `mcp::peers`
453/// helper's shape.
454fn 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/// Test-only: write undecodable bytes into the `(project, branch)` row
464/// so a caller can exercise the load-error path (a corrupt / partially
465/// written redb row). Feature-gated for cross-crate test access.
466#[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/// Test-only sibling of [`write_corrupt_row_for_test`] for the `reviews`
478/// table, so a caller can exercise the reviews load-error path.
479#[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    /// Append an unfiled user turn, as the overlay does when the reviewer
527    /// replies on a thread.
528    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    /// A doc-scoped set round-trips under its own key, and the scope - a
555    /// path with slashes and the `doc:` marker - survives encode and
556    /// decode unchanged: the viewer reads its threads back by the exact
557    /// ref it wrote, and a branch of that name stays a different row.
558    #[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        // Re-upsert of "a" with a changed anchor replaces, never duplicates.
584        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        // Removing the last thread clears the row.
626        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        // Deleting one key leaves the others untouched.
649        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        // Undecodable bytes directly in the row (a corrupt / partial write).
659        {
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        // `load` surfaces the decode failure rather than a silent empty vec.
668        assert!(load(&db, "forge", "feat").is_err(), "a corrupt row is an error, not empty");
669        // Every write path re-decodes with `?` and aborts before writing, so
670        // the corrupt bytes survive intact (the non-clobber guarantee).
671        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        // A branch with threads but no submitted review is not a branch
784        // with reviews.
785        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    /// The range starts at the project's first key, so a project that
794    /// sorts after another doesn't pick up its neighbour's rows.
795    #[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    /// Both rows are halves of one fact. Deleting them separately
809    /// leaves a window where a crash strands a reviews row whose threads
810    /// are gone, and `review__get` would resolve a review whose comments
811    /// no longer exist.
812    #[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        // A branch with no rows at all is not an error.
827        delete_branch_state(&db, "forge", "absent").expect("absent branch deletes cleanly");
828    }
829
830    /// This feeds a delete, so a key that bleeds past its project takes a
831    /// neighbour's rows with it. Also covers the branch each table holds
832    /// alone: threads with no submitted review, and the reverse.
833    #[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        // Neighbours on either side of "aaa" in key order.
841        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        // A second submit mints number 2 and files only c; a/b keep r1.
896        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        // A later round that only files another thread must not disturb a
971        // thread the agent has already answered.
972        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        // A row written while membership lived on the thread: its user turns
999        // must read as part of that review, not as unfiled work that would
1000        // re-trip the submit gate.
1001        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}