feat: add gitea agentic runtime control plane
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1 +1,2 @@
|
||||
.worktrees/
|
||||
.tmp/
|
||||
|
||||
6
.ralph/ralph-context.md
Normal file
6
.ralph/ralph-context.md
Normal file
@@ -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.
|
||||
101
.ralph/ralph-history.md
Normal file
101
.ralph/ralph-history.md
Normal file
@@ -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.
|
||||
28
.ralph/ralph-loop-plan.md
Normal file
28
.ralph/ralph-loop-plan.md
Normal file
@@ -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`
|
||||
7
.ralph/ralph-tasks.md
Normal file
7
.ralph/ralph-tasks.md
Normal file
@@ -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
|
||||
102
README.md
102
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 <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 <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=<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)
|
||||
|
||||
|
||||
266
docs/superpowers/plans/2026-03-13-gitea-agentic-runtime-plan.md
Normal file
266
docs/superpowers/plans/2026-03-13-gitea-agentic-runtime-plan.md
Normal file
@@ -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
|
||||
@@ -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.
|
||||
5
engine/devops_agent/__init__.py
Normal file
5
engine/devops_agent/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Runtime package for agentic DevOps workflow execution."""
|
||||
|
||||
__all__ = ["__version__"]
|
||||
|
||||
__version__ = "0.1.0"
|
||||
BIN
engine/devops_agent/__pycache__/__init__.cpython-314.pyc
Normal file
BIN
engine/devops_agent/__pycache__/__init__.cpython-314.pyc
Normal file
Binary file not shown.
BIN
engine/devops_agent/__pycache__/cli.cpython-314.pyc
Normal file
BIN
engine/devops_agent/__pycache__/cli.cpython-314.pyc
Normal file
Binary file not shown.
BIN
engine/devops_agent/__pycache__/compiler.cpython-314.pyc
Normal file
BIN
engine/devops_agent/__pycache__/compiler.cpython-314.pyc
Normal file
Binary file not shown.
BIN
engine/devops_agent/__pycache__/evidence.cpython-314.pyc
Normal file
BIN
engine/devops_agent/__pycache__/evidence.cpython-314.pyc
Normal file
Binary file not shown.
BIN
engine/devops_agent/__pycache__/policies.cpython-314.pyc
Normal file
BIN
engine/devops_agent/__pycache__/policies.cpython-314.pyc
Normal file
Binary file not shown.
BIN
engine/devops_agent/__pycache__/runtime.cpython-314.pyc
Normal file
BIN
engine/devops_agent/__pycache__/runtime.cpython-314.pyc
Normal file
Binary file not shown.
BIN
engine/devops_agent/__pycache__/spec.cpython-314.pyc
Normal file
BIN
engine/devops_agent/__pycache__/spec.cpython-314.pyc
Normal file
Binary file not shown.
BIN
engine/devops_agent/__pycache__/validator.cpython-314.pyc
Normal file
BIN
engine/devops_agent/__pycache__/validator.cpython-314.pyc
Normal file
Binary file not shown.
130
engine/devops_agent/cli.py
Normal file
130
engine/devops_agent/cli.py
Normal file
@@ -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())
|
||||
42
engine/devops_agent/compiler.py
Normal file
42
engine/devops_agent/compiler.py
Normal file
@@ -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,
|
||||
}
|
||||
16
engine/devops_agent/evidence.py
Normal file
16
engine/devops_agent/evidence.py
Normal file
@@ -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
|
||||
43
engine/devops_agent/policies.py
Normal file
43
engine/devops_agent/policies.py
Normal file
@@ -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}"
|
||||
)
|
||||
4
engine/devops_agent/providers/__init__.py
Normal file
4
engine/devops_agent/providers/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from engine.devops_agent.providers.base import IssueProvider
|
||||
from engine.devops_agent.providers.gitea import GiteaProvider
|
||||
|
||||
__all__ = ["IssueProvider", "GiteaProvider"]
|
||||
Binary file not shown.
BIN
engine/devops_agent/providers/__pycache__/base.cpython-314.pyc
Normal file
BIN
engine/devops_agent/providers/__pycache__/base.cpython-314.pyc
Normal file
Binary file not shown.
BIN
engine/devops_agent/providers/__pycache__/gitea.cpython-314.pyc
Normal file
BIN
engine/devops_agent/providers/__pycache__/gitea.cpython-314.pyc
Normal file
Binary file not shown.
16
engine/devops_agent/providers/base.py
Normal file
16
engine/devops_agent/providers/base.py
Normal file
@@ -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]: ...
|
||||
73
engine/devops_agent/providers/gitea.py
Normal file
73
engine/devops_agent/providers/gitea.py
Normal file
@@ -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", "")),
|
||||
}
|
||||
65
engine/devops_agent/runtime.py
Normal file
65
engine/devops_agent/runtime.py
Normal file
@@ -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
|
||||
60
engine/devops_agent/spec.py
Normal file
60
engine/devops_agent/spec.py
Normal file
@@ -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,
|
||||
)
|
||||
49
engine/devops_agent/validator.py
Normal file
49
engine/devops_agent/validator.py
Normal file
@@ -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
|
||||
14
pyproject.toml
Normal file
14
pyproject.toml
Normal file
@@ -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"]
|
||||
@@ -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 <url> --repo <owner/repo> --token <token> --issue-number <n> --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 <payload.json> --output-dir .tmp/runtime-run --base-url <url> --token <token>`
|
||||
- `python -m engine.devops_agent.cli acceptance workflows/gitea-issue-delivery.md --base-url <url> --repo <owner/repo> --token <token> --issue-number <n> --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.
|
||||
|
||||
Binary file not shown.
47
tests/acceptance/test_gitea_acceptance.py
Normal file
47
tests/acceptance/test_gitea_acceptance.py
Normal file
@@ -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()
|
||||
12
tests/fixtures/gitea/comment_event.json
vendored
Normal file
12
tests/fixtures/gitea/comment_event.json
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"action": "created",
|
||||
"comment": {
|
||||
"body": "@devops-agent please process issue 48"
|
||||
},
|
||||
"issue": {
|
||||
"number": 48
|
||||
},
|
||||
"repository": {
|
||||
"full_name": "Fun_MD/devops-skills"
|
||||
}
|
||||
}
|
||||
6
tests/fixtures/gitea/issue.json
vendored
Normal file
6
tests/fixtures/gitea/issue.json
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"number": 48,
|
||||
"title": "Fix issue delivery flow",
|
||||
"body": "The agent should post evidence back to the issue.",
|
||||
"state": "open"
|
||||
}
|
||||
4
tests/fixtures/specs/invalid_missing_provider.md
vendored
Normal file
4
tests/fixtures/specs/invalid_missing_provider.md
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
---
|
||||
name: invalid-workflow
|
||||
---
|
||||
# Invalid
|
||||
17
tests/fixtures/specs/invalid_path_scope.md
vendored
Normal file
17
tests/fixtures/specs/invalid_path_scope.md
vendored
Normal file
@@ -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.
|
||||
12
tests/fixtures/specs/no_safe_outputs_for_write.md
vendored
Normal file
12
tests/fixtures/specs/no_safe_outputs_for_write.md
vendored
Normal file
@@ -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.
|
||||
17
tests/fixtures/specs/valid_workflow.md
vendored
Normal file
17
tests/fixtures/specs/valid_workflow.md
vendored
Normal file
@@ -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.
|
||||
Binary file not shown.
62
tests/integration/test_runtime_flow.py
Normal file
62
tests/integration/test_runtime_flow.py
Normal file
@@ -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()
|
||||
BIN
tests/unit/__pycache__/test_cli.cpython-314-pytest-8.4.2.pyc
Normal file
BIN
tests/unit/__pycache__/test_cli.cpython-314-pytest-8.4.2.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
84
tests/unit/test_cli.py
Normal file
84
tests/unit/test_cli.py
Normal file
@@ -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"
|
||||
26
tests/unit/test_compiler.py
Normal file
26
tests/unit/test_compiler.py
Normal file
@@ -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"]
|
||||
72
tests/unit/test_gitea_provider.py
Normal file
72
tests/unit/test_gitea_provider.py
Normal file
@@ -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"]
|
||||
34
tests/unit/test_policies.py
Normal file
34
tests/unit/test_policies.py
Normal file
@@ -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")
|
||||
11
tests/unit/test_smoke_imports.py
Normal file
11
tests/unit/test_smoke_imports.py
Normal file
@@ -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)
|
||||
29
tests/unit/test_spec_loader.py
Normal file
29
tests/unit/test_spec_loader.py
Normal file
@@ -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"]
|
||||
30
tests/unit/test_validator.py
Normal file
30
tests/unit/test_validator.py
Normal file
@@ -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)
|
||||
29
workflows/gitea-issue-delivery.lock.json
Normal file
29
workflows/gitea-issue-delivery.lock.json
Normal file
@@ -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"
|
||||
}
|
||||
34
workflows/gitea-issue-delivery.md
Normal file
34
workflows/gitea-issue-delivery.md
Normal file
@@ -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.
|
||||
Reference in New Issue
Block a user