Compare commits
3 Commits
594c7e1a4d
...
9ce0295747
| Author | SHA1 | Date | |
|---|---|---|---|
| 9ce0295747 | |||
| ae540c7890 | |||
| 6f6acdb0e6 |
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
.worktrees/
|
||||
.tmp/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
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.
|
||||
114
.ralph/ralph-history.md
Normal file
114
.ralph/ralph-history.md
Normal file
@@ -0,0 +1,114 @@
|
||||
# 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.
|
||||
|
||||
## Iteration 6
|
||||
|
||||
- Goal: remove generated Python cache artifacts accidentally staged into git
|
||||
- Changed files:
|
||||
- `.gitignore`
|
||||
- removed tracked `__pycache__/*.pyc` artifacts under `engine/` and `tests/`
|
||||
- Verification:
|
||||
- `python -m pytest tests/unit tests/integration tests/acceptance -q`
|
||||
- `git diff --check`
|
||||
- Result: `19 passed`
|
||||
- Blockers:
|
||||
- Two sandbox-created `pytest-cache-files-*` directories remain unreadable to git status in this environment, but they are not tracked and do not affect the runtime or tests.
|
||||
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"
|
||||
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"]
|
||||
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.
|
||||
|
||||
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.
|
||||
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()
|
||||
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