f0923e08ba
CI / helm (pull_request) Successful in 34s
CI / push-validation (pull_request) Successful in 27s
CI / build (pull_request) Successful in 1m17s
CI / lint (pull_request) Successful in 1m36s
CI / quality (pull_request) Successful in 1m37s
CI / typecheck (pull_request) Successful in 1m51s
CI / security (pull_request) Successful in 2m5s
CI / integration_tests (pull_request) Successful in 4m4s
CI / e2e_tests (pull_request) Successful in 4m21s
CI / unit_tests (pull_request) Successful in 8m0s
CI / docker (pull_request) Successful in 1m41s
CI / coverage (pull_request) Successful in 12m36s
CI / status-check (pull_request) Successful in 3s
CI / lint (push) Successful in 50s
CI / helm (push) Successful in 32s
CI / push-validation (push) Successful in 30s
CI / build (push) Successful in 45s
CI / quality (push) Successful in 1m16s
CI / typecheck (push) Successful in 1m19s
CI / security (push) Successful in 1m33s
CI / e2e_tests (push) Successful in 4m8s
CI / integration_tests (push) Successful in 4m56s
CI / unit_tests (push) Successful in 5m58s
CI / docker (push) Successful in 2m17s
CI / benchmark-publish (pull_request) Has been skipped
CI / coverage (push) Successful in 12m21s
CI / status-check (push) Successful in 4s
CI / benchmark-regression (push) Has been skipped
CI / benchmark-regression (pull_request) Failing after 1h12m31s
CI / benchmark-publish (push) Successful in 1h17m21s
Per spec §19310-19312, each resource gets its own sandbox and Apply commits each sandbox separately. When a plan is linked to multiple projects with git-checkout resources: - _create_sandbox_for_plan creates a worktree for EACH resource (not just the first), returning a list of _SandboxInfo objects - LLM output is written to the first resource's worktree (primary) - _route_sandbox_files_to_worktrees moves files that belong to other resources into their worktrees by matching against each resource's git ls-files output - Each worktree is committed independently - _apply_sandbox_changes merges each resource's worktree separately, showing per-resource Apply Summary and Sandbox Cleanup panels - Partial apply: if one resource's merge fails, others still proceed Single-resource plans are fully backward compatible — same behavior as before. ISSUES CLOSED: #7270
375 lines
13 KiB
Python
375 lines
13 KiB
Python
"""Steps for multi_project_sandbox.feature."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
from io import StringIO
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from behave import given, then, when
|
|
|
|
_PLAN_ID = "01TESTMULTIPROJ000000000000"
|
|
|
|
|
|
def _git(args: list[str], cwd: str) -> subprocess.CompletedProcess[str]:
|
|
return subprocess.run(
|
|
["git", *args],
|
|
cwd=cwd,
|
|
capture_output=True,
|
|
text=True,
|
|
check=True,
|
|
timeout=10,
|
|
)
|
|
|
|
|
|
def _init_git_repo(path: str) -> None:
|
|
_git(["init", "-q", "-b", "main"], path)
|
|
_git(["config", "user.name", "T"], path)
|
|
_git(["config", "user.email", "t@t"], path)
|
|
_git(["config", "commit.gpgsign", "false"], path)
|
|
|
|
|
|
# ── Given ──────────────────────────────────────────────
|
|
|
|
|
|
@given('a temp git project "{name}" for mps')
|
|
def step_create_project(context: object, name: str) -> None:
|
|
if not hasattr(context, "mps_projects"):
|
|
context.mps_projects = {}
|
|
d = tempfile.mkdtemp(prefix=f"mps-{name}-")
|
|
context.add_cleanup(shutil.rmtree, d, True)
|
|
_init_git_repo(d)
|
|
Path(d, "README.md").write_text(f"# {name}\n")
|
|
_git(["add", "."], d)
|
|
_git(["commit", "-q", "-m", "init"], d)
|
|
context.mps_projects[name] = d
|
|
|
|
|
|
@given('a temp git project named "{name}" containing "{filename}" for mps')
|
|
def step_create_project_with_file(context: object, name: str, filename: str) -> None:
|
|
if not hasattr(context, "mps_projects"):
|
|
context.mps_projects = {}
|
|
d = tempfile.mkdtemp(prefix=f"mps-{name}-")
|
|
context.add_cleanup(shutil.rmtree, d, True)
|
|
_init_git_repo(d)
|
|
fpath = os.path.join(d, filename)
|
|
os.makedirs(os.path.dirname(fpath), exist_ok=True)
|
|
Path(fpath).write_text(f"# {name} {filename}\n")
|
|
_git(["add", "."], d)
|
|
_git(["commit", "-q", "-m", "init"], d)
|
|
context.mps_projects[name] = d
|
|
|
|
|
|
@given('a temp git project "{name}" with a worktree branch for mps')
|
|
def step_create_project_with_worktree(context: object, name: str) -> None:
|
|
if not hasattr(context, "mps_projects"):
|
|
context.mps_projects = {}
|
|
d = tempfile.mkdtemp(prefix=f"mps-{name}-")
|
|
context.add_cleanup(shutil.rmtree, d, True)
|
|
_init_git_repo(d)
|
|
Path(d, f"{name}.py").write_text(f"# original {name}\n")
|
|
_git(["add", "."], d)
|
|
_git(["commit", "-q", "-m", "init"], d)
|
|
|
|
branch = f"cleveragents/plan-{_PLAN_ID}"
|
|
wt_dir = tempfile.mkdtemp(prefix=f"mps-wt-{name}-")
|
|
context.add_cleanup(shutil.rmtree, wt_dir, True)
|
|
_git(["worktree", "add", "-b", branch, wt_dir, "HEAD"], d)
|
|
Path(wt_dir, f"{name}.py").write_text(f"# fixed {name}\n")
|
|
_git(["add", "."], wt_dir)
|
|
_git(["commit", "-q", "-m", f"fix {name}"], wt_dir)
|
|
|
|
context.mps_projects[name] = d
|
|
|
|
|
|
@given('a temp git project "{name}" with a conflicting worktree branch for mps')
|
|
def step_create_project_with_conflict(context: object, name: str) -> None:
|
|
if not hasattr(context, "mps_projects"):
|
|
context.mps_projects = {}
|
|
d = tempfile.mkdtemp(prefix=f"mps-{name}-")
|
|
context.add_cleanup(shutil.rmtree, d, True)
|
|
_init_git_repo(d)
|
|
Path(d, f"{name}.py").write_text(f"# original {name}\n")
|
|
_git(["add", "."], d)
|
|
_git(["commit", "-q", "-m", "init"], d)
|
|
|
|
branch = f"cleveragents/plan-{_PLAN_ID}"
|
|
wt_dir = tempfile.mkdtemp(prefix=f"mps-wt-{name}-")
|
|
context.add_cleanup(shutil.rmtree, wt_dir, True)
|
|
_git(["worktree", "add", "-b", branch, wt_dir, "HEAD"], d)
|
|
Path(wt_dir, f"{name}.py").write_text(f"# branch {name}\n")
|
|
_git(["add", "."], wt_dir)
|
|
_git(["commit", "-q", "-m", f"branch {name}"], wt_dir)
|
|
|
|
# Create conflict on main
|
|
Path(d, f"{name}.py").write_text(f"# main {name}\n")
|
|
_git(["add", "."], d)
|
|
_git(["commit", "-q", "-m", f"main {name}"], d)
|
|
|
|
context.mps_projects[name] = d
|
|
|
|
|
|
def _build_mocks(context: object, project_names: list[str]) -> tuple:
|
|
"""Build mock service + container for the given projects."""
|
|
links = []
|
|
resources = {}
|
|
for name in project_names:
|
|
rid = f"res-mps-{name}"
|
|
mock_lr = MagicMock()
|
|
mock_lr.resource_id = rid
|
|
links.append((name, mock_lr))
|
|
|
|
mock_resource = MagicMock()
|
|
mock_resource.resource_type_name = "git-checkout"
|
|
mock_resource.location = context.mps_projects[name]
|
|
mock_resource.resource_id = rid
|
|
resources[rid] = mock_resource
|
|
|
|
mock_plan = MagicMock()
|
|
mock_plan.project_links = [MagicMock(project_name=name) for name, _ in links]
|
|
|
|
mock_service = MagicMock()
|
|
mock_service.get_plan.return_value = mock_plan
|
|
|
|
mock_projects = {}
|
|
for name, lr in links:
|
|
mock_proj = MagicMock()
|
|
mock_proj.linked_resources = [lr]
|
|
mock_projects[name] = mock_proj
|
|
|
|
mock_project_repo = MagicMock()
|
|
mock_project_repo.get.side_effect = lambda n: mock_projects.get(n)
|
|
|
|
mock_resource_registry = MagicMock()
|
|
mock_resource_registry.show_resource.side_effect = lambda rid: resources[rid]
|
|
|
|
mock_container = MagicMock()
|
|
mock_container.namespaced_project_repo.return_value = mock_project_repo
|
|
mock_container.resource_registry_service.return_value = mock_resource_registry
|
|
|
|
context.mps_service = mock_service
|
|
context.mps_container = mock_container
|
|
return mock_service, mock_container
|
|
|
|
|
|
@given('a mocked plan service linking project "{name}" for mps')
|
|
def step_mock_single(context: object, name: str) -> None:
|
|
_build_mocks(context, [name])
|
|
|
|
|
|
@given('a mocked plan service linking projects "{a}" and "{b}" for mps')
|
|
def step_mock_multi(context: object, a: str, b: str) -> None:
|
|
_build_mocks(context, [a, b])
|
|
|
|
|
|
@given("sandbox_infos for both projects for mps")
|
|
def step_create_sandbox_infos(context: object) -> None:
|
|
from cleveragents.cli.commands.plan import _create_sandbox_for_plan
|
|
|
|
with patch(
|
|
"cleveragents.application.container.get_container",
|
|
return_value=context.mps_container,
|
|
):
|
|
context.mps_sandbox_root, context.mps_sandbox_infos = _create_sandbox_for_plan(
|
|
_PLAN_ID, context.mps_service
|
|
)
|
|
for info in context.mps_sandbox_infos:
|
|
context.add_cleanup(info.sandbox_obj.cleanup)
|
|
|
|
|
|
@given("sandbox_infos with only one entry for mps")
|
|
def step_create_single_sandbox_info(context: object) -> None:
|
|
names = list(context.mps_projects.keys())
|
|
_build_mocks(context, [names[0]])
|
|
|
|
from cleveragents.cli.commands.plan import _create_sandbox_for_plan
|
|
|
|
with patch(
|
|
"cleveragents.application.container.get_container",
|
|
return_value=context.mps_container,
|
|
):
|
|
context.mps_sandbox_root, context.mps_sandbox_infos = _create_sandbox_for_plan(
|
|
_PLAN_ID, context.mps_service
|
|
)
|
|
for info in context.mps_sandbox_infos:
|
|
context.add_cleanup(info.sandbox_obj.cleanup)
|
|
|
|
|
|
@given('a file "{filename}" exists in the primary sandbox for mps')
|
|
def step_write_file_to_primary(context: object, filename: str) -> None:
|
|
primary = context.mps_sandbox_infos[0]
|
|
fpath = os.path.join(primary.sandbox_path, filename)
|
|
os.makedirs(os.path.dirname(fpath), exist_ok=True)
|
|
Path(fpath).write_text("# routed content\n")
|
|
|
|
|
|
# ── When ───────────────────────────────────────────────
|
|
|
|
|
|
@when("I call _create_sandbox_for_plan for mps")
|
|
def step_call_create(context: object) -> None:
|
|
from cleveragents.cli.commands.plan import _create_sandbox_for_plan
|
|
|
|
with patch(
|
|
"cleveragents.application.container.get_container",
|
|
return_value=context.mps_container,
|
|
):
|
|
context.mps_sandbox_root, context.mps_sandbox_infos = _create_sandbox_for_plan(
|
|
_PLAN_ID, context.mps_service
|
|
)
|
|
for info in context.mps_sandbox_infos:
|
|
context.add_cleanup(info.sandbox_obj.cleanup)
|
|
|
|
|
|
@when("I call _route_sandbox_files_to_worktrees for mps")
|
|
def step_call_route(context: object) -> None:
|
|
from cleveragents.cli.commands.plan import _route_sandbox_files_to_worktrees
|
|
|
|
_route_sandbox_files_to_worktrees(context.mps_sandbox_infos)
|
|
|
|
|
|
@when("I call _apply_sandbox_changes for mps")
|
|
def step_call_apply(context: object) -> None:
|
|
from rich.console import Console
|
|
|
|
from cleveragents.cli.commands.plan import _apply_sandbox_changes
|
|
|
|
output = StringIO()
|
|
console = Console(file=output, width=200)
|
|
|
|
with patch(
|
|
"cleveragents.application.container.get_container",
|
|
return_value=context.mps_container,
|
|
):
|
|
context.mps_apply_result = _apply_sandbox_changes(
|
|
_PLAN_ID,
|
|
context.mps_service,
|
|
console,
|
|
)
|
|
context.mps_console_output = output.getvalue()
|
|
|
|
|
|
# ── Then ───────────────────────────────────────────────
|
|
|
|
|
|
@then("sandbox_infos should have {count:d} entry for mps")
|
|
@then("sandbox_infos should have {count:d} entries for mps")
|
|
def step_check_count(context: object, count: int) -> None:
|
|
assert len(context.mps_sandbox_infos) == count, (
|
|
f"Expected {count} sandbox_infos, got {len(context.mps_sandbox_infos)}"
|
|
)
|
|
|
|
|
|
@then("sandbox_root should be a directory for mps")
|
|
def step_check_root_dir(context: object) -> None:
|
|
assert os.path.isdir(context.mps_sandbox_root), (
|
|
f"sandbox_root is not a directory: {context.mps_sandbox_root}"
|
|
)
|
|
|
|
|
|
@then("each sandbox_info should have a different sandbox_path for mps")
|
|
def step_check_unique_paths(context: object) -> None:
|
|
paths = [info.sandbox_path for info in context.mps_sandbox_infos]
|
|
assert len(paths) == len(set(paths)), f"Duplicate sandbox paths: {paths}"
|
|
|
|
|
|
@then('"{filename}" should exist in the beta sandbox for mps')
|
|
def step_file_in_beta(context: object, filename: str) -> None:
|
|
beta_info = context.mps_sandbox_infos[1]
|
|
fpath = os.path.join(beta_info.sandbox_path, filename)
|
|
assert os.path.isfile(fpath), f"{filename} not found in beta sandbox"
|
|
|
|
|
|
@then('"{filename}" should not exist in the alpha sandbox for mps')
|
|
def step_file_not_in_alpha(context: object, filename: str) -> None:
|
|
alpha_info = context.mps_sandbox_infos[0]
|
|
fpath = os.path.join(alpha_info.sandbox_path, filename)
|
|
assert not os.path.isfile(fpath), f"{filename} still in alpha sandbox"
|
|
|
|
|
|
@then('"{filename}" should still exist in the alpha sandbox for mps')
|
|
def step_file_still_in_alpha(context: object, filename: str) -> None:
|
|
alpha_info = context.mps_sandbox_infos[0]
|
|
fpath = os.path.join(alpha_info.sandbox_path, filename)
|
|
assert os.path.isfile(fpath), f"{filename} not found in alpha sandbox"
|
|
|
|
|
|
@then("both projects should have the merged changes for mps")
|
|
def step_both_merged(context: object) -> None:
|
|
for name, path in context.mps_projects.items():
|
|
content = Path(path, f"{name}.py").read_text()
|
|
assert "fixed" in content, f"Project {name} not merged: {content}"
|
|
|
|
|
|
@then("alpha should have the merged changes for mps")
|
|
def step_alpha_merged(context: object) -> None:
|
|
path = context.mps_projects["alpha"]
|
|
content = Path(path, "alpha.py").read_text()
|
|
assert "fixed" in content, f"Alpha not merged: {content}"
|
|
|
|
|
|
@given(
|
|
'the file "{filename}" in the primary sandbox is overwritten with '
|
|
'"{content}" for mps'
|
|
)
|
|
def step_overwrite_primary_file(context: object, filename: str, content: str) -> None:
|
|
primary = context.mps_sandbox_infos[0]
|
|
fpath = os.path.join(primary.sandbox_path, filename)
|
|
Path(fpath).write_text(content + "\n")
|
|
|
|
|
|
@then('"{filename}" in the alpha sandbox should contain "{text}" for mps')
|
|
def step_alpha_file_contains(context: object, filename: str, text: str) -> None:
|
|
alpha_info = context.mps_sandbox_infos[0]
|
|
content = Path(alpha_info.sandbox_path, filename).read_text()
|
|
assert text in content, (
|
|
f"Expected '{text}' in alpha's {filename} but got: {content}"
|
|
)
|
|
|
|
|
|
@then('"{filename}" in the beta sandbox should not contain "{text}" for mps')
|
|
def step_beta_file_not_contains(context: object, filename: str, text: str) -> None:
|
|
beta_info = context.mps_sandbox_infos[1]
|
|
fpath = os.path.join(beta_info.sandbox_path, filename)
|
|
if not os.path.isfile(fpath):
|
|
return # File doesn't exist in beta — that's fine
|
|
content = Path(fpath).read_text()
|
|
assert text not in content, (
|
|
f"'{text}' should not be in beta's {filename} but found: {content}"
|
|
)
|
|
|
|
|
|
@then('"{filename}" should not exist in the beta sandbox for mps')
|
|
def step_file_not_in_beta(context: object, filename: str) -> None:
|
|
beta_info = context.mps_sandbox_infos[1]
|
|
fpath = os.path.join(beta_info.sandbox_path, filename)
|
|
assert not os.path.isfile(fpath), f"{filename} found in beta sandbox"
|
|
|
|
|
|
@then('the console output should contain "Apply Summary" for mps')
|
|
def step_console_has_apply_summary(context: object) -> None:
|
|
output = context.mps_console_output
|
|
assert "Apply Summary" in output, (
|
|
f"Expected 'Apply Summary' in console output but got:\n{output[:500]}"
|
|
)
|
|
|
|
|
|
@then("beta should have the original content for mps")
|
|
def step_beta_unchanged(context: object) -> None:
|
|
path = context.mps_projects["beta"]
|
|
content = Path(path, "beta.py").read_text()
|
|
assert "original" in content or "main" in content, (
|
|
f"Expected beta to be unchanged but got: {content}"
|
|
)
|
|
|
|
|
|
@then("_apply_sandbox_changes should return False for mps")
|
|
def step_apply_returns_false(context: object) -> None:
|
|
assert context.mps_apply_result is False, (
|
|
f"Expected False but got {context.mps_apply_result}"
|
|
)
|