v0.1 wave 3 (steps 9+10): autonomous patch loop + production recipes
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
This commit is contained in:
parent
ecb9d76e6d
commit
4eab869df0
17 changed files with 2752 additions and 78 deletions
|
|
@ -82,6 +82,25 @@ class SmtpConfig:
|
|||
# --- helpers ----------------------------------------------------------------
|
||||
|
||||
|
||||
def _parse_pr_url(pr_url: str) -> tuple[str, str, int] | None:
|
||||
"""Pull (owner, repo, number) out of a Gitea-style PR URL.
|
||||
|
||||
Accepts URLs like ``http://192.168.0.5:3001/Sulkta-Coop/clawdforge/pulls/42``.
|
||||
Returns None if the URL doesn't look right — caller treats that as
|
||||
"can't determine state, assume open".
|
||||
"""
|
||||
try:
|
||||
from urllib.parse import urlparse
|
||||
u = urlparse(pr_url)
|
||||
parts = [p for p in u.path.split("/") if p]
|
||||
# owner/repo/pulls/N
|
||||
if len(parts) >= 4 and parts[-2] in ("pulls", "issues"):
|
||||
return parts[-4], parts[-3], int(parts[-1])
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _job_event_tags(job: dict, findings: list[dict]) -> set[str]:
|
||||
"""Map a job + its findings to notify.on event tags.
|
||||
|
||||
|
|
@ -167,10 +186,16 @@ def _filter_for_project(jobs_with_findings: list[tuple[dict, list[dict]]], notif
|
|||
# --- rendering --------------------------------------------------------------
|
||||
|
||||
|
||||
def _render_text(date_str: str, sections: list[dict], full_log_url: str) -> str:
|
||||
def _render_text(
|
||||
date_str: str,
|
||||
sections: list[dict],
|
||||
full_log_url: str,
|
||||
*,
|
||||
open_followups: int = 0,
|
||||
) -> str:
|
||||
"""Build the text body. Matches the worked example in the spec."""
|
||||
total_runs = sum(len(s["runs"]) for s in sections)
|
||||
total_drafted = 0 # placeholder, wave 3
|
||||
total_drafted = sum(len(s.get("patches", [])) for s in sections)
|
||||
total_cves = sum(s["cves"] for s in sections)
|
||||
subj_summary = f"{total_runs} build" + ("s" if total_runs != 1 else "")
|
||||
lines = []
|
||||
|
|
@ -187,19 +212,32 @@ def _render_text(date_str: str, sections: list[dict], full_log_url: str) -> str:
|
|||
lines.append(
|
||||
f" {glyph} {proj_sub:<32s} {run['recipe']:<6s} {run['status']:<5s} ({run['summary']})"
|
||||
)
|
||||
for patch in s.get("patches", []):
|
||||
if patch.get("branch_name"):
|
||||
lines.append(
|
||||
f" → patch drafted: branch {patch['branch_name']}"
|
||||
)
|
||||
if patch.get("pr_url"):
|
||||
lines.append(f" → PR: {patch['pr_url']}")
|
||||
lines.append("")
|
||||
lines.append("Open follow-ups:")
|
||||
lines.append(" - 0 unmerged auto-patches")
|
||||
lines.append(f" - {open_followups} unmerged auto-patches")
|
||||
lines.append(" - 0 manual review tickets in bugs.sulkta.com")
|
||||
lines.append("")
|
||||
lines.append(f"Full log: {full_log_url}")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def _render_html(date_str: str, sections: list[dict], full_log_url: str) -> str:
|
||||
def _render_html(
|
||||
date_str: str,
|
||||
sections: list[dict],
|
||||
full_log_url: str,
|
||||
*,
|
||||
open_followups: int = 0,
|
||||
) -> str:
|
||||
"""Build the HTML body. Same content, table styling, monospace font."""
|
||||
total_runs = sum(len(s["runs"]) for s in sections)
|
||||
total_drafted = 0
|
||||
total_drafted = sum(len(s.get("patches", [])) for s in sections)
|
||||
total_cves = sum(s["cves"] for s in sections)
|
||||
|
||||
rows = []
|
||||
|
|
@ -211,6 +249,16 @@ def _render_html(date_str: str, sections: list[dict], full_log_url: str) -> str:
|
|||
f"<td>{run['recipe']}</td><td>{run['status']}</td>"
|
||||
f"<td>{run['summary']}</td></tr>"
|
||||
)
|
||||
for patch in s.get("patches", []):
|
||||
cell = ""
|
||||
if patch.get("branch_name"):
|
||||
cell += f"branch <code>{patch['branch_name']}</code>"
|
||||
if patch.get("pr_url"):
|
||||
cell += f" — <a href=\"{patch['pr_url']}\">PR</a>"
|
||||
if cell:
|
||||
rows.append(
|
||||
f'<tr><td>↳</td><td colspan="4">{cell}</td></tr>'
|
||||
)
|
||||
if not rows:
|
||||
rows.append('<tr><td colspan="5"><i>(no activity)</i></td></tr>')
|
||||
|
||||
|
|
@ -234,7 +282,7 @@ tr td:first-child {{ font-size: 1.2em; }}
|
|||
</table>
|
||||
<h3>Open follow-ups</h3>
|
||||
<ul>
|
||||
<li>0 unmerged auto-patches</li>
|
||||
<li>{open_followups} unmerged auto-patches</li>
|
||||
<li>0 manual review tickets in bugs.sulkta.com</li>
|
||||
</ul>
|
||||
<p class="foot">Full log: <a href="{full_log_url}">{full_log_url}</a></p>
|
||||
|
|
@ -267,6 +315,7 @@ class DigestScheduler:
|
|||
hour: int = 6,
|
||||
minute: int = 0,
|
||||
full_log_base_url: str = "http://192.168.0.5:8810/digests",
|
||||
gitea_pr_state_check=None,
|
||||
):
|
||||
self.db = db
|
||||
self.smtp = smtp
|
||||
|
|
@ -274,6 +323,10 @@ class DigestScheduler:
|
|||
self.hour = hour
|
||||
self.minute = minute
|
||||
self.full_log_base_url = full_log_base_url
|
||||
# Optional callable: (owner, repo, number) -> "open" | "closed" | None.
|
||||
# Used to count open follow-ups across all PR-opened patches in the
|
||||
# window. Tests inject a stub so we don't make real network calls.
|
||||
self.gitea_pr_state_check = gitea_pr_state_check
|
||||
|
||||
self._loop_task: asyncio.Task | None = None
|
||||
self._stopping = False
|
||||
|
|
@ -389,6 +442,8 @@ class DigestScheduler:
|
|||
per_project_sections: list[dict] = []
|
||||
per_project_meta: list[dict] = []
|
||||
full_log_url = f"{self.full_log_base_url}/{date_str}"
|
||||
# Total open follow-ups across all projects in the window.
|
||||
open_followups_total = 0
|
||||
|
||||
for prow in projects:
|
||||
recipe = json.loads(prow.get("recipe_json") or "{}")
|
||||
|
|
@ -421,10 +476,44 @@ class DigestScheduler:
|
|||
})
|
||||
cves += sum(1 for f in findings if f.get("kind") == "cve")
|
||||
|
||||
# Patch attempts for this project in the same window.
|
||||
patch_rows = self.db.list_patch_attempts_in_window(
|
||||
window_start=window_start,
|
||||
window_end=window_end,
|
||||
project_name=prow["name"],
|
||||
statuses=("pushed", "pr_opened"),
|
||||
)
|
||||
patch_entries: list[dict] = []
|
||||
for pa in patch_rows:
|
||||
patch_entries.append({
|
||||
"branch_name": pa.get("branch_name"),
|
||||
"pr_url": pa.get("pr_url"),
|
||||
"status": pa.get("status"),
|
||||
})
|
||||
# Count open follow-ups via Gitea state check (when configured).
|
||||
if pa.get("status") == "pr_opened" and pa.get("pr_url"):
|
||||
if self.gitea_pr_state_check is not None:
|
||||
owner_repo_n = _parse_pr_url(pa["pr_url"])
|
||||
if owner_repo_n is not None:
|
||||
owner, repo, n = owner_repo_n
|
||||
try:
|
||||
state = self.gitea_pr_state_check(owner, repo, n)
|
||||
except Exception as e:
|
||||
log.warning(
|
||||
"digest: gitea PR state check failed: %s", e
|
||||
)
|
||||
state = None
|
||||
if state in (None, "open"):
|
||||
open_followups_total += 1
|
||||
else:
|
||||
# Without a checker, treat all pr_opened rows as still open.
|
||||
open_followups_total += 1
|
||||
|
||||
section = {
|
||||
"project": prow["name"],
|
||||
"runs": section_runs,
|
||||
"cves": cves,
|
||||
"patches": patch_entries,
|
||||
}
|
||||
|
||||
meta = {
|
||||
|
|
@ -446,7 +535,7 @@ class DigestScheduler:
|
|||
meta["skipped_reason"] = "no_recipients"
|
||||
per_project_meta.append(meta)
|
||||
continue
|
||||
if not section_runs and not wants_summary:
|
||||
if not section_runs and not patch_entries and not wants_summary:
|
||||
meta["skipped_reason"] = "zero_activity"
|
||||
per_project_meta.append(meta)
|
||||
continue
|
||||
|
|
@ -454,8 +543,18 @@ class DigestScheduler:
|
|||
per_project_sections.append(section)
|
||||
per_project_meta.append(meta)
|
||||
|
||||
text_body = _render_text(date_str, per_project_sections, full_log_url)
|
||||
html_body = _render_html(date_str, per_project_sections, full_log_url)
|
||||
text_body = _render_text(
|
||||
date_str,
|
||||
per_project_sections,
|
||||
full_log_url,
|
||||
open_followups=open_followups_total,
|
||||
)
|
||||
html_body = _render_html(
|
||||
date_str,
|
||||
per_project_sections,
|
||||
full_log_url,
|
||||
open_followups=open_followups_total,
|
||||
)
|
||||
|
||||
# Per-project send loop. Idempotency check via digest_runs UNIQUE.
|
||||
for meta, section in zip(
|
||||
|
|
@ -470,11 +569,22 @@ class DigestScheduler:
|
|||
continue
|
||||
|
||||
# Build a per-project-scoped body.
|
||||
proj_text = _render_text(date_str, [section], full_log_url)
|
||||
proj_html = _render_html(date_str, [section], full_log_url)
|
||||
proj_text = _render_text(
|
||||
date_str,
|
||||
[section],
|
||||
full_log_url,
|
||||
open_followups=open_followups_total,
|
||||
)
|
||||
proj_html = _render_html(
|
||||
date_str,
|
||||
[section],
|
||||
full_log_url,
|
||||
open_followups=open_followups_total,
|
||||
)
|
||||
n_patches = len(section.get("patches", []))
|
||||
subject = (
|
||||
f"crafting-table digest — {date_str} "
|
||||
f"({len(section['runs'])} runs, 0 patches drafted, {section['cves']} CVEs)"
|
||||
f"({len(section['runs'])} runs, {n_patches} patches drafted, {section['cves']} CVEs)"
|
||||
)
|
||||
|
||||
if dry_run or self.smtp is None:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue