mirror of
https://github.com/0xWheatyz/handler.git
synced 2026-08-30 16:36:24 +00:00
6fb26115ce
Phase 2 configures forge for the agents (operator only sets a credential_ref + optional version pin) and lets them drive a junior→senior→deploy workflow: - Credential resolution/injection (control/credentials.py): credential_ref pointers (env:/file:/cmd:) resolved only at spawn, injected as FORGE_TOKEN + host var, with a forge-host-scoped git credential helper reading the token from env (never on disk / in the DB). Resolution is a fail-fast spawn gate. - Role-based forge skills committed into the managed repo (control/skills_gen.py, `handler forge-init`): forge-junior/senior/deploy + a workflow overview. - Hard approval gate (hooks/gate.py, approvals table, migration 0002): merge/deploy — and direct pushes to protected branches — are denied unless a DIFFERENT agent has an `approved` record for the branch, pinned to the reviewed commit (approved_sha). Senior records verdicts via `handler approve`/`reject`. - forge/git seams (control/forge.py, control/gitops.py) matching the Phase 1 seam pattern. - CI status poller (control/poller.py, `handler poll-ci [--watch]`) backfilling ci_status/ci_checked_at via `forge ci list`. - Fix: migrations/env.py commits explicitly after run_migrations — pysqlite on Py 3.12+ was rolling back the final migration's DDL + alembic_version stamp (latent in Phase 1). Reviewed via a separate code-reviewer pass; gate-bypass and credential-scoping findings addressed. 106 tests, ruff clean, verified end-to-end against real git + migrations.
80 lines
3.0 KiB
Python
80 lines
3.0 KiB
Python
"""Phase 2 CLI: approve/reject (env identity), poll-ci, forge-init."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from handler.control import cli
|
|
from handler.db import repository as repo
|
|
from handler.db.engine import get_engine
|
|
|
|
|
|
def _seed_project_agent(role="senior", name="senior"):
|
|
with get_engine().begin() as conn:
|
|
repo.create_project(conn, "p", "/tmp/p")
|
|
return repo.create_agent(conn, "p", name, "/tmp/p/s", role=role)
|
|
|
|
|
|
def test_approve_via_cli_uses_env_identity(env, monkeypatch, capsys):
|
|
agent = _seed_project_agent()
|
|
monkeypatch.setenv("HANDLER_PROJECT_ID", "p")
|
|
monkeypatch.setenv("HANDLER_AGENT_ID", str(agent["id"]))
|
|
|
|
rc = cli.main(["approve", "--branch", "feat/x", "--pr", "7", "--note", "lgtm"])
|
|
assert rc == 0
|
|
with get_engine().begin() as conn:
|
|
latest = repo.get_latest_approval(conn, "p", "feat/x")
|
|
assert latest["status"] == "approved"
|
|
assert latest["approved_by_agent_id"] == agent["id"]
|
|
assert latest["pr_ref"] == "7"
|
|
|
|
|
|
def test_reject_via_cli(env, monkeypatch):
|
|
agent = _seed_project_agent()
|
|
monkeypatch.setenv("HANDLER_PROJECT_ID", "p")
|
|
monkeypatch.setenv("HANDLER_AGENT_ID", str(agent["id"]))
|
|
assert cli.main(["reject", "--branch", "feat/x", "--note", "fix it"]) == 0
|
|
with get_engine().begin() as conn:
|
|
assert repo.get_latest_approval(conn, "p", "feat/x")["status"] == "rejected"
|
|
|
|
|
|
def test_approve_without_identity_errors(env, monkeypatch, capsys):
|
|
_seed_project_agent()
|
|
monkeypatch.delenv("HANDLER_PROJECT_ID", raising=False)
|
|
monkeypatch.delenv("HANDLER_AGENT_ID", raising=False)
|
|
assert cli.main(["approve", "--branch", "feat/x"]) == 1
|
|
assert "no project" in capsys.readouterr().err
|
|
|
|
|
|
def test_approve_rejects_unknown_agent(env, monkeypatch, capsys):
|
|
_seed_project_agent()
|
|
monkeypatch.setenv("HANDLER_PROJECT_ID", "p")
|
|
monkeypatch.setenv("HANDLER_AGENT_ID", "9999")
|
|
assert cli.main(["approve", "--branch", "feat/x"]) == 1
|
|
assert "not found" in capsys.readouterr().err
|
|
|
|
|
|
def test_poll_ci_cli(env, fake_forge, capsys):
|
|
with get_engine().begin() as conn:
|
|
repo.create_project(conn, "p", "/tmp/p")
|
|
a = repo.create_agent(conn, "p", "a", "/tmp/p/a")
|
|
repo.insert_log_entry(conn, a["id"], status="working", push_sha="s", ci_status="pending")
|
|
fake_forge["runs"] = [{"conclusion": "success"}]
|
|
assert cli.main(["poll-ci"]) == 0
|
|
assert "resolved=1" in capsys.readouterr().out
|
|
|
|
|
|
def test_forge_init_writes_and_commits(env, fake_gitops, capsys):
|
|
root = env["tmp"] / "proj"
|
|
root.mkdir(parents=True, exist_ok=True)
|
|
with get_engine().begin() as conn:
|
|
repo.create_project(conn, "proj", str(root))
|
|
|
|
assert cli.main(["forge-init", "--project", "proj"]) == 0
|
|
assert (root / ".claude" / "skills" / "forge-junior" / "SKILL.md").exists()
|
|
# Auto-committed via the git seam.
|
|
assert len(fake_gitops["commit"]) == 1
|
|
|
|
|
|
def test_forge_init_unknown_project_errors(env, capsys):
|
|
assert cli.main(["forge-init", "--project", "nope"]) == 1
|
|
assert "not registered" in capsys.readouterr().err
|