from __future__ import annotations

import contextlib
import importlib.util
import io
import os
from pathlib import Path
import subprocess
import tempfile
import unittest
from unittest import mock


SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "prepare_workspace.py"
SPEC = importlib.util.spec_from_file_location("prepare_workspace", SCRIPT)
assert SPEC and SPEC.loader
MODULE = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(MODULE)


def command(cwd: Path, *args: str) -> str:
    result = subprocess.run(
        list(args),
        cwd=cwd,
        text=True,
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        check=True,
    )
    return result.stdout.strip()


class PrepareWorkspaceTest(unittest.TestCase):
    def setUp(self) -> None:
        self.runtime_environment = mock.patch.dict(
            os.environ,
            {
                "AGENT_TASK_EXECUTION_CATALOG": "/runtime/catalog.json",
                "AGENT_TASK_PLANNER_TARGET": "planner-primary",
            },
        )
        self.runtime_environment.start()

    def tearDown(self) -> None:
        self.runtime_environment.stop()

    def test_relative_workspace_is_resolved_from_repository_root(self) -> None:
        repo = Path("/tmp/example/sample-repo")
        self.assertEqual(
            MODULE.resolve_workspace(repo, "../sample-feature-worktree"),
            Path("/tmp/example/sample-feature-worktree"),
        )

    def test_dispatcher_uses_common_runtime_only(self) -> None:
        with tempfile.TemporaryDirectory() as raw:
            workspace = Path(raw)
            common = (
                workspace
                / "agent-ops/skills/common/orchestrate-agent-task-loop/scripts/dispatch.py"
            )
            project = (
                workspace
                / "agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatch.py"
            )
            private_root = (
                workspace / "agent-ops/skills/private/orchestrate-agent-task-loop"
            )
            private = private_root / "scripts/dispatch.py"
            for path in (common, project, private):
                path.parent.mkdir(parents=True, exist_ok=True)
                path.touch()

            (private_root / "SKILL.md").touch()
            self.assertEqual(MODULE.dispatcher_script(workspace), common)

    def test_dispatcher_command_always_injects_catalog(self) -> None:
        workspace = Path("/repo")
        common = (
            workspace
            / "agent-ops/skills/common/orchestrate-agent-task-loop/scripts/dispatch.py"
        )

        common_command = MODULE.dispatcher_command(
            workspace=workspace,
            dispatcher=common,
            task_group="m-sample",
            execution_catalog="/runtime/catalog.json",
        )
        self.assertIn("--execution-catalog", common_command)

    def test_epic_document_range_is_one_based_and_inclusive(self) -> None:
        epics = MODULE.parse_epics(
            """## 기능

### Epic: [first] First

- [ ] [first-task] first

### Epic: [second] Second

- [ ] [second-task] second

### Epic: [third] Third

- [ ] [third-task] third
"""
        )
        self.assertEqual(
            [epic.epic_id for epic in MODULE.select_epics(epics, "1..2")],
            ["first", "second"],
        )
        self.assertEqual(
            [epic.epic_id for epic in MODULE.select_epics(epics, "second,first")],
            ["first", "second"],
        )

    def test_remaining_epics_selects_only_incomplete_epics_in_document_order(self) -> None:
        epics = MODULE.parse_epics(
            """## 기능

### Epic: [first] First

- [x] [first-task] first

### Epic: [second] Second

- [X] [second-task] second

### Epic: [third] Third

- [ ] [third-task] third

### Epic: [fourth] Fourth

- [ ] [fourth-task] fourth
"""
        )
        self.assertEqual(
            [epic.epic_id for epic in MODULE.select_epics(epics, "remaining")],
            ["third", "fourth"],
        )
        self.assertEqual(
            MODULE.select_epics(epics[:2], "remaining"),
            [],
        )

    def test_workspace_uses_runtime_injected_catalog_and_targets(self) -> None:
        args = MODULE.parser().parse_args(
            ["--repo", "/repo", "--milestone", "milestone.md", "--workspace", "/workspace"]
        )
        MODULE.apply_defaults(args)
        self.assertEqual(args.execution_catalog, "/runtime/catalog.json")
        self.assertEqual(args.planner_target, "planner-primary")
        self.assertEqual(args.review_target, "planner-primary")

        other = MODULE.parser().parse_args(
            [
                "--repo",
                "/repo",
                "--milestone",
                "milestone.md",
                "--workspace",
                "/workspace",
                "--planner-target",
                "planner-secondary",
                "--review-target",
                "review-primary",
            ]
        )
        MODULE.apply_defaults(other)
        self.assertEqual(other.planner_target, "planner-secondary")
        self.assertEqual(other.review_target, "review-primary")

    def test_agent_probe_bypass_is_test_only(self) -> None:
        output = io.StringIO()
        with contextlib.redirect_stdout(output), mock.patch.dict(
            os.environ, {"AGENT_OPS_TESTING": ""}
        ):
            result = MODULE.main(
                [
                    "--repo",
                    "/missing-repository",
                    "--milestone",
                    "missing.md",
                    "--workspace",
                    "/missing-workspace",
                    "--planner-target",
                    "planner-primary",
                    "--skip-agent-probe",
                ]
            )
        self.assertEqual(result, 2)
        self.assertIn("test-only", output.getvalue())

    def test_prepares_git_flow_branch_worktree_and_current(self) -> None:
        with tempfile.TemporaryDirectory() as raw:
            root = Path(raw)
            remote = root / "remote.git"
            repo = root / "source"
            worktree = root / "feature-worktree"
            command(root, "git", "init", "--bare", str(remote))
            command(root, "git", "init", "-b", "dev", str(repo))
            command(repo, "git", "config", "user.name", "Test Agent")
            command(repo, "git", "config", "user.email", "agent@example.test")
            command(repo, "git", "config", "gitflow.branch.develop", "dev")
            command(repo, "git", "config", "gitflow.prefix.feature", "feature/")
            command(repo, "git", "remote", "add", "origin", str(remote))
            phase = repo / "agent-roadmap" / "phase" / "phase-one"
            milestone = phase / "milestones" / "sample-milestone.md"
            milestone.parent.mkdir(parents=True)
            (repo / ".gitignore").write_text("agent-roadmap/current.md\n", encoding="utf-8")
            (phase / "PHASE.md").write_text(
                "# Phase: Sample Phase\n\n## 상태\n\n[계획]\n",
                encoding="utf-8",
            )
            milestone.write_text(
                "# Milestone: [sample-01] Sample Milestone\n\n"
                "## 상태\n\n[계획]\n\n"
                "## 구현 잠금\n\n- 상태: 해제\n- 결정 필요: 없음\n",
                encoding="utf-8",
            )
            command(repo, "git", "add", ".gitignore", "agent-roadmap")
            command(repo, "git", "commit", "-m", "init")
            command(repo, "git", "push", "-u", "origin", "dev")

            output = io.StringIO()
            with contextlib.redirect_stdout(output), mock.patch.dict(
                os.environ, {"AGENT_OPS_TESTING": "1"}
            ):
                result = MODULE.main(
                    [
                        "--repo",
                        str(repo),
                        "--milestone",
                        str(milestone.relative_to(repo)),
                        "--workspace",
                        str(worktree),
                        "--planner-target",
                        "planner-primary",
                        "--skip-agent-probe",
                    ]
                )
            self.assertEqual(result, 0, output.getvalue())
            self.assertIn('"event": "WORKSPACE_READY"', output.getvalue())
            self.assertEqual(command(worktree, "git", "branch", "--show-current"), "feature/sample-milestone")
            self.assertEqual(
                command(repo, "git", "rev-parse", "feature/sample-milestone"),
                command(repo, "git", "rev-parse", "origin/feature/sample-milestone"),
            )
            current = (worktree / "agent-roadmap" / "current.md").read_text(encoding="utf-8")
            self.assertIn("sample-milestone.md", current)
            self.assertFalse(command(worktree, "git", "status", "--porcelain=v1"))

    def test_existing_feature_workspace_starts_selected_epic_batch(self) -> None:
        with tempfile.TemporaryDirectory() as raw:
            root = Path(raw)
            remote = root / "remote.git"
            workspace = root / "workspace"
            command(root, "git", "init", "--bare", str(remote))
            command(root, "git", "init", "-b", "dev", str(workspace))
            command(workspace, "git", "config", "user.name", "Test Agent")
            command(workspace, "git", "config", "user.email", "agent@example.test")
            command(workspace, "git", "config", "gitflow.branch.develop", "dev")
            command(workspace, "git", "config", "gitflow.prefix.feature", "feature/")
            command(workspace, "git", "remote", "add", "origin", str(remote))
            milestone = (
                workspace
                / "agent-roadmap"
                / "phase"
                / "phase-one"
                / "milestones"
                / "sample-milestone.md"
            )
            milestone.parent.mkdir(parents=True)
            milestone.write_text(
                "# Milestone: Sample\n\n"
                "## 상태\n\n[계획]\n\n"
                "## 구현 잠금\n\n- 상태: 해제\n- 결정 필요: 없음\n\n"
                "## 기능\n\n"
                "### Epic: [first] First\n\n- [ ] [first-task] first\n\n"
                "### Epic: [second] Second\n\n- [ ] [second-task] second\n",
                encoding="utf-8",
            )
            (workspace / ".gitignore").write_text(
                "agent-roadmap/current.md\n", encoding="utf-8"
            )
            command(workspace, "git", "add", ".gitignore", "agent-roadmap")
            command(workspace, "git", "commit", "-m", "init")
            command(workspace, "git", "push", "-u", "origin", "dev")
            command(workspace, "git", "switch", "-c", "feature/sample-milestone")
            command(workspace, "git", "push", "-u", "origin", "feature/sample-milestone")
            (workspace / "agent-roadmap" / "current.md").write_text(
                "# Current\n\n"
                "- [Sample](phase/phase-one/milestones/sample-milestone.md)\n",
                encoding="utf-8",
            )
            output = io.StringIO()
            with (
                contextlib.redirect_stdout(output),
                mock.patch.dict(os.environ, {"AGENT_OPS_TESTING": "1"}),
                mock.patch.object(MODULE, "coordinate_batch", return_value=0) as coordinate,
            ):
                result = MODULE.main(
                    [
                        "--existing-workspace",
                        "--workspace",
                        str(workspace),
                        "--milestone",
                        str(milestone.relative_to(workspace)),
                        "--epics",
                        "2..2",
                        "--skip-agent-probe",
                    ]
                )
            self.assertEqual(result, 0, output.getvalue())
            self.assertIn('"existing_workspace": true', output.getvalue())
            self.assertEqual(coordinate.call_args.kwargs["workspace"], workspace)
            self.assertEqual(coordinate.call_args.kwargs["milestone"], milestone)
            self.assertEqual(coordinate.call_args.kwargs["args"].epics, "2..2")
            self.assertFalse(command(workspace, "git", "status", "--porcelain=v1"))

    def test_existing_workspace_mode_creates_feature_branch_from_develop(self) -> None:
        with tempfile.TemporaryDirectory() as raw:
            root = Path(raw)
            remote = root / "remote.git"
            workspace = root / "workspace"
            command(root, "git", "init", "--bare", str(remote))
            command(root, "git", "init", "-b", "dev", str(workspace))
            command(workspace, "git", "config", "user.name", "Test Agent")
            command(workspace, "git", "config", "user.email", "agent@example.test")
            command(workspace, "git", "config", "gitflow.branch.develop", "dev")
            command(workspace, "git", "config", "gitflow.prefix.feature", "feature/")
            command(workspace, "git", "remote", "add", "origin", str(remote))
            milestone = (
                workspace
                / "agent-roadmap"
                / "phase"
                / "phase-one"
                / "milestones"
                / "sample.md"
            )
            milestone.parent.mkdir(parents=True)
            milestone.write_text(
                "# Milestone: Sample\n\n"
                "## 상태\n\n[계획]\n\n"
                "## 구현 잠금\n\n- 상태: 해제\n- 결정 필요: 없음\n\n"
                "## 기능\n\n### Epic: [first] First\n\n- [ ] [first-task] first\n",
                encoding="utf-8",
            )
            (workspace / ".gitignore").write_text(
                "agent-roadmap/current.md\n", encoding="utf-8"
            )
            command(workspace, "git", "add", ".gitignore", "agent-roadmap")
            command(workspace, "git", "commit", "-m", "init")
            command(workspace, "git", "push", "-u", "origin", "dev")
            (workspace / "agent-roadmap" / "current.md").write_text(
                "phase/phase-one/milestones/sample.md\n", encoding="utf-8"
            )
            output = io.StringIO()
            with (
                contextlib.redirect_stdout(output),
                mock.patch.dict(os.environ, {"AGENT_OPS_TESTING": "1"}),
                mock.patch.object(MODULE, "coordinate_batch", return_value=0) as coordinate,
            ):
                result = MODULE.main(
                    [
                        "--existing-workspace",
                        "--workspace",
                        str(workspace),
                        "--milestone",
                        str(milestone.relative_to(workspace)),
                        "--epics",
                        "1..1",
                        "--skip-agent-probe",
                    ]
                )
            self.assertEqual(result, 0, output.getvalue())
            self.assertIn('"event": "FEATURE_BRANCH_PUSHED"', output.getvalue())
            self.assertEqual(
                command(workspace, "git", "branch", "--show-current"),
                "feature/sample",
            )
            self.assertEqual(
                command(workspace, "git", "rev-parse", "feature/sample"),
                command(workspace, "git", "rev-parse", "origin/feature/sample"),
            )
            self.assertEqual(coordinate.call_args.kwargs["workspace"], workspace)
            self.assertFalse(command(workspace, "git", "status", "--porcelain=v1"))

    def test_two_epic_batch_opens_dispatcher_barrier_once_after_both(self) -> None:
        with tempfile.TemporaryDirectory() as raw:
            root = Path(raw)
            workspace = root / "workspace"
            common = root / "git-common"
            milestone = workspace / "agent-roadmap/phase/phase-one/milestones/sample.md"
            milestone.parent.mkdir(parents=True)

            def write_milestone(done: bool = False) -> None:
                mark = "x" if done else " "
                milestone.write_text(
                    "# Milestone: Sample\n\n## 기능\n\n"
                    "### Epic: [first] First\n\n"
                    f"- [{mark}] [first-task] first\n\n"
                    "### Epic: [second] Second\n\n"
                    f"- [{mark}] [second-task] second\n",
                    encoding="utf-8",
                )

            write_milestone()
            args = MODULE.apply_defaults(
                MODULE.parser().parse_args(
                    [
                        "--repo",
                        str(workspace),
                        "--milestone",
                        str(milestone),
                        "--workspace",
                        str(workspace),
                        "--epics",
                        "1..2",
                    ]
                )
            )
            order: list[str] = []

            def fake_run(command: list[str], **_: object) -> subprocess.CompletedProcess[str]:
                if "--validate-only" in command:
                    order.append("validate")
                    return subprocess.CompletedProcess(command, 0)
                if "--epic" in command:
                    epic_id = command[command.index("--epic") + 1]
                    order.append(f"epic:{epic_id}")
                    state_path = common / "epic-work-preparation" / "sample" / epic_id / "state.json"
                    MODULE.atomic_json(
                        state_path,
                        {
                            "identity": f"sample:{epic_id}",
                            "status": "completed",
                            "event": "EPIC_WORK_ITEMS_READY",
                        },
                    )
                    return subprocess.CompletedProcess(command, 0)
                if "--dry-run" in command:
                    order.append("dry-run")
                    return subprocess.CompletedProcess(command, 0)
                raise AssertionError(command)

            class FakeDispatcher:
                pid = 999_999_991

                def __init__(self, command: list[str], **_: object) -> None:
                    order.append("live")

                def wait(self) -> int:
                    write_milestone(done=True)
                    return 0

            def fake_cross_review(**_: object) -> tuple[int, dict[str, object]]:
                order.append("cross-review")
                return 0, {}

            with (
                mock.patch.object(MODULE, "epic_cycle_script", return_value=Path("/cycle.py")),
                mock.patch.object(MODULE, "dispatcher_script", return_value=Path("/dispatch.py")),
                mock.patch.object(MODULE, "run", side_effect=fake_run),
                mock.patch.object(MODULE, "cross_epic_review", side_effect=fake_cross_review),
                mock.patch.object(MODULE, "git", return_value="head"),
                mock.patch.object(MODULE.subprocess, "Popen", FakeDispatcher),
            ):
                first = MODULE.coordinate_batch(
                    args=args,
                    workspace=workspace,
                    milestone=milestone,
                    milestone_slug="sample",
                    phase_slug="phase-one",
                    common=common,
                )
                second = MODULE.coordinate_batch(
                    args=args,
                    workspace=workspace,
                    milestone=milestone,
                    milestone_slug="sample",
                    phase_slug="phase-one",
                    common=common,
                )

            self.assertEqual((first, second), (0, 0))
            self.assertLess(order.index("epic:first"), order.index("epic:second"))
            self.assertLess(order.index("epic:second"), order.index("cross-review"))
            self.assertLess(order.index("cross-review"), order.index("dry-run"))
            self.assertLess(order.index("dry-run"), order.index("live"))
            self.assertEqual(order.count("live"), 1)

    def test_all_direct_batch_skips_dispatcher_and_user_review_stops_batch(self) -> None:
        with tempfile.TemporaryDirectory() as raw:
            root = Path(raw)
            workspace = root / "workspace"
            common = root / "git-common"
            milestone = workspace / "agent-roadmap/phase/phase-one/milestones/sample.md"
            milestone.parent.mkdir(parents=True)
            milestone.write_text(
                "# Milestone: Sample\n\n## 기능\n\n"
                "### Epic: [first] First\n\n- [ ] [first-task] first\n\n"
                "### Epic: [second] Second\n\n- [ ] [second-task] second\n",
                encoding="utf-8",
            )
            args = MODULE.apply_defaults(
                MODULE.parser().parse_args(
                    [
                        "--repo",
                        str(workspace),
                        "--milestone",
                        str(milestone),
                        "--workspace",
                        str(workspace),
                        "--epics",
                        "1..2",
                    ]
                )
            )

            def all_direct(command: list[str], **_: object) -> subprocess.CompletedProcess[str]:
                if "--validate-only" in command:
                    return subprocess.CompletedProcess(command, 0)
                epic_id = command[command.index("--epic") + 1]
                state_path = common / "epic-work-preparation" / "sample" / epic_id / "state.json"
                MODULE.atomic_json(
                    state_path,
                    {"status": "completed", "event": "EPIC_COMPLETED"},
                )
                return subprocess.CompletedProcess(command, 0)

            with (
                mock.patch.object(MODULE, "epic_cycle_script", return_value=Path("/cycle.py")),
                mock.patch.object(MODULE, "run", side_effect=all_direct),
                mock.patch.object(MODULE, "cross_epic_review", return_value=(0, {})),
                mock.patch.object(MODULE, "git", return_value="head"),
                mock.patch.object(MODULE.subprocess, "Popen") as popen,
            ):
                result = MODULE.coordinate_batch(
                    args=args,
                    workspace=workspace,
                    milestone=milestone,
                    milestone_slug="sample",
                    phase_slug="phase-one",
                    common=common,
                )
            self.assertEqual(result, 0)
            popen.assert_not_called()

            other_common = root / "other-common"

            def user_review(command: list[str], **_: object) -> subprocess.CompletedProcess[str]:
                epic_id = command[command.index("--epic") + 1]
                state_path = other_common / "epic-work-preparation" / "sample" / epic_id / "state.json"
                MODULE.atomic_json(
                    state_path,
                    {"status": "user-review", "event": "USER_REVIEW"},
                )
                return subprocess.CompletedProcess(command, 2)

            with (
                mock.patch.object(MODULE, "epic_cycle_script", return_value=Path("/cycle.py")),
                mock.patch.object(MODULE, "run", side_effect=user_review),
                mock.patch.object(MODULE.subprocess, "Popen") as blocked_popen,
            ):
                blocked = MODULE.coordinate_batch(
                    args=args,
                    workspace=workspace,
                    milestone=milestone,
                    milestone_slug="sample",
                    phase_slug="phase-one",
                    common=other_common,
                )
            self.assertEqual(blocked, 2)
            blocked_popen.assert_not_called()

    def test_live_dispatcher_state_prevents_duplicate_launch(self) -> None:
        with tempfile.TemporaryDirectory() as raw:
            root = Path(raw)
            workspace = root / "workspace"
            common = root / "git-common"
            milestone = workspace / "agent-roadmap/phase/phase-one/milestones/sample.md"
            milestone.parent.mkdir(parents=True)
            milestone.write_text(
                "# Milestone: Sample\n\n## 기능\n\n"
                "### Epic: [first] First\n\n- [ ] [first-task] first\n",
                encoding="utf-8",
            )
            args = MODULE.apply_defaults(
                MODULE.parser().parse_args(
                    [
                        "--repo",
                        str(workspace),
                        "--milestone",
                        str(milestone),
                        "--workspace",
                        str(workspace),
                        "--epics",
                        "1..1",
                    ]
                )
            )
            state_path = common / "milestone-work-preparation" / "sample" / "batch-state.json"
            MODULE.atomic_json(
                state_path,
                {
                    "milestone": str(milestone),
                    "workspace": str(workspace),
                    "selected_epics": ["first"],
                    "batch_task_ids": ["first-task"],
                    "execution_catalog": "/runtime/catalog.json",
                    "planner_target": "planner-primary",
                    "review_target": "planner-primary",
                    "status": "dispatching",
                    "epic_events": {"first": "EPIC_WORK_ITEMS_READY"},
                    "dispatcher_pid": os.getpid(),
                    "dispatcher_process_start_token": MODULE.process_start_token(os.getpid()),
                },
            )
            with mock.patch.object(MODULE.subprocess, "Popen") as popen:
                result = MODULE.coordinate_batch(
                    args=args,
                    workspace=workspace,
                    milestone=milestone,
                    milestone_slug="sample",
                    phase_slug="phase-one",
                    common=common,
                )
            self.assertEqual(result, 3)
            popen.assert_not_called()

    def test_completed_batch_can_start_a_different_epic_selection(self) -> None:
        with tempfile.TemporaryDirectory() as raw:
            root = Path(raw)
            workspace = root / "workspace"
            common = root / "git-common"
            milestone = workspace / "agent-roadmap/phase/phase-one/milestones/sample.md"
            milestone.parent.mkdir(parents=True)
            milestone.write_text(
                "# Milestone: Sample\n\n## 기능\n\n"
                "### Epic: [first] First\n\n- [x] [first-task] first\n\n"
                "### Epic: [second] Second\n\n- [x] [second-task] second\n",
                encoding="utf-8",
            )
            args = MODULE.apply_defaults(
                MODULE.parser().parse_args(
                    [
                        "--milestone",
                        str(milestone),
                        "--workspace",
                        str(workspace),
                        "--epics",
                        "2..2",
                    ]
                )
            )
            batch_state = common / "milestone-work-preparation" / "sample" / "batch-state.json"
            MODULE.atomic_json(
                batch_state,
                {
                    "milestone": str(milestone),
                    "workspace": str(workspace),
                    "selected_epics": ["first"],
                    "batch_task_ids": ["first-task"],
                    "status": "completed",
                    "epic_events": {"first": "EPIC_COMPLETED"},
                },
            )

            def fake_run(command: list[str], **_: object) -> subprocess.CompletedProcess[str]:
                if "--validate-only" not in command:
                    epic_state = (
                        common
                        / "epic-work-preparation"
                        / "sample"
                        / "second"
                        / "state.json"
                    )
                    MODULE.atomic_json(
                        epic_state,
                        {"status": "completed", "event": "EPIC_COMPLETED"},
                    )
                return subprocess.CompletedProcess(command, 0)

            with (
                mock.patch.object(MODULE, "epic_cycle_script", return_value=Path("/cycle.py")),
                mock.patch.object(MODULE, "run", side_effect=fake_run),
                mock.patch.object(MODULE, "git", return_value="head"),
                mock.patch.object(MODULE.subprocess, "Popen") as popen,
            ):
                result = MODULE.coordinate_batch(
                    args=args,
                    workspace=workspace,
                    milestone=milestone,
                    milestone_slug="sample",
                    phase_slug="phase-one",
                    common=common,
                )
            self.assertEqual(result, 0)
            self.assertEqual(MODULE.read_json(batch_state)["selected_epics"], ["second"])
            popen.assert_not_called()

    def test_recovered_cross_epic_review_still_validates_and_publishes(self) -> None:
        with tempfile.TemporaryDirectory() as raw:
            root = Path(raw)
            workspace = root / "workspace"
            common = root / "git-common"
            milestone = workspace / "agent-roadmap/phase/phase-one/milestones/sample.md"
            milestone.parent.mkdir(parents=True)
            milestone.write_text("# Milestone: Sample\n", encoding="utf-8")
            epics = [
                MODULE.Epic("first", "First", ("first-task",), ("first-task",)),
                MODULE.Epic("second", "Second", ("second-task",), ("second-task",)),
            ]
            args = MODULE.apply_defaults(
                MODULE.parser().parse_args(
                    [
                        "--repo",
                        str(workspace),
                        "--milestone",
                        str(milestone),
                        "--workspace",
                        str(workspace),
                    ]
                )
            )
            result_path = (
                common
                / "epic-work-preparation"
                / "sample"
                / "_batch"
                / "cross-epic-review.json"
            )
            MODULE.atomic_json(
                result_path,
                {
                    "workspace": str(workspace),
                    "label": "sample-cross-epic-review",
                    "status": "succeeded",
                    "locator": str(result_path),
                },
            )
            with (
                mock.patch.object(MODULE, "validate_batch") as validate,
                mock.patch.object(MODULE, "publish_batch_review", return_value="head") as publish,
                mock.patch.object(MODULE, "run") as run,
            ):
                result, _ = MODULE.cross_epic_review(
                    args=args,
                    workspace=workspace,
                    milestone=milestone,
                    milestone_slug="sample",
                    phase_slug="phase-one",
                    epics=epics,
                    batch_ids=["first-task", "second-task"],
                    common=common,
                )
            self.assertEqual(result, 0)
            run.assert_not_called()
            validate.assert_called_once()
            publish.assert_called_once_with(workspace)


if __name__ == "__main__":
    unittest.main()
