Step 9 — autonomous patch loop:
- patcher.py: clawdforge session → unified diff → worktree apply → verify recipe → push branch → open Gitea PR
- migration 007: patch_attempts (UNIQUE per finding+attempt, max 3 attempts)
- runner.py: post-parse hook fires patcher.maybe_draft_for_job when notify.auto_patch=true
- server.py: POST /jobs/{id}/patches, GET /patches, GET /patches/{id}
- digest.py: patch-drafted lines + open-follow-up count via Gitea PR state check
- mcp: crafting_table_draft_patch stub replaced with real implementation
- tests/test_patcher.py + tests/test_patches_api.py: 27 new tests
No auto-merge — patches stop at PR-open. Cobb merges.
Step 10 — production recipes:
- examples/recipes/clawdforge.json: 14 subprojects across all SDKs, audit nightly
- examples/recipes/cauldron.json: single Flask subproject, audit nightly
- examples/recipes/tradecraft.json: nightly audit, auto_patch=false (manual review)
- examples/register-all.sh: bulk-register helper with GITEA_TOKEN substitution
- README "Autonomous patch loop" + "First production recipes" sections
Tests: server 116→143, mcp 65→67. All green.
Spec: memory/spec-crafting-table.md
289 lines
8.6 KiB
Python
289 lines
8.6 KiB
Python
"""HTTP API tests for the wave-3 patches surface.
|
|
|
|
Covers POST /jobs/{id}/patches (manual trigger), GET /patches (list with
|
|
filters), GET /patches/{id} (detail with cross-token guards). The patcher
|
|
itself is stubbed so we don't make real clawdforge / Gitea calls.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import time
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import pytest
|
|
|
|
from crafting_table.patcher import PatchAttempt
|
|
from tests.conftest import sample_project_payload
|
|
|
|
|
|
def _install_stub_patcher(server, *, attempt: PatchAttempt | None = None):
|
|
"""Replace server.patcher with a stub that returns the given attempt.
|
|
|
|
``attempt=None`` simulates the "no actionable finding" code path.
|
|
Returns the stub for assertion-side access.
|
|
"""
|
|
stub = MagicMock()
|
|
stub.maybe_draft = AsyncMock(return_value=attempt)
|
|
server.patcher = stub
|
|
return stub
|
|
|
|
|
|
def _register_demo_project(tc, bearer: str, *, name: str = "demo") -> None:
|
|
payload = sample_project_payload(name=name)
|
|
r = tc.post(
|
|
"/projects",
|
|
headers={"Authorization": f"Bearer {bearer}"},
|
|
json=payload,
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
|
|
|
|
def _seed_job_row(server, *, project_name: str = "demo", job_id: str = "j-1") -> None:
|
|
snapshot = {
|
|
"git_url": "/dev/null",
|
|
"default_branch": "main",
|
|
"subprojects": [{
|
|
"path": ".", "language": "python", "lint": "echo x"
|
|
}],
|
|
"languages": ["python"],
|
|
}
|
|
server.db.insert_job(
|
|
job_id=job_id,
|
|
project_name=project_name,
|
|
subproject_path=".",
|
|
recipe="lint",
|
|
branch="main",
|
|
log_path="/tmp/_x.log",
|
|
recipe_snapshot_json=json.dumps(snapshot),
|
|
)
|
|
server.db.mark_job_finished(job_id=job_id, status="failed", exit_code=1)
|
|
|
|
|
|
def test_post_patches_with_finding_id(client):
|
|
tc, ctx = client
|
|
server = ctx["server"]
|
|
_register_demo_project(tc, ctx["alpha_bearer"])
|
|
_seed_job_row(server)
|
|
|
|
fake = PatchAttempt(
|
|
finding_id=42,
|
|
job_id="j-1",
|
|
project_name="demo",
|
|
attempt_number=1,
|
|
status="pr_opened",
|
|
branch_name="crafting-table/auto/j-1-42",
|
|
pr_url="http://192.168.0.5:3001/X/Y/pulls/9",
|
|
diff_excerpt="--- a/x\n+++ b/x",
|
|
session_id="s-1",
|
|
)
|
|
fake.id = 7
|
|
stub = _install_stub_patcher(server, attempt=fake)
|
|
|
|
r = tc.post(
|
|
"/jobs/j-1/patches",
|
|
headers={"Authorization": f"Bearer {ctx['alpha_bearer']}"},
|
|
json={"finding_id": 42},
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
body = r.json()
|
|
assert body["ok"] is True
|
|
assert body["attempt"]["status"] == "pr_opened"
|
|
assert body["attempt"]["pr_url"].endswith("/9")
|
|
# Patcher was called with the explicit finding_id.
|
|
assert stub.maybe_draft.await_count == 1
|
|
args, kwargs = stub.maybe_draft.call_args
|
|
assert kwargs.get("finding_id") == 42 or (len(args) > 1 and args[1] == 42)
|
|
|
|
|
|
def test_post_patches_without_finding_id_auto_picks(client):
|
|
tc, ctx = client
|
|
server = ctx["server"]
|
|
_register_demo_project(tc, ctx["alpha_bearer"])
|
|
_seed_job_row(server)
|
|
|
|
fake = PatchAttempt(
|
|
finding_id=99, job_id="j-1", project_name="demo",
|
|
attempt_number=1, status="drafted",
|
|
)
|
|
fake.id = 1
|
|
_install_stub_patcher(server, attempt=fake)
|
|
|
|
r = tc.post(
|
|
"/jobs/j-1/patches",
|
|
headers={"Authorization": f"Bearer {ctx['alpha_bearer']}"},
|
|
json={},
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
assert r.json()["attempt"]["finding_id"] == 99
|
|
|
|
|
|
def test_post_patches_no_actionable_returns_attempt_none(client):
|
|
tc, ctx = client
|
|
server = ctx["server"]
|
|
_register_demo_project(tc, ctx["alpha_bearer"])
|
|
_seed_job_row(server)
|
|
_install_stub_patcher(server, attempt=None)
|
|
|
|
r = tc.post(
|
|
"/jobs/j-1/patches",
|
|
headers={"Authorization": f"Bearer {ctx['alpha_bearer']}"},
|
|
json={},
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
body = r.json()
|
|
assert body["ok"] is True
|
|
assert body["attempt"] is None
|
|
assert body.get("reason") == "no_actionable_finding"
|
|
|
|
|
|
def test_post_patches_503_when_patcher_disabled(client):
|
|
tc, ctx = client
|
|
server = ctx["server"]
|
|
_register_demo_project(tc, ctx["alpha_bearer"])
|
|
_seed_job_row(server)
|
|
server.patcher = None
|
|
|
|
r = tc.post(
|
|
"/jobs/j-1/patches",
|
|
headers={"Authorization": f"Bearer {ctx['alpha_bearer']}"},
|
|
json={},
|
|
)
|
|
assert r.status_code == 503
|
|
|
|
|
|
def test_post_patches_cross_token_returns_404(client):
|
|
tc, ctx = client
|
|
server = ctx["server"]
|
|
_register_demo_project(tc, ctx["alpha_bearer"])
|
|
_seed_job_row(server)
|
|
_install_stub_patcher(server, attempt=None)
|
|
|
|
# bravo cannot see alpha's job → 404 (existence-leak guard).
|
|
r = tc.post(
|
|
"/jobs/j-1/patches",
|
|
headers={"Authorization": f"Bearer {ctx['bravo_bearer']}"},
|
|
json={},
|
|
)
|
|
assert r.status_code == 404
|
|
|
|
|
|
def test_post_patches_rejects_non_int_finding_id(client):
|
|
tc, ctx = client
|
|
server = ctx["server"]
|
|
_register_demo_project(tc, ctx["alpha_bearer"])
|
|
_seed_job_row(server)
|
|
_install_stub_patcher(server, attempt=None)
|
|
|
|
r = tc.post(
|
|
"/jobs/j-1/patches",
|
|
headers={"Authorization": f"Bearer {ctx['alpha_bearer']}"},
|
|
json={"finding_id": "not-an-int"},
|
|
)
|
|
assert r.status_code == 400
|
|
|
|
|
|
def test_get_patches_filtered_by_project_and_status(client):
|
|
tc, ctx = client
|
|
server = ctx["server"]
|
|
_register_demo_project(tc, ctx["alpha_bearer"])
|
|
_seed_job_row(server)
|
|
|
|
# Insert two attempts with different statuses, plus one for a different
|
|
# (synthetic) project so the filter actually has to exclude.
|
|
fid = server.db.insert_finding(
|
|
job_id="j-1", kind="lint", severity="warn", message="m",
|
|
fingerprint="f", file="x.py", line=1, code="X",
|
|
)
|
|
server.db.insert_patch_attempt(
|
|
finding_id=fid, job_id="j-1", project_name="demo",
|
|
attempt_number=1, status="pr_opened",
|
|
pr_url="http://gitea/X/Y/pulls/1",
|
|
)
|
|
server.db.insert_patch_attempt(
|
|
finding_id=fid, job_id="j-1", project_name="demo",
|
|
attempt_number=2, status="apply_failed",
|
|
)
|
|
|
|
r = tc.get(
|
|
"/patches?project=demo&status=pr_opened",
|
|
headers={"Authorization": f"Bearer {ctx['alpha_bearer']}"},
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
rows = r.json()["patches"]
|
|
assert len(rows) == 1
|
|
assert rows[0]["status"] == "pr_opened"
|
|
|
|
|
|
def test_get_patches_owner_scoped(client):
|
|
"""Bravo's /patches list never includes Alpha's attempts."""
|
|
tc, ctx = client
|
|
server = ctx["server"]
|
|
_register_demo_project(tc, ctx["alpha_bearer"])
|
|
_seed_job_row(server)
|
|
fid = server.db.insert_finding(
|
|
job_id="j-1", kind="lint", severity="warn", message="m",
|
|
fingerprint="f", file="x.py", line=1, code="X",
|
|
)
|
|
server.db.insert_patch_attempt(
|
|
finding_id=fid, job_id="j-1", project_name="demo",
|
|
attempt_number=1, status="pr_opened",
|
|
)
|
|
|
|
r = tc.get(
|
|
"/patches",
|
|
headers={"Authorization": f"Bearer {ctx['bravo_bearer']}"},
|
|
)
|
|
assert r.status_code == 200
|
|
assert r.json()["patches"] == []
|
|
|
|
|
|
def test_get_patches_detail_owner(client):
|
|
tc, ctx = client
|
|
server = ctx["server"]
|
|
_register_demo_project(tc, ctx["alpha_bearer"])
|
|
_seed_job_row(server)
|
|
fid = server.db.insert_finding(
|
|
job_id="j-1", kind="lint", severity="warn", message="m",
|
|
fingerprint="f", file="x.py", line=1, code="X",
|
|
)
|
|
pid = server.db.insert_patch_attempt(
|
|
finding_id=fid, job_id="j-1", project_name="demo",
|
|
attempt_number=1, status="pr_opened",
|
|
)
|
|
|
|
r = tc.get(
|
|
f"/patches/{pid}",
|
|
headers={"Authorization": f"Bearer {ctx['alpha_bearer']}"},
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
assert r.json()["patch"]["id"] == pid
|
|
|
|
|
|
def test_get_patches_detail_other_token_404(client):
|
|
tc, ctx = client
|
|
server = ctx["server"]
|
|
_register_demo_project(tc, ctx["alpha_bearer"])
|
|
_seed_job_row(server)
|
|
fid = server.db.insert_finding(
|
|
job_id="j-1", kind="lint", severity="warn", message="m",
|
|
fingerprint="f", file="x.py", line=1, code="X",
|
|
)
|
|
pid = server.db.insert_patch_attempt(
|
|
finding_id=fid, job_id="j-1", project_name="demo",
|
|
attempt_number=1, status="pr_opened",
|
|
)
|
|
|
|
r = tc.get(
|
|
f"/patches/{pid}",
|
|
headers={"Authorization": f"Bearer {ctx['bravo_bearer']}"},
|
|
)
|
|
assert r.status_code == 404
|
|
|
|
|
|
def test_get_patches_detail_missing(client):
|
|
tc, ctx = client
|
|
r = tc.get(
|
|
"/patches/999999",
|
|
headers={"Authorization": f"Bearer {ctx['alpha_bearer']}"},
|
|
)
|
|
assert r.status_code == 404
|