1use std::collections::HashMap;
16use std::sync::Arc;
17
18use forge_primitives::review::{is_doc_scope, is_tree_scope};
19use forge_primitives::{ReviewStatus, ReviewThread};
20
21use crate::protocol::SessionUpdate;
22use crate::target::SessionSlot;
23use crate::workspace::Workspace;
24
25impl Workspace {
26 pub fn load_review_threads(
37 &self,
38 project: &str,
39 branch: &str,
40 ) -> Result<Vec<ReviewThread>, String> {
41 let guard = self.db.lock();
42 let Some(db) = guard.as_ref() else {
43 return Ok(Vec::new());
44 };
45 crate::store::review::load(db, project, branch).map_err(|error| {
46 tracing::warn!(
47 target: "forge_workspace::review",
48 %error,
49 project = %project,
50 branch = %branch,
51 "loading review threads failed",
52 );
53 format!("{error:#}")
54 })
55 }
56
57 pub fn upsert_review_thread(&self, project: &str, branch: &str, thread: ReviewThread) -> bool {
62 let guard = self.db.lock();
63 let Some(db) = guard.as_ref() else {
64 return false;
65 };
66 match crate::store::review::upsert(db, project, branch, thread) {
67 Ok(()) => true,
68 Err(error) => {
69 tracing::warn!(
70 target: "forge_workspace::review",
71 %error,
72 project = %project,
73 branch = %branch,
74 "upserting a review thread failed",
75 );
76 false
77 }
78 }
79 }
80
81 pub fn delete_review_thread(&self, project: &str, branch: &str, id: &str) -> bool {
85 let guard = self.db.lock();
86 let Some(db) = guard.as_ref() else {
87 return false;
88 };
89 match crate::store::review::remove_thread(db, project, branch, id) {
90 Ok(removed) => removed,
91 Err(error) => {
92 tracing::warn!(
93 target: "forge_workspace::review",
94 %error,
95 project = %project,
96 branch = %branch,
97 "deleting a review thread failed",
98 );
99 false
100 }
101 }
102 }
103
104 pub fn set_review_thread_status(
106 &self,
107 project: &str,
108 branch: &str,
109 id: &str,
110 status: ReviewStatus,
111 ) {
112 if let Some(db) = self.db.lock().as_ref()
113 && let Err(error) = crate::store::review::set_status(db, project, branch, id, status)
114 {
115 tracing::warn!(
116 target: "forge_workspace::review",
117 %error,
118 project = %project,
119 branch = %branch,
120 id = %id,
121 "setting a review-thread status failed",
122 );
123 }
124 }
125
126 pub fn delete_branch_review_state(&self, project: &str, branch: &str) {
132 if let Some(db) = self.db.lock().as_ref()
133 && let Err(error) = crate::store::review::delete_branch_state(db, project, branch)
134 {
135 tracing::warn!(
136 target: "forge_workspace::review",
137 %error,
138 project = %project,
139 branch = %branch,
140 "deleting review state failed",
141 );
142 }
143 }
144
145 const MIN_POPULATED_REFS: usize = 3;
170
171 pub fn sweep_dead_review_branches(&self) -> usize {
181 let mut cleared = 0;
182 for view in self.list_projects() {
183 let Some(repo) = forge_agent::env::worktree::repo_branch_names(&view.path) else {
184 continue;
185 };
186 let stored = {
187 let guard = self.db.lock();
188 let Some(db) = guard.as_ref() else {
192 return cleared;
193 };
194 match crate::store::review::stored_branches(db, &view.name) {
195 Ok(stored) => stored,
196 Err(error) => {
197 tracing::warn!(
198 target: "forge_workspace::review",
199 %error,
200 project = %view.name,
201 "review-branch sweep skipped: listing stored branches failed",
202 );
203 continue;
204 }
205 }
206 };
207 let spawning: std::collections::HashSet<String> = self
214 .list_live_workers(&view.key)
215 .into_iter()
216 .map(|worker| format!("worktree-{}", worker.label))
217 .collect();
218 let dead: Vec<&String> = stored
219 .iter()
220 .filter(|branch| {
224 !is_doc_scope(branch)
225 && !is_tree_scope(branch)
226 && !repo.names.contains(*branch)
227 && !spawning.contains(*branch)
228 })
229 .collect();
230 if dead.is_empty() {
231 continue;
232 }
233 let branches = stored
240 .iter()
241 .filter(|branch| !is_doc_scope(branch) && !is_tree_scope(branch))
242 .count();
243 if dead.len() * 2 > branches && repo.ref_count < Self::MIN_POPULATED_REFS {
244 tracing::warn!(
245 target: "forge_workspace::review",
246 project = %view.name,
247 dead = dead.len(),
248 stored = stored.len(),
249 refs_seen = repo.ref_count,
250 "review-branch sweep refused: most of this project's stored branches read as dead against a repo that knows almost no branches, which is what a shallow or single-branch clone looks like; nothing deleted",
251 );
252 continue;
253 }
254 for branch in dead {
255 let guard = self.db.lock();
256 let Some(db) = guard.as_ref() else {
257 return cleared;
258 };
259 match crate::store::review::delete_branch_state(db, &view.name, branch) {
260 Ok(()) => {
261 tracing::info!(
262 target: "forge_workspace::review",
263 project = %view.name,
264 branch = %branch,
265 "dropped review state for a branch that no longer exists",
266 );
267 cleared += 1;
268 }
269 Err(error) => tracing::warn!(
270 target: "forge_workspace::review",
271 %error,
272 project = %view.name,
273 branch = %branch,
274 "dropping review state failed",
275 ),
276 }
277 }
278 }
279 cleared
280 }
281
282 pub fn start_review_branch_sweep(self: &Arc<Self>) {
286 let workspace = Arc::clone(self);
287 tokio::task::spawn_blocking(move || {
288 let cleared = workspace.sweep_dead_review_branches();
289 if cleared > 0 {
290 tracing::info!(
291 target: "forge_workspace::review",
292 cleared,
293 "review-branch sweep cleared orphaned branches",
294 );
295 }
296 });
297 }
298
299 pub fn load_reviews(
304 &self,
305 project: &str,
306 branch: &str,
307 ) -> Result<Vec<forge_primitives::ReviewSet>, String> {
308 let guard = self.db.lock();
309 let Some(db) = guard.as_ref() else {
310 return Ok(Vec::new());
311 };
312 crate::store::review::load_reviews(db, project, branch).map_err(|error| {
313 tracing::warn!(
314 target: "forge_workspace::review",
315 %error,
316 project = %project,
317 branch = %branch,
318 "loading reviews failed",
319 );
320 format!("{error:#}")
321 })
322 }
323
324 pub fn review_replies_waiting(
337 &self,
338 project: &str,
339 branch: &str,
340 ) -> Option<(usize, std::time::SystemTime)> {
341 use time::format_description::well_known::Rfc3339;
342
343 let threads = self.load_review_threads(project, branch).ok()?;
344 let waiting: Vec<_> = threads.iter().filter(|t| t.awaits_reviewer()).collect();
345 if waiting.is_empty() {
346 return None;
347 }
348 let since = waiting
349 .iter()
350 .filter_map(|t| time::OffsetDateTime::parse(&t.updated_at, &Rfc3339).ok())
351 .min()
352 .map_or_else(std::time::SystemTime::now, std::time::SystemTime::from);
353 Some((waiting.len(), since))
354 }
355
356 pub fn review_branches(&self, project: &str) -> Result<Vec<String>, String> {
362 let guard = self.db.lock();
363 let Some(db) = guard.as_ref() else {
364 return Ok(Vec::new());
365 };
366 crate::store::review::review_branches(db, project).map_err(|error| {
367 tracing::warn!(
368 target: "forge_workspace::review",
369 %error,
370 project = %project,
371 "listing review branches failed",
372 );
373 format!("{error:#}")
374 })
375 }
376
377 pub fn submit_review(
384 &self,
385 project: &str,
386 branch: &str,
387 summary: Option<String>,
388 thread_ids: &[String],
389 origin: SessionSlot,
390 ) -> Option<forge_primitives::ReviewSet> {
391 let review = {
399 let guard = self.db.lock();
400 let db = guard.as_ref()?;
401 match crate::store::review::submit_review(db, project, branch, summary, thread_ids) {
402 Ok(review) => review,
403 Err(error) => {
404 tracing::warn!(
405 target: "forge_workspace::review",
406 %error,
407 project = %project,
408 branch = %branch,
409 "submitting a review failed",
410 );
411 return None;
412 }
413 }
414 };
415 self.review_origin.lock().insert((project.to_owned(), branch.to_owned()), origin);
416 Some(review)
417 }
418
419 pub fn review_list(
425 &self,
426 project: &str,
427 branch: &str,
428 ) -> Result<Vec<crate::mcp::review::ReviewSummary>, String> {
429 let reviews = self.load_reviews(project, branch)?;
430 let threads = self.load_review_threads(project, branch)?;
431 Ok(crate::mcp::review::summarize(&reviews, &threads))
432 }
433
434 pub fn review_get(
439 &self,
440 project: &str,
441 branch: &str,
442 review_id: &str,
443 ) -> Result<Option<crate::mcp::review::ReviewDetail>, String> {
444 let reviews = self.load_reviews(project, branch)?;
445 let threads = self.load_review_threads(project, branch)?;
446 Ok(crate::mcp::review::detail(&reviews, &threads, review_id))
447 }
448
449 pub fn review_reply(
457 &self,
458 caller: &SessionSlot,
459 project: &str,
460 branch: &str,
461 comment_id: &str,
462 author_label: &str,
463 text: &str,
464 at: &str,
465 ) -> Result<crate::mcp::review::CommentAnchorView, String> {
466 let anchor = {
467 let guard = self.db.lock();
468 let db = guard.as_ref().ok_or_else(|| "review store is unavailable".to_owned())?;
469 let (thread, reviews) = comment_anchor_parts(db, project, branch, comment_id)?;
474 let status = crate::store::review::append_reply(
475 db,
476 project,
477 branch,
478 comment_id,
479 author_label,
480 text,
481 at,
482 )
483 .map_err(|error| {
484 tracing::warn!(
485 target: "forge_workspace::review",
486 %error,
487 project = %project,
488 branch = %branch,
489 comment_id = %comment_id,
490 "appending a review reply failed",
491 );
492 format!("{error:#}")
493 })?;
494 crate::mcp::review::comment_anchor(&thread, &reviews, status)
495 };
496 self.note_review_activity(caller, project, branch, comment_id, true);
497 Ok(anchor)
498 }
499
500 pub fn review_resolve(
505 &self,
506 caller: &SessionSlot,
507 project: &str,
508 branch: &str,
509 comment_id: &str,
510 ) -> Result<crate::mcp::review::CommentAnchorView, String> {
511 let anchor = {
512 let guard = self.db.lock();
513 let db = guard.as_ref().ok_or_else(|| "review store is unavailable".to_owned())?;
514 let (thread, reviews) = comment_anchor_parts(db, project, branch, comment_id)?;
517 match crate::store::review::set_status(
518 db,
519 project,
520 branch,
521 comment_id,
522 ReviewStatus::Resolved,
523 ) {
524 Ok(true) => {}
525 Ok(false) => {
526 return Err(format!("no review comment {comment_id} on ({project}, {branch})"));
527 }
528 Err(error) => {
529 tracing::warn!(
530 target: "forge_workspace::review",
531 %error,
532 project = %project,
533 branch = %branch,
534 comment_id = %comment_id,
535 "resolving a review comment failed",
536 );
537 return Err(format!("{error:#}"));
538 }
539 }
540 crate::mcp::review::comment_anchor(&thread, &reviews, ReviewStatus::Resolved)
541 };
542 self.note_review_activity(caller, project, branch, comment_id, false);
543 Ok(anchor)
544 }
545
546 fn note_review_activity(
550 &self,
551 caller: &SessionSlot,
552 project: &str,
553 branch: &str,
554 comment_id: &str,
555 replied: bool,
556 ) {
557 let review_id = {
558 let guard = self.db.lock();
559 let Some(db) = guard.as_ref() else { return };
560 match crate::store::review::find_thread_by_id(db, project, branch, comment_id) {
561 Ok(Some(thread)) => thread.latest_review().map(str::to_owned),
562 Ok(None) => None,
563 Err(error) => {
564 tracing::warn!(
565 target: "forge_workspace::review",
566 %error,
567 "resolving a review comment's owning review failed",
568 );
569 None
570 }
571 }
572 };
573 let Some(review_id) = review_id else { return };
574 self.review_activity.lock().entry(caller.clone()).or_default().push(
575 crate::mcp::review::ReviewActivity {
576 project: project.to_owned(),
577 branch: branch.to_owned(),
578 review_id,
579 replied,
580 },
581 );
582 }
583
584 pub(crate) fn drain_review_activity(&self, caller: &SessionSlot) -> Vec<SessionUpdate> {
593 let touches = { self.review_activity.lock().remove(caller).unwrap_or_default() };
600 if touches.is_empty() {
601 return Vec::new();
602 }
603 let mut order: Vec<String> = Vec::new();
605 let mut by_review: HashMap<String, (String, String, usize, usize)> = HashMap::new();
606 for touch in touches {
607 let entry = by_review.entry(touch.review_id.clone()).or_insert_with(|| {
608 order.push(touch.review_id.clone());
609 (touch.project, touch.branch, 0, 0)
610 });
611 if touch.replied {
612 entry.2 += 1;
613 } else {
614 entry.3 += 1;
615 }
616 }
617
618 let mut messages: Vec<((String, String), usize, String)> = Vec::new();
625 for review_id in order {
626 let Some((project, branch, replied, resolved)) = by_review.remove(&review_id) else {
627 continue;
628 };
629 let loaded = self.load_reviews(&project, &branch).and_then(|reviews| {
630 self.load_review_threads(&project, &branch).map(|threads| (reviews, threads))
631 });
632 let (reviews, threads) = match loaded {
633 Ok(pair) => pair,
634 Err(error) => {
635 tracing::warn!(
638 target: "forge_workspace::review",
639 %error,
640 project = %project,
641 branch = %branch,
642 "review-activity notice skipped: loading reviews failed",
643 );
644 continue;
645 }
646 };
647 let rows = crate::mcp::review::summarize(&reviews, &threads);
648 let Some(summary) = rows.into_iter().find(|s| s.review_id == review_id) else {
649 continue;
650 };
651 let waiting = threads.iter().filter(|t| t.awaits_reviewer()).count();
654 let message = crate::mcp::review::notice_message(
655 summary.number,
656 replied,
657 resolved,
658 summary.open,
659 &branch,
660 );
661 messages.push(((project, branch), waiting, message));
662 }
663
664 let origins = self.review_origin.lock();
672 messages
673 .into_iter()
674 .filter_map(|(scope, waiting, message)| {
675 if let Some(key) = origins.get(&scope) {
676 Some(SessionUpdate::ReviewActivityNotice {
677 key: key.clone(),
678 branch: scope.1.clone(),
679 waiting,
680 message,
681 })
682 } else {
683 tracing::debug!(
684 target: "forge_workspace::review",
685 project = %scope.0,
686 branch = %scope.1,
687 caller = %caller.display(),
688 "review-activity notice dropped: no submit origin recorded",
689 );
690 None
691 }
692 })
693 .collect()
694 }
695}
696
697fn comment_anchor_parts(
703 db: &crate::store::Db,
704 project: &str,
705 branch: &str,
706 comment_id: &str,
707) -> Result<
708 (forge_primitives::review::ReviewThread, Vec<forge_primitives::review::ReviewSet>),
709 String,
710> {
711 let missing = || format!("no review comment {comment_id} on ({project}, {branch})");
712 let thread = crate::store::review::find_thread_by_id(db, project, branch, comment_id)
713 .map_err(|error| format!("{error:#}"))?
714 .ok_or_else(missing)?;
715 let reviews = crate::store::review::load_reviews(db, project, branch)
716 .map_err(|error| format!("{error:#}"))?;
717 Ok((thread, reviews))
718}
719
720#[cfg(test)]
721mod tests {
722 use super::*;
723
724 use tempfile::tempdir;
725
726 fn seed(ws: &Workspace, project: &str, branch: &str, threads: &[ReviewThread]) {
730 let db = ws.db.lock();
731 crate::store::review::save(db.as_ref().expect("test db present"), project, branch, threads)
732 .expect("seed threads");
733 }
734
735 #[test]
736 fn review_thread_crud_round_trips_through_the_workspace() {
737 use forge_primitives::review::{
738 ReviewAnchor, ReviewAuthor, ReviewComment, ReviewSide, ReviewStatus, ReviewThread,
739 };
740 let make = |id: &str, line: u32| ReviewThread {
741 id: id.to_owned(),
742 anchor: ReviewAnchor {
743 path: "src/x.rs".to_owned(),
744 side: ReviewSide::New,
745 line,
746 content_hash: u64::from(line),
747 context: vec!["ctx".to_owned()],
748 base_ref: "main".to_owned(),
749 },
750 comments: vec![ReviewComment {
751 author: ReviewAuthor::User,
752 text: format!("c{id}"),
753 at: "2026-07-19T10:00:00Z".to_owned(),
754 review_id: None,
755 }],
756 status: ReviewStatus::Open,
757 created_at: "2026-07-19T10:00:00Z".to_owned(),
758 updated_at: "2026-07-19T10:00:00Z".to_owned(),
759 commit: None,
760 };
761
762 let dir = tempdir().expect("tempdir");
763 let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
764 ws.install_db_for_test(
765 crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
766 );
767
768 assert!(ws.load_review_threads("forge", "feat").expect("load").is_empty(), "empty on miss");
769
770 ws.upsert_review_thread("forge", "feat", make("a", 10));
771 ws.upsert_review_thread("forge", "feat", make("b", 20));
772 assert_eq!(ws.load_review_threads("forge", "feat").expect("load").len(), 2);
773
774 ws.set_review_thread_status("forge", "feat", "a", ReviewStatus::Resolved);
775 let loaded = ws.load_review_threads("forge", "feat").expect("load");
776 assert_eq!(loaded.iter().find(|t| t.id == "a").expect("a").status, ReviewStatus::Resolved);
777 assert_eq!(loaded.iter().find(|t| t.id == "b").expect("b").status, ReviewStatus::Open);
778
779 seed(&ws, "forge", "other", &[make("c", 30)]);
781 assert_eq!(
782 ws.load_review_threads("forge", "feat").expect("load").len(),
783 2,
784 "other branch is isolated"
785 );
786 assert_eq!(ws.load_review_threads("forge", "other").expect("load").len(), 1);
787
788 ws.delete_branch_review_state("forge", "feat");
789 assert!(
790 ws.load_review_threads("forge", "feat").expect("load").is_empty(),
791 "teardown clears the branch"
792 );
793 assert_eq!(
794 ws.load_review_threads("forge", "other").expect("load").len(),
795 1,
796 "delete is scoped"
797 );
798 }
799
800 #[test]
805 fn review_replies_waiting_tallies_answers_and_dates_them_from_the_oldest() {
806 use forge_primitives::review::{
807 ReviewAnchor, ReviewAuthor, ReviewComment, ReviewSide, ReviewStatus, ReviewThread,
808 };
809 let thread = |id: &str, status: ReviewStatus, answered_at: Option<&str>| {
810 let mut comments = vec![ReviewComment {
811 author: ReviewAuthor::User,
812 text: "look at this".to_owned(),
813 at: "2026-07-19T10:00:00Z".to_owned(),
814 review_id: None,
815 }];
816 if let Some(at) = answered_at {
817 comments.push(ReviewComment {
818 author: ReviewAuthor::Agent { label: "impl".to_owned() },
819 text: "done".to_owned(),
820 at: at.to_owned(),
821 review_id: None,
822 });
823 }
824 ReviewThread {
825 id: id.to_owned(),
826 anchor: ReviewAnchor {
827 path: "src/x.rs".to_owned(),
828 side: ReviewSide::New,
829 line: 1,
830 content_hash: 1,
831 context: vec!["ctx".to_owned()],
832 base_ref: "main".to_owned(),
833 },
834 comments,
835 status,
836 created_at: "2026-07-19T10:00:00Z".to_owned(),
837 updated_at: answered_at.unwrap_or("2026-07-19T10:00:00Z").to_owned(),
838 commit: None,
839 }
840 };
841
842 let dir = tempdir().expect("tempdir");
843 let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
844 ws.install_db_for_test(
845 crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
846 );
847 assert!(ws.review_replies_waiting("forge", "feat").is_none(), "no rows, no signal");
848
849 seed(
850 &ws,
851 "forge",
852 "feat",
853 &[
854 thread("answered-late", ReviewStatus::Addressed, Some("2026-07-20T12:00:00Z")),
855 thread("answered-early", ReviewStatus::Addressed, Some("2026-07-19T11:00:00Z")),
856 thread("unanswered", ReviewStatus::Open, None),
857 thread("resolved", ReviewStatus::Resolved, Some("2026-07-20T09:00:00Z")),
858 ],
859 );
860
861 let (count, since) = ws.review_replies_waiting("forge", "feat").expect("answers waiting");
862 assert_eq!(count, 2, "only the answered-and-unread threads are owed a turn");
863 let expected = std::time::SystemTime::from(
864 time::OffsetDateTime::parse(
865 "2026-07-19T11:00:00Z",
866 &time::format_description::well_known::Rfc3339,
867 )
868 .expect("parse"),
869 );
870 assert_eq!(since, expected, "the wait is dated from the answer that has sat longest");
871
872 assert!(
873 ws.review_replies_waiting("forge", "other").is_none(),
874 "the tally is scoped to its own branch",
875 );
876 }
877
878 fn sweep_git(dir: &std::path::Path, args: &[&str]) {
879 let out =
880 std::process::Command::new("git").arg("-C").arg(dir).args(args).output().expect("git");
881 assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr));
882 }
883
884 fn sweep_init_repo(dir: &std::path::Path, branch: &str) {
887 sweep_git(dir, &["init", "-q"]);
888 sweep_git(dir, &["symbolic-ref", "HEAD", &format!("refs/heads/{branch}")]);
889 sweep_git(dir, &["config", "user.email", "test@example.com"]);
890 sweep_git(dir, &["config", "user.name", "Test"]);
891 sweep_git(dir, &["config", "commit.gpgsign", "false"]);
892 std::fs::write(dir.join("README.md"), "hi\n").expect("write");
893 sweep_git(dir, &["add", "."]);
894 sweep_git(dir, &["commit", "-q", "-m", "init"]);
895 }
896
897 fn sweep_symref_head(root: &std::path::Path, target: &str) {
900 sweep_git(root, &["update-ref", &format!("refs/remotes/origin/{target}"), "HEAD"]);
901 sweep_git(
902 root,
903 &["symbolic-ref", "refs/remotes/origin/HEAD", &format!("refs/remotes/origin/{target}")],
904 );
905 }
906
907 fn sweep_thread(id: &str) -> forge_primitives::review::ReviewThread {
908 use forge_primitives::review::{
909 ReviewAnchor, ReviewAuthor, ReviewComment, ReviewSide, ReviewStatus, ReviewThread,
910 };
911 ReviewThread {
912 id: id.to_owned(),
913 anchor: ReviewAnchor {
914 path: "src/x.rs".to_owned(),
915 side: ReviewSide::New,
916 line: 1,
917 content_hash: 1,
918 context: vec!["ctx".to_owned()],
919 base_ref: "main".to_owned(),
920 },
921 comments: vec![ReviewComment {
922 author: ReviewAuthor::User,
923 text: "look".to_owned(),
924 at: "2026-07-19T10:00:00Z".to_owned(),
925 review_id: None,
926 }],
927 status: ReviewStatus::Open,
928 created_at: "2026-07-19T10:00:00Z".to_owned(),
929 updated_at: "2026-07-19T10:00:00Z".to_owned(),
930 commit: None,
931 }
932 }
933
934 fn sweep_ws(
936 dir: &std::path::Path,
937 name: &str,
938 root: &std::path::Path,
939 ) -> (Arc<Workspace>, tokio::sync::mpsc::UnboundedReceiver<crate::SeatUpdate>) {
940 let (ws, rx) = Workspace::testing_stub_with_config_dir(dir.to_owned());
941 ws.install_db_for_test(crate::store::Db::open(&dir.join("db.redb")).expect("open db"));
942 ws.seed_test_project(name, &root.to_string_lossy());
943 (ws, rx)
944 }
945
946 fn sweep_has_state(ws: &Arc<Workspace>, project: &str, branch: &str) -> bool {
947 !ws.load_review_threads(project, branch).expect("load").is_empty()
948 || !ws.load_reviews(project, branch).expect("load").is_empty()
949 }
950
951 #[test]
960 fn the_sweep_clears_dead_branches_and_spares_every_live_one() {
961 let dir = tempdir().expect("tempdir");
962 let root = dir.path().join("myproj");
963 std::fs::create_dir_all(&root).expect("mkdir");
964 sweep_init_repo(&root, "main");
965 for branch in ["feat/live", "feat/second", "feat/third", "fix/renamed"] {
966 sweep_git(&root, &["branch", branch]);
967 }
968 sweep_git(&root, &["update-ref", "refs/remotes/origin/feat/pushed", "HEAD"]);
969
970 let (ws, _rx) = sweep_ws(dir.path(), "myproj", &root);
971 let reviewer = SessionSlot::from_str_for_test("lead-uuid");
972 for branch in
976 ["feat/live", "feat/second", "feat/third", "feat/pushed", "feat/merged", "fix"]
977 {
978 seed(&ws, "myproj", branch, &[sweep_thread("a")]);
979 ws.submit_review("myproj", branch, None, &["a".to_owned()], reviewer.clone())
980 .expect("submit");
981 }
982 seed(&ws, "myproj", "feat/drafts", &[sweep_thread("d")]);
984
985 assert_eq!(ws.sweep_dead_review_branches(), 3, "feat/merged, fix and feat/drafts");
986
987 assert!(sweep_has_state(&ws, "myproj", "feat/live"), "a local head survives");
988 assert!(sweep_has_state(&ws, "myproj", "feat/second"), "so do the other live ones");
989 assert!(sweep_has_state(&ws, "myproj", "feat/third"), "so do the other live ones");
990 assert!(sweep_has_state(&ws, "myproj", "feat/pushed"), "a remote-tracking ref survives");
991 assert!(!sweep_has_state(&ws, "myproj", "feat/merged"), "no ref anywhere, cleared");
992 assert!(!sweep_has_state(&ws, "myproj", "fix"), "fix/renamed is not fix");
993 assert!(!sweep_has_state(&ws, "myproj", "feat/drafts"), "drafts go with their branch");
994 }
995
996 #[test]
1000 fn the_sweep_spares_a_project_it_cannot_check() {
1001 let dir = tempdir().expect("tempdir");
1002 let root = dir.path().join("plainproj");
1003 std::fs::create_dir_all(&root).expect("mkdir");
1004
1005 let (ws, _rx) = sweep_ws(dir.path(), "plainproj", &root);
1006 seed(&ws, "plainproj", "feat/x", &[sweep_thread("a")]);
1007
1008 assert_eq!(ws.sweep_dead_review_branches(), 0, "nothing to verify, nothing to clear");
1009 assert!(sweep_has_state(&ws, "plainproj", "feat/x"));
1010 }
1011
1012 #[test]
1018 fn the_sweep_refuses_a_repo_that_knows_almost_no_branches() {
1019 let dir = tempdir().expect("tempdir");
1020 let root = dir.path().join("recloned");
1021 std::fs::create_dir_all(&root).expect("mkdir");
1022 sweep_init_repo(&root, "main");
1023 sweep_symref_head(&root, "main");
1024
1025 let (ws, _rx) = sweep_ws(dir.path(), "recloned", &root);
1026 for branch in ["feat/a", "feat/b", "feat/c"] {
1027 seed(&ws, "recloned", branch, &[sweep_thread("a")]);
1028 }
1029
1030 assert_eq!(ws.sweep_dead_review_branches(), 0, "refused, not swept");
1031 for branch in ["feat/a", "feat/b", "feat/c"] {
1032 assert!(sweep_has_state(&ws, "recloned", branch), "{branch} kept");
1033 }
1034 }
1035
1036 #[test]
1041 fn the_sweep_spares_a_doc_scope() {
1042 let dir = tempdir().expect("tempdir");
1043 let root = dir.path().join("docproj");
1044 std::fs::create_dir_all(&root).expect("mkdir");
1045 sweep_init_repo(&root, "main");
1046 for branch in ["feat/live", "feat/second", "feat/third"] {
1047 sweep_git(&root, &["branch", branch]);
1048 }
1049
1050 let (ws, _rx) = sweep_ws(dir.path(), "docproj", &root);
1051 for branch in ["feat/live", "feat/second", "feat/third"] {
1052 seed(&ws, "docproj", branch, &[sweep_thread("a")]);
1053 }
1054 let doc_scope = forge_primitives::review::doc_scope_ref("/Users/me/proj/docs/plan.md");
1055 seed(&ws, "docproj", &doc_scope, &[sweep_thread("d")]);
1056
1057 assert_eq!(
1058 ws.sweep_dead_review_branches(),
1059 0,
1060 "three live branches and a document: nothing is dead",
1061 );
1062 assert!(
1063 sweep_has_state(&ws, "docproj", &doc_scope),
1064 "the document's threads survive a boot",
1065 );
1066 }
1067
1068 #[test]
1075 fn the_sweep_refusal_counts_branch_entries_only() {
1076 let dir = tempdir().expect("tempdir");
1077 let root = dir.path().join("scoped");
1078 std::fs::create_dir_all(&root).expect("mkdir");
1079 sweep_init_repo(&root, "main");
1080 sweep_symref_head(&root, "main");
1081
1082 let (ws, _rx) = sweep_ws(dir.path(), "scoped", &root);
1083 for branch in ["feat/gone-a", "feat/gone-b"] {
1084 seed(&ws, "scoped", branch, &[sweep_thread("a")]);
1085 }
1086 let doc = forge_primitives::review::doc_scope_ref("/Users/me/proj/docs/plan.md");
1087 seed(&ws, "scoped", &doc, &[sweep_thread("d")]);
1088 let tree = forge_primitives::review::tree_scope_ref(&SessionSlot::from_str_for_test(
1089 "Busytools/forge/lead",
1090 ));
1091 seed(&ws, "scoped", &tree, &[sweep_thread("t")]);
1092
1093 assert_eq!(
1094 ws.sweep_dead_review_branches(),
1095 0,
1096 "every branch dead against a repo that knows almost none: refused",
1097 );
1098 for branch in ["feat/gone-a", "feat/gone-b"] {
1099 assert!(sweep_has_state(&ws, "scoped", branch), "{branch} kept");
1100 }
1101 assert!(sweep_has_state(&ws, "scoped", &doc), "and the scopes, of course");
1102 assert!(sweep_has_state(&ws, "scoped", &tree), "both of them");
1103 }
1104
1105 #[test]
1111 fn the_sweep_spares_a_tree_scope() {
1112 let dir = tempdir().expect("tempdir");
1113 let root = dir.path().join("treews");
1114 std::fs::create_dir_all(&root).expect("mkdir");
1115 sweep_init_repo(&root, "main");
1116 sweep_git(&root, &["branch", "feat/live"]);
1117
1118 let (ws, _rx) = sweep_ws(dir.path(), "treews", &root);
1119 seed(&ws, "treews", "feat/live", &[sweep_thread("a")]);
1120 let slot = SessionSlot::worker("Busytools", "treews", "sweeper");
1121 let tree_scope = forge_primitives::review::tree_scope_ref(&slot);
1122 seed(&ws, "treews", &tree_scope, &[sweep_thread("t")]);
1123
1124 assert_eq!(
1125 ws.sweep_dead_review_branches(),
1126 0,
1127 "one live branch and a seat's tree: nothing is dead",
1128 );
1129 assert!(
1130 sweep_has_state(&ws, "treews", &tree_scope),
1131 "the seat's tree threads survive a boot",
1132 );
1133 }
1134
1135 #[test]
1140 fn the_sweep_clears_a_long_backlog_when_the_repo_is_well_populated() {
1141 let dir = tempdir().expect("tempdir");
1142 let root = dir.path().join("busy");
1143 std::fs::create_dir_all(&root).expect("mkdir");
1144 sweep_init_repo(&root, "main");
1145 for branch in ["feat/live", "feat/second", "feat/third"] {
1146 sweep_git(&root, &["branch", branch]);
1147 }
1148
1149 let (ws, _rx) = sweep_ws(dir.path(), "busy", &root);
1150 for n in 0..8 {
1153 seed(&ws, "busy", &format!("feat/merged-{n}"), &[sweep_thread("a")]);
1154 }
1155 seed(&ws, "busy", "feat/live", &[sweep_thread("b")]);
1156
1157 assert_eq!(ws.sweep_dead_review_branches(), 8, "the backlog is what this is for");
1158 assert!(sweep_has_state(&ws, "busy", "feat/live"));
1159 }
1160
1161 #[test]
1166 fn a_sparse_repo_still_sweeps_when_only_a_minority_reads_dead() {
1167 let dir = tempdir().expect("tempdir");
1168 let root = dir.path().join("small");
1169 std::fs::create_dir_all(&root).expect("mkdir");
1170 sweep_init_repo(&root, "main");
1171 sweep_symref_head(&root, "main");
1172
1173 let (ws, _rx) = sweep_ws(dir.path(), "small", &root);
1174 seed(&ws, "small", "main", &[sweep_thread("a")]);
1175 seed(&ws, "small", "feat/merged", &[sweep_thread("b")]);
1176
1177 assert_eq!(ws.sweep_dead_review_branches(), 1, "one of two dead is not a majority");
1178 assert!(sweep_has_state(&ws, "small", "main"), "the live branch keeps its state");
1179 assert!(!sweep_has_state(&ws, "small", "feat/merged"));
1180 }
1181
1182 #[test]
1188 fn the_refusal_threshold_sits_between_two_and_three_branch_refs() {
1189 let scenario = |name: &str, extra_branches: usize| {
1190 let dir = tempdir().expect("tempdir");
1191 let root = dir.path().join(name);
1192 std::fs::create_dir_all(&root).expect("mkdir");
1193 sweep_init_repo(&root, "main");
1194 sweep_symref_head(&root, "main");
1195 for n in 0..extra_branches {
1196 sweep_git(&root, &["branch", &format!("feat/keep-{n}")]);
1197 }
1198 let (ws, _rx) = sweep_ws(dir.path(), name, &root);
1199 for branch in ["feat/a", "feat/b"] {
1200 seed(&ws, name, branch, &[sweep_thread("a")]);
1201 }
1202 (dir, ws.sweep_dead_review_branches())
1203 };
1204 let (_d1, under) = scenario("bare", 0);
1207 assert_eq!(under, 0, "two branch refs is not enough to trust a majority-dead pass");
1208 let (_d2, over) = scenario("populated", 1);
1210 assert_eq!(over, 2, "at three branch refs the same ratio sweeps");
1211 }
1212
1213 #[test]
1224 fn a_slash_in_a_branch_name_does_not_switch_the_refusal_off() {
1225 let shallow_clone_shaped = |name: &str, default: &str| {
1226 let dir = tempdir().expect("tempdir");
1227 let root = dir.path().join(name);
1228 std::fs::create_dir_all(&root).expect("mkdir");
1229 sweep_init_repo(&root, default);
1230 sweep_git(&root, &["update-ref", &format!("refs/remotes/origin/{default}"), "HEAD"]);
1233 sweep_symref_head(&root, default);
1234 let (ws, _rx) = sweep_ws(dir.path(), name, &root);
1235 for branch in ["feat/a", "feat/b"] {
1236 seed(&ws, name, branch, &[sweep_thread("a")]);
1237 }
1238 (dir, ws.sweep_dead_review_branches())
1239 };
1240 let (_d1, plain) = shallow_clone_shaped("plain", "main");
1241 assert_eq!(plain, 0, "the shallow clone is refused");
1242 let (_d2, slashed) = shallow_clone_shaped("slashed", "release/2.0/rc1");
1243 assert_eq!(slashed, 0, "and renaming its default branch does not turn the guard off");
1244 }
1245
1246 #[test]
1250 fn the_sweep_spares_a_branch_a_live_worker_has_yet_to_create() {
1251 let dir = tempdir().expect("tempdir");
1252 let root = dir.path().join("myproj");
1253 std::fs::create_dir_all(&root).expect("mkdir");
1254 sweep_init_repo(&root, "main");
1255
1256 let (ws, _rx) = sweep_ws(dir.path(), "myproj", &root);
1257 let project_key =
1258 ws.list_projects().into_iter().find(|v| v.name == "myproj").expect("project").key;
1259 ws.insert_live_worker(
1260 &project_key,
1261 crate::WorkerEntry {
1262 label: "reviewer".to_owned(),
1263 charter: "review".to_owned(),
1264 slot: SessionSlot::from_str_for_test("worker-uuid"),
1265 session_id: None,
1266 status: forge_primitives::WorkerLiveness::Spawning,
1267 spawned_at: std::time::SystemTime::UNIX_EPOCH,
1268 spawned_by: SessionSlot::from_str_for_test("lead"),
1269 needs_tag: false,
1270 is_git_repo_at_spawn: true,
1271 cwd: None,
1272 diagnostic: None,
1273 kick: None,
1274 },
1275 );
1276 seed(&ws, "myproj", "worktree-reviewer", &[sweep_thread("a")]);
1277
1278 assert_eq!(ws.sweep_dead_review_branches(), 0, "its worktree is still being created");
1279 assert!(sweep_has_state(&ws, "myproj", "worktree-reviewer"));
1280 }
1281
1282 #[test]
1283 fn submit_review_files_listed_threads_through_the_workspace() {
1284 use forge_primitives::review::{
1285 ReviewAnchor, ReviewAuthor, ReviewComment, ReviewSide, ReviewStatus, ReviewThread,
1286 };
1287 let make = |id: &str| ReviewThread {
1288 id: id.to_owned(),
1289 anchor: ReviewAnchor {
1290 path: "src/x.rs".to_owned(),
1291 side: ReviewSide::New,
1292 line: 1,
1293 content_hash: 1,
1294 context: vec!["ctx".to_owned()],
1295 base_ref: "main".to_owned(),
1296 },
1297 comments: vec![ReviewComment {
1298 author: ReviewAuthor::User,
1299 text: format!("c{id}"),
1300 at: "2026-07-23T10:00:00Z".to_owned(),
1301 review_id: None,
1302 }],
1303 status: ReviewStatus::Open,
1304 created_at: "2026-07-23T10:00:00Z".to_owned(),
1305 updated_at: "2026-07-23T10:00:00Z".to_owned(),
1306 commit: None,
1307 };
1308
1309 let dir = tempdir().expect("tempdir");
1310 let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
1311 ws.install_db_for_test(
1312 crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
1313 );
1314
1315 assert!(ws.load_reviews("forge", "feat").expect("load").is_empty(), "empty on miss");
1316 seed(&ws, "forge", "feat", &[make("a"), make("b"), make("c")]);
1317
1318 let filed = |id: &str| {
1319 ws.load_review_threads("forge", "feat")
1320 .expect("load")
1321 .into_iter()
1322 .find(|t| t.id == id)
1323 .expect("thread")
1324 .origin_review()
1325 .map(str::to_owned)
1326 };
1327
1328 let origin = SessionSlot::from_str_for_test("reviewer");
1329 let r1 = ws
1330 .submit_review(
1331 "forge",
1332 "feat",
1333 Some("first".to_owned()),
1334 &["a".to_owned(), "b".to_owned()],
1335 origin.clone(),
1336 )
1337 .expect("submit mints a review");
1338 assert_eq!(r1.number, 1);
1339 assert_eq!(filed("a"), Some(r1.id.clone()), "a filed");
1340 assert_eq!(filed("b"), Some(r1.id.clone()), "b filed");
1341 assert_eq!(filed("c"), None, "c unlisted stays unfiled");
1342 assert_eq!(ws.load_reviews("forge", "feat").expect("load").len(), 1);
1343
1344 let r2 =
1345 ws.submit_review("forge", "feat", None, &["c".to_owned()], origin).expect("submit 2");
1346 assert_eq!(r2.number, 2, "the number increments");
1347 assert_eq!(filed("a"), Some(r1.id.clone()), "a stays in r1");
1348 assert_eq!(filed("c"), Some(r2.id), "c filed into r2");
1349 }
1350
1351 #[test]
1352 fn review_conversation_methods_round_trip_through_the_workspace() {
1353 use forge_primitives::review::{
1354 ReviewAnchor, ReviewAuthor, ReviewComment, ReviewSide, ReviewStatus, ReviewThread,
1355 };
1356 let make = |id: &str| ReviewThread {
1357 id: id.to_owned(),
1358 anchor: ReviewAnchor {
1359 path: "src/x.rs".to_owned(),
1360 side: ReviewSide::New,
1361 line: 12,
1362 content_hash: 1,
1363 context: vec!["fn f() {".to_owned()],
1364 base_ref: "main".to_owned(),
1365 },
1366 comments: vec![ReviewComment {
1367 author: ReviewAuthor::User,
1368 text: format!("look at {id}"),
1369 at: "2026-07-23T10:00:00Z".to_owned(),
1370 review_id: None,
1371 }],
1372 status: ReviewStatus::Open,
1373 created_at: "2026-07-23T10:00:00Z".to_owned(),
1374 updated_at: "2026-07-23T10:00:00Z".to_owned(),
1375 commit: None,
1376 };
1377
1378 let dir = tempdir().expect("tempdir");
1379 let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
1380 ws.install_db_for_test(
1381 crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
1382 );
1383 seed(&ws, "forge", "feat", &[make("a"), make("b")]);
1384 let caller = SessionSlot::from_str_for_test("worker");
1385 let r1 = ws
1386 .submit_review(
1387 "forge",
1388 "feat",
1389 Some("overview".to_owned()),
1390 &["a".to_owned(), "b".to_owned()],
1391 SessionSlot::from_str_for_test("reviewer"),
1392 )
1393 .expect("submit");
1394
1395 let list = ws.review_list("forge", "feat").expect("list");
1396 assert_eq!(list.len(), 1);
1397 assert_eq!(list[0].review_id, r1.id);
1398 assert_eq!(list[0].comment_count, 2);
1399 assert_eq!(list[0].open, 2);
1400
1401 let detail = ws.review_get("forge", "feat", &r1.id).expect("get").expect("review present");
1402 assert_eq!(detail.comments.len(), 2);
1403 assert_eq!(detail.summary.as_deref(), Some("overview"));
1404 assert!(!detail.comments[0].context.is_empty(), "anchor context is handed back");
1405
1406 let status = ws
1407 .review_reply(
1408 &caller,
1409 "forge",
1410 "feat",
1411 "a",
1412 "implementer",
1413 "fixed",
1414 "2026-07-23T12:00:00Z",
1415 )
1416 .expect("reply");
1417 assert_eq!(status.status, "addressed", "a reply flips Open -> Addressed");
1418 let list = ws.review_list("forge", "feat").expect("list");
1419 assert_eq!((list[0].open, list[0].addressed), (1, 1));
1420
1421 ws.review_resolve(&caller, "forge", "feat", "a").expect("resolve");
1422 let list = ws.review_list("forge", "feat").expect("list");
1423 assert_eq!((list[0].addressed, list[0].resolved), (0, 1));
1424
1425 assert!(ws.review_reply(&caller, "forge", "feat", "missing", "x", "y", "z").is_err());
1428 assert!(ws.review_resolve(&caller, "forge", "feat", "missing").is_err());
1429 assert!(
1430 ws.review_reply(&caller, "forge", "other", "a", "x", "y", "z").is_err(),
1431 "reply: a lives on feat, not other",
1432 );
1433 assert!(
1434 ws.review_resolve(&caller, "forge", "other", "a").is_err(),
1435 "resolve: a lives on feat, not other",
1436 );
1437 }
1438
1439 #[test]
1451 fn reply_and_resolve_echo_the_comment_anchor() {
1452 use forge_primitives::review::{
1453 ReviewAnchor, ReviewAuthor, ReviewComment, ReviewSide, ReviewStatus, ReviewThread,
1454 };
1455 let thread = |id: &str, path: &str, line: u32, side: ReviewSide| ReviewThread {
1456 id: id.to_owned(),
1457 anchor: ReviewAnchor {
1458 path: path.to_owned(),
1459 side,
1460 line,
1461 content_hash: 1,
1462 context: vec!["const glyph = ...".to_owned()],
1463 base_ref: "main".to_owned(),
1464 },
1465 comments: vec![ReviewComment {
1466 author: ReviewAuthor::User,
1467 text: format!("look at {id}"),
1468 at: "2026-07-23T10:00:00Z".to_owned(),
1469 review_id: None,
1470 }],
1471 status: ReviewStatus::Open,
1472 created_at: "2026-07-23T10:00:00Z".to_owned(),
1473 updated_at: "2026-07-23T10:00:00Z".to_owned(),
1474 commit: None,
1475 };
1476 let dir = tempdir().expect("tempdir");
1477 let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
1478 ws.install_db_for_test(
1479 crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
1480 );
1481 seed(
1482 &ws,
1483 "forge",
1484 "feat",
1485 &[
1486 thread("c-1", "src/other.rs", 3, ReviewSide::New),
1487 thread("c-71", "src/chat/units.ts", 919, ReviewSide::Old),
1488 ],
1489 );
1490 let reviewer = SessionSlot::from_str_for_test("reviewer");
1491 ws.submit_review(
1492 "forge",
1493 "feat",
1494 None,
1495 &["c-1".to_owned(), "c-71".to_owned()],
1496 reviewer.clone(),
1497 )
1498 .expect("first round seals both");
1499 let mut threads = ws.load_review_threads("forge", "feat").expect("load");
1502 let c71 = threads.iter_mut().find(|t| t.id == "c-71").expect("c-71 is there");
1503 c71.comments.push(ReviewComment {
1504 author: ReviewAuthor::User,
1505 text: "still unguarded".to_owned(),
1506 at: "2026-07-24T10:00:00Z".to_owned(),
1507 review_id: None,
1508 });
1509 seed(&ws, "forge", "feat", &threads);
1510 ws.submit_review("forge", "feat", None, &["c-71".to_owned()], reviewer.clone())
1511 .expect("second round");
1512 ws.submit_review("forge", "feat", None, &["c-1".to_owned()], reviewer)
1513 .expect("third round, c-71 in neither the first nor the last");
1514 let caller = SessionSlot::from_str_for_test("worker");
1515
1516 let replied = ws
1517 .review_reply(&caller, "forge", "feat", "c-71", "implementer", "replay-only", "t")
1518 .expect("reply");
1519 assert_eq!(replied.comment_id, "c-71");
1520 assert_eq!(replied.file, "src/chat/units.ts", "the file the comment is anchored to");
1521 assert_eq!(replied.line, 919);
1522 assert_eq!(replied.side, "old", "the side the anchor holds, not a default");
1523 assert_eq!(replied.number, Some(2), "the round this comment's latest turn is filed in");
1524 assert_eq!(replied.status, "addressed");
1525
1526 let resolved = ws.review_resolve(&caller, "forge", "feat", "c-71").expect("resolve");
1527 assert_eq!(resolved.comment_id, "c-71");
1528 assert_eq!(resolved.file, "src/chat/units.ts", "the anchor survives the resolve");
1529 assert_eq!(resolved.line, 919);
1530 assert_eq!(resolved.side, "old");
1531 assert_eq!(resolved.number, Some(2));
1532 assert_eq!(resolved.status, "resolved");
1533 }
1534
1535 #[test]
1542 fn a_reply_whose_anchor_cannot_be_read_lands_nothing() {
1543 use forge_primitives::review::{
1544 ReviewAnchor, ReviewAuthor, ReviewComment, ReviewSide, ReviewStatus, ReviewThread,
1545 };
1546 let dir = tempdir().expect("tempdir");
1547 let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
1548 ws.install_db_for_test(
1549 crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
1550 );
1551 seed(
1552 &ws,
1553 "forge",
1554 "feat",
1555 &[ReviewThread {
1556 id: "c-1".to_owned(),
1557 anchor: ReviewAnchor {
1558 path: "src/x.rs".to_owned(),
1559 side: ReviewSide::New,
1560 line: 1,
1561 content_hash: 1,
1562 context: vec!["ctx".to_owned()],
1563 base_ref: "main".to_owned(),
1564 },
1565 comments: vec![ReviewComment {
1566 author: ReviewAuthor::User,
1567 text: "look here".to_owned(),
1568 at: "2026-07-23T10:00:00Z".to_owned(),
1569 review_id: None,
1570 }],
1571 status: ReviewStatus::Open,
1572 created_at: "2026-07-23T10:00:00Z".to_owned(),
1573 updated_at: "2026-07-23T10:00:00Z".to_owned(),
1574 commit: None,
1575 }],
1576 );
1577 ws.submit_review(
1578 "forge",
1579 "feat",
1580 None,
1581 &["c-1".to_owned()],
1582 SessionSlot::from_str_for_test("reviewer"),
1583 )
1584 .expect("a reviews row exists to corrupt");
1585 {
1586 let guard = ws.db.lock();
1587 crate::store::review::write_corrupt_reviews_row_for_test(
1588 guard.as_ref().expect("db installed"),
1589 "forge",
1590 "feat",
1591 )
1592 .expect("corrupt the reviews row");
1593 }
1594 let caller = SessionSlot::from_str_for_test("worker");
1595
1596 let err = ws
1597 .review_reply(&caller, "forge", "feat", "c-1", "implementer", "fixed", "t")
1598 .expect_err("the anchor's read fails, so the reply is refused");
1599
1600 assert!(!err.is_empty(), "the failure is reported: {err}");
1601 let threads = ws.load_review_threads("forge", "feat").expect("the threads row still reads");
1602 assert_eq!(
1603 threads[0].comments.len(),
1604 1,
1605 "nothing landed behind the refused read, so a retry duplicates nothing",
1606 );
1607 assert_eq!(threads[0].status, ReviewStatus::Open, "and the status is untouched");
1608 }
1609
1610 #[test]
1614 fn an_unfiled_comment_answers_a_null_number() {
1615 use forge_primitives::review::{
1616 ReviewAnchor, ReviewAuthor, ReviewComment, ReviewSide, ReviewStatus, ReviewThread,
1617 };
1618 let dir = tempdir().expect("tempdir");
1619 let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
1620 ws.install_db_for_test(
1621 crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
1622 );
1623 seed(
1624 &ws,
1625 "forge",
1626 "feat",
1627 &[ReviewThread {
1628 id: "c-9".to_owned(),
1629 anchor: ReviewAnchor {
1630 path: "src/x.rs".to_owned(),
1631 side: ReviewSide::New,
1632 line: 1,
1633 content_hash: 1,
1634 context: vec!["ctx".to_owned()],
1635 base_ref: "main".to_owned(),
1636 },
1637 comments: vec![ReviewComment {
1638 author: ReviewAuthor::User,
1639 text: "unfiled".to_owned(),
1640 at: "2026-07-23T10:00:00Z".to_owned(),
1641 review_id: None,
1642 }],
1643 status: ReviewStatus::Open,
1644 created_at: "2026-07-23T10:00:00Z".to_owned(),
1645 updated_at: "2026-07-23T10:00:00Z".to_owned(),
1646 commit: None,
1647 }],
1648 );
1649 let caller = SessionSlot::from_str_for_test("worker");
1650
1651 let replied = ws
1652 .review_reply(&caller, "forge", "feat", "c-9", "implementer", "x", "t")
1653 .expect("a reply on an unfiled thread still lands");
1654
1655 assert_eq!(replied.comment_id, "c-9");
1656 assert_eq!(replied.number, None, "no review is filed yet, so there is no round to name");
1657 assert_eq!(replied.status, "addressed");
1658 }
1659
1660 #[test]
1661 fn drain_review_activity_batches_one_notice_per_review_routed_to_origin() {
1662 use forge_primitives::review::{
1663 ReviewAnchor, ReviewAuthor, ReviewComment, ReviewSide, ReviewStatus, ReviewThread,
1664 };
1665 let make = |id: &str| ReviewThread {
1666 id: id.to_owned(),
1667 anchor: ReviewAnchor {
1668 path: "src/x.rs".to_owned(),
1669 side: ReviewSide::New,
1670 line: 1,
1671 content_hash: 1,
1672 context: vec!["ctx".to_owned()],
1673 base_ref: "main".to_owned(),
1674 },
1675 comments: vec![ReviewComment {
1676 author: ReviewAuthor::User,
1677 text: format!("look at {id}"),
1678 at: "2026-07-23T10:00:00Z".to_owned(),
1679 review_id: None,
1680 }],
1681 status: ReviewStatus::Open,
1682 created_at: "2026-07-23T10:00:00Z".to_owned(),
1683 updated_at: "2026-07-23T10:00:00Z".to_owned(),
1684 commit: None,
1685 };
1686 let dir = tempdir().expect("tempdir");
1687 let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
1688 ws.install_db_for_test(
1689 crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
1690 );
1691 seed(&ws, "forge", "feat", &[make("a"), make("b"), make("c")]);
1692 let reviewer = SessionSlot::from_str_for_test("reviewer");
1693 let worker = SessionSlot::from_str_for_test("worker");
1694 ws.submit_review(
1695 "forge",
1696 "feat",
1697 None,
1698 &["a".to_owned(), "b".to_owned(), "c".to_owned()],
1699 reviewer.clone(),
1700 )
1701 .expect("submit");
1702
1703 assert!(ws.drain_review_activity(&worker).is_empty(), "no activity, no notice");
1705
1706 ws.review_reply(&worker, "forge", "feat", "a", "impl", "fixed a", "t").expect("reply a");
1709 ws.review_resolve(&worker, "forge", "feat", "b").expect("resolve b");
1710
1711 let notices = ws.drain_review_activity(&worker);
1712 assert_eq!(notices.len(), 1, "one batched notice for the single touched review");
1713 match ¬ices[0] {
1714 SessionUpdate::ReviewActivityNotice { key, branch, waiting, message } => {
1715 assert_eq!(key, &reviewer, "routed to the submit origin, not the worker");
1716 assert_eq!(branch, "feat", "the notice names the branch it is about");
1717 assert_eq!(*waiting, 1, "only the replied-to thread awaits the reviewer");
1719 assert!(message.contains("1 replied"), "tally counts the reply: {message}");
1720 assert!(message.contains("1 resolved"), "tally counts the resolve: {message}");
1721 assert!(
1722 message.contains("1 open"),
1723 "tally reflects the store's open count: {message}"
1724 );
1725 }
1726 other => panic!("expected ReviewActivityNotice, got {other:?}"),
1727 }
1728 assert!(ws.drain_review_activity(&worker).is_empty(), "the turn buffer drained");
1730 }
1731
1732 #[test]
1743 fn a_review_notice_routes_by_slot_not_by_the_id_the_reviewer_started_under() {
1744 use forge_primitives::review::{
1745 ReviewAnchor, ReviewAuthor, ReviewComment, ReviewSide, ReviewStatus, ReviewThread,
1746 };
1747 let thread = ReviewThread {
1748 id: "a".to_owned(),
1749 anchor: ReviewAnchor {
1750 path: "src/x.rs".to_owned(),
1751 side: ReviewSide::New,
1752 line: 1,
1753 content_hash: 1,
1754 context: vec!["ctx".to_owned()],
1755 base_ref: "main".to_owned(),
1756 },
1757 comments: vec![ReviewComment {
1758 author: ReviewAuthor::User,
1759 text: "look at a".to_owned(),
1760 at: "2026-07-23T10:00:00Z".to_owned(),
1761 review_id: None,
1762 }],
1763 status: ReviewStatus::Open,
1764 created_at: "2026-07-23T10:00:00Z".to_owned(),
1765 updated_at: "2026-07-23T10:00:00Z".to_owned(),
1766 commit: None,
1767 };
1768 let dir = tempdir().expect("tempdir");
1769 let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
1770 ws.install_db_for_test(
1771 crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
1772 );
1773 seed(&ws, "forge", "feat", &[thread]);
1774 let reviewer = SessionSlot::worker("TestOrg", "test-project", "reviewer");
1775 let worker = SessionSlot::worker("TestOrg", "test-project", "worker");
1776 ws.submit_review("forge", "feat", None, &["a".to_owned()], reviewer.clone())
1777 .expect("submit");
1778
1779 ws.review_reply(&worker, "forge", "feat", "a", "impl", "fixed", "t").expect("reply");
1780 let notices = ws.drain_review_activity(&worker);
1781 assert_eq!(notices.len(), 1, "the reply still produces a notice");
1782 match ¬ices[0] {
1783 SessionUpdate::ReviewActivityNotice { key, .. } => {
1784 assert_eq!(key, &reviewer, "routed to the slot the review was submitted from");
1785 }
1786 other => panic!("expected ReviewActivityNotice, got {other:?}"),
1787 }
1788 }
1789
1790 #[test]
1791 fn drain_drops_notice_when_no_submit_origin_recorded() {
1792 use forge_primitives::review::{
1793 ReviewAnchor, ReviewAuthor, ReviewComment, ReviewSet, ReviewSide, ReviewStatus,
1794 ReviewThread,
1795 };
1796 let dir = tempdir().expect("tempdir");
1802 let db = crate::store::Db::open(&dir.path().join("db.redb")).expect("open db");
1803 let thread = ReviewThread {
1804 id: "a".to_owned(),
1805 anchor: ReviewAnchor {
1806 path: "src/x.rs".to_owned(),
1807 side: ReviewSide::New,
1808 line: 1,
1809 content_hash: 1,
1810 context: vec!["ctx".to_owned()],
1811 base_ref: "main".to_owned(),
1812 },
1813 comments: vec![ReviewComment {
1814 author: ReviewAuthor::User,
1815 text: "look".to_owned(),
1816 at: "2026-07-23T10:00:00Z".to_owned(),
1817 review_id: None,
1818 }],
1819 status: ReviewStatus::Open,
1820 created_at: "2026-07-23T10:00:00Z".to_owned(),
1821 updated_at: "2026-07-23T10:00:00Z".to_owned(),
1822 commit: None,
1823 };
1824 crate::store::review::save(&db, "forge", "feat", &[thread]).expect("seed threads");
1825 crate::store::review::save_reviews(
1826 &db,
1827 "forge",
1828 "feat",
1829 &[ReviewSet {
1830 id: "r1".to_owned(),
1831 number: 1,
1832 summary: None,
1833 created_at: "2026-07-23T10:00:00Z".to_owned(),
1834 }],
1835 )
1836 .expect("seed reviews");
1837
1838 let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
1839 ws.install_db_for_test(db);
1840 let worker = SessionSlot::from_str_for_test("worker");
1841 ws.review_reply(&worker, "forge", "feat", "a", "impl", "fixed", "t").expect("reply");
1842
1843 assert!(
1844 ws.drain_review_activity(&worker).is_empty(),
1845 "with no recorded origin the notice is dropped, not mis-routed to the worker",
1846 );
1847 }
1848
1849 #[test]
1850 fn submit_and_drain_do_not_deadlock_under_concurrency() {
1851 static SUBMITS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
1855 static DRAINS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
1856 use forge_primitives::review::{
1857 ReviewAnchor, ReviewAuthor, ReviewComment, ReviewSide, ReviewStatus, ReviewThread,
1858 };
1859 let make = |id: &str| ReviewThread {
1860 id: id.to_owned(),
1861 anchor: ReviewAnchor {
1862 path: "src/x.rs".to_owned(),
1863 side: ReviewSide::New,
1864 line: 1,
1865 content_hash: 1,
1866 context: vec!["ctx".to_owned()],
1867 base_ref: "main".to_owned(),
1868 },
1869 comments: vec![ReviewComment {
1870 author: ReviewAuthor::User,
1871 text: format!("look at {id}"),
1872 at: "2026-07-23T10:00:00Z".to_owned(),
1873 review_id: None,
1874 }],
1875 status: ReviewStatus::Open,
1876 created_at: "2026-07-23T10:00:00Z".to_owned(),
1877 updated_at: "2026-07-23T10:00:00Z".to_owned(),
1878 commit: None,
1879 };
1880 let dir = tempdir().expect("tempdir");
1881 let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
1882 ws.install_db_for_test(
1883 crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
1884 );
1885 seed(&ws, "forge", "feat", &[make("a"), make("b"), make("c")]);
1886 let reviewer = SessionSlot::from_str_for_test("reviewer");
1887 let worker = SessionSlot::from_str_for_test("worker");
1888 ws.submit_review(
1889 "forge",
1890 "feat",
1891 None,
1892 &["a".to_owned(), "b".to_owned(), "c".to_owned()],
1893 reviewer.clone(),
1894 );
1895
1896 SUBMITS.store(0, std::sync::atomic::Ordering::Relaxed);
1903 DRAINS.store(0, std::sync::atomic::Ordering::Relaxed);
1904 let (tx, rx) = std::sync::mpsc::channel();
1905 let ws_submit = ws.clone();
1906 let reviewer2 = reviewer.clone();
1907 let ws_drain = ws.clone();
1908 let worker2 = worker.clone();
1909 let coordinator = std::thread::spawn(move || {
1910 let submitter = std::thread::spawn(move || {
1911 for _ in 0..300 {
1912 ws_submit.submit_review("forge", "feat", None, &[], reviewer2.clone());
1913 SUBMITS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1914 }
1915 });
1916 let drainer = std::thread::spawn(move || {
1917 for _ in 0..300 {
1918 let _ = ws_drain.review_reply(&worker2, "forge", "feat", "a", "impl", "x", "t");
1919 let _ = ws_drain.drain_review_activity(&worker2);
1920 DRAINS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1921 }
1922 });
1923 let _ = submitter.join();
1924 let _ = drainer.join();
1925 let _ = tx.send(());
1926 });
1927 let step = std::time::Duration::from_millis(200);
1936 let quiet = std::time::Duration::from_secs(10);
1937 let ceiling = std::time::Duration::from_secs(90);
1938 let started = std::time::Instant::now();
1939 let mut seen = (
1940 SUBMITS.load(std::sync::atomic::Ordering::Relaxed),
1941 DRAINS.load(std::sync::atomic::Ordering::Relaxed),
1942 );
1943 let mut still_since = std::time::Instant::now();
1944 while seen.0 < 300 || seen.1 < 300 {
1945 if rx.try_recv().is_ok() {
1946 break;
1947 }
1948 std::thread::sleep(step);
1949 let now = (
1950 SUBMITS.load(std::sync::atomic::Ordering::Relaxed),
1951 DRAINS.load(std::sync::atomic::Ordering::Relaxed),
1952 );
1953 if now != seen {
1954 seen = now;
1955 still_since = std::time::Instant::now();
1956 } else if still_since.elapsed() >= quiet {
1957 panic!(
1958 "submit_review + drain_review_activity deadlocked (AB-BA on db vs \
1959 review_origin): submitted {}/300, drained {}/300, and neither moved for \
1960 {quiet:?}",
1961 seen.0, seen.1,
1962 );
1963 }
1964 assert!(
1965 started.elapsed() < ceiling,
1966 "the two paths neither finished nor stalled: submitted {}/300, drained {}/300",
1967 seen.0,
1968 seen.1,
1969 );
1970 }
1971 let _ = coordinator.join();
1972 assert_eq!(
1976 (
1977 SUBMITS.load(std::sync::atomic::Ordering::Relaxed),
1978 DRAINS.load(std::sync::atomic::Ordering::Relaxed),
1979 ),
1980 (300, 300),
1981 "both threads ran to the end",
1982 );
1983 }
1984}