1#![cfg_attr(not(test), allow(dead_code))]
2
3use std::collections::BTreeSet;
6use std::path::Path;
7use std::time::{SystemTime, UNIX_EPOCH};
8
9use regex::Regex;
10use serde::{Deserialize, Serialize};
11
12use crate::task::Task;
13
14use super::workflow::{ReviewDisposition, TaskState, WorkflowMeta, can_transition};
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(rename_all = "snake_case")]
18pub enum MergeDisposition {
19 MergeReady,
20 ReworkRequired,
21 Discarded,
22 Escalated,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26pub struct ReviewState {
27 pub reviewer: String,
28 #[serde(default)]
29 pub packet_ref: Option<String>,
30 pub disposition: MergeDisposition,
31 #[serde(default)]
32 pub notes: Option<String>,
33 #[serde(default)]
34 pub reviewed_at: Option<u64>,
35 #[serde(default)]
36 pub nudge_sent: bool,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub enum ReviewEligibility {
41 Eligible,
42 MissingMetadata { reasons: Vec<String> },
43 AlreadyMerged { reason: String },
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub(crate) enum ReviewNormalizationStep {
48 Merge,
49 Archive,
50 Rework,
51}
52
53impl ReviewNormalizationStep {
54 pub(crate) fn as_str(self) -> &'static str {
55 match self {
56 Self::Merge => "merge",
57 Self::Archive => "archive",
58 Self::Rework => "rework",
59 }
60 }
61}
62
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub(crate) struct StaleReviewState {
65 pub(crate) reason: String,
66 pub(crate) next_step: ReviewNormalizationStep,
67}
68
69impl StaleReviewState {
70 pub(crate) fn status_next_action(&self) -> String {
71 format!(
72 "stale review -> {}: {}",
73 self.next_step.as_str(),
74 self.reason
75 )
76 }
77}
78
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub(crate) enum ReviewQueueState {
81 Current,
82 Stale(StaleReviewState),
83}
84
85pub fn apply_review(
86 meta: &mut WorkflowMeta,
87 disposition: MergeDisposition,
88 reviewer: &str,
89) -> Result<(), String> {
90 validate_review_readiness(meta)?;
91
92 let packet_ref = meta
93 .review
94 .as_ref()
95 .and_then(|review| review.packet_ref.clone());
96 let notes = meta.review.as_ref().and_then(|review| review.notes.clone());
97
98 let (next_state, review_disposition, blocked_on) = match disposition {
99 MergeDisposition::MergeReady => (TaskState::Done, Some(ReviewDisposition::Approved), None),
100 MergeDisposition::ReworkRequired => (
101 TaskState::InProgress,
102 Some(ReviewDisposition::ChangesRequested),
103 None,
104 ),
105 MergeDisposition::Discarded => {
106 (TaskState::Archived, Some(ReviewDisposition::Rejected), None)
107 }
108 MergeDisposition::Escalated => (
109 TaskState::Blocked,
110 None,
111 Some(format!("escalated by {reviewer}")),
112 ),
113 };
114
115 can_transition(meta.state, next_state)?;
116 meta.state = next_state;
117 meta.review_owner = Some(reviewer.to_string());
118 meta.review_disposition = review_disposition;
119 let now = SystemTime::now()
120 .duration_since(UNIX_EPOCH)
121 .unwrap_or_default()
122 .as_secs();
123 meta.review = Some(ReviewState {
124 reviewer: reviewer.to_string(),
125 packet_ref,
126 disposition,
127 notes,
128 reviewed_at: Some(now),
129 nudge_sent: false,
130 });
131 meta.blocked_on = blocked_on;
132
133 Ok(())
134}
135
136pub fn validate_review_readiness(meta: &WorkflowMeta) -> Result<(), String> {
137 if meta.state == TaskState::Review {
138 Ok(())
139 } else {
140 Err(format!(
141 "task must be in Review state before applying review, found {:?}",
142 meta.state
143 ))
144 }
145}
146
147pub fn validate_review_candidate(
148 project_root: &Path,
149 meta: &WorkflowMeta,
150) -> anyhow::Result<ReviewEligibility> {
151 let branch = meta
152 .branch
153 .as_deref()
154 .map(str::trim)
155 .filter(|value| !value.is_empty());
156 let commit = meta
157 .commit
158 .as_deref()
159 .map(str::trim)
160 .filter(|value| !value.is_empty());
161 let mut reasons = Vec::new();
162
163 if branch.is_none() {
164 reasons.push("branch metadata missing".to_string());
165 }
166 if commit.is_none() {
167 reasons.push("commit metadata missing".to_string());
168 }
169 if !reasons.is_empty() {
170 return Ok(ReviewEligibility::MissingMetadata { reasons });
171 }
172
173 let branch = branch.expect("branch checked above");
174 let commit = commit.expect("commit checked above");
175
176 let commit_merged =
177 match crate::team::git_cmd::merge_base_is_ancestor(project_root, commit, "main") {
178 Ok(merged) => merged,
179 Err(_) => {
180 return Ok(ReviewEligibility::MissingMetadata {
181 reasons: vec![format!("commit metadata is invalid: `{commit}`")],
182 });
183 }
184 };
185 if commit_merged {
186 return Ok(ReviewEligibility::AlreadyMerged {
187 reason: format!("commit `{commit}` is already on main"),
188 });
189 }
190
191 let branch_merged =
192 match crate::team::task_loop::branch_is_merged_into(project_root, branch, "main") {
193 Ok(merged) => merged,
194 Err(_) => {
195 return Ok(ReviewEligibility::MissingMetadata {
196 reasons: vec![format!("branch metadata is invalid: `{branch}`")],
197 });
198 }
199 };
200 if branch_merged {
201 return Ok(ReviewEligibility::AlreadyMerged {
202 reason: format!("branch `{branch}` is already merged into main"),
203 });
204 }
205
206 Ok(ReviewEligibility::Eligible)
207}
208
209pub(crate) fn classify_review_task(
210 project_root: &Path,
211 task: &Task,
212 board_tasks: &[Task],
213) -> ReviewQueueState {
214 if task.status != "review" {
215 return ReviewQueueState::Current;
216 }
217
218 if let Some(branch) = task.branch.as_deref() {
219 let blockers = task_reference_mismatch_blockers(task.id, branch, &[]);
220 if !blockers.is_empty() {
221 return ReviewQueueState::Stale(StaleReviewState {
222 reason: blockers.join("; "),
223 next_step: ReviewNormalizationStep::Rework,
224 });
225 }
226 }
227
228 if let Some(ReviewEligibility::AlreadyMerged { reason }) =
229 review_eligibility_for_task(project_root, task)
230 {
231 return ReviewQueueState::Stale(StaleReviewState {
232 reason,
233 next_step: ReviewNormalizationStep::Merge,
234 });
235 }
236
237 let Some(engineer) = task.claimed_by.as_deref() else {
238 return ReviewQueueState::Current;
239 };
240
241 let active_claims = board_tasks
242 .iter()
243 .filter(|candidate| candidate.id != task.id)
244 .filter(|candidate| candidate.claimed_by.as_deref() == Some(engineer))
245 .filter(|candidate| candidate.status != "review")
246 .filter(|candidate| candidate.status != "done")
247 .filter(|candidate| candidate.status != "archived")
248 .collect::<Vec<_>>();
249
250 if let Some(current_lane) = select_current_lane(engineer, &active_claims, project_root) {
251 let branch_suffix = current_lane
252 .branch
253 .as_deref()
254 .map(|branch| format!(" on branch `{branch}`"))
255 .unwrap_or_default();
256 return ReviewQueueState::Stale(StaleReviewState {
257 reason: format!(
258 "{engineer} already moved to task #{}{}",
259 current_lane.id, branch_suffix
260 ),
261 next_step: ReviewNormalizationStep::Merge,
262 });
263 }
264
265 if let Some(current_branch) = review_worktree_branch(project_root, engineer) {
266 let blockers = task_reference_mismatch_blockers(task.id, ¤t_branch, &[]);
267 if !blockers.is_empty() {
268 return ReviewQueueState::Stale(StaleReviewState {
269 reason: blockers.join("; "),
270 next_step: ReviewNormalizationStep::Archive,
271 });
272 }
273 }
274
275 ReviewQueueState::Current
276}
277
278fn review_eligibility_for_task(project_root: &Path, task: &Task) -> Option<ReviewEligibility> {
279 let meta = WorkflowMeta {
280 state: TaskState::Review,
281 branch: task.branch.clone().or_else(|| {
282 task.claimed_by
283 .as_deref()
284 .and_then(|engineer| review_worktree_branch(project_root, engineer))
285 }),
286 commit: task.commit.clone().or_else(|| {
287 task.claimed_by
288 .as_deref()
289 .and_then(|engineer| review_worktree_commit(project_root, engineer))
290 }),
291 ..WorkflowMeta::default()
292 };
293 validate_review_candidate(project_root, &meta).ok()
294}
295
296fn select_current_lane<'a>(
297 engineer: &str,
298 active_claims: &[&'a Task],
299 project_root: &Path,
300) -> Option<&'a Task> {
301 if active_claims.is_empty() {
302 return None;
303 }
304
305 let current_branch = review_worktree_branch(project_root, engineer);
306 if let Some(current_branch) = current_branch.as_deref() {
307 let mut branch_matches = active_claims.iter().copied().filter(|candidate| {
308 candidate
309 .branch
310 .as_deref()
311 .map(|branch| branch == current_branch)
312 .unwrap_or_else(|| format!("{engineer}/{}", candidate.id) == current_branch)
313 });
314 if let Some(candidate) = branch_matches.next()
315 && branch_matches.next().is_none()
316 {
317 return Some(candidate);
318 }
319 return None;
324 }
325
326 if active_claims.len() == 1 {
331 return Some(active_claims[0]);
332 }
333
334 None
335}
336
337fn review_worktree_branch(project_root: &Path, engineer: &str) -> Option<String> {
338 let worktree_dir = review_worktree_dir(project_root, engineer);
339 worktree_dir
340 .is_dir()
341 .then_some(worktree_dir)
342 .and_then(|worktree_dir| {
343 crate::team::task_loop::current_worktree_branch(&worktree_dir).ok()
344 })
345}
346
347fn review_worktree_commit(project_root: &Path, engineer: &str) -> Option<String> {
348 let worktree_dir = review_worktree_dir(project_root, engineer);
349 if !worktree_dir.is_dir() {
350 return None;
351 }
352
353 let output = std::process::Command::new("git")
354 .args(["rev-parse", "--verify", "HEAD"])
355 .current_dir(&worktree_dir)
356 .output()
357 .ok()?;
358 if !output.status.success() {
359 return None;
360 }
361
362 let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
363 (!value.is_empty()).then_some(value)
364}
365
366fn review_worktree_dir(project_root: &Path, engineer: &str) -> std::path::PathBuf {
367 project_root.join(".batty").join("worktrees").join(engineer)
368}
369
370pub(crate) fn task_reference_mismatch_blockers(
371 expected_task_id: u32,
372 branch_name: &str,
373 commit_messages: &[String],
374) -> Vec<String> {
375 let branch_task_ids = extract_task_ids(branch_name);
376 let commit_task_ids = commit_messages
377 .iter()
378 .flat_map(|message| extract_task_ids(message))
379 .collect::<BTreeSet<_>>();
380 let mut blockers = Vec::new();
381
382 if !branch_task_ids.is_empty() && !branch_task_ids.contains(&expected_task_id) {
383 blockers.push(format!(
384 "branch `{branch_name}` references task(s) {} but assigned task is #{expected_task_id}",
385 format_task_id_list(&branch_task_ids)
386 ));
387 }
388
389 if !commit_task_ids.is_empty() && !commit_task_ids.contains(&expected_task_id) {
390 blockers.push(format!(
391 "commit messages reference task(s) {} but assigned task is #{expected_task_id}",
392 format_task_id_list(&commit_task_ids)
393 ));
394 }
395
396 blockers
397}
398
399fn extract_task_ids(text: &str) -> BTreeSet<u32> {
400 let mut ids = BTreeSet::new();
401 let hash_pattern = Regex::new(r"(?i)#(\d+)\b").expect("hash task id regex should compile");
402 let task_pattern =
403 Regex::new(r"(?i)\btask[-_ /]?(\d+)\b").expect("task task id regex should compile");
404
405 for captures in hash_pattern.captures_iter(text) {
406 if let Some(id) = captures
407 .get(1)
408 .and_then(|value| value.as_str().parse::<u32>().ok())
409 {
410 ids.insert(id);
411 }
412 }
413 for captures in task_pattern.captures_iter(text) {
414 if let Some(id) = captures
415 .get(1)
416 .and_then(|value| value.as_str().parse::<u32>().ok())
417 {
418 ids.insert(id);
419 }
420 }
421
422 ids
423}
424
425fn format_task_id_list(task_ids: &BTreeSet<u32>) -> String {
426 task_ids
427 .iter()
428 .map(|task_id| format!("#{task_id}"))
429 .collect::<Vec<_>>()
430 .join(", ")
431}
432
433#[cfg(test)]
434mod tests {
435 use super::*;
436 use crate::team::task_loop::{
437 checkout_worktree_branch_from_main, engineer_base_branch_name, setup_engineer_worktree,
438 };
439 use crate::team::test_support::{git_ok, init_git_repo};
440 use std::fs;
441 use std::process::Command;
442 use tempfile::tempdir;
443
444 fn review_meta() -> WorkflowMeta {
445 WorkflowMeta {
446 state: TaskState::Review,
447 review: Some(ReviewState {
448 reviewer: "manager-0".to_string(),
449 packet_ref: Some("review/packet-1.json".to_string()),
450 disposition: MergeDisposition::MergeReady,
451 notes: Some("initial packet".to_string()),
452 reviewed_at: None,
453 nudge_sent: false,
454 }),
455 ..WorkflowMeta::default()
456 }
457 }
458
459 #[test]
460 fn merge_ready_moves_review_to_done() {
461 let mut meta = review_meta();
462
463 apply_review(&mut meta, MergeDisposition::MergeReady, "manager-1").unwrap();
464
465 assert_eq!(meta.state, TaskState::Done);
466 assert_eq!(meta.review_owner.as_deref(), Some("manager-1"));
467 assert_eq!(meta.review_disposition, Some(ReviewDisposition::Approved));
468 assert_eq!(meta.blocked_on, None);
469 let review = meta.review.unwrap();
470 assert_eq!(review.disposition, MergeDisposition::MergeReady);
471 assert_eq!(review.packet_ref.as_deref(), Some("review/packet-1.json"));
472 }
473
474 #[test]
475 fn rework_required_moves_review_to_in_progress() {
476 let mut meta = review_meta();
477
478 apply_review(&mut meta, MergeDisposition::ReworkRequired, "manager-1").unwrap();
479
480 assert_eq!(meta.state, TaskState::InProgress);
481 assert_eq!(
482 meta.review_disposition,
483 Some(ReviewDisposition::ChangesRequested)
484 );
485 assert_eq!(meta.blocked_on, None);
486 assert_eq!(
487 meta.review.as_ref().map(|review| review.disposition),
488 Some(MergeDisposition::ReworkRequired)
489 );
490 }
491
492 #[test]
493 fn discarded_moves_review_to_archived() {
494 let mut meta = review_meta();
495
496 apply_review(&mut meta, MergeDisposition::Discarded, "manager-1").unwrap();
497
498 assert_eq!(meta.state, TaskState::Archived);
499 assert_eq!(meta.review_disposition, Some(ReviewDisposition::Rejected));
500 assert_eq!(meta.blocked_on, None);
501 assert_eq!(
502 meta.review.as_ref().map(|review| review.disposition),
503 Some(MergeDisposition::Discarded)
504 );
505 }
506
507 #[test]
508 fn escalated_moves_review_to_blocked() {
509 let mut meta = review_meta();
510
511 apply_review(&mut meta, MergeDisposition::Escalated, "manager-1").unwrap();
512
513 assert_eq!(meta.state, TaskState::Blocked);
514 assert_eq!(meta.review_disposition, None);
515 assert_eq!(meta.blocked_on.as_deref(), Some("escalated by manager-1"));
516 assert_eq!(
517 meta.review.as_ref().map(|review| review.disposition),
518 Some(MergeDisposition::Escalated)
519 );
520 }
521
522 #[test]
523 fn apply_review_rejects_non_review_tasks() {
524 let mut meta = WorkflowMeta {
525 state: TaskState::InProgress,
526 ..WorkflowMeta::default()
527 };
528
529 let err = apply_review(&mut meta, MergeDisposition::MergeReady, "manager-1")
530 .expect_err("non-review tasks should be rejected");
531
532 assert!(err.contains("Review state"));
533 assert_eq!(meta.state, TaskState::InProgress);
534 assert_eq!(meta.review_disposition, None);
535 assert_eq!(meta.blocked_on, None);
536 assert!(meta.review.is_none());
537 }
538
539 #[test]
540 fn validate_review_readiness_rejects_non_review_state() {
541 let meta = WorkflowMeta {
542 state: TaskState::Todo,
543 ..WorkflowMeta::default()
544 };
545
546 let err = validate_review_readiness(&meta).expect_err("todo should not be review-ready");
547 assert!(err.contains("Review state"));
548 }
549
550 #[test]
551 fn validate_review_candidate_rejects_missing_metadata() {
552 let tmp = tempdir().unwrap();
553 let repo = init_git_repo(&tmp, "review_missing_meta");
554 let meta = WorkflowMeta {
555 state: TaskState::Review,
556 ..WorkflowMeta::default()
557 };
558
559 let eligibility = validate_review_candidate(&repo, &meta).unwrap();
560 assert_eq!(
561 eligibility,
562 ReviewEligibility::MissingMetadata {
563 reasons: vec![
564 "branch metadata missing".to_string(),
565 "commit metadata missing".to_string()
566 ]
567 }
568 );
569 }
570
571 #[test]
572 fn validate_review_candidate_rejects_already_merged_commit() {
573 let tmp = tempdir().unwrap();
574 let repo = init_git_repo(&tmp, "review_merged_meta");
575
576 git_ok(&repo, &["checkout", "-b", "eng-1/task-42"]);
577 fs::write(
578 repo.join("src").join("review_candidate.rs"),
579 "pub fn review_candidate() {}\n",
580 )
581 .unwrap();
582 git_ok(&repo, &["add", "."]);
583 git_ok(&repo, &["commit", "-m", "review candidate"]);
584 let commit = String::from_utf8(
585 Command::new("git")
586 .args(["rev-parse", "HEAD"])
587 .current_dir(&repo)
588 .output()
589 .unwrap()
590 .stdout,
591 )
592 .unwrap()
593 .trim()
594 .to_string();
595 git_ok(&repo, &["checkout", "main"]);
596 git_ok(
597 &repo,
598 &[
599 "merge",
600 "--no-ff",
601 "eng-1/task-42",
602 "-m",
603 "merge review candidate",
604 ],
605 );
606
607 let meta = WorkflowMeta {
608 state: TaskState::Review,
609 branch: Some("eng-1/task-42".to_string()),
610 commit: Some(commit.clone()),
611 ..WorkflowMeta::default()
612 };
613
614 let eligibility = validate_review_candidate(&repo, &meta).unwrap();
615 assert_eq!(
616 eligibility,
617 ReviewEligibility::AlreadyMerged {
618 reason: format!("commit `{commit}` is already on main")
619 }
620 );
621 }
622
623 #[test]
624 fn review_state_uses_merge_disposition() {
625 let state = ReviewState {
626 reviewer: "manager-1".to_string(),
627 packet_ref: Some("packet-42".to_string()),
628 disposition: MergeDisposition::MergeReady,
629 notes: Some("ready to merge".to_string()),
630 reviewed_at: Some(1700000000),
631 nudge_sent: false,
632 };
633
634 let json = serde_json::to_string(&state).unwrap();
635 assert!(json.contains("\"disposition\":\"merge_ready\""));
636 assert!(json.contains("\"reviewed_at\":1700000000"));
637 }
638
639 #[test]
640 fn task_reference_mismatch_detects_branch_and_commit_ids() {
641 let blockers = task_reference_mismatch_blockers(
642 497,
643 "eng-1-1/task-449",
644 &[
645 "Task #449: implement wrong fix".to_string(),
646 "follow-up for task-449".to_string(),
647 ],
648 );
649
650 assert_eq!(blockers.len(), 2);
651 assert!(blockers[0].contains("assigned task is #497"));
652 assert!(blockers[0].contains("#449"));
653 assert!(blockers[1].contains("commit messages"));
654 }
655
656 #[test]
657 fn task_reference_mismatch_allows_expected_task_id() {
658 let blockers = task_reference_mismatch_blockers(
659 497,
660 "eng-1-1/task-497",
661 &[
662 "Task #497: implement expected fix".to_string(),
663 "follow-up for task-497".to_string(),
664 ],
665 );
666
667 assert!(blockers.is_empty());
668 }
669
670 #[test]
671 fn task_reference_mismatch_ignores_unlabeled_commits() {
672 let blockers =
673 task_reference_mismatch_blockers(497, "eng-1-1", &["refactor review flow".to_string()]);
674
675 assert!(blockers.is_empty());
676 }
677
678 fn review_task(id: u32, claimed_by: &str) -> Task {
679 Task {
680 id,
681 title: format!("review-task-{id}"),
682 status: "review".to_string(),
683 priority: "high".to_string(),
684 claimed_by: Some(claimed_by.to_string()),
685 claimed_at: None,
686 claim_ttl_secs: None,
687 claim_expires_at: None,
688 last_progress_at: None,
689 claim_warning_sent_at: None,
690 claim_extensions: None,
691 last_output_bytes: None,
692 blocked: None,
693 tags: Vec::new(),
694 depends_on: Vec::new(),
695 review_owner: Some("manager".to_string()),
696 blocked_on: None,
697 worktree_path: None,
698 branch: None,
699 commit: None,
700 artifacts: Vec::new(),
701 next_action: None,
702 scheduled_for: None,
703 cron_schedule: None,
704 cron_last_run: None,
705 completed: None,
706 description: String::new(),
707 batty_config: None,
708 source_path: Path::new("review.md").to_path_buf(),
709 }
710 }
711
712 #[test]
713 fn classify_review_task_keeps_live_review_current() {
714 let tmp = tempdir().unwrap();
715 let repo = init_git_repo(&tmp, "review-current");
716 let team_config_dir = repo.join(".batty").join("team_config");
717 let worktree_dir = repo.join(".batty").join("worktrees").join("eng-1");
718 let base_branch = engineer_base_branch_name("eng-1");
719 setup_engineer_worktree(&repo, &worktree_dir, &base_branch, &team_config_dir).unwrap();
720 checkout_worktree_branch_from_main(&worktree_dir, "eng-1/42").unwrap();
721 fs::write(worktree_dir.join("live-review.txt"), "live review\n").unwrap();
722 git_ok(&worktree_dir, &["add", "live-review.txt"]);
723 git_ok(&worktree_dir, &["commit", "-m", "live review branch"]);
724
725 let mut review = review_task(42, "eng-1");
726 review.branch = Some("eng-1/42".to_string());
727
728 assert_eq!(
729 classify_review_task(&repo, &review, &[review_task(42, "eng-1")]),
730 ReviewQueueState::Current
731 );
732 }
733
734 #[test]
735 fn classify_review_task_detects_engineer_moved_on() {
736 let tmp = tempdir().unwrap();
737 let repo = init_git_repo(&tmp, "review-moved-on");
738 let team_config_dir = repo.join(".batty").join("team_config");
739 let worktree_dir = repo.join(".batty").join("worktrees").join("eng-1");
740 let base_branch = engineer_base_branch_name("eng-1");
741 setup_engineer_worktree(&repo, &worktree_dir, &base_branch, &team_config_dir).unwrap();
742 checkout_worktree_branch_from_main(&worktree_dir, "eng-1/77").unwrap();
743 fs::write(worktree_dir.join("moved-on.txt"), "moved on\n").unwrap();
744 git_ok(&worktree_dir, &["add", "moved-on.txt"]);
745 git_ok(&worktree_dir, &["commit", "-m", "moved on branch"]);
746
747 let review = review_task(42, "eng-1");
748 let mut active = review_task(77, "eng-1");
749 active.status = "in-progress".to_string();
750 active.branch = Some("eng-1/77".to_string());
751 let review_board_entry = review_task(42, "eng-1");
752
753 assert_eq!(
754 classify_review_task(&repo, &review, &[review_board_entry, active]),
755 ReviewQueueState::Stale(StaleReviewState {
756 reason: "eng-1 already moved to task #77 on branch `eng-1/77`".to_string(),
757 next_step: ReviewNormalizationStep::Merge,
758 })
759 );
760 }
761
762 #[test]
763 fn classify_review_task_keeps_review_current_when_other_task_is_claimed() {
764 let tmp = tempdir().unwrap();
765 let repo = init_git_repo(&tmp, "review-other-claim");
766 let team_config_dir = repo.join(".batty").join("team_config");
767 let worktree_dir = repo.join(".batty").join("worktrees").join("eng-1");
768 let base_branch = engineer_base_branch_name("eng-1");
769 setup_engineer_worktree(&repo, &worktree_dir, &base_branch, &team_config_dir).unwrap();
770 checkout_worktree_branch_from_main(&worktree_dir, "eng-1/42").unwrap();
771 fs::write(
772 worktree_dir.join("review-still-live.txt"),
773 "still on review\n",
774 )
775 .unwrap();
776 git_ok(&worktree_dir, &["add", "review-still-live.txt"]);
777 git_ok(&worktree_dir, &["commit", "-m", "keep review lane current"]);
778
779 let review = Task {
780 branch: Some("eng-1/42".to_string()),
781 ..review_task(42, "eng-1")
782 };
783 let review_board_entry = Task {
784 branch: Some("eng-1/42".to_string()),
785 ..review_task(42, "eng-1")
786 };
787 let active = Task {
788 status: "in-progress".to_string(),
789 branch: Some("eng-1/77".to_string()),
790 ..review_task(77, "eng-1")
791 };
792
793 assert_eq!(
794 classify_review_task(&repo, &review, &[review_board_entry, active]),
795 ReviewQueueState::Current
796 );
797 }
798
799 #[test]
800 fn classify_review_task_detects_branch_mismatch() {
801 let tmp = tempdir().unwrap();
802 let review = Task {
803 branch: Some("eng-1/task-99".to_string()),
804 ..review_task(42, "eng-1")
805 };
806
807 assert_eq!(
808 classify_review_task(tmp.path(), &review, std::slice::from_ref(&review)),
809 ReviewQueueState::Stale(StaleReviewState {
810 reason: "branch `eng-1/task-99` references task(s) #99 but assigned task is #42"
811 .to_string(),
812 next_step: ReviewNormalizationStep::Rework,
813 })
814 );
815 }
816}