Merge pull request 'feat: Epic: Autonomous issue-to-release execution engine' (#15) from timmy/14-epic-autonomous-issue-to-release-execution-engin into main
This commit is contained in:
commit
0cb4c0c1cf
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
.release-engine/
|
||||
32
README.md
32
README.md
|
|
@ -1,3 +1,35 @@
|
|||
# stackchain-dashboard
|
||||
|
||||
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).
|
||||
70
docs/release-engine-spec.md
Normal file
70
docs/release-engine-spec.md
Normal 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
88
release/manifest.json
Normal 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
338
src/release_engine.py
Normal 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())
|
||||
189
tests/test_release_engine.py
Normal file
189
tests/test_release_engine.py
Normal 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()
|
||||
Loading…
Reference in New Issue
Block a user