docs: add PRD for forge native integration
This commit is contained in:
@@ -0,0 +1,378 @@
|
||||
# PRD prd-new: Forge native integration
|
||||
|
||||
- **Status:** Draft
|
||||
- **Author:** claude
|
||||
- **Created:** 2026-06-29
|
||||
- **Issue:** #317
|
||||
|
||||
## Summary
|
||||
|
||||
Add a webhook-driven orchestration layer that lets Gitea issues and PR comments
|
||||
drive bot-bottle sessions end-to-end — no operator in the loop for the happy
|
||||
path. An issue assigned to the agent user and labelled with a bottle name
|
||||
triggers a headless bottle launch; the bottle processes the issue, opens a PR,
|
||||
and is frozen. Subsequent PR comments rehydrate the bottle with the comment as
|
||||
input. The PR is destroyed when it is closed. Every run emits a provenance
|
||||
footer recording the agent identity, bottle, model, egress activity, and
|
||||
gitleaks outcome so each PR carries a verifiable audit trail of how it was
|
||||
produced.
|
||||
|
||||
## Problem
|
||||
|
||||
Today an operator must open the TUI, select an agent and bottle, confirm the
|
||||
preflight, and type prompts interactively. This loop is fine for exploratory
|
||||
work but blocks "issue → PR" automation: nothing triggers a bottle from a forge
|
||||
event, and nothing captures what the agent did in a durable, PR-visible record.
|
||||
The security model already produces the right isolation and egress controls; the
|
||||
missing piece is the orchestration layer that closes the loop between forge
|
||||
events and running bottles, plus a provenance trail that makes the audit story
|
||||
legible to reviewers.
|
||||
|
||||
## Goals / Success Criteria
|
||||
|
||||
1. `./cli.py forge listen` starts a webhook listener. Gitea delivers issue and
|
||||
PR events to it.
|
||||
2. An issue opened with assignee matching the configured agent Gitea username
|
||||
and at least one `bot-bottle:<agent-name>` label launches a headless bottle.
|
||||
The issue title + body is the initial prompt.
|
||||
3. The bottle runs `claude --dangerously-skip-permissions --no-interactive -p
|
||||
"<prompt>"` (non-interactive print mode). When it exits, the orchestrator
|
||||
freezes the bottle and posts a comment with the provenance footer.
|
||||
4. A new comment on the PR associated with the issue rehydrates the bottle with
|
||||
`claude ... --continue -p "<comment body>"` and re-freezes on exit.
|
||||
5. Closing the PR destroys the bottle and cleans up forge state.
|
||||
6. Every comment the orchestrator posts includes a provenance footer: agent
|
||||
name, bottle name(s), model, egress summary, gitleaks pass/fail, start time,
|
||||
and duration.
|
||||
7. Forge state (issue → slug mapping) survives orchestrator restarts: a new
|
||||
`listen` process picks up in-flight bottles from the forge state directory.
|
||||
8. `./cli.py forge status` lists active forge-managed bottles and their
|
||||
associated issue/PR URLs.
|
||||
9. Unit tests cover: label parsing, forge state read/write, provenance footer
|
||||
rendering, headless launch path (no TUI calls), orchestrator event dispatch.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Webhook signature verification (HMAC-SHA256 of the `X-Gitea-Signature` header).
|
||||
Can be added as a follow-up; the listener accepts all POSTs for now.
|
||||
- GitHub or GitLab event support.
|
||||
- Multiple simultaneous forge bottles per issue.
|
||||
- Automatic retry on agent error exit.
|
||||
- Bottle destruction on issue close (only PR close is in scope; issue close is
|
||||
ambiguous — the issue may close before the PR does).
|
||||
- Auto-discovery of repos to watch; the operator configures the Gitea webhook
|
||||
URL manually.
|
||||
- Parallelism between the orchestrator and the running bottle (one active
|
||||
bottle per issue at a time; a new comment while the bottle is running is
|
||||
queued by re-freezing after each exit).
|
||||
|
||||
## Design
|
||||
|
||||
### Label convention
|
||||
|
||||
An issue is forge-targeted when **both** of the following are true:
|
||||
|
||||
- Assignee login matches `FORGE_AGENT_USER` env var (default: `didericis-claude`).
|
||||
- At least one label has the prefix `bot-bottle:`. The suffix names the agent
|
||||
manifest, e.g. `bot-bottle:implementer` → agent `implementer`.
|
||||
|
||||
If the label suffix matches no known agent, the orchestrator posts an error
|
||||
comment and does nothing.
|
||||
|
||||
Optionally, a second label `bot-bottle-bottle:<bottle-name>` overrides the
|
||||
bottle selection (analagous to multi-bottle selection in PRD 0066). When absent,
|
||||
the agent's default bottle is used.
|
||||
|
||||
### Headless launch — `attach_agent_headless`
|
||||
|
||||
A new function in `bot_bottle/cli/start.py`:
|
||||
|
||||
```python
|
||||
def attach_agent_headless(
|
||||
bottle: Bottle,
|
||||
*,
|
||||
prompt: str,
|
||||
resume: bool = False,
|
||||
agent_provider_template: str = "claude",
|
||||
startup_args: tuple[str, ...] = (),
|
||||
) -> int:
|
||||
"""Run the provider CLI inside bottle in non-interactive print mode.
|
||||
|
||||
Blocks until the agent exits; returns the exit code. No tty.
|
||||
resume=True adds --continue so the agent resumes its last session
|
||||
before processing prompt."""
|
||||
runtime = runtime_for(agent_provider_template)
|
||||
agent_args = list(runtime.bypass_args) # --dangerously-skip-permissions
|
||||
agent_args.extend(startup_args)
|
||||
agent_args.append("--no-interactive")
|
||||
if resume:
|
||||
agent_args.extend(runtime.resume_args) # --continue
|
||||
agent_args.extend(["-p", prompt])
|
||||
return bottle.exec_agent(agent_args, tty=False)
|
||||
```
|
||||
|
||||
The system prompt from the agent's manifest `.md` file is still applied via
|
||||
`--append-system-prompt-file` in `startup_args` (provisioned by
|
||||
`ClaudeAgentProvider.provision_prompt`). The `-p` arg is the user-visible
|
||||
prompt the issue or comment supplies.
|
||||
|
||||
### Headless start — `start_headless`
|
||||
|
||||
A new function in `bot_bottle/cli/start.py` that mirrors `_launch_bottle` but
|
||||
skips all TUI steps:
|
||||
|
||||
```python
|
||||
def start_headless(
|
||||
manifest: ManifestIndex,
|
||||
*,
|
||||
agent_name: str,
|
||||
bottle_names: tuple[str, ...],
|
||||
label: str,
|
||||
prompt: str,
|
||||
backend_name: str | None = None,
|
||||
) -> tuple[str, int]:
|
||||
"""Non-interactive bottle launch for forge-driven runs.
|
||||
|
||||
Prepares the bottle, runs attach_agent_headless, and freezes on exit.
|
||||
Returns (slug, exit_code). Does not prompt the operator or open a tty.
|
||||
Raises on backend errors."""
|
||||
```
|
||||
|
||||
`start_headless`:
|
||||
1. Builds a `BottleSpec` with `copy_cwd=False`, `color=""`.
|
||||
2. Calls `backend.prepare` directly (no preflight render, no y/N prompt).
|
||||
3. Enters `backend.launch(plan)` and calls `attach_agent_headless(bottle, prompt=prompt)`.
|
||||
4. Captures session state and returns `(slug, exit_code)`.
|
||||
|
||||
The caller (orchestrator) is responsible for calling
|
||||
`get_freezer(backend_name).commit_slug(slug)` after the bottle exits.
|
||||
|
||||
### Headless resume — `resume_headless`
|
||||
|
||||
A new function in `bot_bottle/cli/resume.py` that mirrors `cmd_resume` but
|
||||
non-interactively:
|
||||
|
||||
```python
|
||||
def resume_headless(
|
||||
slug: str,
|
||||
*,
|
||||
prompt: str,
|
||||
backend_name: str | None = None,
|
||||
) -> int:
|
||||
"""Rehydrate a frozen bottle and run one headless prompt. Returns exit_code."""
|
||||
```
|
||||
|
||||
### Forge state — `bot_bottle/contrib/gitea/forge_state.py`
|
||||
|
||||
Per-issue tracking persisted to disk:
|
||||
|
||||
```
|
||||
~/.bot-bottle/forge/
|
||||
<owner>/
|
||||
<repo>/
|
||||
issue-<n>.json
|
||||
```
|
||||
|
||||
Schema:
|
||||
|
||||
```json
|
||||
{
|
||||
"slug": "implementer-abc12",
|
||||
"pr_number": 42,
|
||||
"agent_name": "implementer",
|
||||
"bottle_names": ["claude"],
|
||||
"backend_name": "docker",
|
||||
"issue_number": 17,
|
||||
"owner": "didericis",
|
||||
"repo": "bot-bottle",
|
||||
"status": "frozen"
|
||||
}
|
||||
```
|
||||
|
||||
`status` is one of `"running"` | `"frozen"` | `"destroyed"`.
|
||||
|
||||
Public API:
|
||||
|
||||
```python
|
||||
def write_forge_state(state: ForgeState) -> None: ...
|
||||
def read_forge_state(owner: str, repo: str, issue_number: int) -> ForgeState | None: ...
|
||||
def delete_forge_state(owner: str, repo: str, issue_number: int) -> None: ...
|
||||
def all_forge_states() -> list[ForgeState]: ...
|
||||
```
|
||||
|
||||
### Provenance — `bot_bottle/contrib/gitea/provenance.py`
|
||||
|
||||
Reads bottle metadata and egress log summary, produces a markdown section:
|
||||
|
||||
```python
|
||||
def build_provenance_footer(
|
||||
slug: str,
|
||||
*,
|
||||
started_at: str,
|
||||
finished_at: str,
|
||||
exit_code: int,
|
||||
egress_log_path: Path | None = None,
|
||||
) -> str:
|
||||
"""Return a markdown string suitable for appending to a PR/comment body."""
|
||||
```
|
||||
|
||||
Output format (collapsed by default via `<details>`):
|
||||
|
||||
```markdown
|
||||
<details><summary>🔬 Run provenance</summary>
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| agent | `implementer` |
|
||||
| bottle | `claude` |
|
||||
| slug | `implementer-abc12` |
|
||||
| started | 2026-06-29T12:00:00-04:00 |
|
||||
| duration | 4m 12s |
|
||||
| exit | 0 ✓ |
|
||||
| gitleaks | ✓ no secrets detected |
|
||||
|
||||
**Egress summary** (deny-by-default; routes allowed: 2)
|
||||
- `api.anthropic.com` — Bearer auth
|
||||
- `gitea.dideric.is` — unauthenticated
|
||||
|
||||
</details>
|
||||
```
|
||||
|
||||
The egress summary is read from the egress log written by the egress proxy
|
||||
sidecar in `~/.bot-bottle/state/<slug>/egress/`. When unavailable (backend
|
||||
has no egress log), the section is omitted rather than erroring.
|
||||
|
||||
### Gitea API client — `bot_bottle/contrib/gitea/client.py`
|
||||
|
||||
Thin stdlib-only HTTP wrapper used by the orchestrator:
|
||||
|
||||
```python
|
||||
class GiteaClient:
|
||||
def __init__(self, *, api_url: str) -> None: ...
|
||||
def post_comment(self, owner: str, repo: str, issue_number: int, body: str) -> None: ...
|
||||
def get_pr_for_issue(self, owner: str, repo: str, issue_number: int) -> int | None:
|
||||
"""Return the PR number whose body references issue_number, or None."""
|
||||
def close_bottle_is_pr_open(self, owner: str, repo: str, pr_number: int) -> bool: ...
|
||||
```
|
||||
|
||||
Authentication is not configured in the client — the egress layer injects the
|
||||
Gitea token on the way out (same pattern as `GiteaDeployKeyProvisioner`).
|
||||
|
||||
### Orchestrator — `bot_bottle/contrib/gitea/orchestrator.py`
|
||||
|
||||
```python
|
||||
class ForgeOrchestrator:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
manifest: ManifestIndex,
|
||||
gitea_client: GiteaClient,
|
||||
agent_user: str,
|
||||
backend_name: str | None = None,
|
||||
) -> None: ...
|
||||
|
||||
def on_issue_opened(self, event: dict) -> None: ...
|
||||
def on_issue_comment_created(self, event: dict) -> None: ...
|
||||
def on_pull_request_closed(self, event: dict) -> None: ...
|
||||
```
|
||||
|
||||
`on_issue_opened`:
|
||||
1. Extract `owner`, `repo`, `issue_number`, `assignees`, `labels`, `title`, `body`.
|
||||
2. Verify assignee contains `agent_user`. Bail silently if not.
|
||||
3. Parse `bot-bottle:<agent-name>` label. Post error comment + return if absent or unknown.
|
||||
4. Parse optional `bot-bottle-bottle:<bottle-name>` label; else `bottle_names = ()`.
|
||||
5. Build prompt: `f"Issue #{issue_number}: {title}\n\n{body}"`.
|
||||
6. Call `start_headless(manifest, agent_name=..., bottle_names=..., label=..., prompt=...)`.
|
||||
7. Write forge state (status=`"running"`).
|
||||
8. On bottle exit: `get_freezer(backend).commit_slug(slug)`.
|
||||
9. Update forge state `status="frozen"`, set `pr_number` by querying Gitea for a PR
|
||||
referencing the issue.
|
||||
10. Post provenance comment on the PR (or the issue if no PR found).
|
||||
|
||||
`on_issue_comment_created`:
|
||||
1. Look up forge state by `(owner, repo, issue_number)`. Skip if not found or destroyed.
|
||||
2. Skip if comment author is `agent_user` (prevents echo loops).
|
||||
3. Skip if forge state `status == "running"` (already active; queue is out of scope).
|
||||
4. Update forge state `status="running"`.
|
||||
5. Call `resume_headless(slug, prompt=comment_body)`.
|
||||
6. Re-freeze: `get_freezer(backend).commit_slug(slug)`.
|
||||
7. Update forge state `status="frozen"`.
|
||||
8. Post provenance comment.
|
||||
|
||||
`on_pull_request_closed`:
|
||||
1. Match `pr_number` against all forge states for `(owner, repo)`.
|
||||
2. Destroy the bottle: call the backend's teardown for `slug` and delete the image.
|
||||
3. Set `status="destroyed"`.
|
||||
|
||||
### Webhook listener — `bot_bottle/contrib/gitea/webhook_server.py`
|
||||
|
||||
Small `http.server.BaseHTTPRequestHandler` that:
|
||||
- Accepts `POST /webhook`.
|
||||
- Reads the `X-Gitea-Event` header to select the handler.
|
||||
- Deserializes the JSON body and calls the orchestrator's matching `on_*` method.
|
||||
- Returns HTTP 200 for known events, 204 for unknown (no-op).
|
||||
|
||||
Runs in the same thread as the CLI (blocking `serve_forever`). The orchestrator
|
||||
handlers are synchronous; long-running launches block the listener thread for
|
||||
the duration. (Concurrent multi-issue handling is out of scope for the MVP.)
|
||||
|
||||
### CLI — `bot_bottle/cli/forge.py`
|
||||
|
||||
```
|
||||
./cli.py forge listen [--host HOST] [--port PORT] [--agent-user USER]
|
||||
./cli.py forge status
|
||||
```
|
||||
|
||||
`listen` defaults: `--host 0.0.0.0 --port 8765 --agent-user $FORGE_AGENT_USER`.
|
||||
|
||||
`status` prints a table of active forge bottles (slug, issue URL, PR URL, status).
|
||||
|
||||
`forge` is registered in `cli.py` alongside `start`, `resume`, `commit`, etc.
|
||||
|
||||
## Provenance as the product
|
||||
|
||||
Every comment the orchestrator posts ends with the provenance footer. The footer
|
||||
is not optional and not configurable off. This is load-bearing: it is the audit
|
||||
trail that lets human reviewers verify what the agent did, what credentials it
|
||||
had access to, what it called out to, and whether gitleaks caught anything.
|
||||
PRs that land without a provenance footer were not opened by the forge
|
||||
integration.
|
||||
|
||||
The footer also links back to the bot-bottle repo (anchored to the commit SHA
|
||||
used for the run, not `main`) so the policy that governed the run is pinned in
|
||||
the PR history.
|
||||
|
||||
## Implementation chunks
|
||||
|
||||
1. **Headless primitives** — `attach_agent_headless` + `start_headless` in
|
||||
`cli/start.py`; `resume_headless` in `cli/resume.py`. Tests: assert no tty,
|
||||
correct arg construction with and without `resume=True`.
|
||||
|
||||
2. **Forge state** — `contrib/gitea/forge_state.py`: `ForgeState` dataclass,
|
||||
`write_forge_state`, `read_forge_state`, `delete_forge_state`,
|
||||
`all_forge_states`. Tests: round-trip JSON, missing file returns None,
|
||||
concurrent-write safety via atomic rename.
|
||||
|
||||
3. **Gitea client** — `contrib/gitea/client.py`: `post_comment`,
|
||||
`get_pr_for_issue`. Tests: mock `urllib.request.urlopen` and assert payloads.
|
||||
|
||||
4. **Provenance** — `contrib/gitea/provenance.py`: `build_provenance_footer`.
|
||||
Tests: verify footer contains all required fields; verify graceful omission
|
||||
when egress log is absent.
|
||||
|
||||
5. **Orchestrator** — `contrib/gitea/orchestrator.py`: `ForgeOrchestrator`
|
||||
with the three `on_*` handlers. Tests: mock `start_headless`,
|
||||
`resume_headless`, `get_freezer`, `GiteaClient`, `forge_state.*`; assert
|
||||
correct calls for each event path (happy path, unknown label, echo-loop
|
||||
prevention, status=running guard).
|
||||
|
||||
6. **Webhook listener** — `contrib/gitea/webhook_server.py`. Tests: mock
|
||||
orchestrator methods; assert correct dispatch per `X-Gitea-Event` value and
|
||||
correct HTTP status codes.
|
||||
|
||||
7. **CLI wiring** — `cli/forge.py` + registration in `cli.py`. Tests:
|
||||
`cmd_forge_status` tabular output, `cmd_forge_listen` argument parsing.
|
||||
|
||||
## Open questions
|
||||
|
||||
None.
|
||||
Reference in New Issue
Block a user