Skip to main content

batty_cli/team/
review.rs

1#![cfg_attr(not(test), allow(dead_code))]
2
3//! Review and merge transitions for Batty-managed workflow metadata.
4
5use 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, &current_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        // Worktree exists but its branch does not match any active claim.
320        // That usually means the engineer is still on the review branch
321        // itself, so we should not mark the review as stale. Return None
322        // to preserve the existing "Current" classification.
323        return None;
324    }
325
326    // No worktree at all (unit tests, or the engineer has not been
327    // provisioned yet). Fall back to the single unambiguous active claim if
328    // there is exactly one — that is the current lane by deduction. With
329    // zero or multiple active claims, return None to avoid guessing.
330    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}