Skip to main content

forge_server/surface/
reviews.rs

1//! `reviews()`: the review threads and submitted reviews for one branch.
2
3use forge_primitives::review::ReviewThread;
4use forge_primitives::{ReviewSet, SessionSlot};
5use forge_workspace::env::git_diff::resolver::{
6    AnchorPlacement, place_anchor_in_lines, place_document_anchor,
7};
8
9use super::ViewSurface;
10
11/// Everything the `/diff` overlay reads for one `(project, branch)`.
12///
13/// Two Results rather than one, because the two live in separate redb
14/// tables (`review_threads` and `reviews`) and fail independently
15/// today. Collapsing them would let a corrupt reviews row start failing
16/// a threads-only reader.
17pub struct ReviewsView {
18    pub threads: Result<Vec<ReviewThread>, String>,
19    pub reviews: Result<Vec<ReviewSet>, String>,
20}
21
22/// One thread and where its anchor sits in the content it was written
23/// against, or `None` when no landing could be taken - the file could not
24/// be read, or the seat's tree has no cwd. A thread with no landing is
25/// CARRIED, never dropped; the page parks it.
26pub struct PlacedThread {
27    pub thread: ReviewThread,
28    pub placement: Option<AnchorPlacement>,
29}
30
31/// The files page's review half: the seat-tree scope's threads, each
32/// placed against the seat's own working files, plus its review sets.
33///
34/// The scope key is the seat (`tree_scope_ref`), so the scope never
35/// changes with the branch a worker happens to be on.
36pub struct TreeReviewsView {
37    pub scope: String,
38    pub threads: Result<Vec<PlacedThread>, String>,
39    pub reviews: Result<Vec<ReviewSet>, String>,
40}
41
42impl ViewSurface {
43    pub fn reviews(&self, project: &str, branch: &str) -> ReviewsView {
44        ReviewsView {
45            threads: self.workspace.load_review_threads(project, branch),
46            reviews: self.workspace.load_reviews(project, branch),
47        }
48    }
49
50    /// The seat's tree-scope reviews, each thread placed against the seat's
51    /// working files by line content and neighbours - one bounded read per
52    /// commented file, zero threads meaning zero reads.
53    pub fn tree_reviews(&self, seat: &SessionSlot) -> TreeReviewsView {
54        let scope = forge_primitives::review::tree_scope_ref(seat);
55        let view = self.reviews(seat.project(), &scope);
56        let threads = view.threads.map(|threads| self.place_tree_threads(seat, threads));
57        TreeReviewsView { scope, threads, reviews: view.reviews }
58    }
59
60    /// Place every seated thread by line content and neighbours against
61    /// the file it was written on, read from the seat's tree NOW.
62    ///
63    /// Each commented file is read once per call, through the same
64    /// bounded document read a viewer's route uses (the ceiling, the
65    /// escape check, the UTF-8 answer); zero threads means zero reads. A
66    /// file the read refused leaves its threads unplaced rather than
67    /// dropping them - a vanished file must not erase the comments about
68    /// it.
69    fn place_tree_threads(
70        &self,
71        seat: &SessionSlot,
72        threads: Vec<ReviewThread>,
73    ) -> Vec<PlacedThread> {
74        let cwd = self.roster().cwd_for(seat);
75        let roots = self.roster().document_roots_for(seat);
76        let ceiling = self.workspace.document_max_bytes();
77        let mut files: std::collections::HashMap<String, Option<Vec<String>>> =
78            std::collections::HashMap::new();
79        threads
80            .into_iter()
81            .map(|thread| {
82                let placement = cwd.as_ref().and_then(|cwd| {
83                    let lines = files.entry(thread.anchor.path.clone()).or_insert_with(|| {
84                        forge_workspace::env::documents::read(
85                            cwd,
86                            &roots,
87                            &thread.anchor.path,
88                            false,
89                            ceiling,
90                        )
91                        .ok()
92                        .and_then(|read| String::from_utf8(read.bytes).ok())
93                        .map(|text| text.lines().map(str::to_owned).collect())
94                    });
95                    lines.as_ref().map(|lines| place_anchor_in_lines(&thread.anchor, lines))
96                });
97                PlacedThread { thread, placement }
98            })
99            .collect()
100    }
101
102    /// A document's threads, each placed against the document's own
103    /// current lines by its first captured line - the code review's
104    /// re-anchoring rule, over a whole document rather than a diff.
105    ///
106    /// `None` when the read's bytes are not text: a viewer that draws no
107    /// source lines has no line to place a thread on.
108    pub fn place_document_threads(
109        &self,
110        threads: Vec<ReviewThread>,
111        lines: Option<&[String]>,
112    ) -> Vec<PlacedThread> {
113        threads
114            .into_iter()
115            .map(|thread| {
116                let placement = lines.map(|lines| place_document_anchor(&thread.anchor, lines));
117                PlacedThread { thread, placement }
118            })
119            .collect()
120    }
121}
122
123#[cfg(test)]
124mod tests {
125    use std::sync::Arc;
126
127    use forge_primitives::review::{
128        ReviewAnchor, ReviewAuthor, ReviewComment, ReviewSide, ReviewStatus,
129    };
130    use forge_workspace::{SessionSlot, Workspace};
131
132    use super::*;
133
134    fn thread(id: &str, line: u32) -> ReviewThread {
135        ReviewThread {
136            id: id.to_owned(),
137            anchor: ReviewAnchor {
138                path: "src/lib.rs".to_owned(),
139                side: ReviewSide::New,
140                line,
141                content_hash: u64::from(line),
142                context: vec!["ctx".to_owned()],
143                base_ref: "main".to_owned(),
144            },
145            comments: vec![ReviewComment {
146                author: ReviewAuthor::User,
147                text: format!("c{id}"),
148                at: "2026-07-19T10:00:00Z".to_owned(),
149                review_id: None,
150            }],
151            status: ReviewStatus::Open,
152            created_at: "2026-07-19T10:00:00Z".to_owned(),
153            updated_at: "2026-07-19T10:00:00Z".to_owned(),
154            commit: None,
155        }
156    }
157
158    /// Catches the two halves being wired to each other's table, or one
159    /// half being dropped - either would show threads where review sets
160    /// belong in the diff overlay's list.
161    #[test]
162    fn reviews_agrees_with_the_calls_it_replaces() {
163        let (workspace, _dir) = crate::surface::testing::workspace();
164        assert!(
165            workspace.upsert_review_thread("forge", "feat", thread("a", 10)),
166            "the seeded thread was stored",
167        );
168        workspace
169            .submit_review(
170                "forge",
171                "feat",
172                Some("first pass".to_owned()),
173                &["a".to_owned()],
174                SessionSlot::from_str_for_test("reviewer"),
175            )
176            .expect("seeded review set");
177
178        let view = ViewSurface::new(Arc::clone(&workspace)).reviews("forge", "feat");
179
180        assert_eq!(
181            view.threads,
182            workspace.load_review_threads("forge", "feat"),
183            "the threads half is the call's own",
184        );
185        assert_eq!(
186            view.reviews,
187            workspace.load_reviews("forge", "feat"),
188            "the reviews half is the call's own",
189        );
190        assert_eq!(
191            view.threads.as_ref().map(Vec::len),
192            Ok(1),
193            "the seeded thread is what the threads half returns",
194        );
195        assert_eq!(
196            view.reviews.as_ref().map(Vec::len),
197            Ok(1),
198            "the seeded review set is what the reviews half returns",
199        );
200    }
201
202    /// The seat-tree half places each thread against the seat's WORKING
203    /// files: in place when the recorded line still carries the comment's
204    /// text, moved when the file shifted under it, and no landing at all -
205    /// never a drop, never a guess - when the file cannot be read.
206    #[test]
207    fn tree_reviews_places_threads_against_the_seats_working_files() {
208        use forge_primitives::review::tree_scope_ref;
209        use forge_workspace::env::git_diff::resolver::{AnchorPlacement, anchor_hash};
210
211        let dir = tempfile::tempdir().expect("tempdir");
212        let (workspace, _updates) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
213        let root = dir.path().join("tree");
214        std::fs::create_dir_all(&root).expect("project dir");
215        workspace.seed_test_project("forge", &root.to_string_lossy());
216        workspace.install_db_for_test(
217            forge_workspace::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
218        );
219
220        let worker = SessionSlot::worker("TestOrg", "forge", "reader");
221        let project = ViewSurface::new(Arc::clone(&workspace))
222            .roster()
223            .project_named("forge")
224            .expect("seeded project")
225            .key
226            .clone();
227        workspace.insert_live_worker(
228            &project,
229            forge_workspace::WorkerEntry {
230                label: "reader".to_owned(),
231                charter: "charter".to_owned(),
232                slot: worker.clone(),
233                session_id: None,
234                status: forge_primitives::WorkerLiveness::Running,
235                spawned_at: std::time::SystemTime::UNIX_EPOCH,
236                spawned_by: SessionSlot::lead("TestOrg", "forge"),
237                needs_tag: false,
238                is_git_repo_at_spawn: false,
239                cwd: None,
240                diagnostic: None,
241                kick: None,
242            },
243        );
244
245        let src = root.join("src");
246        std::fs::create_dir_all(&src).expect("src dir");
247        let file = src.join("x.rs");
248        std::fs::write(&file, "fn f() {\n    let a = 1;\n    let b = 2;\n}\n").expect("write");
249
250        let scope = tree_scope_ref(&worker);
251        let mut seeded = thread("t1", 2);
252        seeded.anchor.path = "src/x.rs".to_owned();
253        seeded.anchor.content_hash = anchor_hash("    let a = 1;");
254        seeded.anchor.context = vec!["fn f() {".to_owned(), "    let b = 2;".to_owned()];
255        assert!(workspace.upsert_review_thread("forge", &scope, seeded), "seeded thread");
256
257        let view = ViewSurface::new(Arc::clone(&workspace)).tree_reviews(&worker);
258        assert_eq!(view.scope, scope, "the read's scope is the seat's tree");
259        let threads = view.threads.expect("the threads read answered");
260        assert_eq!(threads.len(), 1);
261        assert_eq!(
262            threads[0].placement,
263            Some(AnchorPlacement::Placed { line: 2, moved_from: None }),
264            "in place against the working file",
265        );
266
267        // The file shifts under the comment: the landing moves with it.
268        std::fs::write(&file, "// header\nfn f() {\n    let a = 1;\n    let b = 2;\n}\n")
269            .expect("rewrite");
270        let view = ViewSurface::new(Arc::clone(&workspace)).tree_reviews(&worker);
271        let threads = view.threads.expect("the threads read answered");
272        assert_eq!(
273            threads[0].placement,
274            Some(AnchorPlacement::Placed { line: 3, moved_from: Some(2) }),
275            "the shifted line is re-found by content and neighbours",
276        );
277
278        // The file vanishes: the thread is carried with no landing.
279        std::fs::remove_file(&file).expect("remove");
280        let view = ViewSurface::new(Arc::clone(&workspace)).tree_reviews(&worker);
281        let threads = view.threads.expect("the threads read answered");
282        assert_eq!(threads.len(), 1, "the thread survives an unreadable file");
283        assert_eq!(threads[0].placement, None, "no landing rather than a guess");
284    }
285
286    /// The placement read hands the seat's project's document roots to the
287    /// read, so a thread anchored on a document under a root places against
288    /// it. Dropping the roots from the placement read leaves the thread
289    /// unplaced.
290    #[test]
291    fn tree_reviews_places_a_thread_on_a_document_under_a_root() {
292        use forge_primitives::review::tree_scope_ref;
293        use forge_workspace::env::git_diff::resolver::{AnchorPlacement, anchor_hash};
294
295        let tree = tempfile::tempdir().expect("tempdir");
296        let documents = tempfile::tempdir().expect("tempdir");
297        let file = documents.path().join("plan.md");
298        std::fs::write(&file, "line one\nline two\n").expect("the document");
299        let (workspace, _dir) =
300            crate::surface::testing::workspace_with_document_roots(tree.path(), documents.path());
301
302        let worker = SessionSlot::worker("TestOrg", "forge", "reader");
303        let project = ViewSurface::new(Arc::clone(&workspace))
304            .roster()
305            .project_named("forge")
306            .expect("declared project")
307            .key
308            .clone();
309        workspace.insert_live_worker(
310            &project,
311            forge_workspace::WorkerEntry {
312                label: "reader".to_owned(),
313                charter: "charter".to_owned(),
314                slot: worker.clone(),
315                session_id: None,
316                status: forge_primitives::WorkerLiveness::Running,
317                spawned_at: std::time::SystemTime::UNIX_EPOCH,
318                spawned_by: SessionSlot::lead("TestOrg", "forge"),
319                needs_tag: false,
320                is_git_repo_at_spawn: false,
321                cwd: None,
322                diagnostic: None,
323                kick: None,
324            },
325        );
326
327        let scope = tree_scope_ref(&worker);
328        let mut seeded = thread("v1", 2);
329        seeded.anchor.path = file.to_string_lossy().into_owned();
330        seeded.anchor.content_hash = anchor_hash("line two");
331        seeded.anchor.context = vec!["line one".to_owned()];
332        assert!(workspace.upsert_review_thread("forge", &scope, seeded), "seeded thread");
333
334        let view = ViewSurface::new(Arc::clone(&workspace)).tree_reviews(&worker);
335        let threads = view.threads.expect("the threads read answered");
336        assert_eq!(threads.len(), 1);
337        assert_eq!(
338            threads[0].placement,
339            Some(AnchorPlacement::Placed { line: 2, moved_from: None }),
340            "in place against the document",
341        );
342    }
343}