Merge pull request 'ORCH-6: multi-repo (project filter + repo/prefix per project)' (#2) from feature/ORCH-6-multirepo into main

This commit was merged in pull request #2.
This commit is contained in:
2026-06-02 23:42:29 +03:00
19 changed files with 1271 additions and 139 deletions

View File

@@ -101,12 +101,51 @@ uvicorn src.main:app --reload --port 8500
| `ORCH_GITEA_TOKEN` | Gitea API token | — |
| `ORCH_GITEA_WEBHOOK_SECRET` | Gitea webhook secret | — |
| `ORCH_GITEA_OWNER` | Gitea repo owner | `admin` |
| `ORCH_DEFAULT_REPO` | Default repository | `enduro-trails` |
| `ORCH_DEFAULT_REPO` | Default repository (fallback) | `enduro-trails` |
| `ORCH_PROJECTS_JSON` | Multi-repo реестр (JSON-массив, ORCH-6) | `""` → дефолт в `src/projects.py` |
| `ORCH_CLAUDE_BIN` | Путь к Claude CLI | `/opt/claude-code/bin/claude.exe` |
| `ORCH_REPOS_DIR` | Repos dir (container) | `/repos` |
| `ORCH_HOST_REPOS_DIR` | Repos dir (host) | `/home/slin/repos` |
| `ORCH_DB_PATH` | SQLite path | `/app/data/orchestrator.db` |
## Multi-repo: реестр проектов (ORCH-6)
Оркестратор обслуживает несколько репозиториев через реестр проектов
(`src/projects.py`), ключ = **Plane project id**. Plane-webhook фильтрует события
по проекту (неизвестный проект → `ignored`) и резолвит `repo` / `work_item_prefix` /
Plane-проект из маппинга.
По умолчанию (если `ORCH_PROJECTS_JSON` пуст) зарегистрированы два проекта:
| Проект | Plane project id | repo | prefix |
|--------|------------------|------|--------|
| enduro-trails | `7a79f0a9-5278-49cd-9007-9a338f238f9c` | `enduro-trails` | `ET` |
| orchestrator | `8da6aa25-a60e-44d6-a1e2-d8ae59aa7d6a` | `orchestrator` | `ORCH` |
### Как добавить новый проект
1. Убедись, что gitea-репо уже клонировано в `/repos/<repo>` (авто-clone — отдельно).
2. Узнай Plane project uuid (из URL проекта в Plane или через Plane API).
3. Добавь запись в `ORCH_PROJECTS_JSON` в `.env` (JSON-массив). **Важно:** если
задаёшь `ORCH_PROJECTS_JSON`, он полностью заменяет дефолт — перечисли **все**
нужные проекты (включая enduro-trails и orchestrator):
```bash
ORCH_PROJECTS_JSON='[
{"plane_project_id":"7a79f0a9-5278-49cd-9007-9a338f238f9c","repo":"enduro-trails","work_item_prefix":"ET","name":"enduro-trails"},
{"plane_project_id":"8da6aa25-a60e-44d6-a1e2-d8ae59aa7d6a","repo":"orchestrator","work_item_prefix":"ORCH","name":"orchestrator"},
{"plane_project_id":"<новый-uuid>","repo":"<новый-repo>","work_item_prefix":"<PREFIX>","name":"<имя>"}
]'
```
4. Пересобери: `docker compose up -d --build`.
5. Проверь резолв:
```bash
docker exec orchestrator python3 -c "from src.projects import get_project_by_plane_id as g; print(g('<новый-uuid>'))"
```
Поля `name` опционально (по умолчанию = `repo`). Подробности — `docs/ARCHITECTURE.md`.
## Ключевые механизмы
### Auto-advance

View File

@@ -9,9 +9,39 @@ Orchestrator — event-driven FastAPI сервис, который управл
### 1. Webhook Receivers
#### Plane Webhook (`src/webhooks/plane.py`)
- Принимает `work_item.created` — создаёт задачу в DB, запускает analyst
- **Фильтр по проекту (ORCH-6):** извлекает `data.project` (Plane project uuid) и игнорирует событие, если проект не в реестре (`known_plane_project_ids()`) → ответ `{"status":"ignored","reason":"unknown project"}`. Это предотвращает инцидент 2026-06-02 (webhook на весь workspace без фильтра).
- Принимает `work_item.created` — резолвит repo/prefix/Plane-проект из реестра по `project`, создаёт задачу в DB, запускает analyst
- Принимает `work_item.updated` — синхронизация статусов
#### Реестр проектов (`src/projects.py`, multi-repo, ORCH-6)
Маппинг **Plane project id → (repo, work_item_prefix, name)**. Позволяет одному
оркестратору обслуживать несколько репозиториев, не путая их.
```python
@dataclass(frozen=True)
class ProjectConfig:
plane_project_id: str # uuid Plane-проекта (ключ реестра)
repo: str # имя gitea-репо (= папка в /repos)
work_item_prefix: str # ET / ORCH
name: str # человекочитаемое
```
Резолверы:
- `get_project_by_plane_id(uuid) -> ProjectConfig | None` — для фильтра/резолва в plane-webhook.
- `get_project_by_repo(repo) -> ProjectConfig | None` — когда известен только repo (gitea-webhook, plane_sync).
- `known_plane_project_ids() -> set[str]` — множество разрешённых проектов (фильтр).
**Источник конфигурации:** env `ORCH_PROJECTS_JSON` (JSON-массив `ProjectConfig`).
Если пусто/битый JSON — используется встроенный дефолт-реестр (enduro-trails + orchestrator),
чтобы система работала из коробки. Парсинг устойчив: битые записи пропускаются,
полностью невалидный JSON → fallback на дефолт.
Следствия multi-repo:
- **repo per project:** `repo = get_project_by_plane_id(project_id).repo` вместо хардкода `default_repo`.
- **prefix per project:** `get_next_work_item_id(repo, prefix)` нумерует независимо — `ORCH-001` vs `ET-010` (`src/db.py`).
- **plane_sync в правильный проект:** state/comment пишутся в Plane-проект самой задачи (резолв по repo через `get_project_by_repo`), а не в единственный хардкоженный `PROJECT_ID` (обратная совместимость сохранена дефолтом на enduro).
- **gitea-webhook:** push в repo вне реестра → `ignored` (не триггерит конвейер).
#### Gitea Webhook (`src/webhooks/gitea.py`)
- **push** — проверяет наличие артефактов (docs/, src/), продвигает стадию
- **pull_request\*** (wildcard) — обрабатывает review approved/rejected, PR merge
@@ -39,7 +69,7 @@ STAGE_TRANSITIONS = {
|-------|---------------|
| check_analysis_approved | Filesystem: 4 файла + :approved: comment в Plane |
| check_architecture_done | Filesystem: ADR dir или infra-requirements.md |
| check_tests_local | Оркестратор сам гоняет `make test` в `/repos/<repo>` (judge по exit-code). Заменил check_ci_green: Gitea CI не сконфигурирован. |
| check_tests_local | Оркестратор сам гоняет `make test` в **worktree задачи** `/repos/_wt/<repo>/<branch>` (judge по exit-code). Заменил check_ci_green: Gitea CI не сконфигурирован. Worktree-изоляция → безопасно при параллельных задачах (ORCH-2 / S-4). |
| check_reviewer_verdict | Filesystem: читает `verdict: APPROVED\|REQUEST_CHANGES` из YAML-frontmatter `12-review.md` (только машиночитаемое поле, не подстроки в тексте) |
| check_tests_passed | Filesystem: test-report.md содержит "PASS" |
| check_ci_green | (legacy) Gitea API: GET /commits/{branch}/status — больше не используется как QG развития |
@@ -188,7 +218,7 @@ services:
Каждый агент — Claude CLI с:
- **System prompt**: `.openclaw/agents/{role}.md` (в репозитории)
- **Task file**: `.task-{suffix}.md` — генерируется orchestrator **прямой записью в смонтированный volume `/repos/<repo>/`** (B-1, без docker). В `.gitignore` репозитория проекта (рантайм-артефакт, не коммитится).
- **Task file**: `.task-{suffix}.md` — генерируется orchestrator **прямой записью в worktree задачи** `/repos/_wt/<repo>/<branch>/` (B-1, без docker; ORCH-2 — в изолированную рабочую копию, не в shared `/repos/<repo>`). В `.gitignore` репозитория проекта (рантайм-артефакт, не коммитится).
- **Tools**: Read, Write, Edit, Bash
- **Output**: `--print` mode (весь вывод в stdout после завершения)
@@ -201,13 +231,39 @@ services:
| tester | test-report.md, e2e results | 10-25 мин |
| deployer | merge PR + SSH deploy-hook + smoke | 5-10 мин |
## Изоляция через git worktree (ORCH-2 / S-4)
Каждая задача (= одна git-ветка) работает в **изолированной git worktree**, а не в общем
`/repos/<repo>`. Это убирает гонки `git checkout`, когда две задачи активны одновременно.
```
/repos/<repo> ← основной clone (fetch / управление worktree, read-only запросы)
/repos/_wt/<repo>/<safe-branch> ← worktree конкретной задачи (рабочая копия агента)
```
Модуль `src/git_worktree.py`:
- `get_worktree_path(repo, branch)` — путь worktree (не создаёт).
- `ensure_worktree(repo, branch)` — создаёт (или переиспользует) worktree на нужной ветке;
для новой ветки создаёт её от `origin/main`. Возвращает путь.
- `remove_worktree(repo, branch)` — опциональная очистка при `done`.
Где используется worktree:
- **launcher**: агент запускается с `cd <worktree>` (без `git checkout` в cmd); task-файл
пишется в worktree; commit/push в `_monitor_agent` идут в worktree.
- **qg/checks**: чтение артефактов агента (`check_analysis_complete`, `check_architecture_done`,
`check_tests_passed`, `check_reviewer_verdict`) и `check_tests_local` (`make test`) — из worktree.
Артефакт-функции принимают опциональный `branch`; без него падают на shared `/repos/<repo>`
(обратная совместимость).
- **webhooks/gitea**: `git branch -r --contains <sha>` оставлен в основном clone — это
**read-only** запрос (нет checkout/мутации), гонок не создаёт.
> Один branch может быть checked out только в одной worktree одновременно —
> это и есть нужное свойство: одна задача = одна ветка = одна worktree.
## Известные ограничения
- **Shared `/repos` checkout (гонки при параллельных задачах).** Все агенты и
`check_tests_local` делают `git checkout` в одном `/repos/<repo>`. При двух
одновременно активных задачах checkout одной перетрёт рабочую копию другой.
Пока приемлемо (задачи идут последовательно). **Исправление — git worktree per task/branch
(запланировано отдельной задачей S-4).**
- ~~Shared `/repos` checkout (гонки при параллельных задачах).~~ **РЕШЕНО (ORCH-2 / S-4):**
git worktree per task/branch — см. раздел «Изоляция через git worktree» ниже.
- **In-process daemon-потоки.** Агенты запускаются в daemon-потоках uvicorn. При
рестарте uvicorn запущенные агенты осиротевают → ловит orphan-recovery (M-1).
Целевая архитектура — очередь задач (F-2b, отдельно).

View File

@@ -0,0 +1,81 @@
# ORCH-2 / S-4 — git worktree per task (изоляция shared /repos)
**Дата:** 2026-06-02
**Ветка:** `feature/ORCH-2-worktree`
**Источник:** `AUDIT_2026-06-02.md` (SERIOUS S-4), `DEV_TASK_ORCH2_WORKTREE.md`
**Исполнитель:** Dev (Opus 4.8 Tokenator)
## Проблема (S-4)
Все git-операции (`launcher.launch` cmd, `_monitor_agent` commit/push, `check_tests_local`)
делали `git checkout <branch>` в одном общем `/repos/<repo>`. При двух активных задачах
checkout одной перетирал рабочую копию другой → гонки (на ET-009 это дало «два коллектора»
и путаницу веток).
## Решение
**git worktree per branch.** Каждая задача (ветка) работает в изолированной рабочей копии:
```
/repos/<repo> ← основной clone (fetch / worktree mgmt / read-only)
/repos/_wt/<repo>/<safe-branch> ← worktree задачи (рабочая копия агента)
```
## Изменения
| Файл | Что |
|------|-----|
| `src/config.py` | + `worktrees_dir: str = "/repos/_wt"` |
| `src/git_worktree.py` (новый) | `_safe`, `get_worktree_path`, `ensure_worktree`, `remove_worktree` |
| `src/agents/launcher.py` | `launch()`: ветка резолвится заранее → `ensure_worktree`; cmd = `cd <worktree>` без `git checkout`; `_write_task_file(repo, branch, ...)` пишет в worktree; `_monitor_agent` commit/push в worktree (checkout убран); чтение `01-questions.md`/`10-conflict.md` из worktree; QG-диспетчер прокидывает `branch` |
| `src/qg/checks.py` | `_repo_path(repo, branch)` helper (worktree если есть, иначе shared); артефакт-чеки получили опциональный `branch`; `check_tests_local``ensure_worktree` + `make test` в worktree (TODO про S-4 удалён) |
| `src/webhooks/plane.py` | QG-диспетчер прокидывает `branch`; review-файл fallback читается из worktree |
| `src/webhooks/gitea.py` | `git branch -r --contains <sha>` — подтверждено read-only, оставлено в main clone (+ комментарий) |
| `tests/test_git_worktree.py` (новый) | покрытие `_safe`/`get_worktree_path`/`ensure_worktree`/`remove_worktree` + изоляция двух веток (реальные локальные git-репо в tmp, без сети) |
| `tests/test_launcher.py` | `TestWriteTaskFile` обновлён под новую сигнатуру (запись в worktree) |
| `docs/ARCHITECTURE.md` | раздел «Изоляция через git worktree»; убран пункт про shared-checkout гонки |
## Совместимость с прежними фиксами
- **B-1** (запись task-файла без docker, прямой `open()`): сохранена — теперь путь = worktree.
- **B-2** (Popen stdout → файл, monitor `proc.wait()` без зомби): не тронут.
- **S-5** (`check_reviewer_verdict` — только YAML-frontmatter): не тронут, добавлен лишь worktree-путь.
- **S-1** (`check_tests_local` — свой `make test` вместо Gitea CI): сохранён, тесты теперь в worktree.
Обратная совместимость QG-диспетчеризации: артефакт-чеки принимают `branch` опционально
(default `None` → shared `/repos/<repo>`), поэтому существующие 2-арг вызовы/тесты не сломаны.
## Проверка
```bash
# Тесты (в контейнере через образ — хостовый .venv сломан):
IMG=$(docker inspect orchestrator --format '{{.Config.Image}}')
docker run --rm -v /home/slin/repos/orchestrator:/code -w /code --entrypoint python3 $IMG -m pytest tests/ -q
# → 37 passed, 9 failed (pre-existing test_webhooks 401/signature — НЕ относятся к ORCH-2,
# идентичны baseline на main).
# test_git_worktree.py изолированно → 9 passed.
```
### Тест изоляции (в работающем контейнере)
```bash
docker exec orchestrator python3 -c "
import sys; sys.path.insert(0,'/app')
from src.git_worktree import ensure_worktree
import subprocess
p1 = ensure_worktree('enduro-trails','feature/wt-test-A')
p2 = ensure_worktree('enduro-trails','feature/wt-test-B')
b1 = subprocess.run(['git','-C',p1,'branch','--show-current'],capture_output=True,text=True).stdout.strip()
b2 = subprocess.run(['git','-C',p2,'branch','--show-current'],capture_output=True,text=True).stdout.strip()
assert p1!=p2 and b1!=b2, 'NOT ISOLATED'
print('ISOLATION OK', p1, p2, b1, b2)
"
```
(Результат прогона на сервере — см. ниже / в отчёте Стрим.)
## Ограничения / заметки
- Очередь задач (ORCH-1 / F-2b) **не** входит в эту задачу.
- `remove_worktree` существует, но автоматический вызов при `done` не подключён (опционально, отдельным шагом).

View File

@@ -0,0 +1,82 @@
# BUGFIXES / CHANGES — 2026-06-03
## ORCH-6 — Multi-repo: фильтр проекта + маппинг repo per project
**Тип:** root-fix инцидента + новая возможность (multi-repo)
**Ветка:** `feature/ORCH-6-multirepo`
**Plane:** ORCH-6 (project `8da6aa25-a60e-44d6-a1e2-d8ae59aa7d6a`)
**Связанный инцидент:** [`INCIDENT_2026-06-02_webhook_autorun.txt`](./INCIDENT_2026-06-02_webhook_autorun.txt)
### Контекст инцидента
При создании задач ORCH-1..7 в Plane (проект `orchestrator`) Plane-webhook
(id `93f0c342-a614-4248-9d0f-c107276f5620`) сработал на каждую задачу и запустил
конвейер — но **всё ушло в репо `enduro-trails`**, потому что `plane.py:91`
хардкодил `repo = settings.default_repo`. Webhook слушал **весь workspace без
фильтра по проекту**, наплодив мусорные ET-010..016.
Митигация на время фикса: Plane-webhook **деактивирован** (`is_active=false`).
### Root cause
1. Нет фильтра по Plane-проекту — любая issue из любого проекта попадала в конвейер.
2. `repo` хардкожен на единственный `default_repo` (enduro-trails).
3. `work_item_prefix` всегда `ET` (db.py).
4. `plane_sync` ходил в единственный хардкоженный `PROJECT_ID` (enduro).
### Что сделано
| Файл | Изменение |
|------|-----------|
| `src/projects.py` (новый) | Реестр проектов: `ProjectConfig` + дефолт-список (enduro-trails + orchestrator) + резолверы `get_project_by_plane_id` / `get_project_by_repo` / `known_plane_project_ids`. Источник переопределения — `ORCH_PROJECTS_JSON`; устойчивый парсинг (битый JSON / битые записи → fallback на дефолт). |
| `src/config.py` | Добавлен `projects_json: str = ""` (env `ORCH_PROJECTS_JSON`). |
| `src/webhooks/plane.py` | **Фильтр по проекту**: `data.project` не в реестре → `{"status":"ignored","reason":"unknown project"}`. Резолв `repo`/`prefix`/Plane-проекта из реестра. Plane-sync для задачи идёт в её собственный проект. |
| `src/db.py` | `get_next_work_item_id(repo, prefix="ET")` — нумерация per (repo, prefix); `ORCH-001` независимо от `ET-010`. Дефолт `ET` сохранён для обратной совместимости. |
| `src/plane_sync.py` | `_resolve_project_id` + параметризация `project_id` (дефолт на enduro → обратная совместимость существующих вызовов). |
| `src/webhooks/gitea.py` | Неизвестный repo (`get_project_by_repo` → None) → `ignored` в 3 хэндлерах. |
### Тесты
- `tests/test_projects.py` (16 тестов): резолверы (by plane_id, by repo, unknown→None,
known_plane_project_ids), парсинг `ORCH_PROJECTS_JSON` (валидный / битый JSON / не массив /
битые записи → skip / all-bad → fallback), reload с кастомным JSON.
- `tests/test_plane_webhook.py` (4 теста, FastAPI TestClient, `launcher.launch` замокан):
unknown project → `ignored` + нет task/branch/agent; orchestrator-проект → `repo=orchestrator`,
`ORCH-*`; enduro-проект → `repo=enduro-trails`, `ET-*`; независимые префиксы (`ORCH-001`/`ORCH-002`
параллельно с `ET-001`).
**Прогон (в контейнере, образ `orchestrator-orchestrator`):** `57 passed`. 9 падений в
`tests/test_webhooks.py`**pre-existing** (webhook signature 401 / TypeError, не связаны с ORCH-6,
не трогались).
```bash
IMG=$(docker inspect orchestrator --format '{{.Config.Image}}')
docker run --rm -v /home/slin/repos/orchestrator:/code -w /code --entrypoint python3 $IMG -m pytest tests/ -q
```
### Проверка резолва (offline, в работающем контейнере)
```bash
docker exec orchestrator python3 -c "
from src.projects import get_project_by_plane_id, known_plane_project_ids
o = get_project_by_plane_id('8da6aa25-a60e-44d6-a1e2-d8ae59aa7d6a')
e = get_project_by_plane_id('7a79f0a9-5278-49cd-9007-9a338f238f9c')
assert o.repo=='orchestrator' and o.work_item_prefix=='ORCH'
assert e.repo=='enduro-trails' and e.work_item_prefix=='ET'
assert get_project_by_plane_id('00000000-0000-0000-0000-000000000000') is None
print('RESOLVE OK:', o.repo, e.repo, '| known:', len(known_plane_project_ids()))
"
```
### ⚠️ Важно
- Plane-webhook **остаётся выключенным** (`is_active=false`). Включение — отдельный
шаг Стрим после ревью PR.
- `ORCH_PROJECTS_JSON` (если задан) **полностью заменяет** дефолт — перечислять все нужные проекты.
- Обратная совместимость `plane_sync` сохранена (дефолт project_id = enduro), ET-задачи не сломаны.
### Re-enable webhook (после ревью, делает Стрим)
```sql
UPDATE webhooks SET is_active=true WHERE id='93f0c342-a614-4248-9d0f-c107276f5620';
```

View File

@@ -0,0 +1,7 @@
INCIDENT 2026-06-02: Plane webhook auto-triggered pipeline for ALL ORCH-1..7 tasks
- Plane webhook (id 93f0c342) fires on ANY issue creation in workspace, no project filter
- plane.py:91 hardcodes repo=settings.default_repo (enduro-trails)
- Result: ORCH-x tasks ran analyst/architect in WRONG repo (enduro-trails), created junk ET-010..016
- MITIGATION: Plane webhook DEACTIVATED (is_active=false) until ORCH-6 adds project filter
- ROOT FIX = ORCH-6 (multi-repo): filter by plane_project_id + repo mapping per project
- To re-enable webhook after ORCH-6: UPDATE webhooks SET is_active=true WHERE id=93f0c342...

View File

@@ -6,6 +6,7 @@ import signal
from ..config import settings
from ..db import get_db, get_task_by_repo_branch, update_task_stage
from ..stages import get_next_stage, get_qg_for_stage, get_agent_for_stage
from ..git_worktree import ensure_worktree, get_worktree_path
from ..qg.checks import QG_CHECKS
from ..notifications import notify_stage_change, notify_qg_failure, notify_agent_started, notify_agent_finished, notify_approve_requested
from ..plane_sync import notify_stage_change as plane_notify_stage, add_comment as plane_add_comment
@@ -71,15 +72,22 @@ class AgentLauncher:
if not config:
raise ValueError(f"Unknown agent: {agent}")
# Container-local path (repos mounted at /repos)
# Main clone lives at /repos/<repo>; the agent works in an isolated worktree
# (ORCH-2 / S-4) so concurrent tasks never fight over a shared checkout.
local_repo_path = os.path.join(settings.repos_dir, repo)
if not os.path.isdir(local_repo_path):
raise FileNotFoundError(f"Repo not found: {local_repo_path}")
# Write task file if content provided (B-1: direct write to mounted /repos, no docker)
# Determine branch (needed before we touch the worktree / task file).
_br_row = get_db().execute("SELECT branch FROM tasks WHERE id=?", (task_id,)).fetchone() if task_id else None
agent_branch = _br_row[0] if _br_row else "main"
# Ensure the per-branch worktree exists and is on the right branch.
work_path = ensure_worktree(repo, agent_branch)
# Write task file if content provided (B-1: direct write; now into the worktree).
if task_content:
self._write_task_file(repo, config["task_file"], task_content)
self._write_task_file(repo, agent_branch, config["task_file"], task_content)
# Record run in DB
conn = get_db()
@@ -99,15 +107,13 @@ class AgentLauncher:
system_prompt = config["system_prompt"]
allowed_tools = config["allowed_tools"]
# Determine branch for checkout
_br_row = get_db().execute("SELECT branch FROM tasks WHERE id=?", (task_id,)).fetchone() if task_id else None
agent_branch = _br_row[0] if _br_row else "main"
model = config.get("model", "")
model_flag = f"--model {model} " if model else ""
# No git fetch/checkout here: ensure_worktree() already put the worktree on
# the right branch. The agent simply runs inside its isolated work_path.
cmd = (
f'cd {local_repo_path} && git fetch origin 2>/dev/null; git checkout {agent_branch} 2>/dev/null || git checkout -b {agent_branch} origin/{agent_branch} 2>/dev/null; '
f'cd {work_path} && '
f'{self.CLAUDE_BIN} --print '
f'{model_flag}'
f'"$(cat {task_file})" '
@@ -219,8 +225,10 @@ class AgentLauncher:
notify_agent_finished(run_id, agent, exit_code, task_id=_task_id, duration_s=_duration_s)
# Commit and push any changes
repo_path = os.path.join(settings.repos_dir, repo)
# Commit and push any changes — in the per-branch worktree (ORCH-2 / S-4),
# NOT in the shared /repos/<repo>. The worktree is already on `branch`
# (ensure_worktree did the checkout), so no checkout is needed here.
repo_path = get_worktree_path(repo, branch)
try:
git_env = {
**os.environ,
@@ -230,20 +238,6 @@ class AgentLauncher:
"GIT_COMMITTER_NAME": "claude-bot",
"GIT_COMMITTER_EMAIL": "claude-bot@mva154.local",
}
# Checkout feature branch before committing
subprocess.run(
["git", "-C", repo_path, "fetch", "origin"],
capture_output=True, text=True, timeout=30, env=git_env
)
checkout_result = subprocess.run(
["git", "-C", repo_path, "checkout", branch],
capture_output=True, text=True, timeout=30, env=git_env
)
if checkout_result.returncode != 0:
subprocess.run(
["git", "-C", repo_path, "checkout", "-b", branch, f"origin/{branch}"],
capture_output=True, text=True, timeout=30, env=git_env
)
result = subprocess.run(
["git", "-C", repo_path, "status", "--porcelain"],
capture_output=True, text=True, timeout=10, env=git_env
@@ -351,7 +345,7 @@ class AgentLauncher:
if agent == "analyst" and qg_name == "check_analysis_approved" and work_item_id:
files_check = QG_CHECKS.get("check_analysis_complete")
if files_check:
files_ok, _ = files_check(repo, work_item_id)
files_ok, _ = files_check(repo, work_item_id, branch)
if files_ok:
# Full artifacts ready -> In Review
from ..plane_sync import set_issue_in_review
@@ -364,10 +358,10 @@ class AgentLauncher:
notify_approve_requested(task_id)
logger.info(f"Task {task_id}: analyst finished, requested :approved: in Plane")
else:
# Check if questions file exists
# Check if questions file exists (in the task worktree)
import os as _os
questions_path = _os.path.join(
settings.repos_dir, repo,
get_worktree_path(repo, branch),
f"docs/work-items/{work_item_id}/01-questions.md"
)
if _os.path.isfile(questions_path):
@@ -392,11 +386,14 @@ class AgentLauncher:
)
return
elif qg_name in ("check_ci_green", "check_tests_local"):
# (repo, branch) signature — already worktree-aware.
passed, reason = check_fn(repo, branch)
elif qg_name == "check_tests_passed":
passed, reason = check_fn(repo, work_item_id or "")
# Artifact check — pass branch so it reads from the worktree.
passed, reason = check_fn(repo, work_item_id or "", branch)
else:
passed, reason = check_fn(repo, work_item_id or "")
# Other artifact checks (check_architecture_done, etc.) — worktree-aware.
passed, reason = check_fn(repo, work_item_id or "", branch)
if not passed:
logger.info(f"Task {task_id}: QG '{qg_name}' not passed after {agent}: {reason}")
@@ -461,7 +458,7 @@ class AgentLauncher:
if agent == "architect" and qg_name == "check_architecture_done" and not passed:
import os as _os
conflict_path = _os.path.join(
settings.repos_dir, repo,
get_worktree_path(repo, branch),
f"docs/work-items/{work_item_id}/10-conflict.md"
)
if _os.path.isfile(conflict_path):
@@ -578,15 +575,16 @@ class AgentLauncher:
logger.error(f"Auto-merge failed for {branch}: {e}")
return False
def _write_task_file(self, repo: str, task_file: str, content: str):
"""Write task file directly to the mounted repo volume (/repos).
def _write_task_file(self, repo: str, branch: str, task_file: str, content: str):
"""Write task file directly into the task's worktree.
B-1 fix: no docker. The repos directory is mounted RW at settings.repos_dir
(/repos inside the container), so write straight to /repos/<repo>/<task_file>.
B-1 fix: no docker (direct open()). ORCH-2/S-4: the target is the per-branch
worktree (/repos/_wt/<repo>/<branch>), not the shared /repos/<repo>, so the
agent reads the task ZADANIE from its own isolated working copy.
Raise on failure instead of silently swallowing errors.
"""
container_repo_path = os.path.join(settings.repos_dir, repo) # /repos/<repo>
full_path = os.path.join(container_repo_path, task_file)
work_path = get_worktree_path(repo, branch) # /repos/_wt/<repo>/<branch>
full_path = os.path.join(work_path, task_file)
try:
with open(full_path, "w", encoding="utf-8") as f:
f.write(content)

View File

@@ -16,10 +16,16 @@ class Settings(BaseSettings):
gitea_owner: str = "admin"
default_repo: str = "enduro-trails"
# ORCH-6: multi-repo project registry. JSON array of
# {plane_project_id, repo, work_item_prefix, name}.
# Empty -> built-in default registry in src/projects.py.
projects_json: str = ""
# Claude CLI
claude_bin: str = "/opt/claude-code/bin/claude.exe"
repos_dir: str = "/repos"
host_repos_dir: str = "/home/slin/repos"
worktrees_dir: str = "/repos/_wt" # ORCH-2 / S-4: isolated worktree per task/branch
# DB
db_path: str = "/app/data/orchestrator.db"

View File

@@ -79,21 +79,29 @@ def update_task_stage(task_id: int, stage: str):
conn.close()
def get_next_work_item_id(repo: str) -> str:
"""Generate next work item ID (e.g., ET-003)."""
def get_next_work_item_id(repo: str, prefix: str = "ET") -> str:
"""Generate next work item ID (e.g., ET-003 / ORCH-001).
ORCH-6: numbering is per (repo, prefix). The prefix comes from the project
registry (proj.work_item_prefix), so orchestrator issues number ORCH-001,
ORCH-002 independently of the ET sequence in enduro-trails. Default prefix
stays "ET" for backward compatibility with existing callers.
"""
conn = get_db()
row = conn.execute(
"SELECT work_item_id FROM tasks WHERE repo = ? AND work_item_id IS NOT NULL ORDER BY id DESC LIMIT 1",
(repo,),
"SELECT work_item_id FROM tasks "
"WHERE repo = ? AND work_item_id LIKE ? AND work_item_id IS NOT NULL "
"ORDER BY id DESC LIMIT 1",
(repo, f"{prefix}-%"),
).fetchone()
conn.close()
if row and row["work_item_id"]:
# Parse ET-003 -> 3, increment
prefix, num = row["work_item_id"].rsplit("-", 1)
# Parse <PREFIX>-003 -> 3, increment (keep the existing prefix).
existing_prefix, num = row["work_item_id"].rsplit("-", 1)
prefix = existing_prefix
next_num = int(num) + 1
else:
prefix = "ET"
next_num = 1
return f"{prefix}-{next_num:03d}"

107
src/git_worktree.py Normal file
View File

@@ -0,0 +1,107 @@
"""Git worktree management — isolated working copy per task/branch (ORCH-2 / S-4).
Background
----------
Previously every git operation (checkout/commit/push/test) ran in the single shared
clone ``/repos/<repo>``. With two active tasks a ``git checkout`` of one branch would
overwrite the working copy of the other -> races (see AUDIT S-4 / ET-009 "two collectors").
Solution
--------
Each task (branch) gets an isolated git worktree::
/repos/<repo> <- main clone (fetch / worktree management)
/repos/_wt/<repo>/<safe-branch> <- worktree for one task/branch (agent works here)
A branch can only be checked out in ONE worktree at a time, which is exactly the
property we want: one task = one branch = one worktree.
"""
import os
import re
import subprocess
import logging
from .config import settings
logger = logging.getLogger("orchestrator.git_worktree")
def _safe(branch: str) -> str:
"""Filesystem-safe branch name for use in a path component."""
return re.sub(r"[^A-Za-z0-9._-]", "_", branch)
def get_worktree_path(repo: str, branch: str) -> str:
"""Path of the worktree for (repo, branch). Does NOT create it."""
return os.path.join(settings.worktrees_dir, repo, _safe(branch))
def _main_repo(repo: str) -> str:
return os.path.join(settings.repos_dir, repo)
def ensure_worktree(repo: str, branch: str) -> str:
"""Create (or reuse) an isolated worktree for ``branch``. Returns its path.
Main clone stays at ``/repos/<repo>``. Worktree lives at
``/repos/_wt/<repo>/<safe-branch>``.
- If the worktree already exists, it is fetched + fast-aligned to the branch
(and to ``origin/<branch>`` when that remote branch exists).
- If the branch exists (locally or on origin) it is checked out into a fresh
worktree; otherwise a new branch is created from ``origin/main``.
"""
main_repo = _main_repo(repo)
wt = get_worktree_path(repo, branch)
if not os.path.isdir(main_repo):
raise FileNotFoundError(f"Main repo not found: {main_repo}")
# Always refresh refs in the main clone first.
subprocess.run(["git", "-C", main_repo, "fetch", "origin"],
capture_output=True, timeout=60)
# Reuse existing worktree (.git may be a dir or a file pointer for worktrees).
if os.path.isdir(os.path.join(wt, ".git")) or os.path.isfile(os.path.join(wt, ".git")):
subprocess.run(["git", "-C", wt, "fetch", "origin"], capture_output=True, timeout=60)
subprocess.run(["git", "-C", wt, "checkout", branch], capture_output=True, timeout=30)
# Align to remote only if the remote branch exists (avoid wiping local-only work).
rb = subprocess.run(
["git", "-C", wt, "rev-parse", "--verify", "--quiet", f"origin/{branch}"],
capture_output=True,
)
if rb.returncode == 0:
subprocess.run(["git", "-C", wt, "reset", "--hard", f"origin/{branch}"],
capture_output=True, timeout=30)
logger.info(f"Worktree reused: {wt} (branch {branch})")
return wt
os.makedirs(os.path.dirname(wt), exist_ok=True)
# Try to attach an existing branch (local or remote-tracking) to the new worktree.
r = subprocess.run(["git", "-C", main_repo, "worktree", "add", wt, branch],
capture_output=True, text=True, timeout=60)
if r.returncode != 0:
# Branch doesn't exist yet — create it from origin/main.
r2 = subprocess.run(
["git", "-C", main_repo, "worktree", "add", "-b", branch, wt, "origin/main"],
capture_output=True, text=True, timeout=60,
)
if r2.returncode != 0:
raise RuntimeError(
f"git worktree add failed for {repo}:{branch}: "
f"{r.stderr.strip()} | {r2.stderr.strip()}"
)
logger.info(f"Worktree ready: {wt} (branch {branch})")
return wt
def remove_worktree(repo: str, branch: str):
"""Remove the worktree for (repo, branch) — optional cleanup when a task is done."""
main_repo = _main_repo(repo)
wt = get_worktree_path(repo, branch)
subprocess.run(["git", "-C", main_repo, "worktree", "remove", "--force", wt],
capture_output=True, timeout=30)
# Prune dangling administrative entries.
subprocess.run(["git", "-C", main_repo, "worktree", "prune"],
capture_output=True, timeout=30)
logger.info(f"Worktree removed: {wt}")

View File

@@ -11,6 +11,35 @@ PLANE_HEADERS = {"X-API-Key": settings.plane_api_token}
WORKSPACE = settings.plane_workspace_slug
PROJECT_ID = settings.plane_project_id or "7a79f0a9-5278-49cd-9007-9a338f238f9c"
def _resolve_project_id(work_item_id: str = None, project_id: str = None) -> str:
"""ORCH-6: resolve the Plane project id for a sync call.
Priority:
1. explicit project_id arg (caller already knows the project),
2. project derived from the task's repo in the DB (by work_item_id),
3. legacy default PROJECT_ID (enduro) for backward compatibility.
"""
if project_id:
return project_id
if work_item_id:
try:
from .db import get_db
from .projects import get_project_by_repo
conn = get_db()
row = conn.execute(
"SELECT repo FROM tasks WHERE work_item_id = ? ORDER BY id DESC LIMIT 1",
(work_item_id,),
).fetchone()
conn.close()
if row and row[0]:
proj = get_project_by_repo(row[0])
if proj:
return proj.plane_project_id
except Exception as e:
logger.debug(f"_resolve_project_id fallback for {work_item_id}: {e}")
return PROJECT_ID
# Plane state IDs
PLANE_STATES = {
"backlog": "113b24f6-cce8-4be9-9a22-a359b9cf0122",
@@ -36,8 +65,9 @@ STAGE_TO_STATE = {
}
def find_issue_id(work_item_id: str) -> str | None:
def find_issue_id(work_item_id: str, project_id: str = None) -> str | None:
"""Find Plane issue UUID by work_item_id (e.g. 'ET-002')."""
project_id = _resolve_project_id(work_item_id, project_id)
# Primary: lookup from DB (plane_issue_id column)
try:
from .db import get_db
@@ -52,7 +82,7 @@ def find_issue_id(work_item_id: str) -> str | None:
logger.debug(f"DB lookup failed for {work_item_id}: {e}")
# Fallback: search via Plane API
url = f"{PLANE_BASE}/workspaces/{WORKSPACE}/projects/{PROJECT_ID}/issues/"
url = f"{PLANE_BASE}/workspaces/{WORKSPACE}/projects/{project_id}/issues/"
try:
# First try search by work_item_id
resp = httpx.get(url, headers=PLANE_HEADERS, params={"search": work_item_id}, timeout=10)
@@ -83,18 +113,19 @@ def find_issue_id(work_item_id: str) -> str | None:
return None
def update_issue_state(work_item_id: str, stage: str):
def update_issue_state(work_item_id: str, stage: str, project_id: str = None):
"""Update Plane issue state based on orchestrator stage."""
state_id = STAGE_TO_STATE.get(stage)
if not state_id:
return
issue_id = find_issue_id(work_item_id)
project_id = _resolve_project_id(work_item_id, project_id)
issue_id = find_issue_id(work_item_id, project_id)
if not issue_id:
logger.warning(f"Issue not found in Plane for {work_item_id}")
return
url = f"{PLANE_BASE}/workspaces/{WORKSPACE}/projects/{PROJECT_ID}/issues/{issue_id}/"
url = f"{PLANE_BASE}/workspaces/{WORKSPACE}/projects/{project_id}/issues/{issue_id}/"
try:
resp = httpx.patch(url, headers=PLANE_HEADERS, json={"state": state_id}, timeout=10)
resp.raise_for_status()
@@ -103,14 +134,15 @@ def update_issue_state(work_item_id: str, stage: str):
logger.error(f"Failed to update Plane state for {work_item_id}: {e}")
def add_comment(work_item_id: str, text: str):
def add_comment(work_item_id: str, text: str, project_id: str = None):
"""Add a comment to Plane issue."""
issue_id = find_issue_id(work_item_id)
project_id = _resolve_project_id(work_item_id, project_id)
issue_id = find_issue_id(work_item_id, project_id)
if not issue_id:
logger.warning(f"Issue not found in Plane for {work_item_id}, skipping comment")
return
url = f"{PLANE_BASE}/workspaces/{WORKSPACE}/projects/{PROJECT_ID}/issues/{issue_id}/comments/"
url = f"{PLANE_BASE}/workspaces/{WORKSPACE}/projects/{project_id}/issues/{issue_id}/comments/"
html = f"<p>{text}</p>"
try:
resp = httpx.post(url, headers=PLANE_HEADERS, json={"comment_html": html}, timeout=10)
@@ -121,33 +153,34 @@ def add_comment(work_item_id: str, text: str):
def set_issue_needs_input(work_item_id: str):
def set_issue_needs_input(work_item_id: str, project_id: str = None):
"""Set issue to 'Needs Input' state — waiting for stakeholder response."""
_set_issue_state_direct(work_item_id, PLANE_STATES["needs_input"])
_set_issue_state_direct(work_item_id, PLANE_STATES["needs_input"], project_id)
def set_issue_in_review(work_item_id: str):
def set_issue_in_review(work_item_id: str, project_id: str = None):
"""Set issue to 'In Review' state — waiting for :approved: or :rejected:."""
_set_issue_state_direct(work_item_id, PLANE_STATES["in_review"])
_set_issue_state_direct(work_item_id, PLANE_STATES["in_review"], project_id)
def set_issue_blocked(work_item_id: str):
def set_issue_blocked(work_item_id: str, project_id: str = None):
"""Set issue to 'Blocked' state — manual intervention needed."""
_set_issue_state_direct(work_item_id, PLANE_STATES["blocked"])
_set_issue_state_direct(work_item_id, PLANE_STATES["blocked"], project_id)
def set_issue_in_progress(work_item_id: str):
def set_issue_in_progress(work_item_id: str, project_id: str = None):
"""Set issue to 'In Progress' state — agent working."""
_set_issue_state_direct(work_item_id, PLANE_STATES["in_progress"])
_set_issue_state_direct(work_item_id, PLANE_STATES["in_progress"], project_id)
def _set_issue_state_direct(work_item_id: str, state_id: str):
def _set_issue_state_direct(work_item_id: str, state_id: str, project_id: str = None):
"""Set issue state directly by state_id."""
issue_id = find_issue_id(work_item_id)
project_id = _resolve_project_id(work_item_id, project_id)
issue_id = find_issue_id(work_item_id, project_id)
if not issue_id:
logger.warning(f"Issue not found in Plane for {work_item_id}")
return
url = f"{PLANE_BASE}/workspaces/{WORKSPACE}/projects/{PROJECT_ID}/issues/{issue_id}/"
url = f"{PLANE_BASE}/workspaces/{WORKSPACE}/projects/{project_id}/issues/{issue_id}/"
try:
resp = httpx.patch(url, headers=PLANE_HEADERS, json={"state": state_id}, timeout=10)
resp.raise_for_status()
@@ -156,9 +189,10 @@ def _set_issue_state_direct(work_item_id: str, state_id: str):
logger.error(f"Failed to update Plane state for {work_item_id}: {e}")
def notify_stage_change(work_item_id: str, old_stage: str, new_stage: str, agent: str = None):
def notify_stage_change(work_item_id: str, old_stage: str, new_stage: str, agent: str = None, project_id: str = None):
"""Notify Plane about stage transition with links."""
update_issue_state(work_item_id, new_stage)
project_id = _resolve_project_id(work_item_id, project_id)
update_issue_state(work_item_id, new_stage, project_id)
msg = f"🔄 Stage: {old_stage}{new_stage}"
if agent:
@@ -193,15 +227,16 @@ def notify_stage_change(work_item_id: str, old_stage: str, new_stage: str, agent
except Exception:
pass
add_comment(work_item_id, msg)
add_comment(work_item_id, msg, project_id)
def notify_qg_failure(work_item_id: str, stage: str, check: str, reason: str):
def notify_qg_failure(work_item_id: str, stage: str, check: str, reason: str, project_id: str = None):
"""Notify Plane about QG failure."""
add_comment(work_item_id, f"⚠️ QG failed at {stage}: {check}{reason}")
add_comment(work_item_id, f"⚠️ QG failed at {stage}: {check}{reason}", project_id)
def notify_done(work_item_id: str):
def notify_done(work_item_id: str, project_id: str = None):
"""Mark issue as Done in Plane."""
update_issue_state(work_item_id, "done")
add_comment(work_item_id, "✅ Task completed! PR merged and deployed.")
project_id = _resolve_project_id(work_item_id, project_id)
update_issue_state(work_item_id, "done", project_id)
add_comment(work_item_id, "✅ Task completed! PR merged and deployed.", project_id)

127
src/projects.py Normal file
View File

@@ -0,0 +1,127 @@
"""ORCH-6: Project registry — map Plane project id -> repo / work-item prefix.
Root cause of the 2026-06-02 incident: the Plane webhook listened to the whole
workspace and hardcoded ``repo = settings.default_repo`` (enduro-trails). Every
issue from any project was funneled into one repo with one prefix (ET).
This module introduces a small registry keyed by the Plane project uuid so the
orchestrator can:
* filter webhooks by project (ignore unknown projects),
* resolve the gitea repo + work-item prefix for a known project,
* route Plane sync (state/comment) into the issue's own project.
Source of truth: ``settings.projects_json`` (a JSON array set via the
``ORCH_PROJECTS_JSON`` env var). If unset/empty/invalid, a built-in default
registry is used so the system works out of the box.
"""
import json
import logging
from dataclasses import dataclass
from .config import settings
logger = logging.getLogger("orchestrator.projects")
@dataclass(frozen=True)
class ProjectConfig:
plane_project_id: str # uuid of the Plane project (registry key)
repo: str # gitea repo name (== folder under /repos)
work_item_prefix: str # ET / ORCH
name: str # human-readable label
# Built-in default registry (used when ORCH_PROJECTS_JSON is empty/invalid).
# Keep enduro-trails first so existing behaviour is the safe default.
_DEFAULT_PROJECTS = [
ProjectConfig(
plane_project_id="7a79f0a9-5278-49cd-9007-9a338f238f9c",
repo="enduro-trails",
work_item_prefix="ET",
name="enduro-trails",
),
ProjectConfig(
plane_project_id="8da6aa25-a60e-44d6-a1e2-d8ae59aa7d6a",
repo="orchestrator",
work_item_prefix="ORCH",
name="orchestrator",
),
]
def _parse_projects_json(raw: str) -> list[ProjectConfig] | None:
"""Parse ORCH_PROJECTS_JSON. Returns None if empty/invalid (-> use default)."""
if not raw or not raw.strip():
return None
try:
data = json.loads(raw)
except (ValueError, TypeError) as e:
logger.error(f"ORCH_PROJECTS_JSON is not valid JSON, falling back to default: {e}")
return None
if not isinstance(data, list):
logger.error("ORCH_PROJECTS_JSON must be a JSON array, falling back to default")
return None
parsed: list[ProjectConfig] = []
for i, item in enumerate(data):
if not isinstance(item, dict):
logger.error(f"ORCH_PROJECTS_JSON[{i}] is not an object, skipping")
continue
try:
parsed.append(
ProjectConfig(
plane_project_id=str(item["plane_project_id"]),
repo=str(item["repo"]),
work_item_prefix=str(item["work_item_prefix"]),
name=str(item.get("name", item["repo"])),
)
)
except KeyError as e:
logger.error(f"ORCH_PROJECTS_JSON[{i}] missing required key {e}, skipping")
continue
if not parsed:
logger.error("ORCH_PROJECTS_JSON produced no valid entries, falling back to default")
return None
return parsed
def _load_projects() -> list[ProjectConfig]:
parsed = _parse_projects_json(getattr(settings, "projects_json", "") or "")
if parsed is not None:
logger.info(f"Project registry loaded from ORCH_PROJECTS_JSON: {len(parsed)} project(s)")
return parsed
return list(_DEFAULT_PROJECTS)
# Module-level registry, built once at import.
PROJECTS: list[ProjectConfig] = _load_projects()
_BY_PLANE_ID: dict[str, ProjectConfig] = {p.plane_project_id: p for p in PROJECTS}
_BY_REPO: dict[str, ProjectConfig] = {p.repo: p for p in PROJECTS}
def get_project_by_plane_id(plane_project_id: str) -> ProjectConfig | None:
"""Resolve project config by Plane project uuid. None if unknown."""
if not plane_project_id:
return None
return _BY_PLANE_ID.get(plane_project_id)
def get_project_by_repo(repo: str) -> ProjectConfig | None:
"""Resolve project config by gitea repo name. None if unknown."""
if not repo:
return None
return _BY_REPO.get(repo)
def known_plane_project_ids() -> set[str]:
"""Set of Plane project ids the orchestrator is configured to handle."""
return set(_BY_PLANE_ID.keys())
def reload_projects() -> None:
"""Rebuild the registry from current settings (used by tests)."""
global PROJECTS, _BY_PLANE_ID, _BY_REPO
PROJECTS = _load_projects()
_BY_PLANE_ID = {p.plane_project_id: p for p in PROJECTS}
_BY_REPO = {p.repo: p for p in PROJECTS}

View File

@@ -7,12 +7,28 @@ from ..config import settings
logger = logging.getLogger("orchestrator.qg")
from ..git_worktree import get_worktree_path, ensure_worktree
def _repo_path(repo: str, branch: str | None = None) -> str:
"""Resolve the working path to read agent artifacts from.
ORCH-2 / S-4: artifacts now live in the per-branch worktree. When a branch is
given and its worktree exists on disk, read from there; otherwise fall back to
the shared /repos/<repo> clone (keeps backward-compat for 2-arg callers/tests).
"""
if branch:
wt = get_worktree_path(repo, branch)
if os.path.isdir(wt):
return wt
return os.path.join(settings.repos_dir, repo)
# Shared httpx client config
GITEA_HEADERS = {"Authorization": f"token {settings.gitea_token}"}
GITEA_BASE = f"{settings.gitea_url}/api/v1"
def check_analysis_complete(repo: str, work_item_id: str) -> tuple[bool, str]:
def check_analysis_complete(repo: str, work_item_id: str, branch: str | None = None) -> tuple[bool, str]:
"""
Check if analysis artifacts exist in the repo branch.
Required files:
@@ -28,7 +44,7 @@ def check_analysis_complete(repo: str, work_item_id: str) -> tuple[bool, str]:
f"docs/work-items/{work_item_id}/04-test-plan.yaml",
]
repo_path = os.path.join(settings.repos_dir, repo)
repo_path = _repo_path(repo, branch)
missing = []
for f in required_files:
@@ -41,13 +57,13 @@ def check_analysis_complete(repo: str, work_item_id: str) -> tuple[bool, str]:
return True, "All analysis artifacts present"
def check_architecture_done(repo: str, work_item_id: str) -> tuple[bool, str]:
def check_architecture_done(repo: str, work_item_id: str, branch: str | None = None) -> tuple[bool, str]:
"""
Check if architecture artifacts exist.
Required: docs/work-items/<work_item_id>/06-adr/ (at least 1 file)
OR: docs/work-items/<work_item_id>/07-infra-requirements.md
"""
repo_path = os.path.join(settings.repos_dir, repo)
repo_path = _repo_path(repo, branch)
adr_dir = os.path.join(repo_path, f"docs/work-items/{work_item_id}/06-adr")
infra_file = os.path.join(repo_path, f"docs/work-items/{work_item_id}/07-infra-requirements.md")
@@ -119,12 +135,12 @@ def check_review_approved(repo: str, pr_number: int) -> tuple[bool, str]:
return False, f"API error: {e}"
def check_tests_passed(repo: str, work_item_id: str) -> tuple[bool, str]:
def check_tests_passed(repo: str, work_item_id: str, branch: str | None = None) -> tuple[bool, str]:
"""
Check if test report exists and contains PASS indicator.
File: docs/work-items/<work_item_id>/13-test-report.md
"""
repo_path = os.path.join(settings.repos_dir, repo)
repo_path = _repo_path(repo, branch)
report_path = os.path.join(repo_path, f"docs/work-items/{work_item_id}/13-test-report.md")
if not os.path.isfile(report_path):
@@ -141,7 +157,7 @@ def check_tests_passed(repo: str, work_item_id: str) -> tuple[bool, str]:
def check_analysis_approved(repo: str, work_item_id: str) -> tuple[bool, str]:
def check_analysis_approved(repo: str, work_item_id: str, branch: str | None = None) -> tuple[bool, str]:
"""
Check if analysis is complete AND approved by stakeholder.
Requirements:
@@ -152,18 +168,22 @@ def check_analysis_approved(repo: str, work_item_id: str) -> tuple[bool, str]:
so the approval check verifies file completeness as a safety gate.
"""
# First check files
files_ok, files_reason = check_analysis_complete(repo, work_item_id)
files_ok, files_reason = check_analysis_complete(repo, work_item_id, branch)
if not files_ok:
return False, files_reason
# Check for :approved: comment via Plane API
try:
from ..plane_sync import find_issue_id, PLANE_BASE, PLANE_HEADERS, WORKSPACE, PROJECT_ID
issue_id = find_issue_id(work_item_id)
from ..projects import get_project_by_repo
# ORCH-6: verify approval in the issue's own Plane project.
_proj = get_project_by_repo(repo)
_pid = _proj.plane_project_id if _proj else PROJECT_ID
issue_id = find_issue_id(work_item_id, _pid)
if not issue_id:
return False, "Cannot find Plane issue to verify approval"
url = f"{PLANE_BASE}/workspaces/{WORKSPACE}/projects/{PROJECT_ID}/issues/{issue_id}/comments/"
url = f"{PLANE_BASE}/workspaces/{WORKSPACE}/projects/{_pid}/issues/{issue_id}/comments/"
resp = httpx.get(url, headers=PLANE_HEADERS, timeout=10)
resp.raise_for_status()
comments = resp.json()
@@ -187,7 +207,7 @@ def check_analysis_approved(repo: str, work_item_id: str) -> tuple[bool, str]:
def check_reviewer_verdict(repo: str, work_item_id: str) -> tuple[bool, str]:
def check_reviewer_verdict(repo: str, work_item_id: str, branch: str | None = None) -> tuple[bool, str]:
"""
Check reviewer agent verdict from 12-review.md (S-5 fix).
@@ -198,7 +218,7 @@ def check_reviewer_verdict(repo: str, work_item_id: str) -> tuple[bool, str]:
(False, ...) -> verdict: REQUEST_CHANGES, missing verdict, or no frontmatter
"""
import yaml
repo_path = os.path.join(settings.repos_dir, repo)
repo_path = _repo_path(repo, branch)
review_path = os.path.join(repo_path, f"docs/work-items/{work_item_id}/12-review.md")
if not os.path.isfile(review_path):
@@ -229,26 +249,15 @@ def check_reviewer_verdict(repo: str, work_item_id: str) -> tuple[bool, str]:
def check_tests_local(repo: str, branch: str) -> tuple[bool, str]:
"""
S-1 fix: run the project test suite locally in /repos/<repo> and judge by exit
code, instead of depending on Gitea CI (which is not configured -> always false).
S-1 fix: run the project test suite locally and judge by exit code, instead of
depending on Gitea CI (which is not configured -> always false).
Checks out `branch` in the shared /repos checkout and runs `make test`.
NOTE (known limitation): the shared /repos checkout means this is not safe for
concurrent active tasks. git-worktree-per-task is a separate task (S-4).
ORCH-2 / S-4: tests run inside the per-branch worktree (ensure_worktree), so this
is safe for concurrent active tasks — no shared /repos checkout race.
"""
import subprocess
repo_path = os.path.join(settings.repos_dir, repo)
try:
subprocess.run(
["git", "-C", repo_path, "fetch", "origin"],
capture_output=True, timeout=30,
)
co = subprocess.run(
["git", "-C", repo_path, "checkout", branch],
capture_output=True, text=True, timeout=30,
)
if co.returncode != 0:
return False, f"Cannot checkout branch '{branch}': {co.stderr.strip()[-200:]}"
repo_path = ensure_worktree(repo, branch)
r = subprocess.run(
["make", "test"], cwd=repo_path,
capture_output=True, text=True, timeout=600,

View File

@@ -16,6 +16,7 @@ from ..qg.checks import check_ci_green, check_review_approved
from ..notifications import notify_stage_change, notify_qg_failure, notify_error
from ..agents.launcher import launcher
from ..plane_sync import notify_stage_change as plane_notify_stage
from ..projects import get_project_by_repo
logger = logging.getLogger("orchestrator.webhooks.gitea")
@@ -84,6 +85,11 @@ async def handle_push(payload: dict):
repo_name = payload.get("repository", {}).get("name", settings.default_repo)
# ORCH-6: ignore pushes to repos outside the project registry.
if not get_project_by_repo(repo_name):
logger.info(f"Gitea push: ignoring unknown repo '{repo_name}'")
return
task = get_task_by_repo_branch(repo_name, branch)
if not task:
logger.debug(f"Push to '{branch}' — no matching task found")
@@ -146,7 +152,9 @@ async def handle_ci_status(payload: dict):
if not branch:
sha = payload.get("sha", "")
repo_name = payload.get("repository", {}).get("name", settings.default_repo)
# Try to find task by checking git branch containing this SHA
# Try to find task by checking git branch containing this SHA.
# ORCH-2 / S-4: this is a READ-ONLY query of remote-tracking refs in the main
# clone (no checkout / no mutation), so it is safe to keep on /repos/<repo>.
try:
result = subprocess.run(
["git", "-C", os.path.join(settings.repos_dir, repo_name),
@@ -165,6 +173,12 @@ async def handle_ci_status(payload: dict):
return
repo_name = payload.get("repository", {}).get("name", settings.default_repo)
# ORCH-6: ignore CI status for repos outside the project registry.
if not get_project_by_repo(repo_name):
logger.info(f"Gitea CI status: ignoring unknown repo '{repo_name}'")
return
task = get_task_by_repo_branch(repo_name, branch)
if not task:
return
@@ -219,6 +233,11 @@ async def handle_pr(payload: dict):
if not head_branch:
return
# ORCH-6: ignore PR events for repos outside the project registry.
if not get_project_by_repo(repo_name):
logger.info(f"Gitea PR: ignoring unknown repo '{repo_name}'")
return
task = get_task_by_repo_branch(repo_name, head_branch)
if not task:
logger.debug(f"PR event for branch '{head_branch}' — no matching task")

View File

@@ -24,6 +24,11 @@ from ..plane_sync import (
notify_qg_failure as plane_notify_qg,
notify_done as plane_notify_done,
)
from ..projects import (
get_project_by_plane_id,
get_project_by_repo,
known_plane_project_ids,
)
logger = logging.getLogger("orchestrator.webhooks.plane")
@@ -68,15 +73,26 @@ async def plane_webhook(request: Request):
action = payload.get("action", "")
data = payload.get("data", {})
# ORCH-6: filter by Plane project. Ignore issues from unknown/unconfigured
# projects so a webhook on the whole workspace cannot funnel everything into
# the default repo (root cause of the 2026-06-02 incident).
project_id = data.get("project") or data.get("project_id") or ""
if project_id not in known_plane_project_ids():
logger.info(
f"Plane webhook: ignoring event '{event}' from unknown project "
f"'{project_id}' (known: {len(known_plane_project_ids())})"
)
return {"status": "ignored", "reason": "unknown project"}
if (event == "work_item.created") or (event == "issue" and action == "created"):
await handle_work_item_created(data)
await handle_work_item_created(data, project_id)
elif (event == "comment.created") or (event == "issue_comment" and action == "created"):
await handle_comment(data)
await handle_comment(data, project_id)
return {"status": "accepted"}
async def handle_work_item_created(data: dict):
async def handle_work_item_created(data: dict, project_id: str = ""):
"""
New work item created in Plane.
QG-0: validate title, description, priority.
@@ -88,7 +104,17 @@ async def handle_work_item_created(data: dict):
description = data.get("description_stripped", data.get("description", ""))
priority = data.get("priority", {})
priority_name = priority if isinstance(priority, str) else priority.get("name", "")
repo = settings.default_repo
# ORCH-6: resolve repo / prefix / Plane project from the registry instead of
# the single hardcoded default_repo.
if not project_id:
project_id = data.get("project") or data.get("project_id") or ""
proj = get_project_by_plane_id(project_id)
if not proj:
logger.warning(f"handle_work_item_created: unknown project '{project_id}', ignoring {plane_id}")
return
repo = proj.repo
plane_project_id = proj.plane_project_id
# QG-0 validation
errors = []
@@ -102,17 +128,17 @@ async def handle_work_item_created(data: dict):
if errors:
# QG-0 failed
error_text = "\u26a0\ufe0f QG-0 failed:\n" + "\n".join(f"\u2022 {e}" for e in errors)
from ..plane_sync import PLANE_BASE, PLANE_HEADERS, WORKSPACE, PROJECT_ID, PLANE_STATES
from ..plane_sync import PLANE_BASE, PLANE_HEADERS, WORKSPACE, PLANE_STATES
import httpx as _httpx
# Post comment
url = f"{PLANE_BASE}/workspaces/{WORKSPACE}/projects/{PROJECT_ID}/issues/{plane_id}/comments/"
# Post comment (ORCH-6: route to the issue's own project)
url = f"{PLANE_BASE}/workspaces/{WORKSPACE}/projects/{plane_project_id}/issues/{plane_id}/comments/"
try:
_httpx.post(url, headers=PLANE_HEADERS,
json={"comment_html": f"<p>{error_text}</p>"}, timeout=10)
except Exception:
pass
# Set blocked
url2 = f"{PLANE_BASE}/workspaces/{WORKSPACE}/projects/{PROJECT_ID}/issues/{plane_id}/"
url2 = f"{PLANE_BASE}/workspaces/{WORKSPACE}/projects/{plane_project_id}/issues/{plane_id}/"
try:
_httpx.patch(url2, headers=PLANE_HEADERS,
json={"state": PLANE_STATES["blocked"]}, timeout=10)
@@ -122,7 +148,7 @@ async def handle_work_item_created(data: dict):
return
# Generate work item ID
work_item_id = get_next_work_item_id(repo)
work_item_id = get_next_work_item_id(repo, proj.work_item_prefix)
# Create slug from name
slug = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")[:30]
@@ -169,7 +195,7 @@ async def handle_work_item_created(data: dict):
logger.error(f"Failed to launch analyst for {work_item_id}: {e}")
async def handle_comment(data: dict):
async def handle_comment(data: dict, project_id: str = ""):
"""
Handle comment event — check for :approved: or :rejected:.
Advance or rollback stage accordingly.
@@ -237,11 +263,15 @@ async def handle_comment(data: dict):
if not issue_id:
issue_id = plane_id
if issue_id:
from ..plane_sync import PLANE_BASE, PLANE_HEADERS, WORKSPACE, PROJECT_ID
from ..plane_sync import PLANE_BASE, PLANE_HEADERS, WORKSPACE
from ..plane_sync import PROJECT_ID as _DEFAULT_PROJECT_ID
# ORCH-6: route to this task's own Plane project (resolved from repo).
_proj = get_project_by_repo(repo)
_pid = _proj.plane_project_id if _proj else (project_id or _DEFAULT_PROJECT_ID)
import httpx as _httpx
try:
_resp = _httpx.get(
f"{PLANE_BASE}/workspaces/{WORKSPACE}/projects/{PROJECT_ID}/issues/{issue_id}/",
f"{PLANE_BASE}/workspaces/{WORKSPACE}/projects/{_pid}/issues/{issue_id}/",
headers=PLANE_HEADERS, timeout=10
)
if _resp.status_code == 200:
@@ -304,7 +334,8 @@ async def _try_advance_stage(
# Determine args based on QG function
if qg_name in ("check_analysis_approved", "check_analysis_complete", "check_architecture_done", "check_tests_passed", "check_reviewer_verdict"):
passed, reason = qg_func(repo, work_item_id)
# ORCH-2 / S-4: pass branch so artifacts are read from the task worktree.
passed, reason = qg_func(repo, work_item_id, branch)
elif qg_name in ("check_ci_green", "check_tests_local"):
passed, reason = qg_func(repo, branch)
elif qg_name == "check_review_approved":
@@ -327,8 +358,10 @@ async def _try_advance_stage(
else:
# No open PR but review file exists — check file-based
import os
_review_path = os.path.join(_s.repos_dir, repo, f"docs/work-items/{work_item_id}/12-review.md")
_review_path2 = os.path.join(_s.repos_dir, repo, f"docs/work-items/{work_item_id}/09-review.md")
from ..git_worktree import get_worktree_path as _gwp
_wt = _gwp(repo, branch) if os.path.isdir(_gwp(repo, branch)) else os.path.join(_s.repos_dir, repo)
_review_path = os.path.join(_wt, f"docs/work-items/{work_item_id}/12-review.md")
_review_path2 = os.path.join(_wt, f"docs/work-items/{work_item_id}/09-review.md")
if os.path.isfile(_review_path) or os.path.isfile(_review_path2):
passed, reason = True, "Review file exists (file-based approval)"
else:

152
tests/test_git_worktree.py Normal file
View File

@@ -0,0 +1,152 @@
"""Tests for src/git_worktree (ORCH-2 / S-4): isolated worktree per task/branch.
Uses real local git repos in tmp (a bare 'origin' + a working main clone) so that
`git fetch origin`, `git worktree add`, branch creation from origin/main, reuse and
removal are all exercised without network access.
"""
import os
import subprocess
import tempfile
import pytest
# Env must be set before importing app modules (same convention as the other suites).
_test_db = os.path.join(tempfile.gettempdir(), "test_orchestrator_wt.db")
os.environ["ORCH_DB_PATH"] = _test_db
os.environ["ORCH_REPOS_DIR"] = tempfile.gettempdir()
os.environ["ORCH_GITEA_TOKEN"] = "test-token"
os.environ["ORCH_PLANE_API_TOKEN"] = "test-token"
from src import git_worktree
from src.git_worktree import (
_safe,
get_worktree_path,
ensure_worktree,
remove_worktree,
)
def _git(cwd, *args):
return subprocess.run(["git", "-C", cwd, *args], capture_output=True, text=True)
@pytest.fixture
def repos(tmp_path, monkeypatch):
"""Build a bare 'origin' with main + a feature branch, plus a main clone at repos_dir/<repo>.
Returns the repo name. settings.repos_dir / worktrees_dir are pointed at tmp.
"""
repo = "enduro-trails"
repos_dir = tmp_path / "repos"
wt_dir = tmp_path / "repos" / "_wt"
repos_dir.mkdir(parents=True)
monkeypatch.setattr(git_worktree.settings, "repos_dir", str(repos_dir))
monkeypatch.setattr(git_worktree.settings, "worktrees_dir", str(wt_dir))
# Bare origin
origin = tmp_path / "origin.git"
subprocess.run(["git", "init", "--bare", "-b", "main", str(origin)], capture_output=True)
# Seed repo
seed = tmp_path / "seed"
seed.mkdir()
_git(str(seed), "init", "-b", "main")
_git(str(seed), "config", "user.email", "t@t")
_git(str(seed), "config", "user.name", "t")
(seed / "README.md").write_text("# seed\n")
_git(str(seed), "add", ".")
_git(str(seed), "commit", "-m", "init")
_git(str(seed), "remote", "add", "origin", str(origin))
_git(str(seed), "push", "origin", "main")
# An existing feature branch on origin
_git(str(seed), "checkout", "-b", "feature/existing")
(seed / "f.txt").write_text("feature\n")
_git(str(seed), "add", ".")
_git(str(seed), "commit", "-m", "feat")
_git(str(seed), "push", "origin", "feature/existing")
# Main clone at repos_dir/<repo>
main_clone = repos_dir / repo
subprocess.run(["git", "clone", str(origin), str(main_clone)], capture_output=True)
_git(str(main_clone), "config", "user.email", "t@t")
_git(str(main_clone), "config", "user.name", "t")
return repo
# ---------------------------------------------------------------------------
# _safe / get_worktree_path
# ---------------------------------------------------------------------------
class TestSafeAndPath:
def test_safe_replaces_slashes_and_specials(self):
assert _safe("feature/ET-001-x") == "feature_ET-001-x"
assert _safe("a b/c:d") == "a_b_c_d"
assert _safe("keep.dots-and_underscores") == "keep.dots-and_underscores"
def test_get_worktree_path(self, monkeypatch):
monkeypatch.setattr(git_worktree.settings, "worktrees_dir", "/repos/_wt")
assert get_worktree_path("repo", "feature/x") == "/repos/_wt/repo/feature_x"
# ---------------------------------------------------------------------------
# ensure_worktree
# ---------------------------------------------------------------------------
class TestEnsureWorktree:
def test_missing_main_repo_raises(self, tmp_path, monkeypatch):
monkeypatch.setattr(git_worktree.settings, "repos_dir", str(tmp_path / "nope"))
monkeypatch.setattr(git_worktree.settings, "worktrees_dir", str(tmp_path / "_wt"))
with pytest.raises(FileNotFoundError):
ensure_worktree("enduro-trails", "main")
def test_creates_worktree_for_existing_branch(self, repos):
wt = ensure_worktree(repos, "feature/existing")
assert os.path.isdir(wt)
assert wt == get_worktree_path(repos, "feature/existing")
# On the right branch
cur = _git(wt, "branch", "--show-current").stdout.strip()
assert cur == "feature/existing"
# Feature file from that branch is present (proves correct checkout)
assert os.path.isfile(os.path.join(wt, "f.txt"))
def test_creates_new_branch_from_origin_main(self, repos):
wt = ensure_worktree(repos, "feature/brand-new")
assert os.path.isdir(wt)
cur = _git(wt, "branch", "--show-current").stdout.strip()
assert cur == "feature/brand-new"
# Based on main -> README present, no feature file
assert os.path.isfile(os.path.join(wt, "README.md"))
assert not os.path.isfile(os.path.join(wt, "f.txt"))
def test_reuse_returns_same_path(self, repos):
wt1 = ensure_worktree(repos, "feature/existing")
wt2 = ensure_worktree(repos, "feature/existing")
assert wt1 == wt2
assert os.path.isdir(wt2)
def test_two_branches_are_isolated(self, repos):
a = ensure_worktree(repos, "feature/wt-A")
b = ensure_worktree(repos, "feature/wt-B")
assert a != b
ba = _git(a, "branch", "--show-current").stdout.strip()
bb = _git(b, "branch", "--show-current").stdout.strip()
assert ba == "feature/wt-A"
assert bb == "feature/wt-B"
# Writing in A must not affect B
with open(os.path.join(a, "only-a.txt"), "w") as f:
f.write("a")
assert not os.path.isfile(os.path.join(b, "only-a.txt"))
# ---------------------------------------------------------------------------
# remove_worktree
# ---------------------------------------------------------------------------
class TestRemoveWorktree:
def test_remove_deletes_worktree_dir(self, repos):
wt = ensure_worktree(repos, "feature/to-remove")
assert os.path.isdir(wt)
remove_worktree(repos, "feature/to-remove")
assert not os.path.isdir(wt)
def test_remove_nonexistent_is_noop(self, repos):
# Should not raise even if the worktree was never created.
remove_worktree(repos, "feature/never-made")

View File

@@ -26,24 +26,34 @@ from src.qg.checks import check_reviewer_verdict
# B-1: _write_task_file
# ---------------------------------------------------------------------------
class TestWriteTaskFile:
def test_writes_to_repos_volume_path(self, tmp_path, monkeypatch):
"""Task file is written to <repos_dir>/<repo>/<task_file>, content matches."""
monkeypatch.setattr("src.agents.launcher.settings.repos_dir", str(tmp_path))
repo_dir = tmp_path / "enduro-trails"
repo_dir.mkdir()
"""B-1 fix preserved + ORCH-2/S-4: task file now lands in the per-branch worktree.
_write_task_file(repo, branch, task_file, content) writes to
<worktrees_dir>/<repo>/<safe-branch>/<task_file> with a plain open() (no docker).
"""
def _wt_dir(self, tmp_path, repo, branch):
from src.git_worktree import _safe
d = tmp_path / "_wt" / repo / _safe(branch)
d.mkdir(parents=True)
return d
def test_writes_to_worktree_path(self, tmp_path, monkeypatch):
"""Task file is written to the worktree path, content matches (B-1 + S-4)."""
monkeypatch.setattr("src.git_worktree.settings.worktrees_dir", str(tmp_path / "_wt"))
wt = self._wt_dir(tmp_path, "enduro-trails", "feature/ET-001-x")
launcher = AgentLauncher()
launcher._write_task_file("enduro-trails", ".task-dev.md", "hello-content")
launcher._write_task_file("enduro-trails", "feature/ET-001-x", ".task-dev.md", "hello-content")
written = repo_dir / ".task-dev.md"
written = wt / ".task-dev.md"
assert written.is_file()
assert written.read_text() == "hello-content"
def test_does_not_use_docker(self, tmp_path, monkeypatch):
"""No subprocess/docker call: if subprocess.run were used it would error here."""
monkeypatch.setattr("src.agents.launcher.settings.repos_dir", str(tmp_path))
repo_dir = tmp_path / "enduro-trails"
repo_dir.mkdir()
monkeypatch.setattr("src.git_worktree.settings.worktrees_dir", str(tmp_path / "_wt"))
self._wt_dir(tmp_path, "enduro-trails", "main")
called = {"run": False}
@@ -54,17 +64,17 @@ class TestWriteTaskFile:
monkeypatch.setattr("src.agents.launcher.subprocess.run", _fail_run)
launcher = AgentLauncher()
launcher._write_task_file("enduro-trails", ".task.md", "x")
launcher._write_task_file("enduro-trails", "main", ".task.md", "x")
assert called["run"] is False
def test_raises_on_write_failure(self, tmp_path, monkeypatch):
"""If the target dir does not exist, raise RuntimeError (do not fail silently)."""
monkeypatch.setattr("src.agents.launcher.settings.repos_dir", str(tmp_path))
# repo dir intentionally NOT created -> open() raises OSError
"""If the target worktree dir does not exist, raise RuntimeError (no silent fail)."""
monkeypatch.setattr("src.git_worktree.settings.worktrees_dir", str(tmp_path / "_wt"))
# worktree dir intentionally NOT created -> open() raises OSError
launcher = AgentLauncher()
with pytest.raises(RuntimeError):
launcher._write_task_file("nonexistent-repo", ".task.md", "x")
launcher._write_task_file("nonexistent-repo", "main", ".task.md", "x")
# ---------------------------------------------------------------------------

180
tests/test_plane_webhook.py Normal file
View File

@@ -0,0 +1,180 @@
"""ORCH-6: Plane webhook project-filter + repo-resolution tests.
Verifies the core of the 2026-06-02 incident fix:
* webhook from an UNKNOWN Plane project -> {"status": "ignored"} and no task
* webhook from the orchestrator project -> task created with repo=orchestrator
* webhook from the enduro project -> task created with repo=enduro-trails
launcher.launch is mocked so no real agents are spawned. Gitea branch/doc
creation is mocked (network). FastAPI TestClient drives the real endpoint.
This module configures its own registry via monkeypatch + reload_projects so it
is independent of ORCH_PROJECTS_JSON set by other test modules.
"""
import os
import tempfile
import pytest
# Test DB / disable signature checks (same convention as test_webhooks.py).
_test_db = os.path.join(tempfile.gettempdir(), "test_orchestrator_plane.db")
os.environ["ORCH_DB_PATH"] = _test_db
os.environ.setdefault("ORCH_PLANE_WEBHOOK_SECRET", "")
os.environ.setdefault("ORCH_GITEA_WEBHOOK_SECRET", "")
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
from unittest.mock import patch, AsyncMock # noqa: E402
from fastapi.testclient import TestClient # noqa: E402
from src.main import app # noqa: E402
from src.db import init_db, get_db # noqa: E402
from src import projects as P # noqa: E402
from src.projects import reload_projects # noqa: E402
ORCH_PLANE_ID = "8da6aa25-a60e-44d6-a1e2-d8ae59aa7d6a"
ENDURO_PLANE_ID = "7a79f0a9-5278-49cd-9007-9a338f238f9c"
UNKNOWN_PLANE_ID = "deadbeef-0000-0000-0000-000000000000"
client = TestClient(app)
@pytest.fixture(autouse=True)
def setup(monkeypatch):
"""Fresh DB + a known two-project registry for each test."""
# settings.db_path is resolved once at import; force it to our isolated DB so
# this suite is independent of whichever test module imported config first.
monkeypatch.setattr(P.settings, "db_path", _test_db)
import src.db as _db
monkeypatch.setattr(_db.settings, "db_path", _test_db)
if os.path.exists(_test_db):
os.unlink(_test_db)
init_db()
# The webhook signature secret may be baked into the runtime env; this suite
# focuses on the project filter, so bypass signature verification.
monkeypatch.setattr("src.webhooks.plane.verify_plane_signature", lambda body, sig: True)
registry_json = (
f'[{{"plane_project_id": "{ENDURO_PLANE_ID}", "repo": "enduro-trails",'
f' "work_item_prefix": "ET", "name": "enduro-trails"}},'
f' {{"plane_project_id": "{ORCH_PLANE_ID}", "repo": "orchestrator",'
f' "work_item_prefix": "ORCH", "name": "orchestrator"}}]'
)
monkeypatch.setattr(P.settings, "projects_json", registry_json)
reload_projects()
yield
reload_projects() # restore from env
if os.path.exists(_test_db):
os.unlink(_test_db)
def _post_created(plane_project_id, plane_id="wi-1", name="A valid work item title"):
return client.post(
"/webhook/plane",
json={
"event": "work_item.created",
"data": {
"id": plane_id,
"name": name,
"description_stripped": "This is a sufficiently long description.",
"project": plane_project_id,
},
},
)
# ---------------------------------------------------------------------------
# Filter: unknown project is ignored, no side effects
# ---------------------------------------------------------------------------
@patch("src.webhooks.plane.launcher")
@patch("src.webhooks.plane._create_initial_docs", new_callable=AsyncMock)
@patch("src.webhooks.plane._create_gitea_branch", new_callable=AsyncMock)
def test_unknown_project_ignored(mock_branch, mock_docs, mock_launcher):
resp = _post_created(UNKNOWN_PLANE_ID, plane_id="ignore-me")
assert resp.status_code == 200
assert resp.json()["status"] == "ignored"
assert resp.json().get("reason") == "unknown project"
# No task, no branch, no agent.
conn = get_db()
task = conn.execute("SELECT * FROM tasks WHERE plane_id='ignore-me'").fetchone()
conn.close()
assert task is None
mock_branch.assert_not_called()
mock_launcher.launch.assert_not_called()
# ---------------------------------------------------------------------------
# orchestrator project -> repo=orchestrator, prefix ORCH
# ---------------------------------------------------------------------------
@patch("src.webhooks.plane.launcher")
@patch("src.webhooks.plane._create_initial_docs", new_callable=AsyncMock)
@patch("src.webhooks.plane._create_gitea_branch", new_callable=AsyncMock)
def test_orchestrator_project_routes_to_orchestrator_repo(mock_branch, mock_docs, mock_launcher):
mock_launcher.launch.return_value = 1
resp = _post_created(ORCH_PLANE_ID, plane_id="orch-1")
assert resp.status_code == 200
assert resp.json()["status"] == "accepted"
conn = get_db()
task = conn.execute("SELECT * FROM tasks WHERE plane_id='orch-1'").fetchone()
conn.close()
assert task is not None
assert task["repo"] == "orchestrator"
assert task["work_item_id"].startswith("ORCH-")
assert task["stage"] == "analysis"
# Branch created against the orchestrator repo.
args = mock_branch.call_args.args
assert args[0] == "orchestrator"
# ---------------------------------------------------------------------------
# enduro project -> repo=enduro-trails, prefix ET
# ---------------------------------------------------------------------------
@patch("src.webhooks.plane.launcher")
@patch("src.webhooks.plane._create_initial_docs", new_callable=AsyncMock)
@patch("src.webhooks.plane._create_gitea_branch", new_callable=AsyncMock)
def test_enduro_project_routes_to_enduro_repo(mock_branch, mock_docs, mock_launcher):
mock_launcher.launch.return_value = 1
resp = _post_created(ENDURO_PLANE_ID, plane_id="et-1")
assert resp.status_code == 200
assert resp.json()["status"] == "accepted"
conn = get_db()
task = conn.execute("SELECT * FROM tasks WHERE plane_id='et-1'").fetchone()
conn.close()
assert task is not None
assert task["repo"] == "enduro-trails"
assert task["work_item_id"].startswith("ET-")
args = mock_branch.call_args.args
assert args[0] == "enduro-trails"
# ---------------------------------------------------------------------------
# prefixes are independent per repo (ORCH-001 vs ET-001 in parallel)
# ---------------------------------------------------------------------------
@patch("src.webhooks.plane.launcher")
@patch("src.webhooks.plane._create_initial_docs", new_callable=AsyncMock)
@patch("src.webhooks.plane._create_gitea_branch", new_callable=AsyncMock)
def test_prefixes_independent_per_project(mock_branch, mock_docs, mock_launcher):
mock_launcher.launch.return_value = 1
_post_created(ORCH_PLANE_ID, plane_id="o1", name="Orchestrator item one")
_post_created(ENDURO_PLANE_ID, plane_id="e1", name="Enduro item one")
_post_created(ORCH_PLANE_ID, plane_id="o2", name="Orchestrator item two")
conn = get_db()
rows = {r["plane_id"]: r["work_item_id"] for r in
conn.execute("SELECT plane_id, work_item_id FROM tasks").fetchall()}
conn.close()
assert rows["o1"] == "ORCH-001"
assert rows["o2"] == "ORCH-002"
assert rows["e1"] == "ET-001"

177
tests/test_projects.py Normal file
View File

@@ -0,0 +1,177 @@
"""ORCH-6: tests for the project registry (src/projects.py).
Covers resolvers (by plane_id, by repo, unknown -> None, known ids) against the
built-in default registry, plus ORCH_PROJECTS_JSON parsing (valid + malformed
-> default fallback).
The pure parser ``_parse_projects_json`` is tested directly so we don't mutate
the module-global registry. Resolver tests run against the default registry; if
another test (e.g. test_webhooks) set ORCH_PROJECTS_JSON in the env, we restore
the default via monkeypatch + reload_projects to keep this file order-independent.
"""
import pytest
from src import projects as P
from src.projects import (
ProjectConfig,
get_project_by_plane_id,
get_project_by_repo,
known_plane_project_ids,
reload_projects,
_parse_projects_json,
_DEFAULT_PROJECTS,
)
# Known ids from the default registry / task spec.
ENDURO_PLANE_ID = "7a79f0a9-5278-49cd-9007-9a338f238f9c"
ORCH_PLANE_ID = "8da6aa25-a60e-44d6-a1e2-d8ae59aa7d6a"
@pytest.fixture
def default_registry(monkeypatch):
"""Force the default (built-in) registry regardless of ORCH_PROJECTS_JSON
that other test modules may have set in the process env."""
monkeypatch.setattr(P.settings, "projects_json", "")
reload_projects()
yield
# Restore from current settings (whatever env says) after the test.
reload_projects()
# ---------------------------------------------------------------------------
# Resolvers
# ---------------------------------------------------------------------------
def test_get_project_by_plane_id_orchestrator(default_registry):
proj = get_project_by_plane_id(ORCH_PLANE_ID)
assert proj is not None
assert proj.repo == "orchestrator"
assert proj.work_item_prefix == "ORCH"
assert proj.plane_project_id == ORCH_PLANE_ID
def test_get_project_by_plane_id_enduro(default_registry):
proj = get_project_by_plane_id(ENDURO_PLANE_ID)
assert proj is not None
assert proj.repo == "enduro-trails"
assert proj.work_item_prefix == "ET"
def test_get_project_by_plane_id_unknown_returns_none(default_registry):
assert get_project_by_plane_id("00000000-0000-0000-0000-000000000000") is None
def test_get_project_by_plane_id_empty_returns_none(default_registry):
assert get_project_by_plane_id("") is None
assert get_project_by_plane_id(None) is None
def test_get_project_by_repo(default_registry):
assert get_project_by_repo("enduro-trails").work_item_prefix == "ET"
assert get_project_by_repo("orchestrator").work_item_prefix == "ORCH"
def test_get_project_by_repo_unknown_returns_none(default_registry):
assert get_project_by_repo("does-not-exist") is None
assert get_project_by_repo("") is None
assert get_project_by_repo(None) is None
def test_known_plane_project_ids(default_registry):
ids = known_plane_project_ids()
assert isinstance(ids, set)
assert ENDURO_PLANE_ID in ids
assert ORCH_PLANE_ID in ids
assert len(ids) == len(_DEFAULT_PROJECTS)
# ---------------------------------------------------------------------------
# ORCH_PROJECTS_JSON parsing (pure function, no global mutation)
# ---------------------------------------------------------------------------
def test_parse_empty_returns_none():
assert _parse_projects_json("") is None
assert _parse_projects_json(" ") is None
assert _parse_projects_json(None) is None
def test_parse_valid_json():
raw = (
'[{"plane_project_id": "p-1", "repo": "repo-a", '
'"work_item_prefix": "AAA", "name": "Alpha"}]'
)
parsed = _parse_projects_json(raw)
assert parsed is not None
assert len(parsed) == 1
assert isinstance(parsed[0], ProjectConfig)
assert parsed[0].plane_project_id == "p-1"
assert parsed[0].repo == "repo-a"
assert parsed[0].work_item_prefix == "AAA"
assert parsed[0].name == "Alpha"
def test_parse_valid_json_multiple():
raw = (
'[{"plane_project_id": "p-1", "repo": "repo-a", "work_item_prefix": "A"},'
' {"plane_project_id": "p-2", "repo": "repo-b", "work_item_prefix": "B"}]'
)
parsed = _parse_projects_json(raw)
assert len(parsed) == 2
# name defaults to repo when omitted
assert parsed[0].name == "repo-a"
assert parsed[1].repo == "repo-b"
def test_parse_malformed_json_returns_none():
assert _parse_projects_json("{not valid json") is None
assert _parse_projects_json("[}") is None
def test_parse_not_an_array_returns_none():
# A JSON object (not array) is invalid -> fallback.
assert _parse_projects_json('{"plane_project_id": "p-1"}') is None
def test_parse_skips_bad_entries_keeps_good():
raw = (
'[{"repo": "missing-id"},' # missing required key -> skipped
' {"plane_project_id": "p-2", "repo": "repo-b", "work_item_prefix": "B"}]'
)
parsed = _parse_projects_json(raw)
assert parsed is not None
assert len(parsed) == 1
assert parsed[0].plane_project_id == "p-2"
def test_parse_all_bad_entries_returns_none():
# No valid entries -> None (fallback to default).
assert _parse_projects_json('[{"repo": "no-id"}, "not-an-object"]') is None
def test_reload_from_custom_json(monkeypatch):
"""End-to-end: set settings.projects_json, reload, resolvers reflect it."""
custom = (
'[{"plane_project_id": "custom-uuid", "repo": "custom-repo", '
'"work_item_prefix": "CUS", "name": "Custom"}]'
)
monkeypatch.setattr(P.settings, "projects_json", custom)
reload_projects()
try:
assert get_project_by_plane_id("custom-uuid").repo == "custom-repo"
assert get_project_by_repo("custom-repo").work_item_prefix == "CUS"
assert known_plane_project_ids() == {"custom-uuid"}
# The built-in defaults must NOT be present when JSON overrides.
assert get_project_by_plane_id(ENDURO_PLANE_ID) is None
finally:
reload_projects()
def test_reload_invalid_json_falls_back_to_default(monkeypatch):
monkeypatch.setattr(P.settings, "projects_json", "{garbage")
reload_projects()
try:
assert get_project_by_plane_id(ENDURO_PLANE_ID) is not None
assert get_project_by_plane_id(ORCH_PLANE_ID) is not None
finally:
reload_projects()

View File

@@ -14,6 +14,12 @@ os.environ["ORCH_GITEA_TOKEN"] = "test-token"
os.environ["ORCH_PLANE_API_TOKEN"] = "test-token"
os.environ["ORCH_GITEA_OWNER"] = "admin"
os.environ["ORCH_DEFAULT_REPO"] = "enduro-trails"
# ORCH-6: register the test project so the project filter lets these fixtures
# through. proj-1 maps to enduro-trails/ET, preserving the ET-001/ET-002 asserts.
os.environ["ORCH_PROJECTS_JSON"] = (
'[{"plane_project_id": "proj-1", "repo": "enduro-trails", '
'"work_item_prefix": "ET", "name": "enduro-trails"}]'
)
from fastapi.testclient import TestClient
from src.main import app