diff --git a/.gitignore b/.gitignore index e458ed5..0923566 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ .worktrees/ +.tmp/ diff --git a/.ralph/ralph-context.md b/.ralph/ralph-context.md new file mode 100644 index 0000000..e2bcef4 --- /dev/null +++ b/.ralph/ralph-context.md @@ -0,0 +1,6 @@ +# Ralph Context + +- Follow the approved design in `docs/superpowers/specs/2026-03-13-gitea-agentic-runtime-design.md`. +- Execute tasks from `docs/superpowers/plans/2026-03-13-gitea-agentic-runtime-plan.md` in order. +- Keep Gitea as the only real provider in this iteration. +- Preserve platform-agnostic boundaries so GitHub can be added later. diff --git a/.ralph/ralph-history.md b/.ralph/ralph-history.md new file mode 100644 index 0000000..71c4fe4 --- /dev/null +++ b/.ralph/ralph-history.md @@ -0,0 +1,101 @@ +# Ralph History + +## Iteration 0 + +- Initialized worktree `feature/gitea-runtime-control-plane` +- Wrote approved design spec and implementation plan +- Created Ralph loop contract and task list +- Verification: not started yet + +## Iteration 1 + +- Goal: complete Task 1 package scaffold, spec loader, and sample workflow +- Changed files: + - `pyproject.toml` + - `engine/devops_agent/__init__.py` + - `engine/devops_agent/cli.py` + - `engine/devops_agent/spec.py` + - `workflows/gitea-issue-delivery.md` + - `tests/unit/test_smoke_imports.py` + - `tests/unit/test_spec_loader.py` + - `tests/fixtures/specs/valid_workflow.md` + - `tests/fixtures/specs/invalid_missing_provider.md` + - `.gitignore` +- Verification: + - `python -m pytest tests/unit/test_smoke_imports.py tests/unit/test_spec_loader.py -q` + - Result: `4 passed` +- Blockers: + - Pytest tmp and cache facilities are unreliable in this Windows sandbox path, so tests must avoid `tmp_path` and rely on stable fixture files or repo-local paths. + +## Iteration 2 + +- Goal: complete Task 2 compiler, validator, and policy enforcement +- Changed files: + - `engine/devops_agent/compiler.py` + - `engine/devops_agent/validator.py` + - `engine/devops_agent/policies.py` + - `engine/devops_agent/spec.py` + - `tests/unit/test_compiler.py` + - `tests/unit/test_validator.py` + - `tests/unit/test_policies.py` + - `tests/fixtures/specs/no_safe_outputs_for_write.md` + - `tests/fixtures/specs/invalid_path_scope.md` +- Verification: + - `python -m pytest tests/unit/test_smoke_imports.py tests/unit/test_spec_loader.py tests/unit/test_compiler.py tests/unit/test_validator.py tests/unit/test_policies.py -q` + - Result: `11 passed` +- Blockers: + - PyYAML treats `on` as a boolean in default parsing, so the spec loader now normalizes that key explicitly. + +## Iteration 3 + +- Goal: complete Task 3 provider layer, Gitea provider, runtime, and evidence persistence +- Changed files: + - `engine/devops_agent/evidence.py` + - `engine/devops_agent/runtime.py` + - `engine/devops_agent/providers/__init__.py` + - `engine/devops_agent/providers/base.py` + - `engine/devops_agent/providers/gitea.py` + - `tests/unit/test_gitea_provider.py` + - `tests/integration/test_runtime_flow.py` + - `tests/fixtures/gitea/issue.json` + - `tests/fixtures/gitea/comment_event.json` +- Verification: + - `python -m pytest tests/unit tests/integration -q` + - Result: `15 passed` +- Blockers: + - None in code shape. Real Gitea acceptance still depends on environment credentials. + +## Iteration 4 + +- Goal: complete Task 4 CLI, acceptance tests, and documentation updates +- Changed files: + - `engine/devops_agent/cli.py` + - `tests/unit/test_cli.py` + - `tests/acceptance/test_gitea_acceptance.py` + - `README.md` + - `skills/gitea-issue-devops-agent/SKILL.md` + - `workflows/gitea-issue-delivery.lock.json` +- Verification: + - `python -m pytest tests/unit tests/integration tests/acceptance -q` + - `python -m engine.devops_agent.cli compile workflows/gitea-issue-delivery.md --output workflows/gitea-issue-delivery.lock.json` + - `python -m engine.devops_agent.cli validate workflows/gitea-issue-delivery.md` + - Result: `18 passed, 1 skipped` +- Blockers: + - Real Gitea acceptance is blocked until `GITEA_BASE_URL`, `GITEA_REPO`, `GITEA_TOKEN`, and `GITEA_ISSUE_NUMBER` are provided. + +## Iteration 5 + +- Goal: complete Task 5 full verification and real Gitea acceptance handoff +- Verification: + - `python -m pytest tests/unit tests/integration tests/acceptance -q` + - `python -m engine.devops_agent.cli compile workflows/gitea-issue-delivery.md --output workflows/gitea-issue-delivery.lock.json` + - `python -m engine.devops_agent.cli validate workflows/gitea-issue-delivery.md` + - `git diff --check` + - Result: `19 passed` +- Real acceptance evidence: + - repo: `FunMD/document-collab` + - issue: `#48` + - comment id: `246` + - comment url: `http://154.39.79.147:3000/FunMD/document-collab/issues/48#issuecomment-246` +- Blockers: + - None for the Gitea single-platform acceptance target. diff --git a/.ralph/ralph-loop-plan.md b/.ralph/ralph-loop-plan.md new file mode 100644 index 0000000..27c954d --- /dev/null +++ b/.ralph/ralph-loop-plan.md @@ -0,0 +1,28 @@ +# Ralph Loop Plan + +## Goal + +Integrate a Gitea-backed agentic workflow runtime into this repository so the product moves from skill-only guidance to a real execution control plane. Deliver a repo-local workflow spec format, compiler, validator, runtime, Gitea provider, safe-output policy enforcement, evidence persistence, automated tests, and one real Gitea acceptance path. + +## Acceptance Criteria + +1. `workflows/gitea-issue-delivery.md` compiles into a lock artifact. +2. Invalid workflow specs fail validation with explicit errors. +3. Runtime refuses undeclared write actions and records evidence for allowed ones. +4. Automated unit and integration tests pass. +5. Real Gitea acceptance can read the selected issue and publish an evidence comment when credentials are present. +6. README and `skills/gitea-issue-devops-agent/SKILL.md` reflect the new runtime model. + +## Verification Commands + +- `python -m pytest tests/unit tests/integration -q` +- `python -m pytest tests/acceptance/test_gitea_acceptance.py -q` +- `python -m engine.devops_agent.cli compile workflows/gitea-issue-delivery.md --output workflows/gitea-issue-delivery.lock.json` +- `python -m engine.devops_agent.cli validate workflows/gitea-issue-delivery.md` +- `git diff --check` + +## Promises + +- completion_promise: `COMPLETE` +- abort_promise: `ABORT` +- max_iterations: `5` diff --git a/.ralph/ralph-tasks.md b/.ralph/ralph-tasks.md new file mode 100644 index 0000000..8516ea3 --- /dev/null +++ b/.ralph/ralph-tasks.md @@ -0,0 +1,7 @@ +# Ralph Tasks + +- [x] Task 1: Add Python package scaffold, workflow spec loader, and sample workflow +- [x] Task 2: Add compiler, validator, and policy enforcement +- [x] Task 3: Add provider layer, Gitea provider, runtime, and evidence persistence +- [x] Task 4: Add CLI commands, acceptance tests, and documentation updates +- [x] Task 5: Run full verification, compile sample workflow, and prepare manual acceptance handoff diff --git a/README.md b/README.md index ca34eac..8b1a9cd 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,80 @@ 提测沉淀 commit、PR、测试链接、环境 URL、验证步骤;最终合并必须工程师人工确认。 +## 新增运行时能力 + +仓库现在不再只有 skill 文档和辅助脚本,还新增了一个最小可执行控制平面: + +- `workflows/*.md` + - agentic workflow spec 源文件 +- `workflows/*.lock.json` + - 编译后的锁定执行产物 +- `engine/devops_agent/*` + - spec loader、compiler、validator、runtime、policy、provider、evidence、CLI + +当前已实装的真实 provider 是 `Gitea`,并保留了后续接 `GitHub` 的 provider 边界。 + +## 运行时命令 + +### Compile + +```bash +python -m engine.devops_agent.cli compile workflows/gitea-issue-delivery.md --output workflows/gitea-issue-delivery.lock.json +``` + +### Validate + +```bash +python -m engine.devops_agent.cli validate workflows/gitea-issue-delivery.md +``` + +### Run + +```bash +python -m engine.devops_agent.cli run workflows/gitea-issue-delivery.md \ + --event-payload tests/fixtures/gitea/comment_event.json \ + --output-dir .tmp/runtime-run \ + --base-url https://fun-md.com \ + --token +``` + +### Acceptance + +```bash +python -m engine.devops_agent.cli acceptance workflows/gitea-issue-delivery.md \ + --base-url https://fun-md.com \ + --repo Fun_MD/devops-skills \ + --token \ + --issue-number 48 \ + --output-dir .tmp/acceptance/gitea +``` + +运行时输出: + +- `run-artifact.json` +- 计划状态摘要 +- evidence comment 回写结果 + +## Safe Outputs 与定位 + +这次整合没有把产品做成 GitHub Actions 克隆,而是把 `gh-aw` 最有价值的部分内化为你们自己的控制层: + +- `workflow spec` +- `compile / validate` +- `safe outputs` +- `provider abstraction` +- `evidence artifacts` + +对外仍然保持: + +- `issue / git branch / PR / CI/CD / review apps` + +对内则新增: + +- 不允许未声明的写操作 +- 不允许跳过 validation 直接执行 +- 不允许没有 evidence 就宣称完成 + ## 一键安装 安装器现在会先安装 skill,再默认尝试安装 `jj`。 @@ -93,7 +167,31 @@ jj config set --user user.email "you@example.com" - `skills/gitea-issue-devops-agent/references/jj-default-usage.md` -## 工具使用说明 +## 运行时与工具使用说明 + +### workflow spec + +- `workflows/gitea-issue-delivery.md` + - 当前样例 workflow spec +- `workflows/gitea-issue-delivery.lock.json` + - 编译后的锁定产物,建议与 spec 一起提交 + +### acceptance 环境变量 + +真实 Gitea 验收测试读取以下环境变量: + +```bash +GITEA_BASE_URL=https://fun-md.com +GITEA_REPO=Fun_MD/devops-skills +GITEA_TOKEN= +GITEA_ISSUE_NUMBER=48 +``` + +执行: + +```bash +python -m pytest tests/acceptance/test_gitea_acceptance.py -q +``` ### issue_audit.py @@ -124,6 +222,8 @@ python skills/gitea-issue-devops-agent/scripts/preview_slot_allocator.py --state - `.gitea/workflows/issue-branch-preview.yml` - `.gitea/workflows/preview-slot-reclaim.yml` - `.gitea/workflows/publish-site.yml` +- `workflows/gitea-issue-delivery.md` +- `workflows/gitea-issue-delivery.lock.json` ## Skills 调用前置信息(Claude Code / Codex / OpenCode) diff --git a/docs/superpowers/plans/2026-03-13-gitea-agentic-runtime-plan.md b/docs/superpowers/plans/2026-03-13-gitea-agentic-runtime-plan.md new file mode 100644 index 0000000..ae2c418 --- /dev/null +++ b/docs/superpowers/plans/2026-03-13-gitea-agentic-runtime-plan.md @@ -0,0 +1,266 @@ +# Gitea Agentic Runtime Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a Gitea-backed workflow spec/compiler/runtime subsystem that turns the existing delivery skill into a policy-enforced execution engine with automated tests and real Gitea acceptance. + +**Architecture:** Add a small Python control-plane package under `engine/devops_agent`, keep provider logic isolated behind interfaces, compile workflow specs into JSON lock artifacts, and run policy-guarded issue workflows against Gitea. Persist plans and evidence locally so tests and operators can inspect every run deterministically. + +**Tech Stack:** Python 3.11+, pytest, requests-free stdlib HTTP where practical, Markdown/JSON workflow artifacts, existing repository docs and Gitea workflow templates + +--- + +## Chunk 1: Project Scaffold and Spec Format + +### Task 1: Create the Python package scaffold and test harness + +**Files:** +- Create: `pyproject.toml` +- Create: `engine/devops_agent/__init__.py` +- Create: `engine/devops_agent/cli.py` +- Create: `tests/unit/test_smoke_imports.py` + +- [ ] **Step 1: Write the failing package smoke test** + +Create a test that imports the CLI entrypoint and key modules that will exist after scaffolding. + +- [ ] **Step 2: Run the smoke test and confirm failure** + +Run: `python -m pytest tests/unit/test_smoke_imports.py -q` +Expected: import failure because package files do not exist yet + +- [ ] **Step 3: Add the minimal package scaffold** + +Create the package directories, `__init__.py`, and a CLI module with a placeholder command parser. + +- [ ] **Step 4: Add the Python project file** + +Create `pyproject.toml` with the minimum metadata and `pytest` test configuration. + +- [ ] **Step 5: Re-run the smoke test** + +Run: `python -m pytest tests/unit/test_smoke_imports.py -q` +Expected: pass + +### Task 2: Define the workflow spec shape and a sample Gitea workflow + +**Files:** +- Create: `engine/devops_agent/spec.py` +- Create: `workflows/gitea-issue-delivery.md` +- Create: `tests/unit/test_spec_loader.py` + +- [ ] **Step 1: Write the failing spec loader tests** + +Test frontmatter parsing, Markdown body extraction, and required field detection. + +- [ ] **Step 2: Run the spec loader tests and confirm failure** + +Run: `python -m pytest tests/unit/test_spec_loader.py -q` +Expected: missing loader implementation + +- [ ] **Step 3: Implement the minimal spec loader** + +Parse frontmatter and Markdown body into a structured object. + +- [ ] **Step 4: Add the sample Gitea workflow spec** + +Define one workflow that models fixed-issue delivery with safe comment output. + +- [ ] **Step 5: Re-run the spec loader tests** + +Run: `python -m pytest tests/unit/test_spec_loader.py -q` +Expected: pass + +## Chunk 2: Compiler, Validator, and Policy Enforcement + +### Task 3: Implement lock compilation + +**Files:** +- Create: `engine/devops_agent/compiler.py` +- Create: `tests/unit/test_compiler.py` + +- [ ] **Step 1: Write failing compiler tests** + +Cover default normalization, trigger expansion, and lock artifact emission. + +- [ ] **Step 2: Run the compiler tests and confirm failure** + +Run: `python -m pytest tests/unit/test_compiler.py -q` +Expected: missing compiler implementation + +- [ ] **Step 3: Implement the minimal compiler** + +Compile the loaded spec into a deterministic JSON-compatible lock payload. + +- [ ] **Step 4: Re-run the compiler tests** + +Run: `python -m pytest tests/unit/test_compiler.py -q` +Expected: pass + +### Task 4: Implement validation and safe output policies + +**Files:** +- Create: `engine/devops_agent/validator.py` +- Create: `engine/devops_agent/policies.py` +- Create: `tests/unit/test_validator.py` +- Create: `tests/unit/test_policies.py` + +- [ ] **Step 1: Write failing validation and policy tests** + +Cover missing provider, invalid trigger combinations, undeclared write actions, and path-scope checks. + +- [ ] **Step 2: Run those tests and confirm failure** + +Run: `python -m pytest tests/unit/test_validator.py tests/unit/test_policies.py -q` +Expected: missing implementations + +- [ ] **Step 3: Implement the validator** + +Return explicit validation errors for incomplete or unsafe specs. + +- [ ] **Step 4: Implement the policy layer** + +Enforce read-only default, safe output declarations, and bounded write operations. + +- [ ] **Step 5: Re-run the validation and policy tests** + +Run: `python -m pytest tests/unit/test_validator.py tests/unit/test_policies.py -q` +Expected: pass + +## Chunk 3: Provider and Runtime + +### Task 5: Implement the provider interface and Gitea provider + +**Files:** +- Create: `engine/devops_agent/providers/__init__.py` +- Create: `engine/devops_agent/providers/base.py` +- Create: `engine/devops_agent/providers/gitea.py` +- Create: `tests/fixtures/gitea/issue.json` +- Create: `tests/fixtures/gitea/comment_event.json` +- Create: `tests/unit/test_gitea_provider.py` + +- [ ] **Step 1: Write failing provider tests** + +Cover issue fetch, comment post request shaping, and trigger event parsing using fixtures. + +- [ ] **Step 2: Run the provider tests and confirm failure** + +Run: `python -m pytest tests/unit/test_gitea_provider.py -q` +Expected: provider implementation missing + +- [ ] **Step 3: Implement the provider interface and Gitea provider** + +Add a minimal provider abstraction and the first concrete implementation for Gitea. + +- [ ] **Step 4: Re-run the provider tests** + +Run: `python -m pytest tests/unit/test_gitea_provider.py -q` +Expected: pass + +### Task 6: Implement runtime and evidence persistence + +**Files:** +- Create: `engine/devops_agent/runtime.py` +- Create: `engine/devops_agent/evidence.py` +- Create: `tests/integration/test_runtime_flow.py` + +- [ ] **Step 1: Write a failing runtime integration test** + +Cover compile -> validate -> runtime execution -> evidence artifact output using a fake provider transport. + +- [ ] **Step 2: Run the runtime integration test and confirm failure** + +Run: `python -m pytest tests/integration/test_runtime_flow.py -q` +Expected: runtime implementation missing + +- [ ] **Step 3: Implement the runtime and evidence writer** + +Persist run artifacts and plan/evidence summaries under a deterministic output directory. + +- [ ] **Step 4: Re-run the runtime integration test** + +Run: `python -m pytest tests/integration/test_runtime_flow.py -q` +Expected: pass + +## Chunk 4: CLI, Acceptance, and Documentation + +### Task 7: Expose compile, validate, run, and acceptance commands + +**Files:** +- Modify: `engine/devops_agent/cli.py` +- Create: `tests/unit/test_cli.py` + +- [ ] **Step 1: Write failing CLI tests** + +Cover `compile`, `validate`, and `run` command dispatch with filesystem outputs. + +- [ ] **Step 2: Run the CLI tests and confirm failure** + +Run: `python -m pytest tests/unit/test_cli.py -q` +Expected: placeholder CLI insufficient + +- [ ] **Step 3: Implement the CLI commands** + +Add command handling for `compile`, `validate`, `run`, and `acceptance`. + +- [ ] **Step 4: Re-run the CLI tests** + +Run: `python -m pytest tests/unit/test_cli.py -q` +Expected: pass + +### Task 8: Add real Gitea acceptance and update docs + +**Files:** +- Create: `tests/acceptance/test_gitea_acceptance.py` +- Modify: `README.md` +- Modify: `skills/gitea-issue-devops-agent/SKILL.md` + +- [ ] **Step 1: Write acceptance tests** + +Make the suite skip when `GITEA_BASE_URL`, `GITEA_REPO`, `GITEA_TOKEN`, and `GITEA_ISSUE_NUMBER` are absent, and perform real provider read/comment flow when present. + +- [ ] **Step 2: Run the acceptance suite without env vars** + +Run: `python -m pytest tests/acceptance/test_gitea_acceptance.py -q` +Expected: clean skip + +- [ ] **Step 3: Update README and skill docs** + +Document the new spec/runtime model, CLI commands, safe outputs, and Gitea acceptance procedure. + +- [ ] **Step 4: Run real acceptance** + +Run: `python -m pytest tests/acceptance/test_gitea_acceptance.py -q` +Expected: pass against the configured Gitea repository + +## Chunk 5: End-to-End Verification and Delivery + +### Task 9: Run the full automated test suite and review repository state + +**Files:** +- Modify: repo index and generated lock artifacts as needed + +- [ ] **Step 1: Run unit and integration tests** + +Run: `python -m pytest tests/unit tests/integration -q` +Expected: all pass + +- [ ] **Step 2: Run full suite including acceptance** + +Run: `python -m pytest tests/unit tests/integration tests/acceptance -q` +Expected: all pass, with acceptance either passing or explicitly skipped when env vars are absent + +- [ ] **Step 3: Compile the sample workflow and verify output** + +Run: `python -m engine.devops_agent.cli compile workflows/gitea-issue-delivery.md --output workflows/gitea-issue-delivery.lock.json` +Expected: lock file generated successfully + +- [ ] **Step 4: Validate the sample workflow** + +Run: `python -m engine.devops_agent.cli validate workflows/gitea-issue-delivery.md` +Expected: success output and zero exit code + +- [ ] **Step 5: Review diff formatting** + +Run: `git diff --check` +Expected: no formatting errors diff --git a/docs/superpowers/specs/2026-03-13-gitea-agentic-runtime-design.md b/docs/superpowers/specs/2026-03-13-gitea-agentic-runtime-design.md new file mode 100644 index 0000000..8946988 --- /dev/null +++ b/docs/superpowers/specs/2026-03-13-gitea-agentic-runtime-design.md @@ -0,0 +1,268 @@ +# Gitea Agentic Runtime Design + +**Date:** 2026-03-13 +**Status:** Approved for implementation +**Scope:** Gitea single-platform real acceptance, with provider boundaries that keep later GitHub support incremental instead of invasive. + +## Goal + +Upgrade `gitea-issue-devops-agent` from a documentation-heavy delivery skill into a controlled execution subsystem that can: + +- parse repository-local workflow specs +- compile and validate those specs into a locked execution plan +- run a Gitea-triggered issue workflow under hard policies +- persist plan and evidence artifacts +- pass automated unit, integration, and real Gitea acceptance checks + +The external workflow remains: + +`Issue -> Plan -> Branch -> Draft PR -> Preview -> Test Loop -> Human Merge` + +The internal workflow gains a deterministic control plane. + +## Why This Design + +The repository currently has strong delivery guidance, but not a runtime that enforces it. `gh-aw` demonstrates the value of turning natural-language workflow definitions into constrained executable automations. We do not want to clone GitHub-native product shape. We do want equivalent execution discipline: + +- a spec format users can author +- a compiler/validator that rejects unsafe or incomplete workflows +- a provider layer for platform APIs +- hard policy checks before write actions +- run evidence suitable for audit and acceptance + +This keeps the product positioned as an enterprise AI DevOps control layer, not a GitHub Actions clone. + +## In Scope + +- Gitea provider +- workflow spec parsing +- compilation into lock artifacts +- validation of triggers, permissions, safe outputs, edit scope, and evidence contract +- runtime execution for selected issue flows +- safe output enforcement for Gitea writes +- evidence persistence +- CLI entrypoints for compile, validate, run, and acceptance +- automated tests +- one real Gitea acceptance path + +## Out of Scope + +- GitHub provider implementation +- autonomous queue-wide issue fixing +- automatic merge +- hosted webhook service +- UI control plane +- inference cost dashboards + +## Architecture + +### 1. Workflow Spec + +Workflow specs live in-repo and use frontmatter plus Markdown body: + +- frontmatter defines triggers, provider, permissions, safe outputs, required evidence, and policy defaults +- body captures workflow intent, operator notes, and execution hints + +The spec is the source of truth. Runtime behavior never depends on free-form Markdown alone. + +### 2. Compiler + +The compiler loads a workflow spec and emits a lock artifact: + +- normalizes defaults +- expands trigger shorthand into explicit configuration +- resolves safe output declarations into executable policy objects +- records required evidence and path scope + +The lock artifact is immutable input to runtime. It is designed as JSON in this iteration because JSON is easy to diff, assert in tests, and consume from Python. + +### 3. Validator + +Validation rejects unsafe or incomplete specs before runtime: + +- unsupported trigger combinations +- missing provider +- unsafe write permissions +- missing safe outputs for write behaviors +- invalid path scope syntax +- missing evidence requirements + +This is the first hard boundary between intent and execution. + +### 4. Runtime + +Runtime consumes: + +- a compiled lock artifact +- an event payload +- provider configuration and credentials + +Runtime responsibilities: + +- load and sanitize issue-trigger context +- initialize or update the persisted plan state +- enforce policy gates before any write action +- call provider APIs through a narrow interface +- collect evidence and write run artifacts + +The runtime is not a general autonomous coding engine in this iteration. It is a control and orchestration layer for issue delivery actions. + +### 5. Provider Layer + +Provider interfaces isolate platform behavior from workflow logic. + +The first provider is `GiteaProvider`, which supports: + +- repository metadata reads +- issue reads +- issue comments +- branch hint extraction inputs +- draft PR preparation primitives + +The abstraction must make later `GitHubProvider` addition additive rather than structural. + +### 6. Policy and Safe Outputs + +The policy layer turns delivery rules into code: + +- read-only by default +- no merge action +- comment/create/update operations only if declared in safe outputs +- path-scope enforcement for file writes +- evidence-required status promotion +- bounded output counts where relevant + +This is the key product maturity improvement over pure skill text. + +### 7. Evidence Layer + +Every run produces durable artifacts: + +- resolved plan state +- execution summary +- provider operations executed +- evidence bundle for commit/PR/test/preview placeholders +- acceptance result metadata + +Evidence is stored locally under a deterministic run directory so tests and operators can inspect it. + +## File Structure + +New code will be added under: + +- `pyproject.toml` +- `engine/devops_agent/__init__.py` +- `engine/devops_agent/spec.py` +- `engine/devops_agent/compiler.py` +- `engine/devops_agent/validator.py` +- `engine/devops_agent/runtime.py` +- `engine/devops_agent/policies.py` +- `engine/devops_agent/evidence.py` +- `engine/devops_agent/cli.py` +- `engine/devops_agent/providers/__init__.py` +- `engine/devops_agent/providers/base.py` +- `engine/devops_agent/providers/gitea.py` +- `workflows/gitea-issue-delivery.md` +- `tests/unit/...` +- `tests/integration/...` +- `tests/fixtures/gitea/...` +- `tests/acceptance/test_gitea_acceptance.py` + +Existing scripts will remain, but runtime may call them or share logic conceptually: + +- `skills/gitea-issue-devops-agent/scripts/issue_audit.py` +- `skills/gitea-issue-devops-agent/scripts/change_scope.py` +- `skills/gitea-issue-devops-agent/scripts/preview_slot_allocator.py` + +## Data Model + +### Workflow Spec + +Core fields: + +- `name` +- `provider` +- `on` +- `permissions` +- `safe_outputs` +- `plan` +- `policy` +- `evidence` +- `body` + +### Lock Artifact + +Core fields: + +- `version` +- `compiled_at` +- `source` +- `provider` +- `triggers` +- `policy` +- `safe_outputs` +- `required_evidence` +- `plan_defaults` +- `instructions` + +### Run Artifact + +Core fields: + +- `run_id` +- `workflow_name` +- `provider` +- `event` +- `plan_state` +- `operations` +- `evidence` +- `result` + +## Test Strategy + +### Unit Tests + +Verify: + +- frontmatter parsing +- default normalization +- validator failures and successes +- policy enforcement +- provider request shaping + +### Integration Tests + +Verify: + +- spec to lock compilation +- lock to runtime execution +- runtime interaction with a fake Gitea transport +- evidence persistence + +### Acceptance Tests + +Verify against a real Gitea repo using env vars: + +- workflow compile and validate pass +- runtime can load a selected issue +- runtime can publish an evidence comment +- acceptance artifacts are produced locally + +If env vars are absent, the acceptance suite must skip cleanly instead of failing misleadingly. + +## Acceptance Criteria + +This design is complete only when: + +1. A repo-local Gitea workflow spec compiles into a lock artifact. +2. Invalid specs fail validation with clear messages. +3. Runtime enforces safe outputs and refuses undeclared writes. +4. Gitea real acceptance can read an issue and publish an evidence comment. +5. Automated unit and integration tests pass. +6. README and skill docs describe the new execution model and CLI usage. + +## Rollout Notes + +- Gitea is the first real provider. +- GitHub support is intentionally deferred, but the provider interface must be stable enough to add later. +- `jj` remains an internal reliability layer. This subsystem must not require `jj` for external usage. diff --git a/engine/devops_agent/__init__.py b/engine/devops_agent/__init__.py new file mode 100644 index 0000000..d7a17e7 --- /dev/null +++ b/engine/devops_agent/__init__.py @@ -0,0 +1,5 @@ +"""Runtime package for agentic DevOps workflow execution.""" + +__all__ = ["__version__"] + +__version__ = "0.1.0" diff --git a/engine/devops_agent/__pycache__/__init__.cpython-314.pyc b/engine/devops_agent/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..6647b8f Binary files /dev/null and b/engine/devops_agent/__pycache__/__init__.cpython-314.pyc differ diff --git a/engine/devops_agent/__pycache__/cli.cpython-314.pyc b/engine/devops_agent/__pycache__/cli.cpython-314.pyc new file mode 100644 index 0000000..f85e914 Binary files /dev/null and b/engine/devops_agent/__pycache__/cli.cpython-314.pyc differ diff --git a/engine/devops_agent/__pycache__/compiler.cpython-314.pyc b/engine/devops_agent/__pycache__/compiler.cpython-314.pyc new file mode 100644 index 0000000..6f19981 Binary files /dev/null and b/engine/devops_agent/__pycache__/compiler.cpython-314.pyc differ diff --git a/engine/devops_agent/__pycache__/evidence.cpython-314.pyc b/engine/devops_agent/__pycache__/evidence.cpython-314.pyc new file mode 100644 index 0000000..a182ebb Binary files /dev/null and b/engine/devops_agent/__pycache__/evidence.cpython-314.pyc differ diff --git a/engine/devops_agent/__pycache__/policies.cpython-314.pyc b/engine/devops_agent/__pycache__/policies.cpython-314.pyc new file mode 100644 index 0000000..67dfe50 Binary files /dev/null and b/engine/devops_agent/__pycache__/policies.cpython-314.pyc differ diff --git a/engine/devops_agent/__pycache__/runtime.cpython-314.pyc b/engine/devops_agent/__pycache__/runtime.cpython-314.pyc new file mode 100644 index 0000000..508384c Binary files /dev/null and b/engine/devops_agent/__pycache__/runtime.cpython-314.pyc differ diff --git a/engine/devops_agent/__pycache__/spec.cpython-314.pyc b/engine/devops_agent/__pycache__/spec.cpython-314.pyc new file mode 100644 index 0000000..bacf272 Binary files /dev/null and b/engine/devops_agent/__pycache__/spec.cpython-314.pyc differ diff --git a/engine/devops_agent/__pycache__/validator.cpython-314.pyc b/engine/devops_agent/__pycache__/validator.cpython-314.pyc new file mode 100644 index 0000000..a9b9809 Binary files /dev/null and b/engine/devops_agent/__pycache__/validator.cpython-314.pyc differ diff --git a/engine/devops_agent/cli.py b/engine/devops_agent/cli.py new file mode 100644 index 0000000..3e7355f --- /dev/null +++ b/engine/devops_agent/cli.py @@ -0,0 +1,130 @@ +from __future__ import annotations + +import argparse +import json +from collections.abc import Sequence +from pathlib import Path + +from engine.devops_agent.compiler import compile_workflow +from engine.devops_agent.providers.gitea import GiteaProvider +from engine.devops_agent.runtime import run_issue_comment_workflow +from engine.devops_agent.spec import load_workflow_spec +from engine.devops_agent.validator import validate_workflow_spec + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="devops-agent", + description="CLI for the agentic DevOps runtime.", + ) + parser.add_argument( + "--version", + action="store_true", + help="Print the runtime version and exit.", + ) + subparsers = parser.add_subparsers(dest="command") + + compile_parser = subparsers.add_parser("compile") + compile_parser.add_argument("spec_path") + compile_parser.add_argument("--output", required=True) + + validate_parser = subparsers.add_parser("validate") + validate_parser.add_argument("spec_path") + + run_parser = subparsers.add_parser("run") + run_parser.add_argument("spec_path") + run_parser.add_argument("--event-payload", required=True) + run_parser.add_argument("--output-dir", required=True) + run_parser.add_argument("--base-url", required=True) + run_parser.add_argument("--token", required=True) + + acceptance_parser = subparsers.add_parser("acceptance") + acceptance_parser.add_argument("spec_path") + acceptance_parser.add_argument("--base-url", required=True) + acceptance_parser.add_argument("--repo", required=True) + acceptance_parser.add_argument("--token", required=True) + acceptance_parser.add_argument("--issue-number", required=True) + acceptance_parser.add_argument("--output-dir", required=True) + acceptance_parser.add_argument( + "--comment-body", + default="@devops-agent acceptance run", + ) + return parser + + +def _load_compile_and_validate(spec_path: str) -> tuple[dict[str, object], list[str]]: + spec = load_workflow_spec(spec_path) + errors = validate_workflow_spec(spec) + return compile_workflow(spec), errors + + +def main(argv: Sequence[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + if args.version: + from engine.devops_agent import __version__ + + print(__version__) + return 0 + if not getattr(args, "command", None): + parser.print_help() + return 0 + + if args.command == "compile": + lock, errors = _load_compile_and_validate(args.spec_path) + if errors: + print(json.dumps({"errors": errors}, ensure_ascii=False, indent=2)) + return 1 + output_path = Path(args.output) + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(json.dumps(lock, ensure_ascii=False, indent=2), encoding="utf-8") + return 0 + + if args.command == "validate": + _, errors = _load_compile_and_validate(args.spec_path) + if errors: + print(json.dumps({"errors": errors}, ensure_ascii=False, indent=2)) + return 1 + print("workflow is valid") + return 0 + + if args.command == "run": + lock, errors = _load_compile_and_validate(args.spec_path) + if errors: + print(json.dumps({"errors": errors}, ensure_ascii=False, indent=2)) + return 1 + provider = GiteaProvider(base_url=args.base_url, token=args.token) + payload = json.loads(Path(args.event_payload).read_text(encoding="utf-8")) + run_issue_comment_workflow( + lock=lock, + provider=provider, + event_payload=payload, + output_dir=args.output_dir, + ) + return 0 + + if args.command == "acceptance": + lock, errors = _load_compile_and_validate(args.spec_path) + if errors: + print(json.dumps({"errors": errors}, ensure_ascii=False, indent=2)) + return 1 + provider = GiteaProvider(base_url=args.base_url, token=args.token) + payload = { + "repository": {"full_name": args.repo}, + "issue": {"number": int(args.issue_number)}, + "comment": {"body": args.comment_body}, + } + run_issue_comment_workflow( + lock=lock, + provider=provider, + event_payload=payload, + output_dir=args.output_dir, + ) + return 0 + + parser.error(f"unsupported command: {args.command}") + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/engine/devops_agent/compiler.py b/engine/devops_agent/compiler.py new file mode 100644 index 0000000..56ef3e1 --- /dev/null +++ b/engine/devops_agent/compiler.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from typing import Any + +from engine.devops_agent.spec import WorkflowSpec + + +def _compile_triggers(frontmatter: dict[str, Any]) -> list[dict[str, Any]]: + triggers = frontmatter.get("on") or {} + if not isinstance(triggers, dict): + return [] + + compiled: list[dict[str, Any]] = [] + for event_name, event_config in triggers.items(): + normalized = { + "event": str(event_name), + } + if isinstance(event_config, dict): + normalized.update(event_config) + compiled.append(normalized) + return compiled + + +def compile_workflow(spec: WorkflowSpec) -> dict[str, Any]: + policy = spec.frontmatter.get("policy") or {} + evidence = spec.frontmatter.get("evidence") or {} + + return { + "version": 1, + "workflow_name": spec.name, + "provider": spec.provider, + "source": str(spec.source_path.as_posix()), + "triggers": _compile_triggers(spec.frontmatter), + "safe_outputs": spec.frontmatter.get("safe_outputs") or {}, + "required_evidence": evidence.get("required") or [], + "policy": { + "require_human_merge": bool(policy.get("require_human_merge", True)), + "require_fixed_issue": bool(policy.get("require_fixed_issue", False)), + "path_scope": policy.get("path_scope") or [], + }, + "instructions": spec.body, + } diff --git a/engine/devops_agent/evidence.py b/engine/devops_agent/evidence.py new file mode 100644 index 0000000..ceae95f --- /dev/null +++ b/engine/devops_agent/evidence.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + + +def write_run_artifact(output_dir: str | Path, artifact: dict[str, Any]) -> Path: + destination = Path(output_dir) + destination.mkdir(parents=True, exist_ok=True) + artifact_path = destination / "run-artifact.json" + artifact_path.write_text( + json.dumps(artifact, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + return artifact_path diff --git a/engine/devops_agent/policies.py b/engine/devops_agent/policies.py new file mode 100644 index 0000000..5a767f4 --- /dev/null +++ b/engine/devops_agent/policies.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from dataclasses import dataclass, field + + +class PolicyViolation(PermissionError): + """Raised when runtime behavior violates declared workflow policy.""" + + +def _normalize_path(path: str) -> str: + return path.replace("\\", "/").lstrip("./") + + +@dataclass(slots=True) +class RuntimePolicy: + safe_outputs: dict[str, dict[str, int | str | bool]] + path_scope: list[str] + _operation_counts: dict[str, int] = field(default_factory=dict) + + def assert_operation_allowed(self, action: str) -> None: + config = self.safe_outputs.get(action) + if config is None: + raise PolicyViolation(f"write action '{action}' is not declared in safe_outputs") + + current_count = self._operation_counts.get(action, 0) + 1 + max_count = int(config.get("max", current_count)) + if current_count > max_count: + raise PolicyViolation(f"write action '{action}' exceeded max count {max_count}") + + self._operation_counts[action] = current_count + + def assert_path_allowed(self, path: str) -> None: + normalized = _normalize_path(path) + if not self.path_scope: + raise PolicyViolation("file writes are not allowed without an explicit path scope") + + for allowed_prefix in self.path_scope: + if normalized.startswith(_normalize_path(allowed_prefix)): + return + + raise PolicyViolation( + f"path '{normalized}' is outside allowed path scope {self.path_scope}" + ) diff --git a/engine/devops_agent/providers/__init__.py b/engine/devops_agent/providers/__init__.py new file mode 100644 index 0000000..a6be05b --- /dev/null +++ b/engine/devops_agent/providers/__init__.py @@ -0,0 +1,4 @@ +from engine.devops_agent.providers.base import IssueProvider +from engine.devops_agent.providers.gitea import GiteaProvider + +__all__ = ["IssueProvider", "GiteaProvider"] diff --git a/engine/devops_agent/providers/__pycache__/__init__.cpython-314.pyc b/engine/devops_agent/providers/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..3bda4fe Binary files /dev/null and b/engine/devops_agent/providers/__pycache__/__init__.cpython-314.pyc differ diff --git a/engine/devops_agent/providers/__pycache__/base.cpython-314.pyc b/engine/devops_agent/providers/__pycache__/base.cpython-314.pyc new file mode 100644 index 0000000..beac287 Binary files /dev/null and b/engine/devops_agent/providers/__pycache__/base.cpython-314.pyc differ diff --git a/engine/devops_agent/providers/__pycache__/gitea.cpython-314.pyc b/engine/devops_agent/providers/__pycache__/gitea.cpython-314.pyc new file mode 100644 index 0000000..c770a66 Binary files /dev/null and b/engine/devops_agent/providers/__pycache__/gitea.cpython-314.pyc differ diff --git a/engine/devops_agent/providers/base.py b/engine/devops_agent/providers/base.py new file mode 100644 index 0000000..99ceeef --- /dev/null +++ b/engine/devops_agent/providers/base.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from typing import Any, Protocol + + +class IssueProvider(Protocol): + def get_issue(self, repo: str, issue_number: int) -> dict[str, Any]: ... + + def post_issue_comment( + self, + repo: str, + issue_number: int, + body: str, + ) -> dict[str, Any]: ... + + def parse_issue_comment_event(self, payload: dict[str, Any]) -> dict[str, Any]: ... diff --git a/engine/devops_agent/providers/gitea.py b/engine/devops_agent/providers/gitea.py new file mode 100644 index 0000000..cbf979e --- /dev/null +++ b/engine/devops_agent/providers/gitea.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +import json +from typing import Any, Callable +from urllib.request import Request, urlopen + + +Transport = Callable[..., dict[str, Any]] + + +class GiteaProvider: + def __init__( + self, + *, + base_url: str, + token: str, + transport: Transport | None = None, + ) -> None: + self.base_url = base_url.rstrip("/") + self.token = token + self.transport = transport + + def _request( + self, + *, + method: str, + path: str, + body: dict[str, object] | None = None, + ) -> dict[str, Any]: + url = f"{self.base_url}{path}" + headers = { + "Authorization": f"token {self.token}", + "Accept": "application/json", + "Content-Type": "application/json", + } + + if self.transport is not None: + return self.transport(method=method, url=url, headers=headers, body=body) + + payload = None if body is None else json.dumps(body).encode("utf-8") + request = Request(url, method=method, headers=headers, data=payload) + with urlopen(request, timeout=30) as response: + raw = response.read().decode("utf-8") + return json.loads(raw) if raw else {} + + def get_issue(self, repo: str, issue_number: int) -> dict[str, Any]: + return self._request( + method="GET", + path=f"/api/v1/repos/{repo}/issues/{issue_number}", + ) + + def post_issue_comment( + self, + repo: str, + issue_number: int, + body: str, + ) -> dict[str, Any]: + return self._request( + method="POST", + path=f"/api/v1/repos/{repo}/issues/{issue_number}/comments", + body={"body": body}, + ) + + def parse_issue_comment_event(self, payload: dict[str, Any]) -> dict[str, Any]: + repository = payload.get("repository") or {} + issue = payload.get("issue") or {} + comment = payload.get("comment") or {} + + return { + "repo": repository.get("full_name", ""), + "issue_number": int(issue.get("number", 0)), + "comment_body": str(comment.get("body", "")), + } diff --git a/engine/devops_agent/runtime.py b/engine/devops_agent/runtime.py new file mode 100644 index 0000000..3930d79 --- /dev/null +++ b/engine/devops_agent/runtime.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from engine.devops_agent.evidence import write_run_artifact +from engine.devops_agent.policies import RuntimePolicy + + +def run_issue_comment_workflow( + *, + lock: dict[str, Any], + provider: Any, + event_payload: dict[str, Any], + output_dir: str | Path, +) -> dict[str, Any]: + event = provider.parse_issue_comment_event(event_payload) + repo = str(event["repo"]) + issue_number = int(event["issue_number"]) + issue = provider.get_issue(repo, issue_number) + + policy = RuntimePolicy( + safe_outputs=lock.get("safe_outputs") or {}, + path_scope=lock.get("policy", {}).get("path_scope") or [], + ) + policy.assert_operation_allowed("add_comment") + + verification_summary = ( + f"Workflow `{lock['workflow_name']}` processed issue #{issue_number} " + f"and prepared evidence for review." + ) + comment_response = provider.post_issue_comment( + repo, + issue_number, + verification_summary, + ) + + artifact: dict[str, Any] = { + "run_id": f"{lock['workflow_name']}-issue-{issue_number}", + "workflow_name": lock["workflow_name"], + "provider": lock["provider"], + "event": event, + "plan_state": { + "status": "pending_review", + "repo": repo, + "issue_number": issue_number, + "issue_title": issue.get("title", ""), + }, + "operations": [ + { + "action": "add_comment", + "issue_number": issue_number, + "repo": repo, + } + ], + "evidence": { + "issue_comment": comment_response, + "verification_summary": verification_summary, + }, + "result": "success", + } + artifact_path = write_run_artifact(output_dir, artifact) + artifact["artifact_path"] = str(artifact_path.as_posix()) + artifact_path.write_text(__import__("json").dumps(artifact, ensure_ascii=False, indent=2), encoding="utf-8") + return artifact diff --git a/engine/devops_agent/spec.py b/engine/devops_agent/spec.py new file mode 100644 index 0000000..ff28e52 --- /dev/null +++ b/engine/devops_agent/spec.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import yaml + + +class WorkflowSpecError(ValueError): + """Raised when a workflow spec cannot be parsed or is incomplete.""" + + +@dataclass(slots=True) +class WorkflowSpec: + name: str + provider: str + frontmatter: dict[str, Any] + body: str + source_path: Path + + +def _split_frontmatter(raw_text: str) -> tuple[str, str]: + if not raw_text.startswith("---"): + raise WorkflowSpecError("workflow spec must start with frontmatter") + + parts = raw_text.split("\n---", 1) + if len(parts) != 2: + raise WorkflowSpecError("workflow spec frontmatter is not terminated") + + frontmatter_text = parts[0][4:] + body = parts[1].lstrip("\r\n") + return frontmatter_text, body + + +def load_workflow_spec(path: str | Path) -> WorkflowSpec: + source_path = Path(path) + raw_text = source_path.read_text(encoding="utf-8") + frontmatter_text, body = _split_frontmatter(raw_text) + + payload = yaml.safe_load(frontmatter_text) or {} + if not isinstance(payload, dict): + raise WorkflowSpecError("workflow spec frontmatter must be a mapping") + if True in payload and "on" not in payload: + payload["on"] = payload.pop(True) + + name = str(payload.get("name") or "").strip() + provider = str(payload.get("provider") or "").strip() + if not name: + raise WorkflowSpecError("workflow spec is missing required field: name") + if not provider: + raise WorkflowSpecError("workflow spec is missing required field: provider") + + return WorkflowSpec( + name=name, + provider=provider, + frontmatter=payload, + body=body, + source_path=source_path, + ) diff --git a/engine/devops_agent/validator.py b/engine/devops_agent/validator.py new file mode 100644 index 0000000..4dc6e2e --- /dev/null +++ b/engine/devops_agent/validator.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from typing import Any + +from engine.devops_agent.spec import WorkflowSpec + +WRITE_PERMISSIONS = {"issues", "pull_requests", "contents"} + + +def _is_write_permission(value: Any) -> bool: + return str(value).strip().lower() == "write" + + +def validate_workflow_spec(spec: WorkflowSpec) -> list[str]: + errors: list[str] = [] + + if spec.provider not in {"gitea"}: + errors.append(f"unsupported provider: {spec.provider}") + + triggers = spec.frontmatter.get("on") + if not isinstance(triggers, dict) or not triggers: + errors.append("workflow spec must declare at least one trigger in 'on'") + + permissions = spec.frontmatter.get("permissions") or {} + safe_outputs = spec.frontmatter.get("safe_outputs") or {} + + if not isinstance(permissions, dict): + errors.append("'permissions' must be a mapping") + if not isinstance(safe_outputs, dict): + errors.append("'safe_outputs' must be a mapping") + if isinstance(permissions, dict): + has_write_permission = any( + permission_name in WRITE_PERMISSIONS and _is_write_permission(permission_value) + for permission_name, permission_value in permissions.items() + ) + if has_write_permission and not safe_outputs: + errors.append("write permissions require declared safe_outputs") + + policy = spec.frontmatter.get("policy") or {} + if policy and not isinstance(policy, dict): + errors.append("'policy' must be a mapping") + elif isinstance(policy, dict) and "path_scope" in policy: + path_scope = policy["path_scope"] + if not isinstance(path_scope, list) or any( + not isinstance(item, str) or not item.strip() for item in path_scope + ): + errors.append("policy.path_scope must be a list of non-empty path prefixes") + + return errors diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..6466499 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,14 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "devops-agent-runtime" +version = "0.1.0" +description = "Policy-enforced agentic delivery runtime for Gitea issue workflows." +requires-python = ">=3.11" +dependencies = ["PyYAML>=6"] + +[tool.pytest.ini_options] +addopts = "-p no:cacheprovider" +testpaths = ["tests"] diff --git a/skills/gitea-issue-devops-agent/SKILL.md b/skills/gitea-issue-devops-agent/SKILL.md index ea85f4a..00cfa15 100644 --- a/skills/gitea-issue-devops-agent/SKILL.md +++ b/skills/gitea-issue-devops-agent/SKILL.md @@ -15,11 +15,32 @@ This skill is platform-aware for Gitea, but its delivery contract must stay port - Start coding only from a selected issue, not from an unbounded issue queue, unless the user explicitly asks for triage-only queue scanning. - Every delivery issue must have a persisted plan before any code changes. +- Treat workflow spec, compiled lock artifact, and evidence artifacts as executable system-of-record once the runtime is available; do not rely on prose-only guidance for enforcement. - External collaboration stays Git-native: issue, branch, PR, pipeline, review app, merge. - AI output is provisional until tests, smoke paths, and review evidence exist. - Engineers stay in the loop from the initial PR onward and own white-box review. - Keep one active issue per branch and per execution agent unless the user explicitly approves batching. +## Runtime Contract (Required) + +When the repository contains the runtime package, use it instead of ad-hoc execution: + +1. Author or update the workflow spec in `workflows/*.md`. +2. Compile before execution: + - `python -m engine.devops_agent.cli compile workflows/gitea-issue-delivery.md --output workflows/gitea-issue-delivery.lock.json` +3. Validate before execution: + - `python -m engine.devops_agent.cli validate workflows/gitea-issue-delivery.md` +4. Run or accept only through declared safe outputs. +5. Persist run evidence under `.tmp/...` and treat `run-artifact.json` as execution proof. +6. For real Gitea verification, use: + - `python -m engine.devops_agent.cli acceptance workflows/gitea-issue-delivery.md --base-url --repo --token --issue-number --output-dir .tmp/acceptance/gitea` + +Hard rules: + +- No undeclared write actions. +- No execution after failed validation. +- No claiming completion without runtime evidence. + ## Mandatory Guided Start Run this interaction before any coding or issue action: @@ -401,6 +422,15 @@ Use `jj` only under these rules: ## Script Usage +### Runtime CLI + +- `python -m engine.devops_agent.cli compile workflows/gitea-issue-delivery.md --output workflows/gitea-issue-delivery.lock.json` +- `python -m engine.devops_agent.cli validate workflows/gitea-issue-delivery.md` +- `python -m engine.devops_agent.cli run workflows/gitea-issue-delivery.md --event-payload --output-dir .tmp/runtime-run --base-url --token ` +- `python -m engine.devops_agent.cli acceptance workflows/gitea-issue-delivery.md --base-url --repo --token --issue-number --output-dir .tmp/acceptance/gitea` + +- runtime writes `run-artifact.json` and should be used as the evidence source for status promotion and issue comments. + - `scripts/issue_audit.py`: collect issues/comments/attachments, detect duplicates, score quality, detect unresolved/closed-open links, extract issue branch hints, and generate reports. - image intake uses three sources: markdown/html links, payload `assets/attachments` fields, and `/issues/*/assets` API endpoints. - if your Gitea blocks the assets endpoints, pass `--skip-asset-endpoints` and rely on payload extraction. diff --git a/tests/acceptance/__pycache__/test_gitea_acceptance.cpython-314-pytest-8.4.2.pyc b/tests/acceptance/__pycache__/test_gitea_acceptance.cpython-314-pytest-8.4.2.pyc new file mode 100644 index 0000000..42e40f7 Binary files /dev/null and b/tests/acceptance/__pycache__/test_gitea_acceptance.cpython-314-pytest-8.4.2.pyc differ diff --git a/tests/acceptance/test_gitea_acceptance.py b/tests/acceptance/test_gitea_acceptance.py new file mode 100644 index 0000000..daec940 --- /dev/null +++ b/tests/acceptance/test_gitea_acceptance.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +from engine.devops_agent.cli import main + + +REQUIRED_ENV_VARS = ( + "GITEA_BASE_URL", + "GITEA_REPO", + "GITEA_TOKEN", + "GITEA_ISSUE_NUMBER", +) + + +def test_gitea_acceptance_comment_flow() -> None: + missing = [name for name in REQUIRED_ENV_VARS if not os.getenv(name)] + if missing: + pytest.skip(f"missing required env vars: {', '.join(missing)}") + + output_dir = Path(".tmp/acceptance/gitea") + output_dir.mkdir(parents=True, exist_ok=True) + + exit_code = main( + [ + "acceptance", + "workflows/gitea-issue-delivery.md", + "--base-url", + os.environ["GITEA_BASE_URL"], + "--repo", + os.environ["GITEA_REPO"], + "--token", + os.environ["GITEA_TOKEN"], + "--issue-number", + os.environ["GITEA_ISSUE_NUMBER"], + "--output-dir", + str(output_dir), + ] + ) + + artifact_path = output_dir / "run-artifact.json" + + assert exit_code == 0 + assert artifact_path.exists() diff --git a/tests/fixtures/gitea/comment_event.json b/tests/fixtures/gitea/comment_event.json new file mode 100644 index 0000000..1f38d4c --- /dev/null +++ b/tests/fixtures/gitea/comment_event.json @@ -0,0 +1,12 @@ +{ + "action": "created", + "comment": { + "body": "@devops-agent please process issue 48" + }, + "issue": { + "number": 48 + }, + "repository": { + "full_name": "Fun_MD/devops-skills" + } +} diff --git a/tests/fixtures/gitea/issue.json b/tests/fixtures/gitea/issue.json new file mode 100644 index 0000000..b3e77bb --- /dev/null +++ b/tests/fixtures/gitea/issue.json @@ -0,0 +1,6 @@ +{ + "number": 48, + "title": "Fix issue delivery flow", + "body": "The agent should post evidence back to the issue.", + "state": "open" +} diff --git a/tests/fixtures/specs/invalid_missing_provider.md b/tests/fixtures/specs/invalid_missing_provider.md new file mode 100644 index 0000000..27dc214 --- /dev/null +++ b/tests/fixtures/specs/invalid_missing_provider.md @@ -0,0 +1,4 @@ +--- +name: invalid-workflow +--- +# Invalid diff --git a/tests/fixtures/specs/invalid_path_scope.md b/tests/fixtures/specs/invalid_path_scope.md new file mode 100644 index 0000000..42950ac --- /dev/null +++ b/tests/fixtures/specs/invalid_path_scope.md @@ -0,0 +1,17 @@ +--- +name: invalid-path-scope +provider: gitea +on: + issue_comment: + commands: ["@devops-agent"] +permissions: + issues: write +safe_outputs: + add_comment: + max: 1 +policy: + path_scope: "engine/devops_agent/" +--- +# Invalid + +The path scope must be a list of path prefixes. diff --git a/tests/fixtures/specs/no_safe_outputs_for_write.md b/tests/fixtures/specs/no_safe_outputs_for_write.md new file mode 100644 index 0000000..4c6d8b6 --- /dev/null +++ b/tests/fixtures/specs/no_safe_outputs_for_write.md @@ -0,0 +1,12 @@ +--- +name: invalid-write-without-safe-output +provider: gitea +on: + issue_comment: + commands: ["@devops-agent"] +permissions: + issues: write +--- +# Invalid + +Write permission is declared, but no safe output exists. diff --git a/tests/fixtures/specs/valid_workflow.md b/tests/fixtures/specs/valid_workflow.md new file mode 100644 index 0000000..e4be50a --- /dev/null +++ b/tests/fixtures/specs/valid_workflow.md @@ -0,0 +1,17 @@ +--- +name: issue-delivery +provider: gitea +on: + issue_comment: + commands: ["@devops-agent"] +permissions: + issues: write +safe_outputs: + add_comment: + max: 2 +plan: + required: true +--- +# Issue Delivery + +Read the selected issue and post evidence. diff --git a/tests/integration/__pycache__/test_runtime_flow.cpython-314-pytest-8.4.2.pyc b/tests/integration/__pycache__/test_runtime_flow.cpython-314-pytest-8.4.2.pyc new file mode 100644 index 0000000..0e33e20 Binary files /dev/null and b/tests/integration/__pycache__/test_runtime_flow.cpython-314-pytest-8.4.2.pyc differ diff --git a/tests/integration/test_runtime_flow.py b/tests/integration/test_runtime_flow.py new file mode 100644 index 0000000..d8174fb --- /dev/null +++ b/tests/integration/test_runtime_flow.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from engine.devops_agent.compiler import compile_workflow +from engine.devops_agent.runtime import run_issue_comment_workflow +from engine.devops_agent.spec import load_workflow_spec +from engine.devops_agent.validator import validate_workflow_spec + + +class FakeProvider: + def __init__(self) -> None: + self.comments: list[dict[str, object]] = [] + + def parse_issue_comment_event(self, payload: dict[str, object]) -> dict[str, object]: + repository = payload["repository"] + issue = payload["issue"] + comment = payload["comment"] + return { + "repo": repository["full_name"], + "issue_number": issue["number"], + "comment_body": comment["body"], + } + + def get_issue(self, repo: str, issue_number: int) -> dict[str, object]: + return { + "number": issue_number, + "title": "Fix issue delivery flow", + "body": "The agent should post evidence back to the issue.", + "state": "open", + "repo": repo, + } + + def post_issue_comment(self, repo: str, issue_number: int, body: str) -> dict[str, object]: + record = { + "repo": repo, + "issue_number": issue_number, + "body": body, + } + self.comments.append(record) + return {"id": len(self.comments), **record} + + +def test_runtime_executes_flow_and_writes_evidence() -> None: + spec = load_workflow_spec(Path("workflows/gitea-issue-delivery.md")) + assert validate_workflow_spec(spec) == [] + lock = compile_workflow(spec) + payload = json.loads(Path("tests/fixtures/gitea/comment_event.json").read_text(encoding="utf-8")) + provider = FakeProvider() + + artifact = run_issue_comment_workflow( + lock=lock, + provider=provider, + event_payload=payload, + output_dir=Path(".tmp/runtime-flow-test"), + ) + + assert artifact["result"] == "success" + assert artifact["plan_state"]["status"] == "pending_review" + assert provider.comments + assert Path(artifact["artifact_path"]).exists() diff --git a/tests/unit/__pycache__/test_cli.cpython-314-pytest-8.4.2.pyc b/tests/unit/__pycache__/test_cli.cpython-314-pytest-8.4.2.pyc new file mode 100644 index 0000000..9475cef Binary files /dev/null and b/tests/unit/__pycache__/test_cli.cpython-314-pytest-8.4.2.pyc differ diff --git a/tests/unit/__pycache__/test_compiler.cpython-314-pytest-8.4.2.pyc b/tests/unit/__pycache__/test_compiler.cpython-314-pytest-8.4.2.pyc new file mode 100644 index 0000000..4d6247e Binary files /dev/null and b/tests/unit/__pycache__/test_compiler.cpython-314-pytest-8.4.2.pyc differ diff --git a/tests/unit/__pycache__/test_gitea_provider.cpython-314-pytest-8.4.2.pyc b/tests/unit/__pycache__/test_gitea_provider.cpython-314-pytest-8.4.2.pyc new file mode 100644 index 0000000..d2b6072 Binary files /dev/null and b/tests/unit/__pycache__/test_gitea_provider.cpython-314-pytest-8.4.2.pyc differ diff --git a/tests/unit/__pycache__/test_policies.cpython-314-pytest-8.4.2.pyc b/tests/unit/__pycache__/test_policies.cpython-314-pytest-8.4.2.pyc new file mode 100644 index 0000000..6b12f90 Binary files /dev/null and b/tests/unit/__pycache__/test_policies.cpython-314-pytest-8.4.2.pyc differ diff --git a/tests/unit/__pycache__/test_smoke_imports.cpython-314-pytest-8.4.2.pyc b/tests/unit/__pycache__/test_smoke_imports.cpython-314-pytest-8.4.2.pyc new file mode 100644 index 0000000..7f22126 Binary files /dev/null and b/tests/unit/__pycache__/test_smoke_imports.cpython-314-pytest-8.4.2.pyc differ diff --git a/tests/unit/__pycache__/test_spec_loader.cpython-314-pytest-8.4.2.pyc b/tests/unit/__pycache__/test_spec_loader.cpython-314-pytest-8.4.2.pyc new file mode 100644 index 0000000..9af9650 Binary files /dev/null and b/tests/unit/__pycache__/test_spec_loader.cpython-314-pytest-8.4.2.pyc differ diff --git a/tests/unit/__pycache__/test_validator.cpython-314-pytest-8.4.2.pyc b/tests/unit/__pycache__/test_validator.cpython-314-pytest-8.4.2.pyc new file mode 100644 index 0000000..f39879e Binary files /dev/null and b/tests/unit/__pycache__/test_validator.cpython-314-pytest-8.4.2.pyc differ diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py new file mode 100644 index 0000000..201f4f9 --- /dev/null +++ b/tests/unit/test_cli.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from engine.devops_agent import cli + + +class FakeProvider: + def __init__(self, *, base_url: str, token: str) -> None: + self.base_url = base_url + self.token = token + + def parse_issue_comment_event(self, payload: dict[str, object]) -> dict[str, object]: + repository = payload["repository"] + issue = payload["issue"] + comment = payload["comment"] + return { + "repo": repository["full_name"], + "issue_number": issue["number"], + "comment_body": comment["body"], + } + + def get_issue(self, repo: str, issue_number: int) -> dict[str, object]: + return { + "number": issue_number, + "title": "Fix issue delivery flow", + "body": "The agent should post evidence back to the issue.", + "state": "open", + "repo": repo, + } + + def post_issue_comment(self, repo: str, issue_number: int, body: str) -> dict[str, object]: + return {"id": 1, "repo": repo, "issue_number": issue_number, "body": body} + + +def test_compile_command_writes_lock_file() -> None: + output_path = Path(".tmp/cli-tests/gitea-issue-delivery.lock.json") + output_path.parent.mkdir(parents=True, exist_ok=True) + + exit_code = cli.main( + [ + "compile", + "workflows/gitea-issue-delivery.md", + "--output", + str(output_path), + ] + ) + + assert exit_code == 0 + assert output_path.exists() + + +def test_validate_command_returns_success() -> None: + exit_code = cli.main(["validate", "workflows/gitea-issue-delivery.md"]) + + assert exit_code == 0 + + +def test_run_command_writes_runtime_artifact(monkeypatch) -> None: + output_dir = Path(".tmp/cli-tests/runtime-run") + output_dir.mkdir(parents=True, exist_ok=True) + monkeypatch.setattr(cli, "GiteaProvider", FakeProvider) + + exit_code = cli.main( + [ + "run", + "workflows/gitea-issue-delivery.md", + "--event-payload", + "tests/fixtures/gitea/comment_event.json", + "--output-dir", + str(output_dir), + "--base-url", + "https://fun-md.com", + "--token", + "fake-token", + ] + ) + + artifact_path = output_dir / "run-artifact.json" + artifact = json.loads(artifact_path.read_text(encoding="utf-8")) + + assert exit_code == 0 + assert artifact["result"] == "success" diff --git a/tests/unit/test_compiler.py b/tests/unit/test_compiler.py new file mode 100644 index 0000000..dd403ee --- /dev/null +++ b/tests/unit/test_compiler.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from pathlib import Path + +from engine.devops_agent.compiler import compile_workflow +from engine.devops_agent.spec import load_workflow_spec + + +def test_compile_emits_normalized_lock_payload() -> None: + spec = load_workflow_spec(Path("workflows/gitea-issue-delivery.md")) + + lock = compile_workflow(spec) + + assert lock["version"] == 1 + assert lock["workflow_name"] == "gitea-issue-delivery" + assert lock["provider"] == "gitea" + assert lock["triggers"] == [ + { + "event": "issue_comment", + "commands": ["@devops-agent"], + } + ] + assert lock["policy"]["path_scope"] == [] + assert lock["policy"]["require_human_merge"] is True + assert lock["safe_outputs"]["add_comment"]["max"] == 3 + assert "issue_comment" in lock["required_evidence"] diff --git a/tests/unit/test_gitea_provider.py b/tests/unit/test_gitea_provider.py new file mode 100644 index 0000000..fe8754d --- /dev/null +++ b/tests/unit/test_gitea_provider.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from engine.devops_agent.providers.gitea import GiteaProvider + + +class FakeTransport: + def __init__(self) -> None: + self.calls: list[dict[str, object]] = [] + + def __call__( + self, + *, + method: str, + url: str, + headers: dict[str, str], + body: dict[str, object] | None, + ) -> dict[str, object]: + self.calls.append( + { + "method": method, + "url": url, + "headers": headers, + "body": body, + } + ) + if url.endswith("/comments"): + return {"id": 999, "body": body["body"] if body else ""} + return json.loads(Path("tests/fixtures/gitea/issue.json").read_text(encoding="utf-8")) + + +def test_gitea_provider_fetches_issue() -> None: + transport = FakeTransport() + provider = GiteaProvider( + base_url="https://fun-md.com", + token="test-token", + transport=transport, + ) + + issue = provider.get_issue("Fun_MD/devops-skills", 48) + + assert issue["number"] == 48 + assert transport.calls[0]["method"] == "GET" + assert str(transport.calls[0]["url"]).endswith("/api/v1/repos/Fun_MD/devops-skills/issues/48") + + +def test_gitea_provider_posts_issue_comment() -> None: + transport = FakeTransport() + provider = GiteaProvider( + base_url="https://fun-md.com", + token="test-token", + transport=transport, + ) + + response = provider.post_issue_comment("Fun_MD/devops-skills", 48, "Evidence posted") + + assert response["id"] == 999 + assert transport.calls[0]["method"] == "POST" + assert transport.calls[0]["body"] == {"body": "Evidence posted"} + + +def test_gitea_provider_parses_comment_event() -> None: + provider = GiteaProvider(base_url="https://fun-md.com", token="test-token") + payload = json.loads(Path("tests/fixtures/gitea/comment_event.json").read_text(encoding="utf-8")) + + event = provider.parse_issue_comment_event(payload) + + assert event["repo"] == "Fun_MD/devops-skills" + assert event["issue_number"] == 48 + assert "@devops-agent" in event["comment_body"] diff --git a/tests/unit/test_policies.py b/tests/unit/test_policies.py new file mode 100644 index 0000000..4d438db --- /dev/null +++ b/tests/unit/test_policies.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +import pytest + +from engine.devops_agent.policies import PolicyViolation, RuntimePolicy + + +def test_policy_allows_declared_safe_output() -> None: + policy = RuntimePolicy( + safe_outputs={"add_comment": {"max": 2}}, + path_scope=["engine/devops_agent/", "README.md"], + ) + + policy.assert_operation_allowed("add_comment") + + +def test_policy_rejects_undeclared_write_action() -> None: + policy = RuntimePolicy( + safe_outputs={"add_comment": {"max": 2}}, + path_scope=[], + ) + + with pytest.raises(PolicyViolation, match="close_issue"): + policy.assert_operation_allowed("close_issue") + + +def test_policy_rejects_paths_outside_scope() -> None: + policy = RuntimePolicy( + safe_outputs={"write_file": {"max": 5}}, + path_scope=["engine/devops_agent/"], + ) + + with pytest.raises(PolicyViolation, match="outside allowed path scope"): + policy.assert_path_allowed("skills/gitea-issue-devops-agent/SKILL.md") diff --git a/tests/unit/test_smoke_imports.py b/tests/unit/test_smoke_imports.py new file mode 100644 index 0000000..1feed1f --- /dev/null +++ b/tests/unit/test_smoke_imports.py @@ -0,0 +1,11 @@ +import importlib + + +def test_core_modules_are_importable() -> None: + module_names = [ + "engine.devops_agent", + "engine.devops_agent.cli", + ] + + for module_name in module_names: + importlib.import_module(module_name) diff --git a/tests/unit/test_spec_loader.py b/tests/unit/test_spec_loader.py new file mode 100644 index 0000000..10ec0ed --- /dev/null +++ b/tests/unit/test_spec_loader.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from engine.devops_agent.spec import WorkflowSpecError, load_workflow_spec + + +def test_load_workflow_spec_splits_frontmatter_and_body() -> None: + spec = load_workflow_spec(Path("tests/fixtures/specs/valid_workflow.md")) + + assert spec.name == "issue-delivery" + assert spec.provider == "gitea" + assert spec.frontmatter["safe_outputs"]["add_comment"]["max"] == 2 + assert "Read the selected issue" in spec.body + + +def test_load_workflow_spec_rejects_missing_provider() -> None: + with pytest.raises(WorkflowSpecError, match="provider"): + load_workflow_spec(Path("tests/fixtures/specs/invalid_missing_provider.md")) + + +def test_sample_workflow_spec_exists_and_loads() -> None: + spec = load_workflow_spec(Path("workflows/gitea-issue-delivery.md")) + + assert spec.name == "gitea-issue-delivery" + assert spec.provider == "gitea" + assert "add_comment" in spec.frontmatter["safe_outputs"] diff --git a/tests/unit/test_validator.py b/tests/unit/test_validator.py new file mode 100644 index 0000000..6ab4226 --- /dev/null +++ b/tests/unit/test_validator.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from pathlib import Path + +from engine.devops_agent.spec import load_workflow_spec +from engine.devops_agent.validator import validate_workflow_spec + + +def test_validate_accepts_the_sample_workflow() -> None: + spec = load_workflow_spec(Path("workflows/gitea-issue-delivery.md")) + + errors = validate_workflow_spec(spec) + + assert errors == [] + + +def test_validate_rejects_write_permissions_without_safe_outputs() -> None: + spec = load_workflow_spec(Path("tests/fixtures/specs/no_safe_outputs_for_write.md")) + + errors = validate_workflow_spec(spec) + + assert any("safe_outputs" in error for error in errors) + + +def test_validate_rejects_invalid_path_scope() -> None: + spec = load_workflow_spec(Path("tests/fixtures/specs/invalid_path_scope.md")) + + errors = validate_workflow_spec(spec) + + assert any("path_scope" in error for error in errors) diff --git a/workflows/gitea-issue-delivery.lock.json b/workflows/gitea-issue-delivery.lock.json new file mode 100644 index 0000000..23a35b1 --- /dev/null +++ b/workflows/gitea-issue-delivery.lock.json @@ -0,0 +1,29 @@ +{ + "version": 1, + "workflow_name": "gitea-issue-delivery", + "provider": "gitea", + "source": "workflows/gitea-issue-delivery.md", + "triggers": [ + { + "event": "issue_comment", + "commands": [ + "@devops-agent" + ] + } + ], + "safe_outputs": { + "add_comment": { + "max": 3 + } + }, + "required_evidence": [ + "issue_comment", + "verification_summary" + ], + "policy": { + "require_human_merge": true, + "require_fixed_issue": true, + "path_scope": [] + }, + "instructions": "# Gitea Issue Delivery\n\nStart from a fixed issue selected by a human or explicit trigger.\n\nCreate or update plan state before code changes, keep writes within declared safe outputs,\nand publish verification evidence back to the issue thread.\n" +} \ No newline at end of file diff --git a/workflows/gitea-issue-delivery.md b/workflows/gitea-issue-delivery.md new file mode 100644 index 0000000..8656f17 --- /dev/null +++ b/workflows/gitea-issue-delivery.md @@ -0,0 +1,34 @@ +--- +name: gitea-issue-delivery +provider: gitea +on: + issue_comment: + commands: ["@devops-agent"] +permissions: + issues: write + pull_requests: write +safe_outputs: + add_comment: + max: 3 +plan: + required: true + statuses: + - selected + - planned + - in_progress + - pending_test + - pending_review +evidence: + required: + - issue_comment + - verification_summary +policy: + require_fixed_issue: true + require_human_merge: true +--- +# Gitea Issue Delivery + +Start from a fixed issue selected by a human or explicit trigger. + +Create or update plan state before code changes, keep writes within declared safe outputs, +and publish verification evidence back to the issue thread.