feat: dev tools
This commit is contained in:
@@ -71,6 +71,7 @@ def objects(rounds: int, reports: list[ReviewReport]):
|
||||
codex=codex,
|
||||
storage=FakeStorage(),
|
||||
prompts=FakePrompts(),
|
||||
development=SimpleNamespace(description="python"),
|
||||
)
|
||||
workflow = Workflow(
|
||||
id="flow",
|
||||
|
||||
@@ -69,6 +69,26 @@ def test_configures_research_agent_and_optional_context7_key(tmp_path) -> None:
|
||||
assert client._environment()["CONTEXT7_API_KEY"] == "ctx7-secret"
|
||||
|
||||
|
||||
def test_exposes_development_tools_without_service_secrets(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setenv("PATH", "/usr/bin")
|
||||
monkeypatch.setenv("AGENTCI_PRIVATE_VALUE", "secret")
|
||||
tools_bin = tmp_path / "tools" / "bin"
|
||||
client = CodexClient(
|
||||
codex_home=tmp_path / "codex",
|
||||
schemas_dir=tmp_path / "schemas",
|
||||
timeout_seconds=60,
|
||||
research_model="gpt-5.6-luna",
|
||||
research_reasoning="high",
|
||||
context7_api_key=None,
|
||||
tools_bin=tools_bin,
|
||||
)
|
||||
|
||||
environment = client._environment()
|
||||
|
||||
assert environment["PATH"] == f"{tools_bin}:/usr/bin"
|
||||
assert "AGENTCI_PRIVATE_VALUE" not in environment
|
||||
|
||||
|
||||
async def test_invokes_codex_from_workflow_workspace(tmp_path, monkeypatch) -> None:
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from agentci.config import Settings
|
||||
|
||||
|
||||
def test_parses_comma_delimited_install_scripts() -> None:
|
||||
settings = Settings(
|
||||
_env_file=None, # type: ignore[call-arg]
|
||||
install_scripts=" python, dotnet, company-tools, ",
|
||||
)
|
||||
|
||||
assert settings.install_scripts == ["python", "dotnet", "company-tools"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["../script", "tools/setup", "python,python"])
|
||||
def test_rejects_unsafe_or_duplicate_install_scripts(value: str) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
Settings(_env_file=None, install_scripts=value) # type: ignore[call-arg]
|
||||
|
||||
|
||||
def test_empty_install_scripts_disable_setup() -> None:
|
||||
settings = Settings(
|
||||
_env_file=None, # type: ignore[call-arg]
|
||||
install_scripts="",
|
||||
)
|
||||
assert settings.install_scripts == []
|
||||
|
||||
|
||||
def test_reads_comma_delimited_install_scripts_from_environment(monkeypatch) -> None:
|
||||
monkeypatch.setenv("AGENTCI_INSTALL_SCRIPTS", "python,dotnet")
|
||||
|
||||
settings = Settings(_env_file=None) # type: ignore[call-arg]
|
||||
|
||||
assert settings.install_scripts == ["python", "dotnet"]
|
||||
@@ -0,0 +1,81 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from agentci.adapters.development import (
|
||||
DevelopmentEnvironment,
|
||||
DevelopmentEnvironmentError,
|
||||
)
|
||||
|
||||
|
||||
def environment(
|
||||
tmp_path: Path, scripts: list[str], *, timeout_seconds: int = 5
|
||||
) -> DevelopmentEnvironment:
|
||||
scripts_dir = tmp_path / "scripts"
|
||||
scripts_dir.mkdir()
|
||||
return DevelopmentEnvironment(
|
||||
scripts=scripts,
|
||||
scripts_dir=scripts_dir,
|
||||
tools_dir=tmp_path / "tools",
|
||||
timeout_seconds=timeout_seconds,
|
||||
python_version="3.13",
|
||||
dotnet_channel="10.0",
|
||||
)
|
||||
|
||||
|
||||
def script(path: Path, body: str) -> None:
|
||||
path.write_text(f"#!/bin/sh\nset -eu\n{body}\n")
|
||||
path.chmod(0o755)
|
||||
|
||||
|
||||
async def test_runs_custom_scripts_in_order_with_sanitized_environment(
|
||||
tmp_path, monkeypatch
|
||||
) -> None:
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
development = environment(tmp_path, ["first", "second"])
|
||||
script(
|
||||
development.scripts_dir / "first",
|
||||
"printf 'first:%s:%s\\n' \"$DEV_TOOLS_DIR\" \"${AGENTCI_SECRET-unset}\" >> order",
|
||||
)
|
||||
script(development.scripts_dir / "second", "printf 'second\\n' >> order")
|
||||
monkeypatch.setenv("AGENTCI_SECRET", "must-not-leak")
|
||||
|
||||
await development.prepare(workspace)
|
||||
|
||||
assert (workspace / "order").read_text().splitlines() == [
|
||||
f"first:{tmp_path / 'tools'}:unset",
|
||||
"second",
|
||||
]
|
||||
assert (tmp_path / "tools" / "bin").is_dir()
|
||||
|
||||
|
||||
async def test_supplied_script_names_use_the_same_directory(tmp_path) -> None:
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
development = environment(tmp_path, ["python"])
|
||||
script(development.scripts_dir / "python", "printf 'python\\n' > selected")
|
||||
|
||||
await development.prepare(workspace)
|
||||
|
||||
assert (workspace / "selected").read_text() == "python\n"
|
||||
|
||||
|
||||
async def test_reports_script_failure_output(tmp_path) -> None:
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
development = environment(tmp_path, ["broken"])
|
||||
script(development.scripts_dir / "broken", "printf 'failed detail' >&2; exit 7")
|
||||
|
||||
with pytest.raises(DevelopmentEnvironmentError, match="exited with 7: failed detail"):
|
||||
await development.prepare(workspace)
|
||||
|
||||
|
||||
async def test_times_out_install_script(tmp_path) -> None:
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
development = environment(tmp_path, ["slow"], timeout_seconds=1)
|
||||
script(development.scripts_dir / "slow", "exec sleep 10")
|
||||
|
||||
with pytest.raises(DevelopmentEnvironmentError, match="exceeded 1 seconds"):
|
||||
await development.prepare(workspace)
|
||||
@@ -0,0 +1,143 @@
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from agentci.adapters.gitea_models import IssueInfo, PullRequestInfo, RepositoryInfo
|
||||
from agentci.domain.models import Job, JobKind, Workflow, WorkflowKind, WorkflowStatus
|
||||
from agentci.workflows.implement import ImplementWorkflow
|
||||
from agentci.workflows.pull_request import PullRequestWorkflow
|
||||
|
||||
|
||||
class SetupReached(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class FakeDevelopment:
|
||||
description = "python"
|
||||
|
||||
def __init__(self, events: list[str]) -> None:
|
||||
self.events = events
|
||||
|
||||
async def prepare(self, _workspace: Path) -> None:
|
||||
self.events.append("prepare")
|
||||
raise SetupReached
|
||||
|
||||
|
||||
class FakeGit:
|
||||
def __init__(self, events: list[str]) -> None:
|
||||
self.events = events
|
||||
|
||||
async def clone(self, *_args) -> str:
|
||||
self.events.append("clone")
|
||||
return "base-sha"
|
||||
|
||||
async def create_branch(self, *_args) -> None:
|
||||
self.events.append("create branch")
|
||||
|
||||
async def sync_branch(self, *_args) -> None:
|
||||
self.events.append("sync branch")
|
||||
|
||||
|
||||
class FakeStorage:
|
||||
def __init__(self, workflow: Workflow | None = None) -> None:
|
||||
self.workflow = workflow
|
||||
|
||||
async def implementation_workflows(self, *_args):
|
||||
return []
|
||||
|
||||
async def create_workflow(self, _workflow) -> None:
|
||||
return None
|
||||
|
||||
async def update_job(self, *_args, **_kwargs) -> None:
|
||||
return None
|
||||
|
||||
async def workflow_for_pr(self, *_args):
|
||||
return self.workflow
|
||||
|
||||
|
||||
class FakeContext:
|
||||
def __init__(self, pull: PullRequestInfo) -> None:
|
||||
self.pull = pull
|
||||
|
||||
async def pull_request_context(self, *_args):
|
||||
return self.pull, "context"
|
||||
|
||||
|
||||
class FakeGitea:
|
||||
async def repository(self, *_args) -> RepositoryInfo:
|
||||
return RepositoryInfo("org", "repo", "org/repo", "main")
|
||||
|
||||
async def issue(self, *_args) -> IssueInfo:
|
||||
return IssueInfo(1, "Issue", "Body", "open")
|
||||
|
||||
|
||||
def job(kind: JobKind, *, pr_number: int | None = None) -> Job:
|
||||
return Job(
|
||||
id="job",
|
||||
kind=kind,
|
||||
target_key="org/repo:target",
|
||||
repo_owner="org",
|
||||
repo_name="repo",
|
||||
issue_number=1,
|
||||
pr_number=pr_number,
|
||||
requester="alice",
|
||||
message="",
|
||||
comment_id=1,
|
||||
)
|
||||
|
||||
|
||||
def pull() -> PullRequestInfo:
|
||||
return PullRequestInfo(2, "PR", "Body", "open", False, "main", "agent", "sha", "org", "repo")
|
||||
|
||||
|
||||
async def test_initial_implementation_prepares_after_clone_and_branch(tmp_path) -> None:
|
||||
events: list[str] = []
|
||||
settings = SimpleNamespace(branch_prefix="agent", workspaces_dir=tmp_path)
|
||||
deps = SimpleNamespace(
|
||||
settings=settings,
|
||||
storage=FakeStorage(),
|
||||
gitea=FakeGitea(),
|
||||
git=FakeGit(events),
|
||||
development=FakeDevelopment(events),
|
||||
)
|
||||
|
||||
with pytest.raises(SetupReached):
|
||||
await ImplementWorkflow(deps).run(job(JobKind.IMPLEMENT)) # type: ignore[arg-type]
|
||||
|
||||
assert events == ["clone", "create branch", "prepare"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("operation", ["iterate", "fix"])
|
||||
async def test_pull_request_implementation_prepares_after_checkout(
|
||||
tmp_path, operation: str
|
||||
) -> None:
|
||||
events: list[str] = []
|
||||
existing = Workflow(
|
||||
id="flow",
|
||||
kind=WorkflowKind.IMPLEMENT,
|
||||
repo_owner="org",
|
||||
repo_name="repo",
|
||||
issue_number=1,
|
||||
workspace_path=tmp_path / "repo",
|
||||
base_sha="base",
|
||||
branch="agent",
|
||||
primary_session_id="primary",
|
||||
reviewer_session_id="reviewer",
|
||||
status=WorkflowStatus.COMPLETED,
|
||||
)
|
||||
settings = SimpleNamespace(workspaces_dir=tmp_path)
|
||||
deps = SimpleNamespace(
|
||||
settings=settings,
|
||||
storage=FakeStorage(existing),
|
||||
context=FakeContext(pull()),
|
||||
git=FakeGit(events),
|
||||
development=FakeDevelopment(events),
|
||||
)
|
||||
workflow = PullRequestWorkflow(deps) # type: ignore[arg-type]
|
||||
|
||||
with pytest.raises(SetupReached):
|
||||
await getattr(workflow, operation)(job(JobKind.FIX, pr_number=2))
|
||||
|
||||
expected_checkout = "sync branch" if operation == "iterate" else "clone"
|
||||
assert events == [expected_checkout, "prepare"]
|
||||
Reference in New Issue
Block a user