1use std::sync::Arc;
16
17use serde::{Deserialize, Serialize};
18
19#[cfg(test)]
20use forge_sdk::mcp::server::McpServer;
21use forge_sdk::mcp::server::McpServerBuilder;
22use forge_sdk::mcp::tool::{Tool, ToolInput, ToolOutput, ToolOutputBlock};
23
24use forge_primitives::review::{
25 ReviewAuthor, ReviewSet, ReviewSide, ReviewStatus, ReviewThread, is_doc_scope, is_tree_scope,
26 tree_scope_ref,
27};
28
29use crate::SessionSlot;
30use crate::mcp::review::facade::{ReviewFacade, ReviewScope};
31
32pub mod facade;
33
34#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
37pub struct ReviewSummary {
38 pub review_id: String,
39 pub number: u32,
40 pub summary: Option<String>,
41 pub created_at: String,
42 pub comment_count: usize,
43 pub open: usize,
44 pub addressed: usize,
45 pub resolved: usize,
46 pub outdated: usize,
47}
48
49#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
51pub struct ReviewDetail {
52 pub review_id: String,
53 pub number: u32,
54 pub summary: Option<String>,
55 pub comments: Vec<ReviewCommentView>,
56}
57
58#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
62pub struct ReviewCommentView {
63 pub comment_id: String,
64 pub file: String,
65 pub line: u32,
66 pub side: &'static str,
67 pub status: &'static str,
68 pub context: Vec<String>,
72 pub thread: Vec<ReviewTurnView>,
73}
74
75#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
81pub struct ReviewTurnView {
82 pub author: String,
83 pub text: String,
84 pub at: String,
85 pub review: Option<u32>,
86}
87
88#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
93pub struct CommentAnchorView {
94 pub comment_id: String,
95 pub file: String,
96 pub line: u32,
97 pub side: &'static str,
98 pub status: &'static str,
99 pub number: Option<u32>,
100}
101
102pub(crate) fn comment_anchor(
104 thread: &ReviewThread,
105 reviews: &[ReviewSet],
106 status: ReviewStatus,
107) -> CommentAnchorView {
108 CommentAnchorView {
109 comment_id: thread.id.clone(),
110 file: thread.anchor.path.clone(),
111 line: thread.anchor.line,
112 side: side_str(thread.anchor.side),
113 status: status_str(status),
114 number: thread
115 .latest_review()
116 .and_then(|id| reviews.iter().find(|r| r.id == id).map(|r| r.number)),
117 }
118}
119
120#[derive(Debug, PartialEq, Eq)]
123pub(crate) struct ReviewActivity {
124 pub project: String,
125 pub branch: String,
126 pub review_id: String,
127 pub replied: bool,
129}
130
131pub(crate) fn notice_message(
139 number: u32,
140 replied: usize,
141 resolved: usize,
142 open: usize,
143 scope: &str,
144) -> String {
145 let door = if forge_primitives::review::is_doc_scope(scope) {
146 "Open the document."
147 } else if forge_primitives::review::is_tree_scope(scope) {
148 "Open the files page."
149 } else {
150 "Open /diff."
151 };
152 format!(
153 "worker addressed review #{number} - {replied} replied, {resolved} resolved, \
154 {open} open. {door}"
155 )
156}
157
158fn side_str(side: ReviewSide) -> &'static str {
159 match side {
160 ReviewSide::Old => "old",
161 ReviewSide::New => "new",
162 }
163}
164
165fn status_str(status: ReviewStatus) -> &'static str {
166 match status {
167 ReviewStatus::Open => "open",
168 ReviewStatus::Addressed => "addressed",
169 ReviewStatus::Resolved => "resolved",
170 ReviewStatus::Outdated => "outdated",
171 }
172}
173
174fn author_str(author: &ReviewAuthor) -> String {
175 match author {
176 ReviewAuthor::User => "you".to_owned(),
177 ReviewAuthor::Agent { label } => label.clone(),
178 }
179}
180
181pub(crate) fn summarize(reviews: &[ReviewSet], threads: &[ReviewThread]) -> Vec<ReviewSummary> {
186 reviews
187 .iter()
188 .rev()
189 .map(|review| {
190 let (mut open, mut addressed, mut resolved, mut outdated) = (0, 0, 0, 0);
191 let mut count = 0;
192 for t in threads.iter().filter(|t| t.is_in_review(&review.id)) {
193 count += 1;
194 match t.status {
195 ReviewStatus::Open => open += 1,
196 ReviewStatus::Addressed => addressed += 1,
197 ReviewStatus::Resolved => resolved += 1,
198 ReviewStatus::Outdated => outdated += 1,
199 }
200 }
201 ReviewSummary {
202 review_id: review.id.clone(),
203 number: review.number,
204 summary: review.summary.clone(),
205 created_at: review.created_at.clone(),
206 comment_count: count,
207 open,
208 addressed,
209 resolved,
210 outdated,
211 }
212 })
213 .collect()
214}
215
216pub(crate) fn detail(
219 reviews: &[ReviewSet],
220 threads: &[ReviewThread],
221 review_id: &str,
222) -> Option<ReviewDetail> {
223 let review = reviews.iter().find(|r| r.id == review_id)?;
224 let comments = threads
225 .iter()
226 .filter(|t| t.is_in_review(review_id))
227 .map(|t| comment_view(t, reviews))
228 .collect();
229 Some(ReviewDetail {
230 review_id: review.id.clone(),
231 number: review.number,
232 summary: review.summary.clone(),
233 comments,
234 })
235}
236
237fn comment_view(thread: &ReviewThread, reviews: &[ReviewSet]) -> ReviewCommentView {
242 ReviewCommentView {
243 comment_id: thread.id.clone(),
244 file: thread.anchor.path.clone(),
245 line: thread.anchor.line,
246 side: side_str(thread.anchor.side),
247 status: status_str(thread.status),
248 context: thread.anchor.context.clone(),
249 thread: thread
250 .comments
251 .iter()
252 .map(|c| ReviewTurnView {
253 author: author_str(&c.author),
254 text: c.text.clone(),
255 at: c.at.clone(),
256 review: c
257 .review_id
258 .as_deref()
259 .and_then(|id| reviews.iter().find(|r| r.id == id).map(|r| r.number)),
260 })
261 .collect(),
262 }
263}
264
265#[cfg(test)]
269pub fn build_server(facade: Arc<dyn ReviewFacade>, slot: SessionSlot) -> McpServer {
270 add_tools(McpServerBuilder::new("forge", env!("CARGO_PKG_VERSION")), facade, slot).build()
271}
272
273pub(crate) fn add_tools(
277 builder: McpServerBuilder,
278 facade: Arc<dyn ReviewFacade>,
279 slot: SessionSlot,
280) -> McpServerBuilder {
281 builder
282 .tool(ReviewList { facade: facade.clone(), slot: slot.clone() })
283 .tool(ReviewGet { facade: facade.clone(), slot: slot.clone() })
284 .tool(ReviewReply { facade: facade.clone(), slot: slot.clone() })
285 .tool(ReviewResolve { facade, slot })
286}
287
288fn tool_error(text: String) -> ToolOutput {
289 ToolOutput::error(text)
290}
291
292fn json_or_error<T: Serialize>(value: &T) -> ToolOutput {
293 match serde_json::to_string_pretty(value) {
294 Ok(json) => ToolOutput::text(json),
295 Err(err) => tool_error(format!("response serialization failed: {err}")),
296 }
297}
298
299fn rfc3339_now() -> String {
301 use time::OffsetDateTime;
302 use time::format_description::well_known::Rfc3339;
303 OffsetDateTime::now_utc().format(&Rfc3339).unwrap_or_else(|err| {
304 tracing::warn!(error = %err, "rfc3339 format failed; emitting epoch sentinel");
305 "1970-01-01T00:00:00Z".to_owned()
306 })
307}
308
309async fn scope_or_error(
312 facade: &Arc<dyn ReviewFacade>,
313 slot: &SessionSlot,
314 scope: Option<&str>,
315) -> Result<ReviewScope, ToolOutput> {
316 facade.resolve_scope(slot, scope).await.map_err(|err| tool_error(err.message()))
317}
318
319fn seat_tree_scope(scope: &ReviewScope) -> ReviewScope {
325 ReviewScope {
326 project: scope.project.clone(),
327 branch: tree_scope_ref(&scope.caller),
328 author_label: scope.author_label.clone(),
329 caller: scope.caller.clone(),
330 }
331}
332
333fn merge_newest(
338 branch_rows: Vec<ReviewSummary>,
339 tree_rows: Vec<ReviewSummary>,
340) -> Vec<ReviewSummary> {
341 let mut rows = branch_rows;
342 rows.extend(tree_rows);
343 rows.sort_by(|a, b| b.created_at.cmp(&a.created_at));
344 rows
345}
346
347pub(crate) struct ReviewList {
350 pub(crate) facade: Arc<dyn ReviewFacade>,
351 pub(crate) slot: SessionSlot,
352}
353
354#[derive(Deserialize)]
355struct ListArgs {
356 scope: Option<String>,
357}
358
359#[async_trait::async_trait]
360impl Tool for ReviewList {
361 fn name(&self) -> &'static str {
362 "review__list"
363 }
364
365 fn description(&self) -> &'static str {
366 "List the review sets the reviewer submitted on your current branch, \
367 newest first - plus any filed against your seat's own file tree \
368 (the files page's comments). Each entry has a review_id, its 1-based \
369 number, the optional overview summary, when it was created, and a \
370 per-state tally of its comments (open / addressed / resolved / \
371 outdated). Start here when you get a 'review #N ready' nudge, then \
372 call review__get with a review_id to read the individual comments. \
373 Pass `scope` to address a review filed elsewhere: one left on a \
374 document hand-off is filed under the document's own scope, \
375 doc:<resolved path>, which the hand-off answer names."
376 }
377
378 fn input_schema(&self) -> serde_json::Value {
379 serde_json::json!({
380 "type": "object",
381 "properties": {
382 "scope": {
383 "type": "string",
384 "description": "Address a scope other than your branch; a document's \
385 review is filed under doc:<resolved path>, which the \
386 hand-off that opened it names.",
387 },
388 },
389 "additionalProperties": false,
390 })
391 }
392
393 async fn call(&self, input: ToolInput) -> ToolOutput {
394 let args: ListArgs = match serde_json::from_value(input.value) {
395 Ok(a) => a,
396 Err(err) => return tool_error(format!("invalid arguments: {err}")),
397 };
398 let scope = match scope_or_error(&self.facade, &self.slot, args.scope.as_deref()).await {
399 Ok(s) => s,
400 Err(out) => return out,
401 };
402 match self.facade.list(&scope) {
403 Ok(rows) => {
404 let mut note = None;
410 let rows = match args.scope.is_none().then(|| seat_tree_scope(&scope)) {
411 Some(tree) => match self.facade.list(&tree) {
412 Ok(tree_rows) => {
413 if !tree_rows.is_empty() {
414 note = Some(format!(
415 "your seat tree's reviews are included (scope {}).",
416 tree.branch,
417 ));
418 }
419 merge_newest(rows, tree_rows)
420 }
421 Err(err) => {
422 note =
423 Some(format!("your seat tree's reviews could not be read: {err}"));
424 rows
425 }
426 },
427 None => rows,
428 };
429 let mut out = list_output(&self.facade, &scope, &rows);
430 if !out.is_error
431 && let Some(note) = note
432 {
433 out.blocks.push(ToolOutputBlock::Text { text: note });
434 }
435 out
436 }
437 Err(err) => tool_error(err),
438 }
439 }
440}
441
442fn list_output(
447 facade: &Arc<dyn ReviewFacade>,
448 scope: &ReviewScope,
449 rows: &[ReviewSummary],
450) -> ToolOutput {
451 let others: Vec<String> = facade
452 .branches_with_reviews(scope)
453 .into_iter()
454 .filter(|branch| *branch != scope.branch && !is_tree_scope(branch) && !is_doc_scope(branch))
459 .collect();
460 if others.is_empty() {
461 return json_or_error(&rows);
462 }
463 if rows.is_empty() {
464 return tool_error(format!(
465 "no reviews on {}; this project has reviews on: {}.",
466 scope.branch,
467 others.join(", "),
468 ));
469 }
470 let mut out = json_or_error(&rows);
471 if out.is_error {
472 return out;
473 }
474 out.blocks.push(ToolOutputBlock::Text {
475 text: format!("this project also has reviews on: {}.", others.join(", ")),
476 });
477 out
478}
479
480pub(crate) struct ReviewGet {
482 pub(crate) facade: Arc<dyn ReviewFacade>,
483 pub(crate) slot: SessionSlot,
484}
485
486#[derive(Deserialize)]
487struct GetArgs {
488 review_id: String,
489 scope: Option<String>,
490}
491
492#[async_trait::async_trait]
493impl Tool for ReviewGet {
494 fn name(&self) -> &'static str {
495 "review__get"
496 }
497
498 fn description(&self) -> &'static str {
499 "Read one review's overview and its comments. Returns the review's \
500 summary plus a comment array; each comment has a comment_id (use \
501 it with review__reply / review__resolve), its file, line, and \
502 side, its current status, the captured `context` lines of code \
503 around the anchored spot, and the thread of turns so far (author \
504 'you' is the reviewer, otherwise a worker reply). Prefer the \
505 `context` code snippet over the line number to locate the spot - \
506 a comment may be scoped to a commit whose line no longer matches \
507 your working tree. Pass the review_id from review__list, plus \
508 `scope` for a review filed under a document's scope rather than \
509 your branch."
510 }
511
512 fn input_schema(&self) -> serde_json::Value {
513 serde_json::json!({
514 "type": "object",
515 "properties": {
516 "review_id": {
517 "type": "string",
518 "description": "The review_id from review__list.",
519 },
520 "scope": {
521 "type": "string",
522 "description": "A scope other than your branch; a document's review is \
523 filed under doc:<resolved path>, which the hand-off \
524 that opened it names.",
525 },
526 },
527 "required": ["review_id"],
528 "additionalProperties": false,
529 })
530 }
531
532 async fn call(&self, input: ToolInput) -> ToolOutput {
533 let args: GetArgs = match serde_json::from_value(input.value) {
534 Ok(a) => a,
535 Err(err) => return tool_error(format!("invalid arguments: {err}")),
536 };
537 let scope = match scope_or_error(&self.facade, &self.slot, args.scope.as_deref()).await {
538 Ok(s) => s,
539 Err(out) => return out,
540 };
541 match self.facade.get(&scope, &args.review_id) {
542 Ok(Some(detail)) => json_or_error(&detail),
543 Ok(None) => {
544 match args.scope.is_none().then(|| seat_tree_scope(&scope)) {
549 Some(tree) => match self.facade.get(&tree, &args.review_id) {
550 Ok(Some(detail)) => json_or_error(&detail),
551 Ok(None) => tool_error(format!(
552 "no review {} on your branch or your seat tree - call \
553 review__list for the valid ids.",
554 args.review_id,
555 )),
556 Err(err) => tool_error(err),
557 },
558 None => tool_error(format!(
559 "no review {} on your branch - call review__list for the valid ids.",
560 args.review_id,
561 )),
562 }
563 }
564 Err(err) => tool_error(err),
565 }
566 }
567}
568
569pub(crate) struct ReviewReply {
572 pub(crate) facade: Arc<dyn ReviewFacade>,
573 pub(crate) slot: SessionSlot,
574}
575
576#[derive(Deserialize)]
577struct ReplyArgs {
578 comment_id: String,
579 text: String,
580 scope: Option<String>,
581}
582
583#[async_trait::async_trait]
584impl Tool for ReviewReply {
585 fn name(&self) -> &'static str {
586 "review__reply"
587 }
588
589 fn description(&self) -> &'static str {
590 "Reply to one review comment. Appends your message to that comment's \
591 thread and flips it from open to addressed so the reviewer sees you \
592 acted on it (a comment already resolved stays resolved). Use this to \
593 say what you changed, ask a clarifying question, or push back. Pass \
594 the comment_id from review__get. Returns the comment's new status \
595 alongside the file, line and side it is anchored to. \
596 Reply per comment, then use review__resolve for the ones you \
597 consider done, or leave them addressed for the reviewer to resolve. \
598 For a review filed under a document's scope rather than your \
599 branch, pass `scope`."
600 }
601
602 fn input_schema(&self) -> serde_json::Value {
603 serde_json::json!({
604 "type": "object",
605 "properties": {
606 "comment_id": {
607 "type": "string",
608 "description": "The comment_id from review__get.",
609 },
610 "text": {
611 "type": "string",
612 "description": "Your reply, addressed to the reviewer.",
613 },
614 "scope": {
615 "type": "string",
616 "description": "A scope other than your branch; a document's review is \
617 filed under doc:<resolved path>, which the hand-off \
618 that opened it names.",
619 },
620 },
621 "required": ["comment_id", "text"],
622 "additionalProperties": false,
623 })
624 }
625
626 async fn call(&self, input: ToolInput) -> ToolOutput {
627 let args: ReplyArgs = match serde_json::from_value(input.value) {
628 Ok(a) => a,
629 Err(err) => return tool_error(format!("invalid arguments: {err}")),
630 };
631 let scope = match scope_or_error(&self.facade, &self.slot, args.scope.as_deref()).await {
632 Ok(s) => s,
633 Err(out) => return out,
634 };
635 match self.facade.reply(&scope, &args.comment_id, &args.text, &rfc3339_now()) {
636 Ok(anchor) => json_or_error(&anchor),
637 Err(err) => {
638 if let Some(tree) = args.scope.is_none().then(|| seat_tree_scope(&scope))
642 && let Ok(anchor) =
643 self.facade.reply(&tree, &args.comment_id, &args.text, &rfc3339_now())
644 {
645 return json_or_error(&anchor);
646 }
647 tool_error(err)
648 }
649 }
650 }
651}
652
653pub(crate) struct ReviewResolve {
655 pub(crate) facade: Arc<dyn ReviewFacade>,
656 pub(crate) slot: SessionSlot,
657}
658
659#[derive(Deserialize)]
660struct ResolveArgs {
661 comment_id: String,
662 scope: Option<String>,
663}
664
665#[async_trait::async_trait]
666impl Tool for ReviewResolve {
667 fn name(&self) -> &'static str {
668 "review__resolve"
669 }
670
671 fn description(&self) -> &'static str {
672 "Mark one review comment resolved - you consider it done. Prefer \
673 replying first (review__reply) to say what you changed, then \
674 resolve; or leave a comment addressed and let the reviewer resolve \
675 it themselves. Pass the comment_id from review__get. Returns the \
676 comment's new status alongside the file, line and side it is \
677 anchored to. For a review filed under a document's scope rather \
678 than your branch, pass `scope`."
679 }
680
681 fn input_schema(&self) -> serde_json::Value {
682 serde_json::json!({
683 "type": "object",
684 "properties": {
685 "comment_id": {
686 "type": "string",
687 "description": "The comment_id from review__get.",
688 },
689 "scope": {
690 "type": "string",
691 "description": "A scope other than your branch; a document's review is \
692 filed under doc:<resolved path>, which the hand-off \
693 that opened it names.",
694 },
695 },
696 "required": ["comment_id"],
697 "additionalProperties": false,
698 })
699 }
700
701 async fn call(&self, input: ToolInput) -> ToolOutput {
702 let args: ResolveArgs = match serde_json::from_value(input.value) {
703 Ok(a) => a,
704 Err(err) => return tool_error(format!("invalid arguments: {err}")),
705 };
706 let scope = match scope_or_error(&self.facade, &self.slot, args.scope.as_deref()).await {
707 Ok(s) => s,
708 Err(out) => return out,
709 };
710 match self.facade.resolve(&scope, &args.comment_id) {
711 Ok(anchor) => json_or_error(&anchor),
712 Err(err) => {
713 if let Some(tree) = args.scope.is_none().then(|| seat_tree_scope(&scope))
717 && let Ok(anchor) = self.facade.resolve(&tree, &args.comment_id)
718 {
719 return json_or_error(&anchor);
720 }
721 tool_error(err)
722 }
723 }
724 }
725}
726
727#[cfg(test)]
728mod tests {
729 use super::*;
730 use crate::mcp::test_support::{block_text, text_of};
731 use forge_primitives::review::{ReviewAnchor, ReviewComment};
732
733 fn review(id: &str, number: u32, summary: Option<&str>) -> ReviewSet {
734 ReviewSet {
735 id: id.to_owned(),
736 number,
737 summary: summary.map(str::to_owned),
738 created_at: "2026-07-23T10:00:00Z".to_owned(),
739 }
740 }
741
742 fn thread(id: &str, review_id: Option<&str>, status: ReviewStatus) -> ReviewThread {
743 ReviewThread {
744 id: id.to_owned(),
745 anchor: ReviewAnchor {
746 path: "src/x.rs".to_owned(),
747 side: ReviewSide::New,
748 line: 42,
749 content_hash: 7,
750 context: vec!["fn f() {".to_owned(), " todo!()".to_owned()],
751 base_ref: "main".to_owned(),
752 },
753 comments: vec![ReviewComment {
754 author: ReviewAuthor::User,
755 text: format!("look at {id}"),
756 at: "2026-07-23T10:00:00Z".to_owned(),
757 review_id: review_id.map(str::to_owned),
758 }],
759 status,
760 created_at: "2026-07-23T10:00:00Z".to_owned(),
761 updated_at: "2026-07-23T10:00:00Z".to_owned(),
762 commit: None,
763 }
764 }
765
766 #[test]
767 fn summarize_tallies_members_newest_first() {
768 let reviews = vec![review("r1", 1, Some("first")), review("r2", 2, None)];
769 let threads = vec![
770 thread("a", Some("r1"), ReviewStatus::Open),
771 thread("b", Some("r1"), ReviewStatus::Addressed),
772 thread("c", Some("r1"), ReviewStatus::Resolved),
773 thread("d", Some("r2"), ReviewStatus::Outdated),
774 thread("e", None, ReviewStatus::Open),
775 ];
776 let rows = summarize(&reviews, &threads);
777 assert_eq!(rows.len(), 2);
778 assert_eq!(rows[0].review_id, "r2", "newest review leads");
779 assert_eq!(rows[0].comment_count, 1);
780 assert_eq!(rows[0].outdated, 1);
781 assert_eq!(rows[1].review_id, "r1");
782 assert_eq!(rows[1].comment_count, 3, "the unfiled thread does not tally into r1");
783 assert_eq!(rows[1].open, 1);
784 assert_eq!(rows[1].addressed, 1);
785 assert_eq!(rows[1].resolved, 1);
786 assert_eq!(rows[1].summary.as_deref(), Some("first"));
787 }
788
789 #[test]
790 fn detail_maps_comments_with_anchor_context() {
791 let reviews = vec![review("r1", 1, Some("overview"))];
792 let mut t = thread("a", Some("r1"), ReviewStatus::Addressed);
793 t.anchor.side = ReviewSide::Old;
794 t.comments.push(ReviewComment {
795 author: ReviewAuthor::Agent { label: "implementer".to_owned() },
796 text: "done".to_owned(),
797 at: "2026-07-23T11:00:00Z".to_owned(),
798 review_id: None,
799 });
800 let got = detail(&reviews, &[t], "r1").expect("review found");
801 assert_eq!(got.number, 1);
802 assert_eq!(got.summary.as_deref(), Some("overview"));
803 assert_eq!(got.comments.len(), 1);
804 let c = &got.comments[0];
805 assert_eq!(c.comment_id, "a");
806 assert_eq!(c.file, "src/x.rs");
807 assert_eq!(c.line, 42);
808 assert_eq!(c.side, "old");
809 assert_eq!(c.status, "addressed");
810 assert_eq!(c.context, vec!["fn f() {".to_owned(), " todo!()".to_owned()]);
811 assert_eq!(c.thread.len(), 2, "user comment + agent reply");
812 assert_eq!(c.thread[0].author, "you");
813 assert_eq!(c.thread[1].author, "implementer");
814 assert_eq!(c.thread[1].text, "done");
815 }
816
817 #[test]
820 fn detail_shows_a_multi_round_thread_under_every_review() {
821 let reviews = vec![review("r1", 1, None), review("r2", 2, Some("round two"))];
822 let mut t = thread("a", Some("r1"), ReviewStatus::Open);
823 t.comments.push(ReviewComment {
824 author: ReviewAuthor::Agent { label: "implementer".to_owned() },
825 text: "done".to_owned(),
826 at: "2026-07-23T11:00:00Z".to_owned(),
827 review_id: None,
828 });
829 t.comments.push(ReviewComment {
830 author: ReviewAuthor::User,
831 text: "still not right".to_owned(),
832 at: "2026-07-23T12:00:00Z".to_owned(),
833 review_id: Some("r2".to_owned()),
834 });
835 let threads = [t];
836
837 for (id, number) in [("r1", 1), ("r2", 2)] {
838 let got = detail(&reviews, &threads, id).expect("review found");
839 assert_eq!(got.number, number);
840 assert_eq!(got.comments.len(), 1, "the thread is a member of {id}");
841 assert_eq!(
842 got.comments[0].thread.iter().map(|t| t.review).collect::<Vec<_>>(),
843 vec![Some(1), None, Some(2)],
844 "the whole conversation is carried, tagged by round",
845 );
846 }
847
848 let rows = summarize(&reviews, &threads);
849 assert_eq!(rows[0].comment_count, 1, "r2 counts it");
850 assert_eq!(rows[1].comment_count, 1, "and so does r1");
851 }
852
853 #[test]
854 fn detail_is_none_for_unknown_review() {
855 let reviews = vec![review("r1", 1, None)];
856 assert!(detail(&reviews, &[], "nope").is_none());
857 }
858
859 #[test]
860 fn notice_message_reads_as_a_tally_line() {
861 assert_eq!(
862 notice_message(3, 2, 1, 4, "feat"),
863 "worker addressed review #3 - 2 replied, 1 resolved, 4 open. Open /diff.",
864 );
865 }
866
867 #[test]
870 fn notice_message_for_a_document_names_the_viewer() {
871 let message = notice_message(1, 1, 0, 1, "doc:/proj/plan.md");
872 assert!(message.contains("Open the document."), "{message}");
873 assert!(!message.contains("/diff"), "{message}");
874 }
875
876 #[test]
879 fn notice_message_for_a_seat_tree_names_the_files_page() {
880 let message = notice_message(1, 1, 0, 1, "tree:Busytools/forge/lead");
881 assert!(message.contains("Open the files page."), "{message}");
882 assert!(!message.contains("/diff"), "{message}");
883 }
884
885 use crate::mcp::review::facade::{MockReviewFacade, ProdReviewFacade, ScopeError};
886
887 fn caller_slot() -> SessionSlot {
888 SessionSlot::from_str_for_test("caller")
889 }
890
891 fn summary(review_id: &str, number: u32) -> ReviewSummary {
892 ReviewSummary {
893 review_id: review_id.to_owned(),
894 number,
895 summary: Some("overview".to_owned()),
896 created_at: "2026-07-23T10:00:00Z".to_owned(),
897 comment_count: 2,
898 open: 1,
899 addressed: 1,
900 resolved: 0,
901 outdated: 0,
902 }
903 }
904
905 #[tokio::test]
906 async fn review_list_returns_summaries_as_json() {
907 let mock = Arc::new(MockReviewFacade::new());
908 mock.summaries.lock().push(summary("r1", 1));
909 let facade: Arc<dyn ReviewFacade> = mock;
910 let tool = ReviewList { facade, slot: caller_slot() };
911 let out = tool.call(ToolInput { value: serde_json::json!({}) }).await;
912 assert!(!out.is_error, "list happy path: {:?}", out.blocks);
913 let parsed: serde_json::Value = serde_json::from_str(text_of(&out)).expect("json");
914 assert_eq!(parsed[0]["review_id"], "r1");
915 assert_eq!(parsed[0]["addressed"], 1);
916 }
917
918 #[tokio::test]
919 async fn review_list_unresolved_scope_surfaces_the_failing_step() {
920 let mock = Arc::new(MockReviewFacade::new());
921 *mock.scope.lock() = Err(ScopeError::SessionCwdUnknown);
922 let facade: Arc<dyn ReviewFacade> = mock;
923 let tool = ReviewList { facade, slot: caller_slot() };
924 let out = tool.call(ToolInput { value: serde_json::json!({}) }).await;
925 assert!(out.is_error, "an unresolved scope must surface as an error");
926 assert_eq!(text_of(&out), ScopeError::SessionCwdUnknown.message());
927 assert!(
928 !text_of(&out).contains("detached"),
929 "a non-HEAD failure must not claim a detached HEAD: {}",
930 text_of(&out),
931 );
932 }
933
934 #[tokio::test]
935 async fn review_list_empty_names_the_projects_other_branches() {
936 let mock = Arc::new(MockReviewFacade::new());
937 *mock.review_branches.lock() =
938 vec!["feat".to_owned(), "main".to_owned(), "worktree-impl".to_owned()];
939 let facade: Arc<dyn ReviewFacade> = mock;
940 let tool = ReviewList { facade, slot: caller_slot() };
941 let out = tool.call(ToolInput { value: serde_json::json!({}) }).await;
942 assert!(out.is_error, "a review filed against another branch must not read as 'none'");
943 let text = text_of(&out);
944 assert!(text.contains("no reviews on feat"), "{text}");
945 assert!(text.contains("main") && text.contains("worktree-impl"), "{text}");
946 assert!(!text.contains(": feat"), "the caller's own branch is not listed back: {text}");
947 }
948
949 #[tokio::test]
950 async fn review_list_names_other_branches_alongside_a_non_empty_list() {
951 let mock = Arc::new(MockReviewFacade::new());
955 mock.summaries.lock().push(summary("r1", 1));
956 *mock.review_branches.lock() = vec!["feat".to_owned(), "worktree-impl".to_owned()];
957 let facade: Arc<dyn ReviewFacade> = mock;
958 let tool = ReviewList { facade, slot: caller_slot() };
959 let out = tool.call(ToolInput { value: serde_json::json!({}) }).await;
960 assert!(!out.is_error, "a populated list is not an error: {:?}", out.blocks);
961 let parsed: serde_json::Value = serde_json::from_str(text_of(&out)).expect("json");
962 assert_eq!(parsed[0]["review_id"], "r1", "the rows stay the first block, still json");
963 let hint = out.blocks.get(1).map_or("", block_text);
964 assert!(hint.contains("worktree-impl"), "{hint}");
965 assert!(!hint.contains("feat"), "the caller's own branch is not listed back: {hint}");
966 }
967
968 #[tokio::test]
974 async fn review_list_includes_the_seat_trees_reviews_beside_the_branchs() {
975 let mock = Arc::new(MockReviewFacade::new());
976 mock.summaries.lock().push(summary("branch-r1", 1));
977 mock.tree_summaries.lock().push(summary("tree-r1", 1));
978 let facade: Arc<dyn ReviewFacade> = mock.clone();
979 let tool = ReviewList { facade, slot: caller_slot() };
980 let out = tool.call(ToolInput { value: serde_json::json!({}) }).await;
981 assert!(!out.is_error, "both scopes' rows are a healthy list: {:?}", out.blocks);
982 let parsed: serde_json::Value = serde_json::from_str(text_of(&out)).expect("json");
983 let ids: Vec<&str> = parsed
984 .as_array()
985 .expect("rows")
986 .iter()
987 .filter_map(|row| row["review_id"].as_str())
988 .collect();
989 assert!(
990 ids.contains(&"branch-r1") && ids.contains(&"tree-r1"),
991 "both scopes' rows are present: {ids:?}",
992 );
993 assert_eq!(
994 mock.scopes_seen.lock().clone(),
995 vec!["feat".to_owned(), "tree:TestOrg/test-project/caller".to_owned()],
996 "the branch scope first, then the seat's tree",
997 );
998 let note = out.blocks.get(1).map_or("", block_text);
999 assert!(note.contains("tree:TestOrg/test-project/caller"), "{note}");
1000 }
1001
1002 #[tokio::test]
1005 async fn review_list_with_an_explicit_scope_does_not_consult_the_seat_tree() {
1006 let mock = Arc::new(MockReviewFacade::new());
1007 let facade: Arc<dyn ReviewFacade> = mock.clone();
1008 let tool = ReviewList { facade, slot: caller_slot() };
1009 let out = tool
1010 .call(ToolInput { value: serde_json::json!({ "scope": "doc:/proj/plan.md" }) })
1011 .await;
1012 assert!(!out.is_error, "an empty document scope is a healthy empty list");
1013 assert_eq!(
1014 mock.scopes_seen.lock().clone(),
1015 vec!["doc:/proj/plan.md".to_owned()],
1016 "the tree is not consulted beside an explicit scope",
1017 );
1018 }
1019
1020 #[tokio::test]
1023 async fn review_list_states_a_failed_tree_read_beside_the_branch_rows() {
1024 let mock = Arc::new(MockReviewFacade::new());
1025 mock.summaries.lock().push(summary("branch-r1", 1));
1026 *mock.tree_list_error.lock() = Some("the store row would not decode".to_owned());
1027 let facade: Arc<dyn ReviewFacade> = mock;
1028 let tool = ReviewList { facade, slot: caller_slot() };
1029 let out = tool.call(ToolInput { value: serde_json::json!({}) }).await;
1030 assert!(!out.is_error, "the branch's rows still return: {:?}", out.blocks);
1031 let parsed: serde_json::Value = serde_json::from_str(text_of(&out)).expect("json");
1032 assert_eq!(parsed[0]["review_id"], "branch-r1");
1033 let note = out.blocks.get(1).map_or("", block_text);
1034 assert!(note.contains("could not be read"), "{note}");
1035 assert!(note.contains("would not decode"), "{note}");
1036 }
1037
1038 #[tokio::test]
1040 async fn review_get_falls_back_to_the_seat_tree() {
1041 let mock = Arc::new(MockReviewFacade::new());
1042 *mock.tree_detail.lock() = detail(&[review("tree-r1", 1, None)], &[], "tree-r1");
1043 let facade: Arc<dyn ReviewFacade> = mock.clone();
1044 let tool = ReviewGet { facade, slot: caller_slot() };
1045 let out =
1046 tool.call(ToolInput { value: serde_json::json!({ "review_id": "tree-r1" }) }).await;
1047 assert!(!out.is_error, "the tree's review is found: {:?}", out.blocks);
1048 assert!(text_of(&out).contains("tree-r1"), "{}", text_of(&out));
1049 assert_eq!(
1050 mock.scopes_seen.lock().clone(),
1051 vec!["feat".to_owned(), "tree:TestOrg/test-project/caller".to_owned()],
1052 "the branch is tried first, then the tree",
1053 );
1054 }
1055
1056 #[tokio::test]
1059 async fn review_get_not_found_names_branch_and_tree() {
1060 let mock = Arc::new(MockReviewFacade::new());
1061 let facade: Arc<dyn ReviewFacade> = mock;
1062 let tool = ReviewGet { facade, slot: caller_slot() };
1063 let out = tool.call(ToolInput { value: serde_json::json!({ "review_id": "nope" }) }).await;
1064 assert!(out.is_error);
1065 assert!(text_of(&out).contains("or your seat tree"), "{}", text_of(&out));
1066 }
1067
1068 #[tokio::test]
1072 async fn review_reply_falls_back_to_the_seat_tree() {
1073 let mock = Arc::new(MockReviewFacade::new());
1074 *mock.fail_non_tree.lock() = true;
1075 let facade: Arc<dyn ReviewFacade> = mock.clone();
1076 let tool = ReviewReply { facade, slot: caller_slot() };
1077 let out = tool
1078 .call(ToolInput { value: serde_json::json!({ "comment_id": "c1", "text": "done" }) })
1079 .await;
1080 assert!(!out.is_error, "the tree holds the comment: {:?}", out.blocks);
1081 assert_eq!(mock.reply_calls.lock().clone(), vec![("c1".to_owned(), "done".to_owned())],);
1082 assert_eq!(
1083 mock.scopes_seen.lock().clone(),
1084 vec!["feat".to_owned(), "tree:TestOrg/test-project/caller".to_owned()],
1085 );
1086 }
1087
1088 #[tokio::test]
1091 async fn review_resolve_falls_back_and_keeps_the_branch_error_otherwise() {
1092 let mock = Arc::new(MockReviewFacade::new());
1093 *mock.fail_non_tree.lock() = true;
1094 let facade: Arc<dyn ReviewFacade> = mock.clone();
1095 let tool = ReviewResolve { facade, slot: caller_slot() };
1096 let out = tool.call(ToolInput { value: serde_json::json!({ "comment_id": "c1" }) }).await;
1097 assert!(!out.is_error, "the tree holds the comment: {:?}", out.blocks);
1098 assert_eq!(mock.resolve_calls.lock().clone(), vec!["c1".to_owned()]);
1099
1100 let mock = Arc::new(MockReviewFacade::new());
1102 mock.force_error.lock().replace("comment c9 is not in this scope".to_owned());
1103 let facade: Arc<dyn ReviewFacade> = mock;
1104 let tool = ReviewResolve { facade, slot: caller_slot() };
1105 let out = tool.call(ToolInput { value: serde_json::json!({ "comment_id": "c9" }) }).await;
1106 assert!(out.is_error);
1107 assert!(text_of(&out).contains("not in this scope"), "{}", text_of(&out));
1108 }
1109
1110 #[tokio::test]
1113 async fn review_list_hint_never_lists_scope_keys_as_branches() {
1114 let mock = Arc::new(MockReviewFacade::new());
1115 *mock.review_branches.lock() = vec![
1116 "feat".to_owned(),
1117 "tree:TestOrg/test-project/caller".to_owned(),
1118 "doc:/proj/plan.md".to_owned(),
1119 "worktree-impl".to_owned(),
1120 ];
1121 let facade: Arc<dyn ReviewFacade> = mock;
1122 let tool = ReviewList { facade, slot: caller_slot() };
1123 let out = tool.call(ToolInput { value: serde_json::json!({}) }).await;
1124 let text = text_of(&out);
1125 assert!(text.contains("worktree-impl"), "{text}");
1126 assert!(!text.contains("tree:"), "a scope key is not a branch: {text}");
1127 assert!(!text.contains("doc:"), "a scope key is not a branch: {text}");
1128 }
1129
1130 #[tokio::test]
1131 async fn review_list_non_empty_says_nothing_when_no_other_branch_has_reviews() {
1132 let mock = Arc::new(MockReviewFacade::new());
1133 mock.summaries.lock().push(summary("r1", 1));
1134 *mock.review_branches.lock() = vec!["feat".to_owned()];
1135 let facade: Arc<dyn ReviewFacade> = mock;
1136 let tool = ReviewList { facade, slot: caller_slot() };
1137 let out = tool.call(ToolInput { value: serde_json::json!({}) }).await;
1138 assert_eq!(out.blocks.len(), 1, "a normal list stays one json block: {:?}", out.blocks);
1139 }
1140
1141 #[tokio::test]
1142 async fn review_list_empty_stays_an_empty_list_with_nothing_elsewhere() {
1143 let mock = Arc::new(MockReviewFacade::new());
1144 let facade: Arc<dyn ReviewFacade> = mock;
1145 let tool = ReviewList { facade, slot: caller_slot() };
1146 let out = tool.call(ToolInput { value: serde_json::json!({}) }).await;
1147 assert!(!out.is_error, "a project with no reviews anywhere is not an error");
1148 let parsed: serde_json::Value = serde_json::from_str(text_of(&out)).expect("json");
1149 assert_eq!(parsed, serde_json::json!([]));
1150 }
1151
1152 #[tokio::test]
1153 async fn review_get_returns_detail_then_not_found() {
1154 let mock = Arc::new(MockReviewFacade::new());
1155 *mock.detail.lock() = Some(ReviewDetail {
1156 review_id: "r1".to_owned(),
1157 number: 1,
1158 summary: Some("overview".to_owned()),
1159 comments: Vec::new(),
1160 });
1161 let facade: Arc<dyn ReviewFacade> = mock;
1162 let tool = ReviewGet { facade, slot: caller_slot() };
1163 let hit = tool.call(ToolInput { value: serde_json::json!({ "review_id": "r1" }) }).await;
1164 assert!(!hit.is_error);
1165 let parsed: serde_json::Value = serde_json::from_str(text_of(&hit)).expect("json");
1166 assert_eq!(parsed["review_id"], "r1");
1167 let miss = tool.call(ToolInput { value: serde_json::json!({ "review_id": "r9" }) }).await;
1168 assert!(miss.is_error, "an unknown review_id is an error");
1169 }
1170
1171 #[tokio::test]
1172 async fn review_reply_captures_and_returns_status() {
1173 let mock = Arc::new(MockReviewFacade::new());
1174 let facade: Arc<dyn ReviewFacade> = mock.clone();
1175 let tool = ReviewReply { facade, slot: caller_slot() };
1176 let out = tool
1177 .call(ToolInput {
1178 value: serde_json::json!({ "comment_id": "c1", "text": "fixed it" }),
1179 })
1180 .await;
1181 assert!(!out.is_error, "reply happy path: {:?}", out.blocks);
1182 let parsed: serde_json::Value = serde_json::from_str(text_of(&out)).expect("json");
1183 assert_eq!(parsed["status"], "addressed");
1184 assert_eq!(parsed["comment_id"], "c1");
1185 let calls = mock.reply_calls.lock();
1186 assert_eq!(calls.len(), 1);
1187 assert_eq!(calls[0], ("c1".to_owned(), "fixed it".to_owned()));
1188 }
1189
1190 #[tokio::test]
1194 async fn review_reply_echoes_the_anchor() {
1195 let mock = Arc::new(MockReviewFacade::new());
1196 *mock.reply_anchor.lock() = CommentAnchorView {
1197 comment_id: "c-71".to_owned(),
1198 file: "src/chat/units.ts".to_owned(),
1199 line: 919,
1200 side: "new",
1201 status: "addressed",
1202 number: Some(3),
1203 };
1204 let facade: Arc<dyn ReviewFacade> = mock.clone();
1205 let tool = ReviewReply { facade, slot: caller_slot() };
1206
1207 let out = tool
1208 .call(ToolInput { value: serde_json::json!({ "comment_id": "c-71", "text": "fixed" }) })
1209 .await;
1210 assert!(!out.is_error, "reply happy path: {:?}", out.blocks);
1211 assert_eq!(out.blocks.len(), 1, "the anchor stays one text block: {out:?}");
1212 let parsed: serde_json::Value = serde_json::from_str(text_of(&out)).expect("json");
1213 assert_eq!(parsed["comment_id"], "c-71");
1214 assert_eq!(parsed["file"], "src/chat/units.ts");
1215 assert_eq!(parsed["line"], 919);
1216 assert_eq!(parsed["side"], "new");
1217 assert_eq!(parsed["number"], 3);
1218 assert_eq!(parsed["status"], "addressed");
1219 }
1220
1221 #[tokio::test]
1225 async fn a_comment_in_no_round_reports_a_null_number() {
1226 let mock = Arc::new(MockReviewFacade::new());
1227 *mock.reply_anchor.lock() = CommentAnchorView {
1228 comment_id: "c-9".to_owned(),
1229 file: "src/x.rs".to_owned(),
1230 line: 1,
1231 side: "new",
1232 status: "addressed",
1233 number: None,
1234 };
1235 let facade: Arc<dyn ReviewFacade> = mock.clone();
1236 let tool = ReviewReply { facade, slot: caller_slot() };
1237
1238 let out = tool
1239 .call(ToolInput { value: serde_json::json!({ "comment_id": "c-9", "text": "x" }) })
1240 .await;
1241 assert!(!out.is_error, "reply happy path: {:?}", out.blocks);
1242 let parsed: serde_json::Value = serde_json::from_str(text_of(&out)).expect("json");
1243 assert_eq!(parsed["number"], serde_json::Value::Null, "the key is present and null");
1244 assert!(parsed.get("number").is_some(), "not dropped from the shape: {parsed}");
1245 }
1246
1247 #[tokio::test]
1250 async fn review_resolve_echoes_the_anchor() {
1251 let mock = Arc::new(MockReviewFacade::new());
1252 *mock.resolve_anchor.lock() = CommentAnchorView {
1253 comment_id: "c-68".to_owned(),
1254 file: "src/chat/Inbound.svelte".to_owned(),
1255 line: 48,
1256 side: "new",
1257 status: "resolved",
1258 number: Some(3),
1259 };
1260 let facade: Arc<dyn ReviewFacade> = mock.clone();
1261 let tool = ReviewResolve { facade, slot: caller_slot() };
1262
1263 let out = tool.call(ToolInput { value: serde_json::json!({ "comment_id": "c-68" }) }).await;
1264 assert!(!out.is_error, "resolve happy path: {:?}", out.blocks);
1265 assert_eq!(out.blocks.len(), 1, "the anchor stays one text block: {out:?}");
1266 let parsed: serde_json::Value = serde_json::from_str(text_of(&out)).expect("json");
1267 assert_eq!(parsed["comment_id"], "c-68");
1268 assert_eq!(parsed["file"], "src/chat/Inbound.svelte");
1269 assert_eq!(parsed["line"], 48);
1270 assert_eq!(parsed["side"], "new");
1271 assert_eq!(parsed["number"], 3);
1272 assert_eq!(parsed["status"], "resolved");
1273 }
1274
1275 #[tokio::test]
1276 async fn review_reply_scope_rejection_surfaces_error() {
1277 let mock = Arc::new(MockReviewFacade::new());
1278 *mock.force_error.lock() = Some("no review comment c1 on (forge, feat)".to_owned());
1279 let facade: Arc<dyn ReviewFacade> = mock.clone();
1280 let tool = ReviewReply { facade, slot: caller_slot() };
1281 let out = tool
1282 .call(ToolInput { value: serde_json::json!({ "comment_id": "c1", "text": "x" }) })
1283 .await;
1284 assert!(out.is_error, "a cross-scope / unknown comment id must error");
1285 assert!(text_of(&out).contains("no review comment"));
1286 assert_eq!(mock.reply_calls.lock().len(), 0, "a rejected reply captures no call");
1287 }
1288
1289 #[tokio::test]
1290 async fn review_resolve_captures_comment_id() {
1291 let mock = Arc::new(MockReviewFacade::new());
1292 let facade: Arc<dyn ReviewFacade> = mock.clone();
1293 let tool = ReviewResolve { facade, slot: caller_slot() };
1294 let out = tool.call(ToolInput { value: serde_json::json!({ "comment_id": "c2" }) }).await;
1295 assert!(!out.is_error, "resolve happy path: {:?}", out.blocks);
1296 let parsed: serde_json::Value = serde_json::from_str(text_of(&out)).expect("json");
1297 assert_eq!(parsed["status"], "resolved");
1298 assert_eq!(*mock.resolve_calls.lock(), vec!["c2".to_owned()]);
1299 }
1300
1301 #[tokio::test]
1306 async fn the_scope_argument_addresses_all_four_tools() {
1307 let mock = Arc::new(MockReviewFacade::new());
1308 *mock.detail.lock() = Some(ReviewDetail {
1309 review_id: "r1".to_owned(),
1310 number: 1,
1311 summary: None,
1312 comments: Vec::new(),
1313 });
1314 let facade: Arc<dyn ReviewFacade> = mock.clone();
1315 let scope = "doc:/proj/plan.md";
1316
1317 let outputs = vec![
1318 ReviewList { facade: Arc::clone(&facade), slot: caller_slot() }
1319 .call(ToolInput { value: serde_json::json!({ "scope": scope }) })
1320 .await,
1321 ReviewGet { facade: Arc::clone(&facade), slot: caller_slot() }
1322 .call(ToolInput {
1323 value: serde_json::json!({ "review_id": "r1", "scope": scope }),
1324 })
1325 .await,
1326 ReviewReply { facade: Arc::clone(&facade), slot: caller_slot() }
1327 .call(ToolInput {
1328 value: serde_json::json!({ "comment_id": "c1", "text": "done", "scope": scope }),
1329 })
1330 .await,
1331 ReviewResolve { facade: Arc::clone(&facade), slot: caller_slot() }
1332 .call(ToolInput { value: serde_json::json!({ "comment_id": "c2", "scope": scope }) })
1333 .await,
1334 ];
1335 for out in &outputs {
1336 assert!(!out.is_error, "{:?}", out.blocks);
1337 }
1338 assert_eq!(
1339 mock.scopes_seen.lock().as_slice(),
1340 [scope, scope, scope, scope],
1341 "every tool addressed the explicit scope rather than the caller's branch",
1342 );
1343 }
1344
1345 #[tokio::test]
1346 async fn review_reply_invalid_args_is_error() {
1347 let mock = Arc::new(MockReviewFacade::new());
1348 let facade: Arc<dyn ReviewFacade> = mock;
1349 let tool = ReviewReply { facade, slot: caller_slot() };
1350 let out = tool.call(ToolInput { value: serde_json::json!({ "comment_id": "c1" }) }).await;
1351 assert!(out.is_error, "missing 'text' is an error");
1352 assert!(text_of(&out).to_lowercase().contains("invalid"));
1353 }
1354
1355 #[tokio::test]
1361 async fn a_documents_review_reads_back_by_its_scope_to_a_resolved_thread() {
1362 use crate::workspace::Workspace;
1363
1364 let dir = tempfile::tempdir().expect("tempdir");
1365 let mut config = crate::config::LoadedConfig::empty_for_test();
1366 config.projects.push(crate::config::LoadedProject {
1367 name: "myproj".to_owned(),
1368 path: dir.path().to_path_buf(),
1369 display_path: dir.path().to_string_lossy().into_owned(),
1370 org: "TestOrg".to_owned(),
1371 accounts: Vec::new(),
1372 fallback_accounts: Vec::new(),
1373 model: None,
1374 env: std::collections::HashMap::new(),
1375 env_file: None,
1376 max_workers: None,
1377 permission_mode: forge_primitives::permission::PermissionMode::Auto,
1378 document_roots: Vec::new(),
1379 handoff: None,
1380 browser_profile: forge_primitives::browser::ProfileKey::of("TestOrg", "default")
1381 .expect("a profile key"),
1382 slack_workspace: None,
1383 });
1384 let (ws, _rx) = Workspace::testing_stub_with_config(dir.path().to_path_buf(), config)
1385 .expect("the stub config is well-formed");
1386 ws.install_db_for_test(
1387 crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
1388 );
1389
1390 let scope = "doc:/proj/plan.md";
1393 let mut t = thread("a", None, ReviewStatus::Open);
1394 t.anchor.base_ref = scope.to_owned();
1395 assert!(ws.upsert_review_thread("myproj", scope, t), "the thread lands");
1396 let reviewer = SessionSlot::from_str_for_test("reviewer-uuid");
1397 ws.submit_review("myproj", scope, Some("overview".to_owned()), &["a".to_owned()], reviewer)
1398 .expect("the review is sealed");
1399
1400 let facade: Arc<dyn ReviewFacade> = ProdReviewFacade::from_arc(&ws);
1402 let slot = SessionSlot::lead("TestOrg", "myproj");
1403
1404 let list = ReviewList { facade: Arc::clone(&facade), slot: slot.clone() }
1405 .call(ToolInput { value: serde_json::json!({ "scope": scope }) })
1406 .await;
1407 assert!(!list.is_error, "{:?}", list.blocks);
1408 let rows: serde_json::Value = serde_json::from_str(text_of(&list)).expect("list json");
1409 let review_id = rows[0]["review_id"].as_str().expect("a review id").to_owned();
1410
1411 let get = ReviewGet { facade: Arc::clone(&facade), slot: slot.clone() }
1412 .call(ToolInput {
1413 value: serde_json::json!({ "review_id": review_id, "scope": scope }),
1414 })
1415 .await;
1416 assert!(!get.is_error, "{:?}", get.blocks);
1417 let detail: serde_json::Value = serde_json::from_str(text_of(&get)).expect("get json");
1418 let comment_id =
1419 detail["comments"][0]["comment_id"].as_str().expect("a comment id").to_owned();
1420 assert_eq!(detail["comments"][0]["status"], "open", "the viewer's comment starts open");
1421
1422 let reply = ReviewReply { facade: Arc::clone(&facade), slot: slot.clone() }
1423 .call(ToolInput {
1424 value: serde_json::json!({ "comment_id": comment_id, "text": "done", "scope": scope }),
1425 })
1426 .await;
1427 assert!(!reply.is_error, "{:?}", reply.blocks);
1428 let echoed: serde_json::Value = serde_json::from_str(text_of(&reply)).expect("reply json");
1429 assert_eq!(echoed["status"], "addressed", "a reply flips the comment");
1430
1431 let resolve = ReviewResolve { facade: Arc::clone(&facade), slot }
1432 .call(ToolInput {
1433 value: serde_json::json!({ "comment_id": comment_id, "scope": scope }),
1434 })
1435 .await;
1436 assert!(!resolve.is_error, "{:?}", resolve.blocks);
1437
1438 let stored = ws.load_review_threads("myproj", scope).expect("threads read back");
1440 assert_eq!(
1441 stored[0].status,
1442 ReviewStatus::Resolved,
1443 "the stored thread is the assertion; a call that failed cannot read as resolved",
1444 );
1445 }
1446
1447 #[test]
1448 fn build_server_registers_all_four_tools() {
1449 let facade = MockReviewFacade::new().into_arc();
1450 let server = build_server(facade, caller_slot());
1451 let debug = format!("{server:?}");
1452 for expected in ["review__list", "review__get", "review__reply", "review__resolve"] {
1453 assert!(debug.contains(expected), "build_server must include {expected}; {debug}");
1454 }
1455 }
1456}