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:
2026-07-08 22:05:49 -04:00
parent 30e0e51e5b
commit 6fb26115ce
25 changed files with 1792 additions and 40 deletions
+42
View File
@@ -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
+79
View File
@@ -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
+92
View File
@@ -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"]
+104
View File
@@ -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
+128
View File
@@ -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) == {}
+93
View File
@@ -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"
+67
View File
@@ -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"]
+32
View File
@@ -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()