Compare commits
7 Commits
feature/OR
...
feature/OR
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca81f38330 | ||
|
|
c1f35a2047 | ||
|
|
a6f6a43c1c | ||
|
|
171f4eb304 | ||
|
|
a87c633003 | ||
|
|
0797f958dc | ||
|
|
36d5f25f2a |
41
README.md
41
README.md
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
82
docs/BUGFIXES_2026-06-03.md
Normal file
82
docs/BUGFIXES_2026-06-03.md
Normal 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';
|
||||
```
|
||||
7
docs/INCIDENT_2026-06-02_webhook_autorun.txt
Normal file
7
docs/INCIDENT_2026-06-02_webhook_autorun.txt
Normal 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...
|
||||
@@ -16,6 +16,11 @@ 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"
|
||||
|
||||
22
src/db.py
22
src/db.py
@@ -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}"
|
||||
|
||||
@@ -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
127
src/projects.py
Normal 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}
|
||||
@@ -175,11 +175,15 @@ def check_analysis_approved(repo: str, work_item_id: str, branch: str | None = N
|
||||
# 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()
|
||||
|
||||
@@ -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")
|
||||
@@ -167,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
|
||||
@@ -221,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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
180
tests/test_plane_webhook.py
Normal file
180
tests/test_plane_webhook.py
Normal 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
177
tests/test_projects.py
Normal 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()
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user