mirror of
https://github.com/0xWheatyz/handler.git
synced 2026-09-02 23:16:28 +00:00
feat(phase-2): forge integration — credentials, role skills, approval gate, CI poller
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.
This commit is contained in:
@@ -105,3 +105,45 @@ def fake_tmux(monkeypatch):
|
||||
monkeypatch.setattr(tmux, "list_sessions", list_sessions)
|
||||
|
||||
return {"calls": calls, "live": live}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_gitops(monkeypatch):
|
||||
"""Fake the git seam: record config/add/commit, return a controllable branch/sha."""
|
||||
from handler.control import gitops
|
||||
|
||||
state = {"branch": "feat/x", "sha": "abc123def456", "config": [], "add": [], "commit": []}
|
||||
|
||||
def config_local(cwd, key, value):
|
||||
state["config"].append({"cwd": cwd, "key": key, "value": value})
|
||||
return True, ""
|
||||
|
||||
def add(cwd, paths):
|
||||
state["add"].append({"cwd": cwd, "paths": paths})
|
||||
return True, ""
|
||||
|
||||
def commit(cwd, message):
|
||||
state["commit"].append({"cwd": cwd, "message": message})
|
||||
return True, ""
|
||||
|
||||
monkeypatch.setattr(gitops, "current_branch", lambda cwd: state["branch"])
|
||||
monkeypatch.setattr(gitops, "head_sha", lambda cwd: state["sha"])
|
||||
monkeypatch.setattr(gitops, "config_local", config_local)
|
||||
monkeypatch.setattr(gitops, "add", add)
|
||||
monkeypatch.setattr(gitops, "commit", commit)
|
||||
return state
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_forge(monkeypatch):
|
||||
"""Fake the forge seam: controllable version check + CI runs."""
|
||||
from handler.control import forge
|
||||
|
||||
state = {"version_ok": True, "version_out": "forge 1.2.3", "ci_ok": True, "runs": []}
|
||||
|
||||
monkeypatch.setattr(
|
||||
forge, "check_version", lambda cwd=".": (state["version_ok"], state["version_out"])
|
||||
)
|
||||
monkeypatch.setattr(forge, "ci_list", lambda cwd, sha: (state["ci_ok"], state["runs"]))
|
||||
monkeypatch.setattr(forge, "ci_log", lambda cwd, run_id: (True, "log"))
|
||||
return state
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"""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
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Phase 2 spawn wiring: credential injection, git helper, forge version note, role."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from handler.control import spawn
|
||||
from handler.db import repository as repo
|
||||
from handler.db.engine import get_engine
|
||||
|
||||
|
||||
def _write_mise(root):
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
(root / ".mise.toml").write_text("[tasks.test]\nrun = 'pytest'\n")
|
||||
|
||||
|
||||
def _register(root, **kw):
|
||||
with get_engine().begin() as conn:
|
||||
repo.create_project(conn, "proj", str(root), **kw)
|
||||
|
||||
|
||||
def test_spawn_injects_credentials_and_installs_helper(env, fake_tmux, fake_gitops, monkeypatch):
|
||||
monkeypatch.setenv("PROJ_TOKEN", "s3cret")
|
||||
root = env["tmp"] / "proj"
|
||||
_write_mise(root)
|
||||
_register(root, git_remote="https://github.com/me/proj.git", credential_ref="env:PROJ_TOKEN")
|
||||
|
||||
spawn.spawn("proj", "junior", role="junior")
|
||||
|
||||
call = fake_tmux["calls"]["new_session"][0]
|
||||
# Token injected under the generic + host-specific names, never the raw ref stored.
|
||||
assert call["env"]["FORGE_TOKEN"] == "s3cret"
|
||||
assert call["env"]["GITHUB_TOKEN"] == "s3cret"
|
||||
assert call["env"]["HANDLER_AGENT_ROLE"] == "junior"
|
||||
# Git credential helper installed, scoped to the forge host (not global).
|
||||
helper = [c for c in fake_gitops["config"] if c["key"].endswith(".helper")]
|
||||
assert helper and helper[0]["key"] == "credential.https://github.com.helper"
|
||||
assert "$FORGE_TOKEN" in helper[0]["value"]
|
||||
|
||||
|
||||
def test_spawn_ssh_remote_installs_no_https_helper(env, fake_tmux, fake_gitops, monkeypatch):
|
||||
monkeypatch.setenv("PROJ_TOKEN", "s3cret")
|
||||
root = env["tmp"] / "proj"
|
||||
_write_mise(root)
|
||||
_register(root, git_remote="git@github.com:me/proj.git", credential_ref="env:PROJ_TOKEN")
|
||||
spawn.spawn("proj", "junior", role="junior")
|
||||
# ssh remote -> token still injected, but no HTTPS credential helper installed.
|
||||
assert fake_tmux["calls"]["new_session"][0]["env"]["GITHUB_TOKEN"] == "s3cret"
|
||||
assert fake_gitops["config"] == []
|
||||
|
||||
|
||||
def test_spawn_fails_fast_on_broken_credential_ref(env, fake_tmux, fake_gitops, monkeypatch):
|
||||
monkeypatch.delenv("ABSENT_TOKEN", raising=False)
|
||||
root = env["tmp"] / "proj"
|
||||
_write_mise(root)
|
||||
_register(root, credential_ref="env:ABSENT_TOKEN")
|
||||
|
||||
with pytest.raises(spawn.SpawnError, match="not set"):
|
||||
spawn.spawn("proj", "junior", role="junior")
|
||||
# No agent row and no session left behind by the failed spawn.
|
||||
with get_engine().begin() as conn:
|
||||
assert repo.get_agent_by_name(conn, "proj", "junior") is None
|
||||
assert fake_tmux["calls"]["new_session"] == []
|
||||
|
||||
|
||||
def test_spawn_without_credential_ref_injects_no_token(env, fake_tmux, fake_gitops):
|
||||
root = env["tmp"] / "proj"
|
||||
_write_mise(root)
|
||||
_register(root)
|
||||
spawn.spawn("proj", "api")
|
||||
call = fake_tmux["calls"]["new_session"][0]
|
||||
assert "FORGE_TOKEN" not in call["env"]
|
||||
# No token -> no credential helper installed.
|
||||
assert fake_gitops["config"] == []
|
||||
|
||||
|
||||
def test_spawn_reports_forge_version_mismatch(env, fake_tmux, fake_gitops, fake_forge, monkeypatch):
|
||||
monkeypatch.setenv("FORGE_VERSION", "9.9.9")
|
||||
from handler import config
|
||||
from handler.db import engine
|
||||
|
||||
config.get_settings.cache_clear()
|
||||
engine.get_engine.cache_clear()
|
||||
|
||||
root = env["tmp"] / "proj"
|
||||
_write_mise(root)
|
||||
_register(root)
|
||||
fake_forge["version_ok"] = False
|
||||
fake_forge["version_out"] = "forge 1.2.3"
|
||||
|
||||
agent = spawn.spawn("proj", "api")
|
||||
assert "9.9.9" in agent["forge_note"]
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Credential resolution + env/helper derivation (README 3.7)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from handler.control import credentials
|
||||
|
||||
|
||||
def test_resolve_none_returns_none():
|
||||
assert credentials.resolve(None) is None
|
||||
assert credentials.resolve("") is None
|
||||
|
||||
|
||||
def test_resolve_env(monkeypatch):
|
||||
monkeypatch.setenv("MY_TOKEN", "secret-value")
|
||||
assert credentials.resolve("env:MY_TOKEN") == "secret-value"
|
||||
|
||||
|
||||
def test_resolve_env_missing_raises(monkeypatch):
|
||||
monkeypatch.delenv("NOPE", raising=False)
|
||||
with pytest.raises(credentials.CredentialError, match="not set"):
|
||||
credentials.resolve("env:NOPE")
|
||||
|
||||
|
||||
def test_resolve_file(tmp_path):
|
||||
f = tmp_path / "tok"
|
||||
f.write_text(" file-secret\n")
|
||||
assert credentials.resolve(f"file:{f}") == "file-secret"
|
||||
|
||||
|
||||
def test_resolve_file_missing_raises(tmp_path):
|
||||
with pytest.raises(credentials.CredentialError, match="unreadable"):
|
||||
credentials.resolve(f"file:{tmp_path / 'absent'}")
|
||||
|
||||
|
||||
def test_resolve_cmd():
|
||||
assert credentials.resolve("cmd:printf hunter2") == "hunter2"
|
||||
|
||||
|
||||
def test_resolve_cmd_failure_raises():
|
||||
with pytest.raises(credentials.CredentialError, match="exited"):
|
||||
credentials.resolve("cmd:false")
|
||||
|
||||
|
||||
def test_resolve_unknown_scheme_raises():
|
||||
with pytest.raises(credentials.CredentialError, match="unknown scheme"):
|
||||
credentials.resolve("vault:secret/x")
|
||||
|
||||
|
||||
def test_resolve_empty_value_raises():
|
||||
with pytest.raises(credentials.CredentialError, match="no value"):
|
||||
credentials.resolve("env:")
|
||||
|
||||
|
||||
def test_credential_env_always_sets_forge_token():
|
||||
env = credentials.credential_env("tok", None)
|
||||
assert env == {"FORGE_TOKEN": "tok"}
|
||||
|
||||
|
||||
def test_credential_env_adds_host_specific_var():
|
||||
gh = credentials.credential_env("tok", "https://github.com/me/repo.git")
|
||||
assert gh["GITHUB_TOKEN"] == "tok" and gh["FORGE_TOKEN"] == "tok"
|
||||
gitea = credentials.credential_env("tok", "https://gitea.example.com/me/repo.git")
|
||||
assert gitea["GITEA_TOKEN"] == "tok"
|
||||
|
||||
|
||||
def test_credential_env_empty_when_no_token():
|
||||
assert credentials.credential_env(None, "https://github.com/x") == {}
|
||||
|
||||
|
||||
def test_git_credential_helper_reads_from_env():
|
||||
helper = credentials.git_credential_helper_value()
|
||||
# Inline helper hands back the token from $FORGE_TOKEN, never a value on disk.
|
||||
assert "$FORGE_TOKEN" in helper
|
||||
assert helper.startswith("!")
|
||||
|
||||
|
||||
def test_remote_host_parses_https_and_ssh():
|
||||
assert credentials.remote_host("https://github.com/me/repo.git") == "github.com"
|
||||
assert credentials.remote_host("git@gitea.example.com:me/repo.git") == "gitea.example.com"
|
||||
assert credentials.remote_host(None) is None
|
||||
|
||||
|
||||
def test_host_token_env_not_fooled_by_repo_name():
|
||||
# A GitHub repo merely *named* 'gitea' must not be mapped to GITEA_TOKEN.
|
||||
env = credentials.credential_env("tok", "https://github.com/me/gitea-mirror.git")
|
||||
assert "GITEA_TOKEN" not in env and env["GITHUB_TOKEN"] == "tok"
|
||||
|
||||
|
||||
def test_host_token_env_self_hosted_hint():
|
||||
env = credentials.credential_env("tok", "https://gitea.mycorp.internal/me/repo.git")
|
||||
assert env["GITEA_TOKEN"] == "tok"
|
||||
|
||||
|
||||
def test_git_credential_config_scoped_to_host():
|
||||
key, value = credentials.git_credential_config("https://github.com/me/repo.git")
|
||||
assert key == "credential.https://github.com.helper"
|
||||
assert "$FORGE_TOKEN" in value
|
||||
|
||||
|
||||
def test_git_credential_config_none_for_ssh():
|
||||
assert credentials.git_credential_config("git@github.com:me/repo.git") is None
|
||||
assert credentials.git_credential_config(None) is None
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Phase 2 gate behavior: the hard approval gate + push CI-pending recording."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from handler.db import repository as repo
|
||||
from handler.hooks import gate, verify
|
||||
from handler.hooks.context import HookInput, Identity
|
||||
|
||||
|
||||
def _decision(result):
|
||||
return result["hookSpecificOutput"]["permissionDecision"]
|
||||
|
||||
|
||||
def _seed(conn, role=None, name="deploy"):
|
||||
repo.create_project(conn, "p", "/tmp/p")
|
||||
a = repo.create_agent(conn, "p", name, "/tmp/p/a", role=role)
|
||||
return Identity(a["id"], "p", name, "/tmp/p/a")
|
||||
|
||||
|
||||
def _merge_input():
|
||||
return HookInput(
|
||||
{"tool_name": "Bash", "tool_input": {"command": "forge pr merge 7"}}, "pre_tool_use"
|
||||
)
|
||||
|
||||
|
||||
def test_merge_denied_without_approval(conn, fake_gitops):
|
||||
ident = _seed(conn)
|
||||
result = gate.handle_merge_deploy(conn, ident, _merge_input())
|
||||
assert _decision(result) == "deny"
|
||||
assert "no standing approval" in result["hookSpecificOutput"]["permissionDecisionReason"]
|
||||
|
||||
|
||||
def test_merge_denied_when_latest_is_rejected(conn, fake_gitops):
|
||||
ident = _seed(conn)
|
||||
other = repo.create_agent(conn, "p", "senior", "/tmp/p/s", role="senior")
|
||||
repo.record_approval(conn, "p", "feat/x", "rejected", other["id"])
|
||||
assert _decision(gate.handle_merge_deploy(conn, ident, _merge_input())) == "deny"
|
||||
|
||||
|
||||
def test_merge_denied_on_self_approval(conn, fake_gitops):
|
||||
ident = _seed(conn)
|
||||
# Same agent approved the branch it now tries to merge — no self-approval.
|
||||
repo.record_approval(conn, "p", "feat/x", "approved", ident.agent_id)
|
||||
result = gate.handle_merge_deploy(conn, ident, _merge_input())
|
||||
assert _decision(result) == "deny"
|
||||
assert "different agent" in result["hookSpecificOutput"]["permissionDecisionReason"]
|
||||
|
||||
|
||||
def test_merge_allowed_with_different_agent_approval(conn, fake_gitops):
|
||||
ident = _seed(conn)
|
||||
senior = repo.create_agent(conn, "p", "senior", "/tmp/p/s", role="senior")
|
||||
repo.record_approval(conn, "p", "feat/x", "approved", senior["id"])
|
||||
assert _decision(gate.handle_merge_deploy(conn, ident, _merge_input())) == "allow"
|
||||
|
||||
|
||||
def test_deploy_task_is_also_gated(conn, fake_gitops):
|
||||
ident = _seed(conn)
|
||||
hi = HookInput(
|
||||
{"tool_name": "Bash", "tool_input": {"command": "mise run deploy"}}, "pre_tool_use"
|
||||
)
|
||||
# Routed through handle() to prove the matcher catches `mise run deploy`.
|
||||
result = gate.handle(conn, ident, hi)
|
||||
assert _decision(result) == "deny"
|
||||
|
||||
|
||||
def test_merge_denied_when_branch_unknown(conn, fake_gitops):
|
||||
ident = _seed(conn)
|
||||
fake_gitops["branch"] = None
|
||||
result = gate.handle_merge_deploy(conn, ident, _merge_input())
|
||||
assert _decision(result) == "deny"
|
||||
assert "current git branch" in result["hookSpecificOutput"]["permissionDecisionReason"]
|
||||
|
||||
|
||||
def test_merge_denied_when_approval_is_for_a_stale_commit(conn, fake_gitops):
|
||||
ident = _seed(conn)
|
||||
senior = repo.create_agent(conn, "p", "senior", "/tmp/p/s", role="senior")
|
||||
# Approved an earlier commit; HEAD has since moved on.
|
||||
repo.record_approval(conn, "p", "feat/x", "approved", senior["id"], approved_sha="oldsha")
|
||||
fake_gitops["sha"] = "newsha"
|
||||
result = gate.handle_merge_deploy(conn, ident, _merge_input())
|
||||
assert _decision(result) == "deny"
|
||||
assert "re-reviewed" in result["hookSpecificOutput"]["permissionDecisionReason"]
|
||||
|
||||
|
||||
def test_merge_allowed_when_approved_sha_matches_head(conn, fake_gitops):
|
||||
ident = _seed(conn)
|
||||
senior = repo.create_agent(conn, "p", "senior", "/tmp/p/s", role="senior")
|
||||
fake_gitops["sha"] = "samesha"
|
||||
repo.record_approval(conn, "p", "feat/x", "approved", senior["id"], approved_sha="samesha")
|
||||
assert _decision(gate.handle_merge_deploy(conn, ident, _merge_input())) == "allow"
|
||||
|
||||
|
||||
def test_push_records_ci_pending_on_allow(conn, fake_gitops, monkeypatch):
|
||||
ident = _seed(conn, name="junior")
|
||||
fake_gitops["branch"] = "feat/x" # not protected -> no approval needed to push
|
||||
monkeypatch.setattr(verify, "run_test", lambda cwd: (True, "ok"))
|
||||
monkeypatch.setattr(verify, "run_build", lambda cwd: (True, "built"))
|
||||
hi = HookInput({"tool_name": "Bash", "tool_input": {"command": "git push"}}, "pre_tool_use")
|
||||
result = gate.handle_git_push(conn, ident, hi)
|
||||
assert _decision(result) == "allow"
|
||||
# A pending-CI log entry was recorded for the pushed commit.
|
||||
pending = repo.get_pending_ci_entries(conn)
|
||||
assert len(pending) == 1
|
||||
assert pending[0]["push_sha"] == fake_gitops["sha"]
|
||||
|
||||
|
||||
def test_direct_push_to_protected_branch_needs_approval(conn, fake_gitops, monkeypatch):
|
||||
# Closes the "merge locally, push to main" bypass around the forge-merge gate.
|
||||
ident = _seed(conn)
|
||||
fake_gitops["branch"] = "main"
|
||||
monkeypatch.setattr(verify, "run_test", lambda cwd: (True, "ok"))
|
||||
monkeypatch.setattr(verify, "run_build", lambda cwd: (True, "built"))
|
||||
hi = HookInput(
|
||||
{"tool_name": "Bash", "tool_input": {"command": "git push origin main"}}, "pre_tool_use"
|
||||
)
|
||||
result = gate.handle_git_push(conn, ident, hi)
|
||||
assert _decision(result) == "deny"
|
||||
assert "protected branch" in result["hookSpecificOutput"]["permissionDecisionReason"]
|
||||
# Nothing recorded as pending since the push was denied.
|
||||
assert repo.get_pending_ci_entries(conn) == []
|
||||
|
||||
|
||||
def test_pr_title_mentioning_merge_is_not_gated(conn, fake_gitops):
|
||||
# The approval gate must not trip on a PR title/commit message containing "merge".
|
||||
ident = _seed(conn, name="junior")
|
||||
cmd = 'forge pr create --title "add merge helper"'
|
||||
hi = HookInput({"tool_name": "Bash", "tool_input": {"command": cmd}}, "pre_tool_use")
|
||||
assert gate.handle(conn, ident, hi) == {}
|
||||
@@ -0,0 +1,93 @@
|
||||
"""CI backfill poller: run classification + a full sweep against a faked forge."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from handler.control import poller
|
||||
from handler.db import repository as repo
|
||||
|
||||
|
||||
def test_classify_no_runs_is_pending():
|
||||
assert poller.classify([]) == "pending"
|
||||
|
||||
|
||||
def test_classify_any_failure_is_fail():
|
||||
runs = [{"conclusion": "success"}, {"conclusion": "failure"}]
|
||||
assert poller.classify(runs) == "fail"
|
||||
|
||||
|
||||
def test_classify_all_success_is_pass():
|
||||
runs = [{"status": "completed", "conclusion": "success"}]
|
||||
assert poller.classify(runs) == "pass"
|
||||
|
||||
|
||||
def test_classify_running_stays_pending():
|
||||
runs = [{"status": "in_progress", "conclusion": None}]
|
||||
assert poller.classify(runs) == "pending"
|
||||
|
||||
|
||||
def test_classify_tolerates_alternate_spellings():
|
||||
assert poller.classify([{"conclusion": "succeeded"}]) == "pass"
|
||||
assert poller.classify([{"conclusion": "canceled"}]) == "fail"
|
||||
|
||||
|
||||
def test_classify_action_required_stays_pending_not_pass():
|
||||
# A terminal-but-non-success conclusion must NOT be reported as a pass.
|
||||
runs = [{"status": "completed", "conclusion": "action_required"}]
|
||||
assert poller.classify(runs) == "pending"
|
||||
|
||||
|
||||
def test_classify_status_only_forge_success():
|
||||
# No conclusion field at all: fall back to the status.
|
||||
assert poller.classify([{"status": "completed"}]) == "pass"
|
||||
|
||||
|
||||
def _seed_pending(conn):
|
||||
repo.create_project(conn, "p", "/tmp/p")
|
||||
a = repo.create_agent(conn, "p", "a", "/tmp/p/a")
|
||||
return repo.insert_log_entry(
|
||||
conn, a["id"], status="working", push_sha="sha1", ci_status="pending"
|
||||
)
|
||||
|
||||
|
||||
def test_sweep_backfills_pass(engine, fake_forge):
|
||||
with engine.begin() as conn:
|
||||
entry_id = _seed_pending(conn)
|
||||
fake_forge["runs"] = [{"status": "completed", "conclusion": "success"}]
|
||||
|
||||
summary = poller.sweep()
|
||||
assert summary == {"checked": 1, "resolved": 1, "pending": 0}
|
||||
with engine.begin() as conn:
|
||||
assert repo.get_pending_ci_entries(conn) == []
|
||||
row = [e for e in repo.get_log(conn, 1) if e["id"] == entry_id][0]
|
||||
assert row["ci_status"] == "pass"
|
||||
assert row["ci_checked_at"] is not None
|
||||
|
||||
|
||||
def test_sweep_leaves_pending_when_unresolved(engine, fake_forge):
|
||||
with engine.begin() as conn:
|
||||
_seed_pending(conn)
|
||||
fake_forge["runs"] = [{"status": "in_progress"}]
|
||||
summary = poller.sweep()
|
||||
assert summary["resolved"] == 0
|
||||
with engine.begin() as conn:
|
||||
assert len(repo.get_pending_ci_entries(conn)) == 1
|
||||
|
||||
|
||||
def test_sweep_leaves_pending_when_forge_unavailable(engine, fake_forge):
|
||||
with engine.begin() as conn:
|
||||
_seed_pending(conn)
|
||||
fake_forge["ci_ok"] = False
|
||||
summary = poller.sweep()
|
||||
assert summary["resolved"] == 0
|
||||
with engine.begin() as conn:
|
||||
assert len(repo.get_pending_ci_entries(conn)) == 1
|
||||
|
||||
|
||||
def test_watch_runs_bounded_iterations(engine, fake_forge):
|
||||
with engine.begin() as conn:
|
||||
_seed_pending(conn)
|
||||
fake_forge["runs"] = [{"conclusion": "failure"}]
|
||||
summaries = list(poller.watch(iterations=1, interval=0))
|
||||
assert len(summaries) == 1
|
||||
with engine.begin() as conn:
|
||||
assert repo.get_log(conn, 1)[-1]["ci_status"] == "fail"
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Phase 2 DAL: agent role, approvals, and CI backfill helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from handler.db import repository as repo
|
||||
|
||||
|
||||
def test_agent_role_is_stored(conn):
|
||||
repo.create_project(conn, "p", "/tmp/p")
|
||||
a = repo.create_agent(conn, "p", "senior", "/tmp/p/senior", role="senior")
|
||||
assert a["role"] == "senior"
|
||||
assert repo.get_agent_by_id(conn, a["id"])["role"] == "senior"
|
||||
|
||||
|
||||
def test_approval_record_and_latest(conn):
|
||||
repo.create_project(conn, "p", "/tmp/p")
|
||||
junior = repo.create_agent(conn, "p", "junior", "/tmp/p/j", role="junior")
|
||||
senior = repo.create_agent(conn, "p", "senior", "/tmp/p/s", role="senior")
|
||||
|
||||
assert repo.get_latest_approval(conn, "p", "feat/x") is None
|
||||
|
||||
repo.record_approval(conn, "p", "feat/x", "rejected", junior["id"], note="nit")
|
||||
latest = repo.record_approval(conn, "p", "feat/x", "approved", senior["id"], pr_ref="7")
|
||||
got = repo.get_latest_approval(conn, "p", "feat/x")
|
||||
# Latest wins (by insertion order).
|
||||
assert got["id"] == latest["id"]
|
||||
assert got["status"] == "approved"
|
||||
assert got["approved_by_agent_id"] == senior["id"]
|
||||
assert got["pr_ref"] == "7"
|
||||
|
||||
|
||||
def test_approval_scoped_by_project_and_branch(conn):
|
||||
repo.create_project(conn, "p", "/tmp/p")
|
||||
a = repo.create_agent(conn, "p", "s", "/tmp/p/s")
|
||||
repo.record_approval(conn, "p", "feat/x", "approved", a["id"])
|
||||
assert repo.get_latest_approval(conn, "p", "feat/other") is None
|
||||
|
||||
|
||||
def test_pending_ci_entries_and_backfill(conn):
|
||||
repo.create_project(conn, "p", "/tmp/p")
|
||||
a = repo.create_agent(conn, "p", "a", "/tmp/p/a")
|
||||
# A push-recording entry (pending) and a normal entry (not_applicable).
|
||||
pending_id = repo.insert_log_entry(
|
||||
conn, a["id"], status="working", push_sha="deadbeef", ci_status="pending"
|
||||
)
|
||||
repo.insert_log_entry(conn, a["id"], status="working", summary="no push")
|
||||
|
||||
entries = repo.get_pending_ci_entries(conn)
|
||||
assert [e["id"] for e in entries] == [pending_id]
|
||||
assert entries[0]["push_sha"] == "deadbeef"
|
||||
assert entries[0]["project_id"] == "p"
|
||||
assert entries[0]["working_dir"] == "/tmp/p/a"
|
||||
|
||||
assert repo.update_ci_status(conn, pending_id, "pass") is True
|
||||
# No longer pending once resolved.
|
||||
assert repo.get_pending_ci_entries(conn) == []
|
||||
assert repo.get_log(conn, a["id"])[-1]["ci_status"] in ("pass", "not_applicable")
|
||||
|
||||
|
||||
def test_pending_ci_entries_scoped_to_project(conn):
|
||||
repo.create_project(conn, "p1", "/tmp/p1")
|
||||
repo.create_project(conn, "p2", "/tmp/p2")
|
||||
a1 = repo.create_agent(conn, "p1", "a", "/tmp/p1/a")
|
||||
a2 = repo.create_agent(conn, "p2", "a", "/tmp/p2/a")
|
||||
repo.insert_log_entry(conn, a1["id"], status="working", push_sha="s1", ci_status="pending")
|
||||
repo.insert_log_entry(conn, a2["id"], status="working", push_sha="s2", ci_status="pending")
|
||||
assert [e["project_id"] for e in repo.get_pending_ci_entries(conn, project_id="p1")] == ["p1"]
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Role-based forge skills generation (committed into the managed repo)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from handler.control import skills_gen
|
||||
|
||||
|
||||
def test_skill_files_cover_every_role():
|
||||
files = skills_gen.skill_files()
|
||||
paths = set(files)
|
||||
for role in ("forge-workflow", "forge-junior", "forge-senior", "forge-deploy"):
|
||||
assert f".claude/skills/{role}/SKILL.md" in paths
|
||||
|
||||
|
||||
def test_skill_files_have_frontmatter():
|
||||
for _, contents in skills_gen.skill_files().items():
|
||||
assert contents.startswith("---\nname: ")
|
||||
assert "description:" in contents
|
||||
|
||||
|
||||
def test_senior_skill_references_the_approval_command():
|
||||
senior = skills_gen.skill_files()[".claude/skills/forge-senior/SKILL.md"]
|
||||
assert "handler approve" in senior
|
||||
assert "handler reject" in senior
|
||||
|
||||
|
||||
def test_write_skills_materializes_files(tmp_path):
|
||||
written = skills_gen.write_skills(str(tmp_path))
|
||||
assert len(written) == 4
|
||||
junior = tmp_path / ".claude" / "skills" / "forge-junior" / "SKILL.md"
|
||||
assert junior.exists()
|
||||
assert "junior developer" in junior.read_text()
|
||||
Reference in New Issue
Block a user