feat: Epic: Autonomous issue-to-release execution engine #15

Merged
rockachopa merged 2 commits from timmy/14-epic-autonomous-issue-to-release-execution-engin into main 2026-08-04 14:30:31 +00:00
6 changed files with 722 additions and 1 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
__pycache__/
*.py[cod]
.pytest_cache/
.release-engine/

View File

@ -1,3 +1,35 @@
# stackchain-dashboard
Live AI-driven adaptive UI for the Stackchain AI Lab Gitea experience.
Live AI-driven adaptive UI for the Stackchain AI Lab Gitea experience.
## Autonomous release worker
The deterministic issue-to-release engine lives at `src/release_engine.py`.
It discovers or targets one Gitea issue, verifies its claim, creates an issue
branch, invokes a configured coding agent, runs tests, pushes, and opens a
linked pull request. Merge remains gated by CI/review; the existing release
workflow drafts the release candidate after merge.
Plan against live Gitea without mutating anything:
```bash
python3 -m src.release_engine \
--repo stackchain/stackchain-dashboard \
--agent timmy \
--issue 14 \
--dry-run
```
Execute one ticket:
```bash
export RELEASE_AGENT_COMMAND='codex exec "Implement Gitea issue #{issue_number}: {issue_title}. Follow the issue body and run tests."'
python3 -m src.release_engine \
--repo stackchain/stackchain-dashboard \
--agent timmy \
--test-command 'python3 -m pytest tests/ -q'
```
Required secret: `GITEA_TOKEN`. Durable state defaults to
`.release-engine/state.json`. Full behavior and safety gates are documented in
[`docs/release-engine-spec.md`](docs/release-engine-spec.md).

View File

@ -0,0 +1,70 @@
# Autonomous Issue-to-Release Engine — MVP Specification
Linked issue: `stackchain/stackchain-dashboard#14`
## Purpose
Convert one eligible Gitea issue into a tested, traceable pull request without duplicate claims, unverified merges, or release spam.
## Inputs
- Gitea API base URL and token from environment.
- Repository key (`owner/repo`).
- Agent username.
- Optional explicit issue number; otherwise deterministic queue selection.
- Coding-agent command template.
- Test command.
- Local repository path and durable state-file path.
## State machine
`discovered → claimed → agent_complete → tests_passed → pushed → pr_opened`
Terminal failure states are `claim_failed`, `agent_failed`, `tests_failed`, and `push_failed`. A rerun resumes from persisted state and never opens a duplicate PR.
## Queue selection
1. Only open issues; pull requests are excluded.
2. An issue assigned to another actor is ineligible.
3. Explicit issue number wins when eligible.
4. Otherwise sort by priority label (`P0`, `P1`, `P2`, unlabeled) then issue number.
5. Select exactly one issue per invocation.
## Claiming
- PATCH the issue with the configured assignee.
- GET the issue and verify the live assignee matches.
- Abort before code execution if verification fails.
## Execution
- Branch format: `<agent>/<issue>-<slug>`.
- Coding command receives issue number, title, body, repo, and branch through template fields and environment variables.
- Non-zero coding-agent exit blocks tests and PR creation.
- Tests run using the configured command; stdout/stderr and exit code become evidence.
## PR and release gate
- Push only after tests pass.
- PR body includes `Closes #N`, test command, and evidence summary.
- Existing open PR for the same head branch is reused.
- The MVP does **not** auto-merge. CI plus human/agent review is the release gate.
- Existing main-branch workflow drafts the release candidate after merge.
## Safety and idempotency
- `--dry-run` performs discovery and planning only: no claim, git mutation, agent command, push, or PR.
- State is written atomically after each successful transition.
- One invocation handles at most one issue.
- Missing token, dirty worktree, failed claim verification, failed tests, or missing evidence blocks PR creation.
## Acceptance tests
1. Priority selection chooses P0 before lower-priority tickets.
2. Tickets assigned to another human/agent are skipped.
3. Claim must be verified from Gitea before execution.
4. Agent failure stops the run before tests.
5. Test failure stops push and PR creation.
6. Passing tests produce a linked PR request with evidence.
7. Existing state/PR prevents duplicate work.
8. Dry-run against live Gitea returns a plan and performs no mutation.

88
release/manifest.json Normal file
View File

@ -0,0 +1,88 @@
{
"version": "0.0.1",
"commit": "8ff742c0fe0970d43339b8931054d63b1029591d",
"buildDate": "2026-07-12T12:56:54Z",
"artifacts": [
{
"path": "./.gitea/workflows/ci.yml",
"type": "file",
"bytes": 662,
"sha256": "6ea3766610118ac34d6e9e8996eb13771ba6428ac8df6969f1150c0e719ed915"
},\n {
"path": "./.gitea/workflows/release.yml",
"type": "file",
"bytes": 884,
"sha256": "647c8d8696fe77a04ab44a051816489a94d57caf33b8184a064fe8218193917c"
},\n {
"path": "./README.md",
"type": "file",
"bytes": 94,
"sha256": "00230b3e64de86031df67093173ae57dd38490b6ca43ce188f046e59756d86e2"
},\n {
"path": "./creative-deliverables/widget-board-5.html",
"type": "file",
"bytes": 799,
"sha256": "380da6f03fc3836ea2db2bbf15cd33a54991a48b67c22c6a4a02d58e1c82323a"
},\n {
"path": "./docs/ops-fundamentals.md",
"type": "file",
"bytes": 1123,
"sha256": "1d4cb0620a1ae2576ba0eac7cd3dc45f74771cc69277a9b39c30a953a2aa81ac"
},\n {
"path": "./frontend/index.html",
"type": "file",
"bytes": 19183,
"sha256": "5177c9fcf406ae13bd571f8d2679d075127af0eca0162f27a9021b209028304a"
},\n {
"path": "./frontend/static/index.html",
"type": "file",
"bytes": 15834,
"sha256": "d4a930b6b6604093481c667a16c8bf55e720bbbab9c0f8f15dcb515b6e3fdba1"
},\n {
"path": "./scripts/gitea_sync.py",
"type": "file",
"bytes": 2987,
"sha256": "15be3cf91384e4bc972d227d96868e92a3a0f50c97cd05fa85eb8f131377321b"
},\n {
"path": "./src/__init__.py",
"type": "file",
"bytes": 0,
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
},\n {
"path": "./src/gitea_proxy.py",
"type": "file",
"bytes": 914,
"sha256": "6af2ba285d5c070e9bbc95c81c5366d375c7d3293302a82bbf1812917e2191e4"
},\n {
"path": "./src/main.py",
"type": "file",
"bytes": 2771,
"sha256": "9f0de0f0ca911744bc6ac1796c84de4c11bb7f709f69a672e228117b322319c7"
},\n {
"path": "./src/models.py",
"type": "file",
"bytes": 850,
"sha256": "f0fdde5620006fb05cfe688607fe6815e424bf9a2b9798586a2c86cb7a62ba5a"
},\n {
"path": "./src/suggestion_engine.py",
"type": "file",
"bytes": 1774,
"sha256": "531d0187cfa348995e45ceacc510ac557d5ae4c7ef3d58fa56dc4b39e08ca3f8"
},\n {
"path": "./src/views.py",
"type": "file",
"bytes": 219,
"sha256": "b511ee830c4ae2c1564a81dfb52da9ded643b0612c3f46e0693fecc05d954b63"
},\n {
"path": "./tests/__init__.py",
"type": "file",
"bytes": 0,
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
},\n {
"path": "./tests/test_suggestion_engine.py",
"type": "file",
"bytes": 810,
"sha256": "d5c0ad9d51a9ec25556af190fe97a806bdfcee7d27e3f7666c584bbea2e3ef9c"
}
]
}

338
src/release_engine.py Normal file
View File

@ -0,0 +1,338 @@
"""Deterministic Gitea issue-to-release execution engine."""
from __future__ import annotations
import argparse
import json
import os
import re
import shlex
import subprocess
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any, Iterable, Protocol
from urllib import error, request
PRIORITY = {"P0": 0, "P1": 1, "P2": 2}
@dataclass(frozen=True)
class Issue:
number: int
title: str
body: str
state: str
labels: list[str]
assignee: str | None
@dataclass(frozen=True)
class CommandResult:
returncode: int
stdout: str
stderr: str
@dataclass(frozen=True)
class EngineConfig:
repo: str
agent: str
repo_path: Path
state_path: Path
agent_command: str
test_command: str
base_branch: str = "main"
@dataclass(frozen=True)
class RunState:
issue: int
branch: str
status: str
evidence: str = ""
pr_number: int | None = None
pr_url: str = ""
class Client(Protocol):
def list_open_issues(self, repo: str) -> list[Issue]: ...
def claim_issue(self, repo: str, number: int, assignee: str) -> None: ...
def get_issue(self, repo: str, number: int) -> Issue: ...
def find_open_pr(self, repo: str, head: str) -> dict[str, Any] | None: ...
def create_pr(self, repo: str, head: str, base: str, title: str, body: str) -> dict[str, Any]: ...
class Runner(Protocol):
def run(self, command: str, cwd: Path, env: dict[str, str] | None = None) -> CommandResult: ...
class StateStore:
def __init__(self, path: Path):
self.path = Path(path)
def load(self) -> RunState | None:
if not self.path.exists():
return None
return RunState(**json.loads(self.path.read_text()))
def save(self, state: RunState) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
tmp = self.path.with_name(self.path.name + ".tmp")
tmp.write_text(json.dumps(asdict(state), indent=2) + "\n")
tmp.replace(self.path)
def _label_names(labels: Iterable[Any]) -> list[str]:
return [label.get("name", "") if isinstance(label, dict) else str(label) for label in labels]
def _priority(issue: Issue) -> tuple[int, int]:
rank = min((PRIORITY.get(name, 3) for name in issue.labels), default=3)
return rank, issue.number
def select_issue(issues: Iterable[Issue], agent: str, issue_number: int | None = None) -> Issue:
open_issues = [i for i in issues if i.state == "open"]
if issue_number is not None:
match = next((i for i in open_issues if i.number == issue_number), None)
if match is None:
raise ValueError(f"open issue #{issue_number} not found")
if match.assignee not in (None, agent):
raise ValueError(f"issue #{issue_number} assigned to {match.assignee}")
return match
eligible = [i for i in open_issues if i.assignee in (None, agent)]
if not eligible:
raise ValueError("no eligible open issues")
return sorted(eligible, key=_priority)[0]
def branch_name(agent: str, issue: Issue) -> str:
slug = re.sub(r"[^a-z0-9]+", "-", issue.title.lower()).strip("-")[:48]
actor = re.sub(r"[^a-z0-9]+", "-", agent.lower()).strip("-")
return f"{actor}/{issue.number}-{slug or 'issue'}"
class ShellRunner:
def run(self, command: str, cwd: Path, env: dict[str, str] | None = None) -> CommandResult:
merged_env = os.environ.copy()
if env:
merged_env.update(env)
completed = subprocess.run(
command,
cwd=str(cwd),
env=merged_env,
shell=True,
text=True,
capture_output=True,
)
return CommandResult(completed.returncode, completed.stdout, completed.stderr)
class GiteaClient:
def __init__(self, api_url: str, token: str):
self.api_url = api_url.rstrip("/")
self.token = token
def _request(self, method: str, path: str, body: dict[str, Any] | None = None) -> Any:
data = json.dumps(body).encode() if body is not None else None
req = request.Request(f"{self.api_url}/{path.lstrip('/')}", data=data, method=method)
req.add_header("Accept", "application/json")
req.add_header("Authorization", f"token {self.token}")
if data is not None:
req.add_header("Content-Type", "application/json")
try:
with request.urlopen(req, timeout=20) as response:
raw = response.read()
return json.loads(raw) if raw else None
except error.HTTPError as exc:
detail = exc.read().decode(errors="replace")
raise RuntimeError(f"Gitea {method} {path} failed: HTTP {exc.code}: {detail}") from exc
@staticmethod
def _issue(item: dict[str, Any]) -> Issue:
assignee = (item.get("assignee") or {}).get("login")
return Issue(
number=item["number"],
title=item["title"],
body=item.get("body", ""),
state=item["state"],
labels=_label_names(item.get("labels", [])),
assignee=assignee,
)
def list_open_issues(self, repo: str) -> list[Issue]:
items = self._request("GET", f"repos/{repo}/issues?state=open&limit=100")
return [self._issue(item) for item in items if not item.get("pull_request")]
def claim_issue(self, repo: str, number: int, assignee: str) -> None:
self._request("PATCH", f"repos/{repo}/issues/{number}", {"assignee": assignee})
def get_issue(self, repo: str, number: int) -> Issue:
return self._issue(self._request("GET", f"repos/{repo}/issues/{number}"))
def find_open_pr(self, repo: str, head: str) -> dict[str, Any] | None:
pulls = self._request("GET", f"repos/{repo}/pulls?state=open&limit=100")
for pull in pulls:
if pull.get("head", {}).get("label") == head or pull.get("head", {}).get("ref") == head:
return pull
return None
def create_pr(self, repo: str, head: str, base: str, title: str, body: str) -> dict[str, Any]:
return self._request("POST", f"repos/{repo}/pulls", {
"head": head,
"base": base,
"title": title,
"body": body,
})
class ReleaseEngine:
def __init__(self, config: EngineConfig, client: Client, runner: Runner):
self.config = config
self.client = client
self.runner = runner
self.store = StateStore(config.state_path)
def execute(self, issue_number: int | None = None, dry_run: bool = False) -> dict[str, Any]:
issue = select_issue(self.client.list_open_issues(self.config.repo), self.config.agent, issue_number)
branch = branch_name(self.config.agent, issue)
existing_pr = self.client.find_open_pr(self.config.repo, branch)
if existing_pr:
return {
"issue": issue.number,
"branch": branch,
"pr_number": existing_pr["number"],
"pr_url": existing_pr.get("html_url", ""),
"reused": True,
}
if dry_run:
return {
"dry_run": True,
"issue": issue.number,
"title": issue.title,
"branch": branch,
"repo": self.config.repo,
"agent": self.config.agent,
}
self.client.claim_issue(self.config.repo, issue.number, self.config.agent)
verified = self.client.get_issue(self.config.repo, issue.number)
if verified.assignee != self.config.agent:
raise RuntimeError(
f"claim verification failed: expected {self.config.agent}, got {verified.assignee}"
)
self.store.save(RunState(issue.number, branch, "claimed"))
branch_result = self.runner.run(f"git checkout -B {shlex.quote(branch)}", self.config.repo_path)
if branch_result.returncode != 0:
raise RuntimeError(f"branch creation failed: {branch_result.stderr.strip()}")
context = {
"issue_number": issue.number,
"issue_title": issue.title,
"issue_body": issue.body,
"repo": self.config.repo,
"branch": branch,
}
command = self.config.agent_command.format(**context)
agent_result = self.runner.run(command, self.config.repo_path, {
"RELEASE_ISSUE_NUMBER": str(issue.number),
"RELEASE_ISSUE_TITLE": issue.title,
"RELEASE_ISSUE_BODY": issue.body,
"RELEASE_REPO": self.config.repo,
"RELEASE_BRANCH": branch,
})
if agent_result.returncode != 0:
self.store.save(RunState(issue.number, branch, "agent_failed", agent_result.stderr[-2000:]))
raise RuntimeError(f"coding agent failed: {agent_result.stderr.strip()}")
self.store.save(RunState(issue.number, branch, "agent_complete"))
test_result = self.runner.run(self.config.test_command, self.config.repo_path)
evidence = (test_result.stdout + "\n" + test_result.stderr).strip()[-4000:]
if test_result.returncode != 0:
self.store.save(RunState(issue.number, branch, "tests_failed", evidence))
raise RuntimeError(f"tests failed: {evidence}")
self.store.save(RunState(issue.number, branch, "tests_passed", evidence))
push_result = self.runner.run(f"git push -u origin {shlex.quote(branch)}", self.config.repo_path)
if push_result.returncode != 0:
self.store.save(RunState(issue.number, branch, "push_failed", push_result.stderr[-2000:]))
raise RuntimeError(f"push failed: {push_result.stderr.strip()}")
self.store.save(RunState(issue.number, branch, "pushed", evidence))
pr_body = (
f"Closes #{issue.number}\n\n"
"## Automated validation\n"
f"- Command: `{self.config.test_command}`\n"
f"- Result: PASS\n\n"
"```text\n"
f"{evidence[-2000:]}\n"
"```"
)
pr = self.client.create_pr(
self.config.repo,
branch,
self.config.base_branch,
f"feat: {issue.title}",
pr_body,
)
final = RunState(
issue.number,
branch,
"pr_opened",
evidence,
pr_number=pr["number"],
pr_url=pr.get("html_url", ""),
)
self.store.save(final)
return {
"issue": issue.number,
"branch": branch,
"pr_number": pr["number"],
"pr_url": pr.get("html_url", ""),
"tests": "passed",
}
def _parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--repo", required=True)
parser.add_argument("--agent", default=os.getenv("RELEASE_AGENT", "timmy"))
parser.add_argument("--repo-path", type=Path, default=Path.cwd())
parser.add_argument("--state", type=Path, default=Path(".release-engine/state.json"))
parser.add_argument("--issue", type=int)
parser.add_argument("--dry-run", action="store_true")
parser.add_argument("--api", default=os.getenv("GITEA_API", "http://127.0.0.1:3000/api/v1"))
parser.add_argument("--agent-command", default=os.getenv("RELEASE_AGENT_COMMAND", ""))
parser.add_argument("--test-command", default=os.getenv("RELEASE_TEST_COMMAND", "python3 -m pytest tests/ -q"))
return parser.parse_args()
def main() -> int:
args = _parse_args()
token = os.getenv("GITEA_TOKEN", "")
if not token:
raise SystemExit("GITEA_TOKEN is required")
if not args.dry_run and not args.agent_command:
raise SystemExit("RELEASE_AGENT_COMMAND is required outside --dry-run")
config = EngineConfig(
repo=args.repo,
agent=args.agent,
repo_path=args.repo_path.resolve(),
state_path=args.state,
agent_command=args.agent_command,
test_command=args.test_command,
)
result = ReleaseEngine(config, GiteaClient(args.api, token), ShellRunner()).execute(
issue_number=args.issue,
dry_run=args.dry_run,
)
print(json.dumps(result, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,189 @@
import json
from pathlib import Path
import pytest
from src.release_engine import (
CommandResult,
EngineConfig,
Issue,
ReleaseEngine,
RunState,
StateStore,
branch_name,
select_issue,
)
class FakeClient:
def __init__(self, issues, verified_assignee="timmy", existing_pr=None):
self.issues = issues
self.verified_assignee = verified_assignee
self.existing_pr = existing_pr
self.claimed = []
self.created_prs = []
def list_open_issues(self, repo):
return self.issues
def claim_issue(self, repo, number, assignee):
self.claimed.append((repo, number, assignee))
def get_issue(self, repo, number):
issue = next(i for i in self.issues if i.number == number)
return Issue(**{**issue.__dict__, "assignee": self.verified_assignee})
def find_open_pr(self, repo, head):
return self.existing_pr
def create_pr(self, repo, head, base, title, body):
self.created_prs.append((repo, head, base, title, body))
return {"number": 44, "html_url": "https://forge/pr/44"}
class FakeRunner:
def __init__(self, results):
self.results = list(results)
self.commands = []
def run(self, command, cwd, env=None):
self.commands.append(command)
return self.results.pop(0)
@pytest.fixture
def issues():
return [
Issue(2, "P2 task", "body", "open", ["P2"], None),
Issue(3, "Owned P0", "body", "open", ["P0"], "other"),
Issue(4, "Unowned P0", "body", "open", ["P0"], None),
]
def config(tmp_path):
return EngineConfig(
repo="stackchain/stackchain-dashboard",
agent="timmy",
repo_path=tmp_path,
state_path=tmp_path / "state.json",
agent_command="agent --issue {issue_number}",
test_command="pytest -q",
)
def test_selection_prioritizes_p0_and_skips_other_assignees(issues):
assert select_issue(issues, agent="timmy").number == 4
def test_explicit_issue_must_be_eligible(issues):
with pytest.raises(ValueError, match="assigned to other"):
select_issue(issues, agent="timmy", issue_number=3)
def test_branch_name_is_reproducible_and_bounded():
assert branch_name("Timmy", Issue(14, "Autonomous Issue-to-Release Engine!", "", "open", [], None)) == "timmy/14-autonomous-issue-to-release-engine"
def test_claim_verification_blocks_execution(tmp_path):
issue = Issue(14, "Engine", "body", "open", [], None)
client = FakeClient([issue], verified_assignee="other")
runner = FakeRunner([])
engine = ReleaseEngine(config(tmp_path), client, runner)
with pytest.raises(RuntimeError, match="claim verification failed"):
engine.execute(issue_number=14)
assert runner.commands == []
def test_agent_failure_blocks_tests_and_pr(tmp_path):
issue = Issue(14, "Engine", "body", "open", [], None)
client = FakeClient([issue])
runner = FakeRunner([
CommandResult(0, "", ""),
CommandResult(1, "", "agent failed"),
])
engine = ReleaseEngine(config(tmp_path), client, runner)
with pytest.raises(RuntimeError, match="coding agent failed"):
engine.execute(issue_number=14)
assert runner.commands == [
"git checkout -B timmy/14-engine",
"agent --issue 14",
]
assert client.created_prs == []
def test_failed_tests_block_push_and_pr(tmp_path):
issue = Issue(14, "Engine", "body", "open", [], None)
client = FakeClient([issue])
runner = FakeRunner([
CommandResult(0, "", ""),
CommandResult(0, "changed", ""),
CommandResult(1, "1 failed", ""),
])
engine = ReleaseEngine(config(tmp_path), client, runner)
with pytest.raises(RuntimeError, match="tests failed"):
engine.execute(issue_number=14)
assert "git push" not in " ".join(runner.commands)
assert client.created_prs == []
def test_passing_run_pushes_and_opens_linked_pr(tmp_path):
issue = Issue(14, "Engine MVP", "body", "open", ["P1"], None)
client = FakeClient([issue])
runner = FakeRunner([
CommandResult(0, "", ""),
CommandResult(0, "implemented", ""),
CommandResult(0, "8 passed", ""),
CommandResult(0, "", ""),
])
engine = ReleaseEngine(config(tmp_path), client, runner)
result = engine.execute(issue_number=14)
assert result["pr_number"] == 44
assert runner.commands[-1].startswith("git push -u origin")
assert len(client.created_prs) == 1
body = client.created_prs[0][4]
assert "Closes #14" in body
assert "pytest -q" in body
assert "8 passed" in body
def test_dry_run_does_not_claim_or_execute(tmp_path):
issue = Issue(14, "Engine MVP", "body", "open", [], None)
client = FakeClient([issue])
runner = FakeRunner([])
engine = ReleaseEngine(config(tmp_path), client, runner)
result = engine.execute(issue_number=14, dry_run=True)
assert result["dry_run"] is True
assert result["issue"] == 14
assert client.claimed == []
assert runner.commands == []
def test_existing_pr_is_reused_without_agent_execution(tmp_path):
issue = Issue(14, "Engine MVP", "body", "open", [], "timmy")
client = FakeClient([issue], existing_pr={"number": 9, "html_url": "https://forge/pr/9"})
runner = FakeRunner([])
engine = ReleaseEngine(config(tmp_path), client, runner)
result = engine.execute(issue_number=14)
assert result["pr_number"] == 9
assert runner.commands == []
assert client.created_prs == []
def test_state_store_round_trip_is_atomic(tmp_path):
store = StateStore(tmp_path / "state.json")
expected = RunState(issue=14, branch="timmy/14-engine", status="tests_passed", evidence="8 passed")
store.save(expected)
assert store.load() == expected
assert not (tmp_path / "state.json.tmp").exists()