a229a22d54
Implements the bot-bottle side of the forge-native PRD that is self-contained in this repo (the forge sidecar and orchestrate command belong to the separate bot-bottle-orchestrator, a PRD non-goal): - contrib/forge/base.py: Forge ABC + ScopedForge enforcing the read-anywhere / write-scoped model (writes rejected outside the assigned issue/PRs via ForgeScopeError). - contrib/gitea/client.py: GiteaClient (stdlib-only HTTP, mirrors the deploy-key provisioner) + GiteaForge. Token held by the caller (the sidecar), not injected by cred-proxy. - contrib/gitea/forge_state.py: ForgeState dataclass + atomic read/write/delete/all under ~/.bot-bottle/forge/<owner>/<repo>/. - contrib/gitea/provenance.py: build_provenance_footer — collapsed markdown audit footer; watchdog/gitleaks/egress rendering. - cli/resume.py: `resume --headless --prompt` reusing the shipped assume_yes + headless_prompt launch core (the new half of chunk 1). 47 new unit tests; pylint 9.98/10, pyright clean. Forge sidecar (chunk 4), orchestrate command (chunk 6), and forge_env plumbing are deferred: their only consumer is the separate orchestrator and they are untestable in isolation here. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WL77TgFxKbs3cidGMG9dz7
106 lines
3.3 KiB
Python
106 lines
3.3 KiB
Python
"""Forge state persistence (PRD forge-native-integration, chunk 2).
|
|
|
|
The orchestrator tracks one record per forge-targeted issue so it can
|
|
map an incoming webhook back to the bottle handling it, drive the
|
|
freeze / rehydrate loop, and run the watchdog. State lives on disk and
|
|
survives orchestrator restarts:
|
|
|
|
~/.bot-bottle/forge/<owner>/<repo>/issue-<n>.json
|
|
|
|
Writes are atomic (`os.replace`) so a crash mid-write never leaves a
|
|
truncated record.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
from dataclasses import asdict, dataclass, field, fields
|
|
from typing import Any
|
|
from pathlib import Path
|
|
|
|
from ...supervise import bot_bottle_root
|
|
|
|
_FORGE_SUBDIR = "forge"
|
|
|
|
# Lifecycle: a bottle is launched (running), frozen on the done signal,
|
|
# and destroyed when the PR closes.
|
|
STATUS_RUNNING = "running"
|
|
STATUS_FROZEN = "frozen"
|
|
STATUS_DESTROYED = "destroyed"
|
|
|
|
|
|
@dataclass
|
|
class ForgeState:
|
|
"""One forge-targeted issue's bottle lifecycle record."""
|
|
|
|
owner: str
|
|
repo: str
|
|
issue_number: int
|
|
slug: str
|
|
agent_name: str
|
|
bottle_names: list[str] = field(default_factory=list)
|
|
backend_name: str = ""
|
|
agent_git_user: str = ""
|
|
pr_number: int | None = None
|
|
status: str = STATUS_RUNNING
|
|
last_checkin_at: str = ""
|
|
|
|
def to_json(self) -> str:
|
|
return json.dumps(asdict(self), indent=2, sort_keys=True)
|
|
|
|
@classmethod
|
|
def from_dict(cls, data: dict[str, Any]) -> "ForgeState":
|
|
# Tolerate unknown keys (forward-compat) by filtering to fields.
|
|
known = {f.name for f in fields(cls)}
|
|
return cls(**{k: v for k, v in data.items() if k in known})
|
|
|
|
|
|
def _forge_root() -> Path:
|
|
return bot_bottle_root() / _FORGE_SUBDIR
|
|
|
|
|
|
def forge_state_path(owner: str, repo: str, issue_number: int) -> Path:
|
|
return _forge_root() / owner / repo / f"issue-{issue_number}.json"
|
|
|
|
|
|
def write_forge_state(state: ForgeState) -> None:
|
|
"""Persist `state` atomically. Creates parent dirs as needed."""
|
|
path = forge_state_path(state.owner, state.repo, state.issue_number)
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
tmp = path.with_suffix(".json.tmp")
|
|
tmp.write_text(state.to_json())
|
|
os.replace(tmp, path)
|
|
|
|
|
|
def read_forge_state(owner: str, repo: str, issue_number: int) -> ForgeState | None:
|
|
"""Load state for one issue, or None when no record exists."""
|
|
path = forge_state_path(owner, repo, issue_number)
|
|
try:
|
|
data = json.loads(path.read_text())
|
|
except FileNotFoundError:
|
|
return None
|
|
return ForgeState.from_dict(data)
|
|
|
|
|
|
def delete_forge_state(owner: str, repo: str, issue_number: int) -> None:
|
|
"""Remove an issue's record. Missing file is success (idempotent)."""
|
|
path = forge_state_path(owner, repo, issue_number)
|
|
path.unlink(missing_ok=True)
|
|
|
|
|
|
def all_forge_states() -> list[ForgeState]:
|
|
"""Every persisted record, for the orchestrate-status table and the
|
|
watchdog sweep. Unreadable files are skipped rather than aborting the
|
|
whole listing."""
|
|
root = _forge_root()
|
|
if not root.is_dir():
|
|
return []
|
|
states: list[ForgeState] = []
|
|
for path in sorted(root.glob("*/*/issue-*.json")):
|
|
try:
|
|
states.append(ForgeState.from_dict(json.loads(path.read_text())))
|
|
except (OSError, ValueError, TypeError):
|
|
continue
|
|
return states
|