feat: dev tools

This commit is contained in:
2026-07-20 20:47:28 +02:00
parent a12229a147
commit cb2301d709
25 changed files with 614 additions and 7 deletions
+1
View File
@@ -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",
+20
View File
@@ -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()
+35
View File
@@ -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"]
+81
View File
@@ -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)
+143
View File
@@ -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"]