1use serde::{Deserialize, Serialize};
10
11use crate::session_slot::SessionSlot;
12
13#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
17pub enum ReviewStatus {
18 Open,
19 Addressed,
20 Resolved,
21 Outdated,
22}
23
24#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
26pub enum ReviewSide {
27 Old,
28 New,
29}
30
31#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
33pub enum ReviewAuthor {
34 User,
35 Agent { label: String },
36}
37
38#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
44pub struct ReviewComment {
45 pub author: ReviewAuthor,
46 pub text: String,
47 pub at: String,
48 #[serde(default)]
49 pub review_id: Option<String>,
50}
51
52#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
57pub struct ReviewAnchor {
58 pub path: String,
59 pub side: ReviewSide,
60 pub line: u32,
61 #[serde(deserialize_with = "u64_from_number_or_string")]
71 pub content_hash: u64,
72 pub context: Vec<String>,
73 pub base_ref: String,
74}
75
76impl Serialize for ReviewAnchor {
81 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
82 where
83 S: serde::Serializer,
84 {
85 #[derive(Serialize)]
86 struct Wire<'a> {
87 path: &'a str,
88 side: ReviewSide,
89 line: u32,
90 content_hash: String,
91 context: &'a [String],
92 base_ref: &'a str,
93 }
94 Wire {
95 path: &self.path,
96 side: self.side,
97 line: self.line,
98 content_hash: self.content_hash.to_string(),
99 context: &self.context,
100 base_ref: &self.base_ref,
101 }
102 .serialize(serializer)
103 }
104}
105
106fn u64_from_number_or_string<'de, D>(deserializer: D) -> Result<u64, D::Error>
109where
110 D: serde::Deserializer<'de>,
111{
112 #[derive(serde::Deserialize)]
113 #[serde(untagged)]
114 enum Either {
115 Number(u64),
116 Digits(String),
117 }
118 match Either::deserialize(deserializer)? {
119 Either::Number(value) => Ok(value),
120 Either::Digits(digits) => digits
121 .parse()
122 .map_err(|_| serde::de::Error::custom(format!("`{digits}` is not a u64 in decimal"))),
123 }
124}
125
126#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
133pub struct ReviewThread {
134 pub id: String,
135 pub anchor: ReviewAnchor,
136 pub comments: Vec<ReviewComment>,
137 pub status: ReviewStatus,
138 pub created_at: String,
139 pub updated_at: String,
140 #[serde(default)]
141 pub commit: Option<String>,
142}
143
144impl ReviewThread {
145 pub fn is_in_review(&self, review_id: &str) -> bool {
148 self.comments.iter().any(|c| c.review_id.as_deref() == Some(review_id))
149 }
150
151 pub fn origin_review(&self) -> Option<&str> {
154 self.comments.iter().find_map(|c| c.review_id.as_deref())
155 }
156
157 pub fn latest_review(&self) -> Option<&str> {
160 self.comments.iter().rev().find_map(|c| c.review_id.as_deref())
161 }
162
163 pub fn awaits_reviewer(&self) -> bool {
168 matches!(self.status, ReviewStatus::Addressed | ReviewStatus::Outdated)
169 && matches!(self.comments.last(), Some(c) if matches!(c.author, ReviewAuthor::Agent { .. }))
170 }
171
172 pub fn has_unfiled_user_turn(&self) -> bool {
176 self.comments
177 .iter()
178 .any(|c| matches!(c.author, ReviewAuthor::User) && c.review_id.is_none())
179 }
180}
181
182#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
188pub struct ReviewSet {
189 pub id: String,
190 pub number: u32,
191 pub summary: Option<String>,
192 pub created_at: String,
193}
194
195pub const DOC_SCOPE_PREFIX: &str = "doc:";
199
200pub fn doc_scope_ref(path: &str) -> String {
202 format!("{DOC_SCOPE_PREFIX}{path}")
203}
204
205pub fn is_doc_scope(base_ref: &str) -> bool {
211 base_ref.starts_with(DOC_SCOPE_PREFIX)
212}
213
214pub fn doc_scope_path(base_ref: &str) -> Option<&str> {
217 base_ref.strip_prefix(DOC_SCOPE_PREFIX)
218}
219
220pub const TREE_SCOPE_PREFIX: &str = "tree:";
224
225pub fn tree_scope_ref(slot: &SessionSlot) -> String {
229 format!("{TREE_SCOPE_PREFIX}{}/{}/{}", slot.org(), slot.project(), slot.label())
230}
231
232pub fn is_tree_scope(base_ref: &str) -> bool {
236 base_ref.starts_with(TREE_SCOPE_PREFIX)
237}
238
239#[cfg(test)]
240mod tests {
241 use super::*;
242 use crate::session_slot::SessionSlot;
243
244 #[test]
249 fn a_tree_scope_ref_is_recognised_and_carries_its_seat() {
250 let lead = SessionSlot::lead("Busytools", "forge");
251 assert_eq!(tree_scope_ref(&lead), "tree:Busytools/forge/lead");
252 assert!(is_tree_scope(&tree_scope_ref(&lead)));
253
254 let worker = SessionSlot::worker("Busytools", "forge", "tree-review-survey");
255 assert_eq!(tree_scope_ref(&worker), "tree:Busytools/forge/tree-review-survey");
256
257 assert!(!is_tree_scope("trees/rewrite"), "a branch is not a tree scope");
258 assert!(!is_tree_scope("tree"), "the bare prefix is not one");
259 assert!(!is_tree_scope("doc:/proj/docs/plan.md"), "nor is a document");
260 assert!(!is_tree_scope(""), "nor is nothing a scope");
261 }
262
263 #[test]
267 fn a_doc_scope_ref_is_recognised_and_carries_its_path() {
268 assert_eq!(doc_scope_ref("/proj/docs/plan.md"), "doc:/proj/docs/plan.md");
269 assert!(is_doc_scope("doc:/proj/docs/plan.md"));
270 assert_eq!(doc_scope_path("doc:/proj/docs/plan.md"), Some("/proj/docs/plan.md"));
271
272 assert!(!is_doc_scope("docs/fix"), "a branch is not a doc scope");
273 assert!(!is_doc_scope("main"));
274 assert!(!is_doc_scope(""), "nor is nothing a scope");
275 assert_eq!(doc_scope_path("main"), None);
276 }
277
278 #[test]
283 fn an_anchor_hash_crosses_as_decimal_digits() {
284 let mut thread = sample_thread();
285 thread.anchor.content_hash = u64::MAX;
286 let encoded = serde_json::to_value(&thread).expect("encodes");
287 assert_eq!(
288 encoded["anchor"]["content_hash"],
289 serde_json::json!("18446744073709551615"),
290 "serialization is the digits, which JS can carry exactly",
291 );
292 let round_tripped: ReviewThread =
293 serde_json::from_value(encoded.clone()).expect("digits decode");
294 assert_eq!(round_tripped.anchor.content_hash, u64::MAX);
295
296 let mut legacy = encoded.clone();
297 legacy["anchor"]["content_hash"] = serde_json::json!(u64::MAX);
298 let decoded: ReviewThread =
299 serde_json::from_value(legacy).expect("a stored number still decodes");
300 assert_eq!(
301 decoded.anchor.content_hash,
302 u64::MAX,
303 "so blobs written before the digits form need no migration",
304 );
305
306 let mut bad = encoded;
307 bad["anchor"]["content_hash"] = serde_json::json!("twelve");
308 assert!(
309 serde_json::from_value::<ReviewThread>(bad).is_err(),
310 "a non-decimal string is refused rather than rounded",
311 );
312 }
313
314 fn sample_thread() -> ReviewThread {
315 ReviewThread {
316 id: "thread-1".to_owned(),
317 anchor: ReviewAnchor {
318 path: "crates/forge-workspace/src/workspace.rs".to_owned(),
319 side: ReviewSide::New,
320 line: 4781,
321 content_hash: 0xdead_beef_cafe_f00d,
322 context: vec![
323 "prev line".to_owned(),
324 "anchored".to_owned(),
325 "next line".to_owned(),
326 ],
327 base_ref: "main".to_owned(),
328 },
329 comments: vec![
330 ReviewComment {
331 author: ReviewAuthor::User,
332 text: "does this fire for the failure-notice path too?".to_owned(),
333 at: "2026-07-19T10:00:00Z".to_owned(),
334 review_id: None,
335 },
336 ReviewComment {
337 author: ReviewAuthor::Agent { label: "implementer".to_owned() },
338 text: "yes, test added".to_owned(),
339 at: "2026-07-19T10:05:00Z".to_owned(),
340 review_id: None,
341 },
342 ],
343 status: ReviewStatus::Resolved,
344 created_at: "2026-07-19T10:00:00Z".to_owned(),
345 updated_at: "2026-07-19T10:05:00Z".to_owned(),
346 commit: None,
347 }
348 }
349
350 #[test]
351 fn review_thread_round_trips_through_json() {
352 let thread = sample_thread();
353 let json = serde_json::to_string(&thread).expect("serialize");
354 let back: ReviewThread = serde_json::from_str(&json).expect("deserialize");
355 assert_eq!(thread, back);
356 }
357
358 #[test]
359 fn large_content_hash_survives_json_round_trip() {
360 let mut thread = sample_thread();
362 thread.anchor.content_hash = u64::MAX;
363 let json = serde_json::to_string(&thread).expect("serialize");
364 let back: ReviewThread = serde_json::from_str(&json).expect("deserialize");
365 assert_eq!(back.anchor.content_hash, u64::MAX);
366 }
367
368 #[test]
369 fn commit_scoped_thread_round_trips() {
370 let mut thread = sample_thread();
371 thread.commit = Some("abc123".to_owned());
372 let json = serde_json::to_string(&thread).expect("serialize");
373 let back: ReviewThread = serde_json::from_str(&json).expect("deserialize");
374 assert_eq!(back.commit, Some("abc123".to_owned()));
375 }
376
377 #[test]
378 fn thread_without_commit_key_defaults_to_none() {
379 let json = r#"{
382 "id": "t1",
383 "anchor": {
384 "path": "a.rs",
385 "side": "New",
386 "line": 1,
387 "content_hash": 0,
388 "context": [],
389 "base_ref": "main"
390 },
391 "comments": [],
392 "status": "Open",
393 "created_at": "2026-07-19T10:00:00Z",
394 "updated_at": "2026-07-19T10:00:00Z"
395 }"#;
396 let back: ReviewThread = serde_json::from_str(json).expect("deserialize legacy thread");
397 assert_eq!(back.commit, None);
398 }
399
400 #[test]
401 fn turn_review_id_defaults_to_none_when_absent() {
402 let json = r#"{
407 "id": "t1",
408 "anchor": {
409 "path": "a.rs",
410 "side": "New",
411 "line": 1,
412 "content_hash": 0,
413 "context": [],
414 "base_ref": "main"
415 },
416 "comments": [
417 { "author": "User", "text": "hmm", "at": "2026-07-19T10:00:00Z" }
418 ],
419 "status": "Open",
420 "created_at": "2026-07-19T10:00:00Z",
421 "updated_at": "2026-07-19T10:00:00Z",
422 "review_id": "review-3"
423 }"#;
424 let back: ReviewThread = serde_json::from_str(json).expect("deserialize pre-turn thread");
425 assert_eq!(back.comments[0].review_id, None);
426 }
427
428 #[test]
429 fn filed_turn_round_trips_its_review_id() {
430 let mut thread = sample_thread();
431 thread.comments[0].review_id = Some("review-7".to_owned());
432 let json = serde_json::to_string(&thread).expect("serialize");
433 let back: ReviewThread = serde_json::from_str(&json).expect("deserialize");
434 assert_eq!(back.comments[0].review_id, Some("review-7".to_owned()));
435 }
436
437 #[test]
438 fn membership_spans_every_review_the_turns_were_sealed_into() {
439 let mut thread = sample_thread();
440 thread.comments[0].review_id = Some("review-1".to_owned());
441 thread.comments.push(ReviewComment {
442 author: ReviewAuthor::User,
443 text: "still not right".to_owned(),
444 at: "2026-07-19T11:00:00Z".to_owned(),
445 review_id: Some("review-2".to_owned()),
446 });
447 assert!(thread.is_in_review("review-1"));
448 assert!(thread.is_in_review("review-2"));
449 assert!(!thread.is_in_review("review-3"));
450 assert_eq!(thread.origin_review(), Some("review-1"), "origin is the earliest filed turn");
451 assert_eq!(thread.latest_review(), Some("review-2"));
452 assert!(!thread.has_unfiled_user_turn(), "every user turn is sealed");
453 }
454
455 #[test]
456 fn an_unsealed_reply_makes_the_thread_submittable_again() {
457 let mut thread = sample_thread();
458 thread.comments[0].review_id = Some("review-1".to_owned());
459 assert!(!thread.has_unfiled_user_turn());
460 thread.comments.push(ReviewComment {
461 author: ReviewAuthor::User,
462 text: "reopening this".to_owned(),
463 at: "2026-07-19T11:00:00Z".to_owned(),
464 review_id: None,
465 });
466 assert!(thread.has_unfiled_user_turn(), "the new turn is unfiled work");
467 assert_eq!(thread.origin_review(), Some("review-1"), "origin is unaffected by a new turn");
468 }
469
470 #[test]
471 fn an_unfiled_agent_reply_is_not_submittable_work() {
472 let mut thread = sample_thread();
475 thread.comments[0].review_id = Some("review-1".to_owned());
476 assert!(!thread.has_unfiled_user_turn());
477 assert_eq!(thread.comments[1].review_id, None, "the agent turn stays unfiled");
478 }
479
480 #[test]
481 fn an_agent_reply_on_an_addressed_thread_awaits_the_reviewer() {
482 let mut thread = sample_thread();
483 thread.status = ReviewStatus::Addressed;
484 assert!(thread.awaits_reviewer(), "a worker answered and nobody has acted since");
485
486 thread.status = ReviewStatus::Outdated;
488 assert!(thread.awaits_reviewer());
489 }
490
491 #[test]
492 fn a_reviewer_turn_stops_the_thread_awaiting() {
493 let mut thread = sample_thread();
494 thread.status = ReviewStatus::Addressed;
495 thread.comments.push(ReviewComment {
496 author: ReviewAuthor::User,
497 text: "not quite".to_owned(),
498 at: "2026-07-19T11:00:00Z".to_owned(),
499 review_id: None,
500 });
501 assert!(
502 !thread.awaits_reviewer(),
503 "the reviewer replied - the ball is back with the worker"
504 );
505 }
506
507 #[test]
508 fn resolving_or_reopening_stops_the_thread_awaiting() {
509 let mut thread = sample_thread();
510 thread.status = ReviewStatus::Resolved;
511 assert!(!thread.awaits_reviewer(), "resolve is how a read-and-accepted answer clears");
512
513 thread.status = ReviewStatus::Open;
515 assert!(!thread.awaits_reviewer(), "a reopened thread is the worker's again");
516 }
517
518 #[test]
519 fn a_thread_nobody_answered_does_not_await_the_reviewer() {
520 let mut thread = sample_thread();
521 thread.comments.truncate(1);
522 thread.status = ReviewStatus::Open;
523 assert!(!thread.awaits_reviewer(), "no agent turn, nothing to read");
524 }
525
526 #[test]
527 fn review_set_round_trips_through_json() {
528 let review = ReviewSet {
529 id: "review-1".to_owned(),
530 number: 3,
531 summary: Some("Two nits on error handling.".to_owned()),
532 created_at: "2026-07-23T10:00:00Z".to_owned(),
533 };
534 let json = serde_json::to_string(&review).expect("serialize");
535 let back: ReviewSet = serde_json::from_str(&json).expect("deserialize");
536 assert_eq!(review, back);
537 }
538
539 #[test]
540 fn review_set_without_summary_round_trips() {
541 let review = ReviewSet {
542 id: "review-2".to_owned(),
543 number: 1,
544 summary: None,
545 created_at: "2026-07-23T10:00:00Z".to_owned(),
546 };
547 let json = serde_json::to_string(&review).expect("serialize");
548 let back: ReviewSet = serde_json::from_str(&json).expect("deserialize");
549 assert_eq!(back.summary, None);
550 }
551
552 #[test]
553 fn all_status_variants_round_trip() {
554 for status in [
555 ReviewStatus::Open,
556 ReviewStatus::Addressed,
557 ReviewStatus::Resolved,
558 ReviewStatus::Outdated,
559 ] {
560 let json = serde_json::to_string(&status).expect("serialize");
561 let back: ReviewStatus = serde_json::from_str(&json).expect("deserialize");
562 assert_eq!(status, back);
563 }
564 }
565
566 #[test]
567 fn old_side_and_user_author_round_trip() {
568 let anchor = ReviewAnchor {
569 path: "a.rs".to_owned(),
570 side: ReviewSide::Old,
571 line: 12,
572 content_hash: 7,
573 context: Vec::new(),
574 base_ref: "HEAD".to_owned(),
575 };
576 let json = serde_json::to_string(&anchor).expect("serialize");
577 let back: ReviewAnchor = serde_json::from_str(&json).expect("deserialize");
578 assert_eq!(anchor, back);
579 assert_eq!(back.side, ReviewSide::Old);
580 }
581}