From ae540c7890fe660372dd9283073823a7479800a5 Mon Sep 17 00:00:00 2001 From: seekee421 Date: Fri, 13 Mar 2026 15:34:18 +0800 Subject: [PATCH] feat: add gitea agentic runtime control plane --- .gitignore | 1 + .ralph/ralph-context.md | 6 + .ralph/ralph-history.md | 101 +++++++ .ralph/ralph-loop-plan.md | 28 ++ .ralph/ralph-tasks.md | 7 + README.md | 102 ++++++- .../2026-03-13-gitea-agentic-runtime-plan.md | 266 +++++++++++++++++ ...2026-03-13-gitea-agentic-runtime-design.md | 268 ++++++++++++++++++ engine/devops_agent/__init__.py | 5 + .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 330 bytes .../__pycache__/cli.cpython-314.pyc | Bin 0 -> 6451 bytes .../__pycache__/compiler.cpython-314.pyc | Bin 0 -> 2650 bytes .../__pycache__/evidence.cpython-314.pyc | Bin 0 -> 1089 bytes .../__pycache__/policies.cpython-314.pyc | Bin 0 -> 3266 bytes .../__pycache__/runtime.cpython-314.pyc | Bin 0 -> 2688 bytes .../__pycache__/spec.cpython-314.pyc | Bin 0 -> 3407 bytes .../__pycache__/validator.cpython-314.pyc | Bin 0 -> 3971 bytes engine/devops_agent/cli.py | 130 +++++++++ engine/devops_agent/compiler.py | 42 +++ engine/devops_agent/evidence.py | 16 ++ engine/devops_agent/policies.py | 43 +++ engine/devops_agent/providers/__init__.py | 4 + .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 401 bytes .../__pycache__/base.cpython-314.pyc | Bin 0 -> 1632 bytes .../__pycache__/gitea.cpython-314.pyc | Bin 0 -> 4495 bytes engine/devops_agent/providers/base.py | 16 ++ engine/devops_agent/providers/gitea.py | 73 +++++ engine/devops_agent/runtime.py | 65 +++++ engine/devops_agent/spec.py | 60 ++++ engine/devops_agent/validator.py | 49 ++++ pyproject.toml | 14 + skills/gitea-issue-devops-agent/SKILL.md | 30 ++ ...ea_acceptance.cpython-314-pytest-8.4.2.pyc | Bin 0 -> 3266 bytes tests/acceptance/test_gitea_acceptance.py | 47 +++ tests/fixtures/gitea/comment_event.json | 12 + tests/fixtures/gitea/issue.json | 6 + .../specs/invalid_missing_provider.md | 4 + tests/fixtures/specs/invalid_path_scope.md | 17 ++ .../specs/no_safe_outputs_for_write.md | 12 + tests/fixtures/specs/valid_workflow.md | 17 ++ ..._runtime_flow.cpython-314-pytest-8.4.2.pyc | Bin 0 -> 7123 bytes tests/integration/test_runtime_flow.py | 62 ++++ .../test_cli.cpython-314-pytest-8.4.2.pyc | Bin 0 -> 7650 bytes ...test_compiler.cpython-314-pytest-8.4.2.pyc | Bin 0 -> 4625 bytes ...itea_provider.cpython-314-pytest-8.4.2.pyc | Bin 0 -> 8420 bytes ...test_policies.cpython-314-pytest-8.4.2.pyc | Bin 0 -> 2310 bytes ...smoke_imports.cpython-314-pytest-8.4.2.pyc | Bin 0 -> 822 bytes ...t_spec_loader.cpython-314-pytest-8.4.2.pyc | Bin 0 -> 6388 bytes ...est_validator.cpython-314-pytest-8.4.2.pyc | Bin 0 -> 4167 bytes tests/unit/test_cli.py | 84 ++++++ tests/unit/test_compiler.py | 26 ++ tests/unit/test_gitea_provider.py | 72 +++++ tests/unit/test_policies.py | 34 +++ tests/unit/test_smoke_imports.py | 11 + tests/unit/test_spec_loader.py | 29 ++ tests/unit/test_validator.py | 30 ++ workflows/gitea-issue-delivery.lock.json | 29 ++ workflows/gitea-issue-delivery.md | 34 +++ 58 files changed, 1851 insertions(+), 1 deletion(-) create mode 100644 .ralph/ralph-context.md create mode 100644 .ralph/ralph-history.md create mode 100644 .ralph/ralph-loop-plan.md create mode 100644 .ralph/ralph-tasks.md create mode 100644 docs/superpowers/plans/2026-03-13-gitea-agentic-runtime-plan.md create mode 100644 docs/superpowers/specs/2026-03-13-gitea-agentic-runtime-design.md create mode 100644 engine/devops_agent/__init__.py create mode 100644 engine/devops_agent/__pycache__/__init__.cpython-314.pyc create mode 100644 engine/devops_agent/__pycache__/cli.cpython-314.pyc create mode 100644 engine/devops_agent/__pycache__/compiler.cpython-314.pyc create mode 100644 engine/devops_agent/__pycache__/evidence.cpython-314.pyc create mode 100644 engine/devops_agent/__pycache__/policies.cpython-314.pyc create mode 100644 engine/devops_agent/__pycache__/runtime.cpython-314.pyc create mode 100644 engine/devops_agent/__pycache__/spec.cpython-314.pyc create mode 100644 engine/devops_agent/__pycache__/validator.cpython-314.pyc create mode 100644 engine/devops_agent/cli.py create mode 100644 engine/devops_agent/compiler.py create mode 100644 engine/devops_agent/evidence.py create mode 100644 engine/devops_agent/policies.py create mode 100644 engine/devops_agent/providers/__init__.py create mode 100644 engine/devops_agent/providers/__pycache__/__init__.cpython-314.pyc create mode 100644 engine/devops_agent/providers/__pycache__/base.cpython-314.pyc create mode 100644 engine/devops_agent/providers/__pycache__/gitea.cpython-314.pyc create mode 100644 engine/devops_agent/providers/base.py create mode 100644 engine/devops_agent/providers/gitea.py create mode 100644 engine/devops_agent/runtime.py create mode 100644 engine/devops_agent/spec.py create mode 100644 engine/devops_agent/validator.py create mode 100644 pyproject.toml create mode 100644 tests/acceptance/__pycache__/test_gitea_acceptance.cpython-314-pytest-8.4.2.pyc create mode 100644 tests/acceptance/test_gitea_acceptance.py create mode 100644 tests/fixtures/gitea/comment_event.json create mode 100644 tests/fixtures/gitea/issue.json create mode 100644 tests/fixtures/specs/invalid_missing_provider.md create mode 100644 tests/fixtures/specs/invalid_path_scope.md create mode 100644 tests/fixtures/specs/no_safe_outputs_for_write.md create mode 100644 tests/fixtures/specs/valid_workflow.md create mode 100644 tests/integration/__pycache__/test_runtime_flow.cpython-314-pytest-8.4.2.pyc create mode 100644 tests/integration/test_runtime_flow.py create mode 100644 tests/unit/__pycache__/test_cli.cpython-314-pytest-8.4.2.pyc create mode 100644 tests/unit/__pycache__/test_compiler.cpython-314-pytest-8.4.2.pyc create mode 100644 tests/unit/__pycache__/test_gitea_provider.cpython-314-pytest-8.4.2.pyc create mode 100644 tests/unit/__pycache__/test_policies.cpython-314-pytest-8.4.2.pyc create mode 100644 tests/unit/__pycache__/test_smoke_imports.cpython-314-pytest-8.4.2.pyc create mode 100644 tests/unit/__pycache__/test_spec_loader.cpython-314-pytest-8.4.2.pyc create mode 100644 tests/unit/__pycache__/test_validator.cpython-314-pytest-8.4.2.pyc create mode 100644 tests/unit/test_cli.py create mode 100644 tests/unit/test_compiler.py create mode 100644 tests/unit/test_gitea_provider.py create mode 100644 tests/unit/test_policies.py create mode 100644 tests/unit/test_smoke_imports.py create mode 100644 tests/unit/test_spec_loader.py create mode 100644 tests/unit/test_validator.py create mode 100644 workflows/gitea-issue-delivery.lock.json create mode 100644 workflows/gitea-issue-delivery.md 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 0000000000000000000000000000000000000000..6647b8f3cfbef078e6599c3957375c5db756cf53 GIT binary patch literal 330 zcmXv~!AiqG5Zz7GmIixL@EGt^B(YvRNb#aq6$0KChLB9cn(c1b-K5%6@zh`O?7?p_ zr-DBq^atEnoy&VO!_0fUv+?1GxEg)DbLH@>Z~lw^k?oNqE0PdP5*kxW6VG~ye@Z5M z%gdyZmd`P))2vEM3=5@0hi;gKx7a?^CR`|86+$gSe8#M?T*ae(FiG`@(Z z_Y=Px0OTqI=t(LBfcBjB8`<_&x~H2f_GsK0yHm1aIku`ck*T;4hQ+;%)rf`_nA%2T zRC0@Hq<0RZOi8Pih-#5aWGGASVD|%l9Wwwfxdn*pmu@(ltGp3-t%pv!kHYjZeEXEr VPcqvCC+qXoU^6~`4fKIq`~c+;WGMgu literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..f85e914e514ede202a6734f71785aa69ddf0599e GIT binary patch literal 6451 zcmdrQOKcm*b(S2GOD;)?lBmC|)hCYWcqQ1DEq~%TjulsO9Y@(XM1WAqcoA=(#ym@b)2?l%!O8KvU*1iZK^d)WB1=j)Y>wf@HK$nrs&7oP&;D#ye zm~$8o_H`Og_&VqCtjln*K0N21^%x!oyXN>=ui<5|d(Jm27y^SmbN<Bxx)#E6`?1#%N(K=cL<`*` zXrXHaO<7t@adlvslO1Cy((x5-MmST5r_&iDZfKdbei7RItLiUvYC53;jOXLVGJM+; znboY8Qk82NvT`Mrxdynv&oo1g&y&nmEvXVf^pjj#(R4khDnP!frj6a?9jQz_xr0#j zteSu^-B;r&Eg3f&oLNtT%4*4r8~{6^`Sl9mjqRS^{nmy~2S$pwT1;Iyt|h zw5FS=MvkQOJ#n&>jT2p+I760lRG@jw5M1Hf!C z1$6<%Y&@E$nm3gZB*RXX9n)4{7Hl`~Y9fNB?3A(WlHJq5la^sh=c&WHYuO|7)7GkF zxhOa9-Q8Pu4ziIz4PJ8QgXk%{qiQdHm449d_Q zPf#uM4~h_~AS+Ntv9H%^8<~j3?lxHKHa>-u9uU z6DG{JNs@XMe2$cjU#C7d@3)6ZNsZ)vlC&7tRVhbOd5e&JP|QJp0$#6xwgTa5VEpsIcr7qc>pK3>>+Z3nR(>zD7-{EV8tE@ z9xx!-g((8?UBLsr04Hfs^Ea{C|9YIE#9{S*(q#+nWBfsPe= zBQx=50f777x_uGc&^z9pi&^(JV7VQvBerwXH?UF1VNmlXcQN85^ztS&<|XtH0D@_a zsk<@VDj^x7>y+_cQLLYheuaf__K5>;rP!y9&>)j(xEBC8DZp-t*txOEw<7&Rk4qE8~Er>V7iTH-6&koPrtkVK(iz=nTgN9;n2cG5VJ z7Ms^nC*=hZ72T*!#g@3v31p%!v8l+IWf-4OE-~O$FF*@2HWyp6YY=~u7o#p)h9m$m zdPj#?w>@&c*M=Hjh+-jrndD^mWryssV^o_r$jX7%^kA_9lF)tJRBY<3JL;a_)6@yg z`H{zQG7mrRuIRx%KlV7)lVp$LN9C4%ve4j3SL{3N>}Pg<^+($|_mkRrXU*h*ZPV}F zEkUNm-+r_fKkaUL%3epXJ#|=31+e>?P|%dy>~+iXyHu7A$emyp+l@8Mg1~!p z$>A8ip;!T`V>aCQ+SUf@Zn+0&PBi*#Xp)^$px}juN@~)h5MLI?^3V8I%B(?Msp&l|Hvfp|{O&8fkPv`{+On90=D3CyyE|xw- zu!&Qd#EQv7lEhwv%m7P4Aj)Pbi0P~6Eea$cL#R}_ z@9=6|OOrVCuwW<$TaBhXeZRR}I9C%wRbj9s46a`)3!{aZnn!r|#=AEPH@5l1|KbB{ zD;2)~Zd?17(DDA%df&!ORfv2pL~4An%J-G{z6w78z4z##U)^~Bdc`Ad^&GrC{^`V@ zCdxf$-t({Fe+r>3f9Ut)AMXF{{`KWecX4R)j{4b|O2@^D|K+=#U0dD#>r)?}{KLs| z_tR?vjeDXPe4rQ{sqmw__~a^{!5^(or?@$OOS&oD zG5((aOTN;1evRMi=%(6#bZKL}IyzMvohlCugPXx(Xu5)r-4*;S#5Q?Fq5Bcs8@>1eSD}pA$lgML8yyV6w~+Wb=^=`UxbGwme(+Y)z2{h z>}n>NOR3M1RTx8q2%TE!zLVp)Z8Z8NYWo5S+h}|n_5K?j-$tjl(F@yXdK*3ejT_#b rznSMC8~xT#GoJgi4ut({`HHjeuCMEZOTUYjeS<|j_`r#9Kb!JDh`dib literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..6f199814da6f4c6c415ef847de3726643a204c4d GIT binary patch literal 2650 zcmbtWO>7fK6rT0Zdc9s}^H;}&rb%!T;t<&iBq0=pREkibC77j6D`B#7ylZDwXLmch z&JV4GrS%42$ZMtpR zecm(cHNE{n@bmz|+XHg#CN0pI_Sv*gU?L#ue?|;ODHKSls%EB4MNN+>EX1)bL!n}9EWon5MD8VJT1y9_CxA8tB zj-x;ZYO1-MGEEu&+a@V#^QvLO@d3QqHAF+%%tQ5hcTO?Pt1mBJyGMMaGaAIo2Jzt# zN`3f#@>4^GM)E^VlGm=JRB2ILyOhb~6je^f(uA3e8Fh9ty(%RaWhp&9b1bzuaYD{4 z9+#4`nkBN5yrSubn99nknM`ZTx{{M&OkYQwC^o_(iy~~p+cNkw4&tr=UVtFg{hV~$ zr)y*=O<%C5yLcJc!6F1{_!R5G8pp1aU?rR|+ROD$0Fw>wN$PNepS8o;58^j(3$M6kKyQ1jlV#77?k;0NzvNt`$?Y{bi9>#o&~uCY16$1hg*ks3^uT)D5tPpTO%dsVJZ`I<2;A>iK z7~Wd>*yh-M{m)_`?iP3)B@9=uwzwYyPCKG?EvKZ{5hJfCl1xCxw}Np4UyLZY+PDtu zn8D~!BQh9t3@s1SG9-DAI3tyjMJ;dYdDB20Q2sKnKv@#^>@f}tbu}n)DZiXj#bp_0 z%q7uCYdS_N0)Q}|u3y)z4RNrGZ0;dw2FYyzd&oRc_e>

27z}YbK~p}7r(oR;q6XjL$X36<K@*f ztdZ&R$aJOqc$EniJyv+M93Hj8<7df>0gSx1=6@%U9yR~~ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..a182ebb83b9d869064554e5691908a6dd8cc4d4e GIT binary patch literal 1089 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 PwP)y8Kg#d}5t{x4cif}@ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..bacf27258bedca0c6efddb271bb1ec05271e10a7 GIT binary patch literal 3407 zcmbVO-ESMm5#Qr|km5@q-0>2Ty7C8E?F zv$qT_CO`<-Xzc=lRTM#0pg{HLz6Iz@{|qWLAmSh+2I4;WO|IPft?0}ikD^tiUjy9k z?Ck8!?(EF(_O(P$oB;jx&wnaBPzd=J2O-G;BOGi2QzI)x7Z%98z=Sd6BMXsvk%^oa z7o>Ta$()xKl=&!&a$a7DvDhFPC%Q6B^yqTetQgp2@nsRDF}-U}2qau94qEDLB#_Jo z9bu~84f390QtV~jdII<)=X-SdWs>TBf_f>zS53!pnaK*Sms<3{JxJ|`ty9ztz0kJxpygb zY4uIrEO@r1Rn}}rGqp-fLGwUE%klkaSz9Za6t!Hc6+Err}`EJAbxNH_%GfBh97pTXI1;=(=n2s8T<>r7HGz zwr3c$3r=%@2lSWUxs>^dXHzfpj%(RBubYmQb#H!{&!er(bPk1?X)h3Gauq9+wXNLw z3vZa&a~JJ=cFxM!&MN9!_9t%1Gt5=nVHrH)bZLXezz1(Ik33A7Uyz5=JnaqTHz4e? zS9F>8Lfl)4ARN30iXCxJFmVzRZ5bke8fG{ra!vx7+?FYvi-Ii1xh}Y;INX!!cX!^C zFM5pn5{A)_m8knk!Ln&JVHI-hXE1YCgM?W2)jV|_whj@rK{@MM8x&9CtDak?Iol{f z_VAFxeNj9pRk7*DrUw<_emb%b;-ADRyo4YSeaH=jO#&G2Bs9MykK#SI7Mh8^TOTz0 z#v0OClaKobccsB#B`(TglC~0+NWeqzgaZwj8cCCt$c5X4SRflE*Mv1e7xaiO>e5UE z!XZDwYVu>$tQgF`$><;s6>76Gb*f2LhD;0s0nu3IJBIsG@-BsFc<6J2;^r|QTPK9< zq~jnOhR+MC<~fNA6Hl?(?%&7m{um$eBkEG)Uj|f@3nWV z)mOe&U)f8H)wTBdZMn5*QN2pgbULO_xnGT z3&pbZB`o+V1dVE+Y541)V;~L+81FbGPz}0{`rsA8n@OAq%VI=Oo)T!be!7p{{i}R* zxJ%IGgeb~)xdi1}BbjjbP$2E}5z@j^y8v42pwpok!k=gm-meW#N(BpHhOu&C)RW7sq)CEBH zm<`VltKh8uJ2c@?1n(A8TQ^H3P`=?uT!#*U51&wsU3Kb3ECT#lFL)lPYSjL?T%hpW z$rtRRbxFftZ}r@NtsefPQ!@9*z0nbHN%52v3oF$V{GGd~ZCU{|A?EnfhPht!yF4>* z8%5W&{CEL&1jy&)Y+u4(et>;q$t?x{L2=b)0ELKtytq>_dN%aJ!H}kKh@Smu$=pDz zhuiY7jt~oxT6l;WMtE2NM>|I^fffgae*jY>d&h=0*>BF&-ft$4?&eMR z@_cQ6@8rx^vBt@ZJJNB$xyOlP_ncoj^^;SL#Pn`rww{>%YNGbzJ#}z%_4jO7o%~vz z>_o|CRJ~KV{rT4HcICl|>E_7TAJpHe+gBPRsXNh4>2A+sHF2kM@6)@V0%|5Fc9XBx zldo=P8)|A-o!L=m8tS>)mA&M#&AH!R{`KXpYNP+mZvX4`{@3qk8_9FK$+?~6TqAk0 zHvf&9Dy kQ8x!32t&^!vM{typg9mpWOyHkf9r+E&hz5{?fhu}1hq`!5C8xG literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..a9b98093c50cc6b53ee9ace9791a3bc446ba67f6 GIT binary patch literal 3971 zcmbtXT}&I<6~5!~A08WnO-wc+@qh!wVdFr8O9(9OYO@HEkg!hbtb!ZMc*evVGc&z+ zoCKsu?y72c)rZZ)KBVnRi&P#+w-PJ$CGUOgiw&-ZPP1D2vk!SAWvgi4dhU$JIE0{P zd!)H@f6qPVeCOPAZ(p;|gMj}0_>cKf7efD}4U24Mh1tFUl@hv%63ir;U{oed>$XYT z1go+}ot?B#I8;X%^`k`7Jfk`j_I8wTw4*|c>Pk4F?lS7`ggb(wo~M*?l+n42B*|(< z&C3#jCi}Lu06*__8Q;zo%|oSxlIW)G?9UMrpsxeH$J}BP zOv0966ZTU!*v9dcDxf>=WC}$Q)3v%2i)s-|x_wrb3y-J>&YXIwmlI%2%nq6cCbW1(7 z$AQO-*1ibAa1>7Msc;khd!`O|qSxVQ>7qE24vVt@hZ%T}-D>Y^Th8L}S|07SR_*CM z&ZN`Y+q6;xYR-L7Ycu5DJ0^YBjvyx!Y4n4AIGV^Mnv>39z(%-j@OLJf-=xQVjTBdd zbgop3)&EjVj?mRFc|iy54Won~yukH8A>CR-+6PGQT4m{2x88nn-(t-+EfNjrjycE{ znZ;01B1J`!u__9@g5^7TLB!{H-6l(mLw9T0ou}tBKVKv&FNoPf28(<~fBCBA-b1ja$;I8s!CBMi{ajev0jtsEU@F}Gm4Uz<`((h#`$~9 zcpW&8r5M30D6r5uwCF>{eJye4G z(Zz0r4C_OBjk9^?5py|e(_616Ca=Alo_;qmHF@>w*0Ab3pb6zr9-@@;4wW~yLpId%j#)r_1I`Z&SnZ^ELNY&0|#;%il?CM z7GfiwzsDykEdvkyTP*T*RKxuhZ@=c9DSKzC@yYUq8|(3zs(0pZ@rmyUy#{<3I?4RH z?Id$;eDRcV1K@XrBs>;>TFe8$HJ}B)5f0s{$c21%VewF}S=j?0-J@jGTWOM&LHznv z2lk~|*TCu*P^n`zgdqDiT#1-eEx)~-=}t7@Hy1MTgRD&X?jq|nwPNE0s$mLF(1t4TFCnd<=>=;Ufc3m{8cxc>X#* z{aE%CC;j(vl<=X0vM>oDmRindI@9>{K_>Rzg{vrQ1!~E_j6pJIK zJ!3nzYlsbc9pdF2FUitLab8gucsdQjRm|n@iDZmg8NUHVv`KecL99C=qC`p1xg3@y z6>iR|h;?>ORPk|`s@sKpR@K>zw4gf-4Rt%+C+f!Sbl53kEMua(Yr#$MVY&+(VQ_i8 zmD%I+2=Rh&4-|%%fsY$xI(let@5XVMZd~EM0v$?dhonb#J(v zKkw(~7Kg4>-P4EcGPT?@tps~l zn%9FvTJWuM@U6$EOPBryd~YugYTWTP?s%Q+P}O~w?pRnpuW`{eE?OTsTXhdVakW0^ z`%Kkb$JSiOz7BW&mOP`Ox*7kG79X#~$E)#)Z3lFGPCh5UdBxZ^VtG&5-SOOwy!@(n z!yEWy>f@>Mp^H`TrA=R}<~vgH9npN96<_Dl@pWIH<{K#c23Bxs{A-u*(+`$He`wP} zv9(aFF4^g-`^;7g@&-y%-};fK?ZNnG=e4HJwWiLc!k4*f&*;X1!w)b002xjH-e%#yN><75U+j90Z2g{p(_+hsd_hyx>s9ajn_O#Y%D9DJ z9;3&f?u6$?Q3T)0f@8v~i|9V3SSY0F*&`B_fE#<#>0A*Wfb`*Iu2C0gelQ=02hFEj ztnuj<+j&I5eH|Icvf*FG8^YM$^hNjRyet$8;us!z=#eEn%$xFD@q&Pyhe` literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..3bda4fe597f42146a27e27abd57df7c4489625a5 GIT binary patch literal 401 zcmaJ*Jx{|h5Vez(LaVZ|uvDTFQUu8lK!t^YX5d4S*et9-j>w8@N48VMhWHh%jGdYI z7tE0=Au+Kb^$*}AEh8u0`Q7>6yLYfX=ph@g?{92w->JyD>)ulAyoYFa3){ZK3dPJ_Rj3(z&nVPXlPxn|= z6Peo_#IqN{tH-@9c+s0zZ=2IX!GhpH_BLVE1y86qG4k2Vau+0p+N!Z)*wteGobHi`KGTegDuR_QV_}hMd z&J@DJI@E$^w5%~Z6QyZpA`@q+u6fpfFCEy@hbl8!GfSHJnOu7L#K!j!4B-F_3&D7Y zf$>9zPP{54R}9Y`I*hLHf8cNs7ELiMq3g1p+tIo&(aA~NlB$Ur0(~?B{4i2BehtA8 zK7~W??w0^9{C5LBVxO^)gF4mXGF&&nPgCPG^dhn1K2-=zIMn+Qzw{7qJdyP-DRJ!p&mL4}+@+9l&x;~DR zL^n3S$kee>QtC!qMrcviJF$^bUG-BFcV)eqrAB2*y_ZC(Y{;||^e%pM^cONaG_+b^7W88w~q|SsL ztGF(YiPj;6unrH|p1Ojons2$5Nn25;saX`7PHt>$s70J@xg(8mMB3`bkX~%KcsME# z;pZi_jNn2kOZWp#vhf>EQ%^w6Cc(hVX>%pYr`jtB?8q zPkV@RwkGcbYW!>Cony<(*}8>_nodm_y55VFmU-7rDH8I8hvx?+&UKW7NmkH5_Io0HRM7&-6`tkpa=HAGXzE{~Y+6!e(UM5V5hAyC)i_FP*+^7%ZA&RknV`s(Oq<%J zXO~JOG_490g_0VzgdU_8Eh?X^6a^}vm-J?|$6lDSfrv%h7|5aMjRp}2@Tu?3F7?CO zwGSPFZ{N(k`FZo+``*kzL%>U*Jow`uvOn?=@+DSkMYI{Nx(Lh)xk(i6G8yMg?ga9# z%P!LeU;Z*bE||i&Xo{>|xa=O6OljO>dQOlTQ4we48Sjknx@)krHZdVbYo@P-oFt07 zjVRKv%a$_zimQi2J^#jPl(T#(P1DVknbkF8BI>gEi`tUq8B67Iso9(g4U(eXEvSZR zNd=nI^Qsn0AFM|RZ&$eLEHEo%hM1f}Kmni0E1cqra|#d33vDD9%(+ZK5m_7ClG7$C z9*~f+X1WzG@IJ;%iXZp@<2_1)DYK0?T7gShQ%y}$eKDI+sU@22x7w8n~Bk>+aas6W0xu8i{vx z?M@=2F6wz>$hecu<%~q^eVyJhsj3=@IW-0Is6+Fp-w-WmW_CdxO6!_Q_1sWCm(tXP zs?BHNW6zOH&8wQ3$U8@2B-qGUe#!DDla5{0WYX$9a$>Q=eSX-XF$`pd>=9Y)d4i${xGXL{5f$L5!5rB<4L}!dM1nSf4#7}!b9K#z0DXPE zy}h&<7|WAPX0@yd=h0e!9(FrBi{l`=LMjcUo}mqF?tIF;MR6y~?U`)aya7rNM)cX+YTBG}yp9H; z`)R{LH^l7>4MAg+qfPL!osX$bTcA!`f#BBn&D|o#o)92l?qkgr2{{jG3{_<0yrl6| z#fKc2yZuQ3IHvd!b4xHC7)q}G6U>M)HVF;&#ZRVOesb287ZgA`kl8o$fh6{`XcQDa z&gVstLgUpH73>(sRU8C8+&$eqeN}OfIHP*V6h8+$3ll|rw>ROgNlqdv)RmJUTXV=s zUtK3u0XrQh&ASumc&tocoSsrVwe|8+0=9m?RmxHN%K9^0i z&iHLZ*OvWbfL(x4L)Vt_DugeMz-BBSVO6@NrU1PS+snm*IX84J$}<6qo3VsU%1i;2 zN(h2_!HoLqqT3R14NJ@v7V?JWR<*Pa6QZRlx}_yhSiy2fTbo}4=heQi@LwSNCqsS z){U<9uC>N3VRNzA(6`Dz_P?^ZxRrT$`@!w4M6v5^+234j?JxT~i{V&B5}LfL5(J?? zcz^Vx(ZBj$EjP7ojINJvw>~{=8e+wZWXkMG%n%xaVKMzE=-z$eYH-^@SejeWq^?w%X-)<{~ zURxd8<4MEme|5il&%D3<@$zQRPk&hS3~iqUvL`|h41Q`LZ2hS@G8Pv99QKV3%15ve z%>WL{RePD%&rQB8^~QNXc*({=uo|RthJyaAVHDJ)R#=#YG|y)t4I<{zZX_=Px)=x< zXkpB2g6MsMWHVjC&rTEje0rQU+NkD46AT~(-3RJ&6(f?NniK!nSt|_7I6vIi7`g#& z)gm605iI`Fx2`>Hh^O+|_~L7ERLY1y^~9M>Erg*rxiX)C!fJNPw zF!`OU*CTD=Z35+ zj_(J-HuxEkwF8n__(f6qCWssOT(NJPU|bo(@puJ4k0w~nHsawc0;XgZj_1HfUwo%L z&modynTJtfNMdo4cRlAO%GFZgGe_~y8Rhz)qW}t~T+h7sLd?{jVTwE9I2VEI$1|m+ zo1`XPTTk&M_OCuu!ZSy~^`L$f!&k56Whj(2b-HAUHk3$?yRpEvp%3C}jj~(I|@lnzCbmkG`&_FLE!FPaxd+zIljA>(8M?OX2b!EeQ zZDw^5LdiN+=l0JNd=sbr7zkipOL*hP`i-q{sU^1CGF)sK-sxWTmV<2@zIET`rBbkW zH+Z@jJiYB&70aQHje+%n&1@;uzZ;4bL$U3*Ai26pplR*O#>D!>*7+S*srhxty}ugU z5+BMB1gLrSOQ|C_VH&@NIv4=dDs?q!c~}`T1}}0SeSTO}zN-z%zw+hc}sA z&@+V`^6)~tTS(=cp2lQyE(>p)96atcJ(;9o(2eB<`c35CMluP+YDp#ye6FXHDHEPT zvjtN%KprEHg$_GnDq|H129JSQ@e@*U+0VFB1AFWl*J@w$PsjG~n*UV{GUHc%fYH5n zh{l6hWYa_gxcRvPzE5E_bX|HHh~+ky@>y-33fO{nwi@7im&?w^oV(BA8yP-U?A$Kz zKcele9^Jm>xnQ5uC{4hWn3|Xl6`td`e~|7kiSG*%`GO3fHpjxU6J?Pqnx)&fUraP530(ZKL7v# literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..42e40f74945b87a36ed8666d0b59f003fce080df GIT binary patch literal 3266 zcmcIm&2JmW6`x(M__?A)nUY9HF-h5Oyoi>HB)a)%l~$=4i*_wDD~m+|!rK)&qBmOZ zu4adJ#MXd-=3uu6=g@e0~-y{6h&@seF)l9XLh+<$}Q^l z&>?rYCtQf3ca1qxRIs~qNXjOQyn z4!KlV9qI5nuo}jZeoqufX-}vUTaDv*A4}i_#e|BuI*LbgK#Aa_GBOR6@H8;S@tDG& z1XA=XHl)Ongl3u+*064w&Ki}4s)nyI+SYWl!BHjlu*N1CqTLAd?7dmI*&%gN3&&)Yh=WT+R~wy*KI`4n$7H_8-`Oa-muU%Mi4snEvQjHP;TiMYBK7WSZ_nQVVM|NhHM*}3F{a-*r{uc z2DGteHatLeEz|%l+Pg$l)&7#9sv?ojBmg$3_jeV5s^IGcv_Jz`>WB8FKJ10^Vgb;;HU8ph0Ak<- zusIvlxPgwu!d(yho~luw3upl-G=r*%0P(pR=Z`6ZMc(Igpg!trCs1IUOWnbAdtpG}WDufU&SbNU&({C~E)#3>WX zv3jtgf3H8{k2BBD4YHGd2N5N$Ox7av+z~Wdr{9RzhCHA0v4GMQM19XbrAN)ww>SiA zLiHPgrqAtc1##N>rN8Rduh^=UX&U9s{~^kPKR188|1!!0I~wJTk{yag-d|5ZX)MD2 zILh(o>c)KC`OuNr$Nz`e$9*3@?Z;kMrcTn6E=M05=s?VA?J;Jm!HdbCyTgk&j*L`yn8xdk$4iZH3UuUkSKglcd%-0lX#%lEwHO#%dl=Z zo{3Bc>Oi>(4gESqyTx|18<*ux&4F?U86hR~Aa z#DC1XDU)Az6&HPoGJWV3$tk)qa^g47Nb#Fw)E`{6>AlVTF0xli;{L%eFH2l^g88Io z`DM@PlH^Efl2Xd=Y*du;Ikmi2Q)?^AI^ivcgj*2PT_a(8mtCktpqH^tgm*37MD)%B zWTd@KmyOtoMIuyFM{LCoMkM}S&qmb{NvRFZFjN|K)UeuYbBRd1)yVBMtS7#7uV zNJe$8SskP4s;Vu++SCly)Y_25R7cx_%mYcTxUr)JN*N&wkz=Y?MX2yScK5rb3kiEs zkrG63?IZncByn%IR5opT@Bze5Sdw7V@uI&tEG&3}H$Z7v_~x+M(s0jNhg}N!pdluc zSr);6sX%B>wA(b-RhG|)9pi5G@e5iB?19Jmh5cmZUVh=O@cW6+vY%%EQ2H!)2j9XJ z+B@G%R{j`CK8hyqBz~6YO)dQT)NiFvq~D$2Up&`aDEHED-;bXEGBw$olJ`@^y$gpz z@}ZEvC#3IQ`S4s%NZ%J0z8p_~A!ZK6`9m@DNKD?jaQi}U`lSQ$%%Pa>%OCvYKs7 z_Jr(R?)IAxg{=4A7qYirA4mgc2>U$Hc)*MYI>QWgdzi>POyuq*ay_BYOXT(w1tLj#V6{)nObZg(JpX-R$Yk*``@9QXfIY)E3h~N!G3tAEzKqS(~5W7TB zjv3OU5nnU(P51Ldv@!%_8k#NLghj7jR<#yQr((m<-LDd{BEZcn6k{B#>%4r7e!Fco zI|h6ctx$~S8*pfW37&*Fj(ZGd{{q1K;HR<2AoC|M`e$(FNqA(E?}60U 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 0000000000000000000000000000000000000000..0e33e2070fa7b0229cb048792d884f8cdf49ef1f GIT binary patch literal 7123 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 QVd4X-Coc<-{@_CWHy0n5rvLx| literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..9475cefcd2dfc6120c216a42b592f415c8d8006d GIT binary patch literal 7650 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..d2b6072b1ef2e4274298a96d492d277814a80f86 GIT binary patch literal 8420 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..6b12f9018bc3e208ee304aaf684a235041045cae GIT binary patch literal 2310 zcmcIl&u<$=6rS;V?Oi)gL!30UB#Ij&f{9{R5YdVTgaw79`O##dJs>Nq#hxTf*1Ois zEWx>8iJrJ1B>sRLlUpSu;MzMvNmV7$3eHtIwMwf0E zjc8nh5oKDRO7YZGnx}`65g$N?I)I!W-en}fPD(ptXkhEo?lxk1R7gD#%NB5uwp`ca z7Pmc@%@<<9nFY_WtIeCX=g7_=WB4w&>m=;2VsI0WC}Qhy9DFzkMho3UOR>wh5UN4n zAX>z$*uX}tg!5=yc_8Kn2}-z6-5@^ixnx@`pIG*2-QrAqawJ&(;GN11MkuSydG1=J zM!xhKY@Dsxj>9U&b&sxbN(ieg6AQjW##d}ktZ^D{aJ=fdoO;f9!?9da;e>Hk@m-rs z(`<-y*@P7v&7jLPqhk}(q#0OSgNJ3IYM~>Pz;8Vg)$Kmn976Z=n5obq4?ZtFD(!$E z1JGN7BbVUdhEj|f@kTnuxpsJr$5i03@}VS@r65;_^VLTVFlxBkqdr zl2RBD(^`R%(uQtaEq^$7wOFrh$AT_qEfdr8dBf){NHr|JYO<==AOI?j!>Yl{7K6}o z6Z)ux*0dbQTPL+`fI&v5DPhRbE-`&n1Tc(RGn~oH8mT%KB{j2SF?tSW%kW(UqlJFY zoIg05-y~a$o1g9UUp(l0V{2w7d-<`dbSGLLJxn1ry&uo-#`BwVd+|4EH#of*sb~UR zsYt8>rfFzG63N|5Ol9?w7-$2V#Fri+sBV8Mn{aWMa9omLNnn2kqlNws*diq5Gf*oL zya+9bAfG4~P(^G~7DIVPhAY^_e#}=)G!cys3wr3$OGjfUo%AqNL!F376w)P!#?WXq ze+Bk3h-?svAv7$1f>2kKA!Or1g7&~PD#AfUID&N5@fa~}#(V+{P$3JetMdglBuk>v z6!=5NS~eq$ibd&JQ4K)V$&;~8o>1}ysd8re?lD!?DI}?BRfWRP3*amfnE~T8SF-&P zO&+IEs^`0*Z-;*PdaJ&#PwwfH2U_>OHoB{gZe{LiZyzZr^)3v+|E@mylnMNQ>Mi%G zcY4$1y#9YO>J%{XzzBWo@PfP|E^&=JI@>(fu~|hhl?vW}4?U7q_G1zGO6qDivdN=+-b=}Z z^q$3h;*HqrIFYuT1YsyW=O7jnB^Z<5KJ&2zv&VsAzAaL&f{59}m@5&7b`tuWd5TFT zy_5$khg3%KgwY^MVxgpyOtU7XKIgIW6{A#G*gy%7Ttzj5Q>YUAw^r{qbEDb(mrfm> z55ALUxJajCBuGFD9()90@UC-xP~U`aFy|Kz?oAp zK>ab}>ahJEr!x$Le$U8i#b9AD$W{tSqj;2t4BwgsqTG*|%o=~+bYI=BsG`ZbnlULV_y`Gb&&^B9(M)I%_XpVqZ+*2YQMyi}R(%XYk`DAt8HojQ5 z=O#Ku4WoNjW)0FHQ~{S+^I%UHa;noOkLA s(ZI@iSE8(u{-~J6x?fEBKwL(jW?5cDbz%}iegGW7XK0;PaJzZVAKVVp+5i9m literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..9af9650c553b19fd2ac9e7811403e849ea31c7bb GIT binary patch literal 6388 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 BNe=)3 literal 0 HcmV?d00001 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.