Compare commits
5 Commits
docs/ORCH-
...
fix/ORCH-3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f93cba297 | ||
| 053ea3b1c5 | |||
| a2cf1454fd | |||
|
|
00325bcab0 | ||
| 5ecd1c4692 |
@@ -12,11 +12,17 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
python3 -m pip install --user --upgrade pip
|
python3 -m pip install --user --upgrade pip
|
||||||
python3 -m pip install --user -r requirements.txt
|
python3 -m pip install --user -r requirements.txt
|
||||||
- name: Test
|
- name: Test
|
||||||
env:
|
env:
|
||||||
PYTHONPATH: ${{ github.workspace }}
|
PYTHONPATH: ${{ github.workspace }}
|
||||||
run: |
|
run: |
|
||||||
|
# ORCH-39: fail the job on ANY failure. Run the WHOLE suite from the
|
||||||
|
# repo root. --strict-markers + pytest-asyncio (asyncio_mode=auto, see
|
||||||
|
# pytest.ini) make async tests actually run instead of silently
|
||||||
|
# skipping (the hole that hid red tests behind a green CI).
|
||||||
|
set -euo pipefail
|
||||||
export PATH="$HOME/.local/bin:$PATH"
|
export PATH="$HOME/.local/bin:$PATH"
|
||||||
python3 -m pytest tests/ -q
|
python3 -m pytest tests/ -q -p no:cacheprovider --strict-markers
|
||||||
|
|||||||
97
docs/work-items/ORCH-016/15-staging-log.md
Normal file
97
docs/work-items/ORCH-016/15-staging-log.md
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
---
|
||||||
|
staging_status: SUCCESS
|
||||||
|
timestamp: 2026-06-05T12:47:48Z
|
||||||
|
base_url: http://localhost:8501
|
||||||
|
work_item: ORCH-016
|
||||||
|
branch: feature/ORCH-016-plane
|
||||||
|
mode: stub
|
||||||
|
---
|
||||||
|
|
||||||
|
# Staging Gate Log — ORCH-016
|
||||||
|
|
||||||
|
## Verdict
|
||||||
|
|
||||||
|
**`staging_status: SUCCESS`** — staging test suite completed, all 10/10 checks PASS.
|
||||||
|
|
||||||
|
## Окружение
|
||||||
|
|
||||||
|
- **Base URL:** `http://localhost:8501` (orchestrator-staging)
|
||||||
|
- **Mode:** `stub` (без LLM-spend; проверяет ранние артефакты pipeline — branch + queued analyst job)
|
||||||
|
- **Suite:** `scripts/staging_check.py` (ORCH-33)
|
||||||
|
- **Sandbox project:** `8c5a3025-4f9d-4190-b79f-fa06276bb27e` (ORCH Sandbox)
|
||||||
|
- **Repo под тест:** `orchestrator-sandbox`
|
||||||
|
|
||||||
|
## Результаты (10/10 PASS)
|
||||||
|
|
||||||
|
### Block A — SMOKE
|
||||||
|
| ID | Проверка | Результат |
|
||||||
|
|----|----------|-----------|
|
||||||
|
| A1 | `GET /health` → 200, `status=ok` | ✓ PASS |
|
||||||
|
| A2 | `GET /queue` → 200, ключи `counts/max_concurrency/resilience` | ✓ PASS |
|
||||||
|
| A3 | `ORCH_STAGING=true` (защита от прод-окружения) | ✓ PASS |
|
||||||
|
|
||||||
|
### Block B — ACCESS
|
||||||
|
| ID | Проверка | Результат |
|
||||||
|
|----|----------|-----------|
|
||||||
|
| B4 | Plane: sandbox project accessible (5 projects, sandbox=YES) | ✓ PASS |
|
||||||
|
| B5 | Gitea: `orchestrator-sandbox` доступен, `push=true` | ✓ PASS |
|
||||||
|
| B6 | Registry: sandbox в known IDs, prod ET/ORCH отсутствуют | ✓ PASS |
|
||||||
|
|
||||||
|
### Block C — E2E (mode=stub)
|
||||||
|
| ID | Проверка | Результат |
|
||||||
|
|----|----------|-----------|
|
||||||
|
| C7 | Create issue in Plane SANDBOX → HTTP 201, `issue_id=37d91fba-5ac1-460b-ab06-a13f963911bc` | ✓ PASS |
|
||||||
|
| C8 | Trigger pipeline via `POST /webhook/plane` (с HMAC) → HTTP 200, `status=accepted` | ✓ PASS |
|
||||||
|
| C9a | Branch появилась в `orchestrator-sandbox` → `feature/SANDBOX-009-staging-check-e2e-20260605t124` | ✓ PASS |
|
||||||
|
| C9b | Analyst job в очереди staging (`/queue` → recent) → `job_id=5, status=queued, agent=analyst` | ✓ PASS |
|
||||||
|
|
||||||
|
### Cleanup
|
||||||
|
- Удалена тестовая ветка в Gitea (HTTP 204).
|
||||||
|
- Удалён тестовый Plane issue (HTTP 204).
|
||||||
|
- DB-cleanup: task row отсутствовал (нормально для stub-mode), dedup-таблица отсутствует (некритично).
|
||||||
|
|
||||||
|
## Что значит "SUCCESS" для ORCH-016
|
||||||
|
|
||||||
|
ORCH-016 — это унификация финальных коммент-логов агентов (`usage.build_status_comment` + длительность). Изменения затрагивают:
|
||||||
|
- `src/usage.py` — расширен билдер коммента (длительность, defensive формат).
|
||||||
|
- `src/agents/launcher.py` — пробрасывает `duration_s` из `_monitor_agent` в `_post_usage_comments`.
|
||||||
|
- `src/stage_engine.py` — для analyst-стадии использует DB-fallback `usage.get_agent_duration(task_id, agent)`.
|
||||||
|
- `src/frontmatter.py` — defensive `read_frontmatter_value(...)`.
|
||||||
|
|
||||||
|
Staging-стенд (orchestrator-staging) поднят на актуальном образе и:
|
||||||
|
1. Принимает Plane-webhook (HMAC OK).
|
||||||
|
2. Корректно фильтрует проекты через registry (B6 — sandbox разрешён, прод ET/ORCH отрезаны).
|
||||||
|
3. Дотягивает pipeline до постановки analyst job в персистентную очередь (ORCH-1) и создания ветки в Gitea.
|
||||||
|
|
||||||
|
Поведение коммент-логов в реальном e2e (mode=full-real) НЕ проверялось — это требует LLM-spend и реального запуска агентов. В рамках staging-gate для ORCH-016 это считается достаточным: финальный коммент строится из артефактов (`12-review.md`, `13-test-report.md`, ...) и uses-данных из `agent_runs`, которые уже покрыты unit-тестами в `tests/`.
|
||||||
|
|
||||||
|
## Откат не требуется
|
||||||
|
|
||||||
|
Все 10 проверок зелёные → переход на стадию `deploy` разрешён. Прод-контейнер `orchestrator` (8500) в рамках этой стадии НЕ перезапускался (правило self-hosting, `CLAUDE.md`).
|
||||||
|
|
||||||
|
## Команда запуска (для воспроизведения)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Загрузить .env.staging БЕЗ shell-source (JSON-значения ломают bash):
|
||||||
|
python3 -c "
|
||||||
|
import os, subprocess
|
||||||
|
env = dict(os.environ)
|
||||||
|
with open('/repos/orchestrator/.env.staging') as f:
|
||||||
|
for line in f:
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith('#') or '=' not in line:
|
||||||
|
continue
|
||||||
|
k, _, v = line.partition('=')
|
||||||
|
env[k.strip()] = v.strip()
|
||||||
|
r = subprocess.run(
|
||||||
|
['python3', 'scripts/staging_check.py',
|
||||||
|
'--base-url', 'http://localhost:8501', '--mode', 'stub'],
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
exit(r.returncode)
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Stage: `deploy-staging` → `deploy`. Quality Gate `check_staging_status` ожидает `staging_status: SUCCESS` в frontmatter этого файла.*
|
||||||
13
pytest.ini
Normal file
13
pytest.ini
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
[pytest]
|
||||||
|
# ORCH-39: make the async webhook/state tests (test_orch10_states.py) actually
|
||||||
|
# run in every environment. Without pytest-asyncio + asyncio_mode=auto these
|
||||||
|
# @pytest.mark.asyncio tests were silently SKIPPED, so a broken async path
|
||||||
|
# could pass CI. asyncio_mode=auto runs `async def test_*` natively.
|
||||||
|
asyncio_mode = auto
|
||||||
|
|
||||||
|
# Fail loudly on unknown markers so a typo'd @pytest.mark.* can't silently
|
||||||
|
# disable a test.
|
||||||
|
markers =
|
||||||
|
asyncio: mark a coroutine test to be run by pytest-asyncio.
|
||||||
|
|
||||||
|
testpaths = tests
|
||||||
@@ -3,3 +3,4 @@ uvicorn[standard]==0.30.0
|
|||||||
pydantic-settings==2.5.0
|
pydantic-settings==2.5.0
|
||||||
httpx==0.27.0
|
httpx==0.27.0
|
||||||
pytest==8.3.3
|
pytest==8.3.3
|
||||||
|
pytest-asyncio==0.23.8
|
||||||
|
|||||||
@@ -84,31 +84,131 @@ def _resolve_project_id(work_item_id: str = None, project_id: str = None) -> str
|
|||||||
logger.debug(f"_resolve_project_id fallback for {work_item_id}: {e}")
|
logger.debug(f"_resolve_project_id fallback for {work_item_id}: {e}")
|
||||||
return PROJECT_ID
|
return PROJECT_ID
|
||||||
|
|
||||||
# Plane state IDs.
|
# ORCH-10: per-project state resolution.
|
||||||
# TODO(ORCH-10): these UUIDs are PER-PROJECT. The 6 stage-visibility / verdict
|
#
|
||||||
# statuses below were created only in the enduro project (7a79f0a9-...). One
|
# _DEFAULT_STATES keeps the original enduro-trails UUIDs as a safe fallback
|
||||||
# project is in prod today, so a single global dict is acceptable. When more
|
# (used when the Plane API is unreachable and for backward compat).
|
||||||
# projects are onboarded these must be resolved per project (see ORCH-10 in
|
# PLANE_STATES is preserved as an alias so existing call sites that reference
|
||||||
# BACKLOG.md / the ORCH-6 project registry) — do NOT hardcode globally then.
|
# it directly (QG-0 fast-path in webhooks/plane.py, tests) continue to work.
|
||||||
PLANE_STATES = {
|
_DEFAULT_STATES = {
|
||||||
"backlog": "113b24f6-cce8-4be9-9a22-a359b9cf0122",
|
"backlog": "113b24f6-cce8-4be9-9a22-a359b9cf0122",
|
||||||
"todo": "2c7d3df3-9eb9-419b-92b7-d7d560bcdd10",
|
"todo": "2c7d3df3-9eb9-419b-92b7-d7d560bcdd10",
|
||||||
"in_progress": "b873d9eb-993c-48cd-97ac-99a9b1623967",
|
"in_progress": "b873d9eb-993c-48cd-97ac-99a9b1623967",
|
||||||
"needs_input": "babf08a3-ff4d-41f3-a821-5491aa29a8ac",
|
"needs_input": "babf08a3-ff4d-41f3-a821-5491aa29a8ac",
|
||||||
"in_review": "38fb1f64-aa1e-48a3-92e0-0b109679046b",
|
"in_review": "38fb1f64-aa1e-48a3-92e0-0b109679046b",
|
||||||
"blocked": "6c4543f9-ac47-4ef7-ae0f-070020dc9920",
|
"blocked": "6c4543f9-ac47-4ef7-ae0f-070020dc9920",
|
||||||
"done": "381a2833-3c4e-4be5-bd0f-be84cb946ad8",
|
"done": "381a2833-3c4e-4be5-bd0f-be84cb946ad8",
|
||||||
"cancelled": "b1cae7f9-961d-4889-a179-f3acea697d17",
|
"cancelled": "b1cae7f9-961d-4889-a179-f3acea697d17",
|
||||||
# Feature 3 (stage visibility) — per-stage statuses on the board.
|
# Feature 3 (stage visibility) — per-stage statuses on the board.
|
||||||
"architecture": "3020bbb7-6122-4663-930c-0315ba8dfa3d",
|
"architecture": "3020bbb7-6122-4663-930c-0315ba8dfa3d",
|
||||||
"development": "9920609b-f140-4e46-ab95-89acda8412c8",
|
"development": "9920609b-f140-4e46-ab95-89acda8412c8",
|
||||||
"review": "ba0d802c-5218-41d4-ab43-978b0ea123ed",
|
"review": "ba0d802c-5218-41d4-ab43-978b0ea123ed",
|
||||||
"testing": "7855d807-b1bf-42ef-8dae-6cde0df92d02",
|
"testing": "7855d807-b1bf-42ef-8dae-6cde0df92d02",
|
||||||
# Feature 2 (verdict statuses) — Approved / Rejected.
|
# Feature 2 (verdict statuses) — Approved / Rejected.
|
||||||
"approved": "a519a341-dada-4a91-8910-7604f82b79c5",
|
"approved": "a519a341-dada-4a91-8910-7604f82b79c5",
|
||||||
"rejected": "ba958f3c-5db5-461d-8f82-89425e413b97",
|
"rejected": "ba958f3c-5db5-461d-8f82-89425e413b97",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Backward-compat alias — do NOT remove (tests + webhooks/plane.py import it).
|
||||||
|
PLANE_STATES = _DEFAULT_STATES
|
||||||
|
|
||||||
|
# Mapping: Plane state *name* (as returned by the API) -> logical key.
|
||||||
|
_PLANE_NAME_TO_KEY: dict[str, str] = {
|
||||||
|
"Backlog": "backlog",
|
||||||
|
"Todo": "todo",
|
||||||
|
"In Progress": "in_progress",
|
||||||
|
"Architecture": "architecture",
|
||||||
|
"Development": "development",
|
||||||
|
"Review": "review",
|
||||||
|
"Testing": "testing",
|
||||||
|
"Approved": "approved",
|
||||||
|
"Rejected": "rejected",
|
||||||
|
"Done": "done",
|
||||||
|
"Cancelled": "cancelled",
|
||||||
|
"Needs Input": "needs_input",
|
||||||
|
"In Review": "in_review",
|
||||||
|
"Blocked": "blocked",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Per-project state cache: {project_id: {logical_key: state_uuid}}
|
||||||
|
_STATES_CACHE: dict[str, dict[str, str]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def get_project_states(project_id: str) -> dict[str, str]:
|
||||||
|
"""ORCH-10: resolve {logical_key -> state_uuid} for a specific Plane project.
|
||||||
|
|
||||||
|
Source of truth: Plane API GET /projects/<project_id>/states/.
|
||||||
|
Results are cached per project_id for the lifetime of the process.
|
||||||
|
Falls back to _DEFAULT_STATES (enduro-trails values) if:
|
||||||
|
* project_id is empty/None,
|
||||||
|
* the API call fails (network error, non-2xx),
|
||||||
|
* the response contains no recognisable states.
|
||||||
|
|
||||||
|
The enduro-trails project therefore returns the same UUIDs as before
|
||||||
|
(backward compatible). The orchestrator project returns its own UUIDs,
|
||||||
|
fixing the ORCH-10 blocker.
|
||||||
|
"""
|
||||||
|
if not project_id:
|
||||||
|
return _DEFAULT_STATES
|
||||||
|
|
||||||
|
if project_id in _STATES_CACHE:
|
||||||
|
return _STATES_CACHE[project_id]
|
||||||
|
|
||||||
|
url = f"{PLANE_BASE}/workspaces/{WORKSPACE}/projects/{project_id}/states/"
|
||||||
|
try:
|
||||||
|
resp = httpx.get(url, headers=PLANE_HEADERS, timeout=10)
|
||||||
|
resp.raise_for_status()
|
||||||
|
body = resp.json()
|
||||||
|
# Plane returns {"results": [...]} or a bare list.
|
||||||
|
items = body.get("results", body) if isinstance(body, dict) else body
|
||||||
|
if not isinstance(items, list):
|
||||||
|
raise ValueError(f"unexpected states response shape: {type(items)}")
|
||||||
|
|
||||||
|
resolved: dict[str, str] = {}
|
||||||
|
for item in items:
|
||||||
|
name = item.get("name", "")
|
||||||
|
uid = item.get("id", "")
|
||||||
|
key = _PLANE_NAME_TO_KEY.get(name)
|
||||||
|
if key and uid:
|
||||||
|
resolved[key] = uid
|
||||||
|
|
||||||
|
if not resolved:
|
||||||
|
raise ValueError("no recognisable states in API response")
|
||||||
|
|
||||||
|
# Fill any missing keys from _DEFAULT_STATES so callers always get a
|
||||||
|
# complete mapping (defensive against partial Plane configs).
|
||||||
|
for k, v in _DEFAULT_STATES.items():
|
||||||
|
resolved.setdefault(k, v)
|
||||||
|
|
||||||
|
_STATES_CACHE[project_id] = resolved
|
||||||
|
logger.debug(
|
||||||
|
f"get_project_states: cached {len(resolved)} states for project {project_id[:8]}..."
|
||||||
|
)
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
f"get_project_states: API failed for project {project_id[:8]}..., "
|
||||||
|
f"falling back to _DEFAULT_STATES. Error: {e}"
|
||||||
|
)
|
||||||
|
return _DEFAULT_STATES
|
||||||
|
|
||||||
|
|
||||||
|
def reload_project_states(project_id: str = None) -> None:
|
||||||
|
"""ORCH-10: clear the per-project states cache.
|
||||||
|
|
||||||
|
If project_id is given, evict only that project.
|
||||||
|
If None, flush the entire cache (useful in tests and after config reload).
|
||||||
|
"""
|
||||||
|
global _STATES_CACHE
|
||||||
|
if project_id is None:
|
||||||
|
_STATES_CACHE = {}
|
||||||
|
logger.debug("reload_project_states: full cache cleared")
|
||||||
|
else:
|
||||||
|
_STATES_CACHE.pop(project_id, None)
|
||||||
|
logger.debug(f"reload_project_states: evicted project {project_id[:8]}...")
|
||||||
|
|
||||||
|
|
||||||
# Feature 3: map an orchestrator stage -> the Plane status to show on the board
|
# Feature 3: map an orchestrator stage -> the Plane status to show on the board
|
||||||
# when the pipeline ENTERS that stage. analysis stays driven by the existing
|
# when the pipeline ENTERS that stage. analysis stays driven by the existing
|
||||||
# in_progress/in_review/needs_input logic (no dedicated status). deploy keeps
|
# in_progress/in_review/needs_input logic (no dedicated status). deploy keeps
|
||||||
@@ -121,21 +221,44 @@ STAGE_VISIBILITY_STATE = {
|
|||||||
"testing": "testing",
|
"testing": "testing",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Map orchestrator stages to Plane states (used by update_issue_state /
|
# STAGE_TO_STATE kept for backward compat (used by tests that patch it).
|
||||||
# notify_stage_change). Feature 3: architecture/development/review/testing now
|
# update_issue_state now calls stage_to_state() instead of looking up here.
|
||||||
# point at their dedicated board statuses so the task physically moves across
|
|
||||||
# columns. analysis -> in_progress, deploy -> in_progress, done -> done.
|
|
||||||
STAGE_TO_STATE = {
|
STAGE_TO_STATE = {
|
||||||
"created": PLANE_STATES["todo"],
|
"created": _DEFAULT_STATES["todo"],
|
||||||
"analysis": PLANE_STATES["in_progress"],
|
"analysis": _DEFAULT_STATES["in_progress"],
|
||||||
"architecture": PLANE_STATES["architecture"],
|
"architecture": _DEFAULT_STATES["architecture"],
|
||||||
"development": PLANE_STATES["development"],
|
"development": _DEFAULT_STATES["development"],
|
||||||
"review": PLANE_STATES["review"],
|
"review": _DEFAULT_STATES["review"],
|
||||||
"testing": PLANE_STATES["testing"],
|
"testing": _DEFAULT_STATES["testing"],
|
||||||
"deploy": PLANE_STATES["in_progress"],
|
"deploy": _DEFAULT_STATES["in_progress"],
|
||||||
"done": PLANE_STATES["done"],
|
"done": _DEFAULT_STATES["done"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Map orchestrator stage -> logical state key (project-independent).
|
||||||
|
_STAGE_TO_STATE_KEY = {
|
||||||
|
"created": "todo",
|
||||||
|
"analysis": "in_progress",
|
||||||
|
"architecture": "architecture",
|
||||||
|
"development": "development",
|
||||||
|
"review": "review",
|
||||||
|
"testing": "testing",
|
||||||
|
"deploy": "in_progress",
|
||||||
|
"done": "done",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def stage_to_state(stage: str, project_id: str) -> str | None:
|
||||||
|
"""ORCH-10: return the Plane state UUID for a pipeline stage in a project.
|
||||||
|
|
||||||
|
Resolves via get_project_states so the correct per-project UUID is used.
|
||||||
|
Returns None for unknown stages (same behaviour as the old STAGE_TO_STATE
|
||||||
|
dict lookup returning None).
|
||||||
|
"""
|
||||||
|
key = _STAGE_TO_STATE_KEY.get(stage)
|
||||||
|
if not key:
|
||||||
|
return None
|
||||||
|
return get_project_states(project_id).get(key)
|
||||||
|
|
||||||
|
|
||||||
def fetch_issue_sequence_id(issue_id: str, project_id: str) -> int | None:
|
def fetch_issue_sequence_id(issue_id: str, project_id: str) -> int | None:
|
||||||
"""M-6: GET the Plane issue by UUID and return its sequence_id (the
|
"""M-6: GET the Plane issue by UUID and return its sequence_id (the
|
||||||
@@ -284,11 +407,12 @@ def find_issue_id(work_item_id: str, project_id: str = None) -> str | None:
|
|||||||
|
|
||||||
def update_issue_state(work_item_id: str, stage: str, project_id: str = None):
|
def update_issue_state(work_item_id: str, stage: str, project_id: str = None):
|
||||||
"""Update Plane issue state based on orchestrator stage."""
|
"""Update Plane issue state based on orchestrator stage."""
|
||||||
state_id = STAGE_TO_STATE.get(stage)
|
project_id = _resolve_project_id(work_item_id, project_id)
|
||||||
|
# ORCH-10: resolve state UUID for this specific project (not global dict).
|
||||||
|
state_id = stage_to_state(stage, project_id)
|
||||||
if not state_id:
|
if not state_id:
|
||||||
return
|
return
|
||||||
|
|
||||||
project_id = _resolve_project_id(work_item_id, project_id)
|
|
||||||
issue_id = find_issue_id(work_item_id, project_id)
|
issue_id = find_issue_id(work_item_id, project_id)
|
||||||
if not issue_id:
|
if not issue_id:
|
||||||
logger.warning(f"Issue not found in Plane for {work_item_id}")
|
logger.warning(f"Issue not found in Plane for {work_item_id}")
|
||||||
@@ -327,20 +451,25 @@ def add_comment(work_item_id: str, text: str, project_id: str = None, author: st
|
|||||||
logger.error(f"Failed to add comment to {work_item_id}: {e}")
|
logger.error(f"Failed to add comment to {work_item_id}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def set_issue_needs_input(work_item_id: str, project_id: str = None):
|
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 to 'Needs Input' state — waiting for stakeholder response."""
|
||||||
_set_issue_state_direct(work_item_id, PLANE_STATES["needs_input"], project_id)
|
project_id = _resolve_project_id(work_item_id, project_id)
|
||||||
|
state_id = get_project_states(project_id)["needs_input"]
|
||||||
|
_set_issue_state_direct(work_item_id, state_id, project_id)
|
||||||
|
|
||||||
|
|
||||||
def set_issue_in_review(work_item_id: str, project_id: str = None):
|
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 to 'In Review' state — waiting for :approved: or :rejected:."""
|
||||||
_set_issue_state_direct(work_item_id, PLANE_STATES["in_review"], project_id)
|
project_id = _resolve_project_id(work_item_id, project_id)
|
||||||
|
state_id = get_project_states(project_id)["in_review"]
|
||||||
|
_set_issue_state_direct(work_item_id, state_id, project_id)
|
||||||
|
|
||||||
|
|
||||||
def set_issue_blocked(work_item_id: str, project_id: str = None):
|
def set_issue_blocked(work_item_id: str, project_id: str = None):
|
||||||
"""Set issue to 'Blocked' state — manual intervention needed."""
|
"""Set issue to 'Blocked' state — manual intervention needed."""
|
||||||
_set_issue_state_direct(work_item_id, PLANE_STATES["blocked"], project_id)
|
project_id = _resolve_project_id(work_item_id, project_id)
|
||||||
|
state_id = get_project_states(project_id)["blocked"]
|
||||||
|
_set_issue_state_direct(work_item_id, state_id, project_id)
|
||||||
|
|
||||||
|
|
||||||
def set_issue_done(work_item_id: str, project_id: str = None):
|
def set_issue_done(work_item_id: str, project_id: str = None):
|
||||||
@@ -348,15 +477,19 @@ def set_issue_done(work_item_id: str, project_id: str = None):
|
|||||||
|
|
||||||
Used by the deploy->done success path so a completed task always reaches the
|
Used by the deploy->done success path so a completed task always reaches the
|
||||||
terminal Plane state (it used to stick on In Progress because the merge
|
terminal Plane state (it used to stick on In Progress because the merge
|
||||||
webhook bypassed the stage engine). Uses the existing PLANE_STATES['done']
|
webhook bypassed the stage engine). Resolves per-project UUID via
|
||||||
UUID — the mapping itself is NOT changed.
|
get_project_states (ORCH-10).
|
||||||
"""
|
"""
|
||||||
_set_issue_state_direct(work_item_id, PLANE_STATES["done"], project_id)
|
project_id = _resolve_project_id(work_item_id, project_id)
|
||||||
|
state_id = get_project_states(project_id)["done"]
|
||||||
|
_set_issue_state_direct(work_item_id, state_id, project_id)
|
||||||
|
|
||||||
|
|
||||||
def set_issue_in_progress(work_item_id: str, project_id: str = None):
|
def set_issue_in_progress(work_item_id: str, project_id: str = None):
|
||||||
"""Set issue to 'In Progress' state — agent working."""
|
"""Set issue to 'In Progress' state — agent working."""
|
||||||
_set_issue_state_direct(work_item_id, PLANE_STATES["in_progress"], project_id)
|
project_id = _resolve_project_id(work_item_id, project_id)
|
||||||
|
state_id = get_project_states(project_id)["in_progress"]
|
||||||
|
_set_issue_state_direct(work_item_id, state_id, project_id)
|
||||||
|
|
||||||
|
|
||||||
def set_issue_stage_state(work_item_id: str, stage: str, project_id: str = None):
|
def set_issue_stage_state(work_item_id: str, stage: str, project_id: str = None):
|
||||||
@@ -371,7 +504,10 @@ def set_issue_stage_state(work_item_id: str, stage: str, project_id: str = None)
|
|||||||
state_key = STAGE_VISIBILITY_STATE.get(stage)
|
state_key = STAGE_VISIBILITY_STATE.get(stage)
|
||||||
if not state_key:
|
if not state_key:
|
||||||
return
|
return
|
||||||
_set_issue_state_direct(work_item_id, PLANE_STATES[state_key], project_id)
|
project_id = _resolve_project_id(work_item_id, project_id)
|
||||||
|
# ORCH-10: resolve per-project UUID.
|
||||||
|
state_id = get_project_states(project_id)[state_key]
|
||||||
|
_set_issue_state_direct(work_item_id, state_id, project_id)
|
||||||
|
|
||||||
|
|
||||||
def _set_issue_state_direct(work_item_id: str, state_id: str, project_id: str = None):
|
def _set_issue_state_direct(work_item_id: str, state_id: str, project_id: str = None):
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ async def handle_issue_updated(data: dict, project_id: str = ""):
|
|||||||
Any other status (Needs Input, In Review, Blocked, Done, board stages, etc.)
|
Any other status (Needs Input, In Review, Blocked, Done, board stages, etc.)
|
||||||
is ignored here — those are statuses the orchestrator itself sets.
|
is ignored here — those are statuses the orchestrator itself sets.
|
||||||
"""
|
"""
|
||||||
from ..plane_sync import PLANE_STATES
|
from ..plane_sync import get_project_states
|
||||||
|
|
||||||
plane_id = str(data.get("id") or "")
|
plane_id = str(data.get("id") or "")
|
||||||
new_state = _state_id(data)
|
new_state = _state_id(data)
|
||||||
@@ -145,11 +145,15 @@ async def handle_issue_updated(data: dict, project_id: str = ""):
|
|||||||
logger.info("issue updated without id/state, ignoring")
|
logger.info("issue updated without id/state, ignoring")
|
||||||
return
|
return
|
||||||
|
|
||||||
if new_state == PLANE_STATES["in_progress"]:
|
# ORCH-10: resolve expected state UUIDs per the incoming issue's project so
|
||||||
|
# both enduro (b873d9eb) and orchestrator (e331bfb3) In Progress trigger the
|
||||||
|
# pipeline. Using PLANE_STATES["in_progress"] here was the root-cause blocker.
|
||||||
|
proj_states = get_project_states(project_id)
|
||||||
|
if new_state == proj_states["in_progress"]:
|
||||||
await handle_status_start(data, project_id)
|
await handle_status_start(data, project_id)
|
||||||
elif new_state == PLANE_STATES["approved"]:
|
elif new_state == proj_states["approved"]:
|
||||||
await handle_verdict(data, project_id, approved=True)
|
await handle_verdict(data, project_id, approved=True)
|
||||||
elif new_state == PLANE_STATES["rejected"]:
|
elif new_state == proj_states["rejected"]:
|
||||||
await handle_verdict(data, project_id, approved=False)
|
await handle_verdict(data, project_id, approved=False)
|
||||||
else:
|
else:
|
||||||
logger.info(f"issue {plane_id} updated to state {new_state[:8]}..., no pipeline action")
|
logger.info(f"issue {plane_id} updated to state {new_state[:8]}..., no pipeline action")
|
||||||
@@ -422,7 +426,7 @@ async def start_pipeline(data: dict, project_id: str = ""):
|
|||||||
if errors:
|
if errors:
|
||||||
# QG-0 failed
|
# QG-0 failed
|
||||||
error_text = "\u26a0\ufe0f QG-0 failed:\n" + "\n".join(f"\u2022 {e}" for e in errors)
|
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, PLANE_STATES
|
from ..plane_sync import PLANE_BASE, PLANE_HEADERS, WORKSPACE, get_project_states
|
||||||
import httpx as _httpx
|
import httpx as _httpx
|
||||||
# Post comment (ORCH-6: route to the issue's own project)
|
# 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/"
|
url = f"{PLANE_BASE}/workspaces/{WORKSPACE}/projects/{plane_project_id}/issues/{plane_id}/comments/"
|
||||||
@@ -431,11 +435,12 @@ async def start_pipeline(data: dict, project_id: str = ""):
|
|||||||
json={"comment_html": f"<p>{error_text}</p>"}, timeout=10)
|
json={"comment_html": f"<p>{error_text}</p>"}, timeout=10)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
# Set blocked
|
# Set blocked — ORCH-10: resolve per-project UUID.
|
||||||
url2 = f"{PLANE_BASE}/workspaces/{WORKSPACE}/projects/{plane_project_id}/issues/{plane_id}/"
|
url2 = f"{PLANE_BASE}/workspaces/{WORKSPACE}/projects/{plane_project_id}/issues/{plane_id}/"
|
||||||
try:
|
try:
|
||||||
|
_blocked = get_project_states(plane_project_id)["blocked"]
|
||||||
_httpx.patch(url2, headers=PLANE_HEADERS,
|
_httpx.patch(url2, headers=PLANE_HEADERS,
|
||||||
json={"state": PLANE_STATES["blocked"]}, timeout=10)
|
json={"state": _blocked}, timeout=10)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
logger.info(f"QG-0 failed for {plane_id}: {errors}")
|
logger.info(f"QG-0 failed for {plane_id}: {errors}")
|
||||||
|
|||||||
@@ -34,6 +34,27 @@ import src.plane_sync as plane_sync # noqa: E402
|
|||||||
ORCH_PLANE_ID = "8da6aa25-a60e-44d6-a1e2-d8ae59aa7d6a"
|
ORCH_PLANE_ID = "8da6aa25-a60e-44d6-a1e2-d8ae59aa7d6a"
|
||||||
ENDURO_PLANE_ID = "7a79f0a9-5278-49cd-9007-9a338f238f9c"
|
ENDURO_PLANE_ID = "7a79f0a9-5278-49cd-9007-9a338f238f9c"
|
||||||
|
|
||||||
|
# ORCH-39: after ORCH-10 the webhook resolves Plane state UUIDs per-project via
|
||||||
|
# get_project_states(project_id). Mock it deterministically (no network) and
|
||||||
|
# send each request with the UUID that matches its own project.
|
||||||
|
_PROJECT_STATES = {
|
||||||
|
ENDURO_PLANE_ID: {
|
||||||
|
"in_progress": "b873d9eb-993c-48cd-97ac-99a9b1623967",
|
||||||
|
"approved": "a519a341-dada-4a91-8910-7604f82b79c5",
|
||||||
|
"rejected": "ba958f3c-5db5-461d-8f82-89425e413b97",
|
||||||
|
},
|
||||||
|
ORCH_PLANE_ID: {
|
||||||
|
"in_progress": "e331bfb3-e17e-4699-ba48-4abb89c21b7b",
|
||||||
|
"approved": "63f2c8fe-dcda-4ace-952f-dd88bd0118ff",
|
||||||
|
"rejected": "4c769e90-bf80-4a52-b97a-e1c84904bfc3",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _fake_get_project_states(project_id):
|
||||||
|
return _PROJECT_STATES.get(project_id, _PROJECT_STATES[ENDURO_PLANE_ID])
|
||||||
|
|
||||||
|
|
||||||
client = TestClient(app)
|
client = TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
@@ -48,6 +69,10 @@ def setup(monkeypatch):
|
|||||||
|
|
||||||
monkeypatch.setattr("src.webhooks.plane.verify_plane_signature", lambda body, sig: True)
|
monkeypatch.setattr("src.webhooks.plane.verify_plane_signature", lambda body, sig: True)
|
||||||
|
|
||||||
|
# ORCH-39: deterministic per-project Plane states, clean cache per test.
|
||||||
|
plane_sync.reload_project_states()
|
||||||
|
monkeypatch.setattr(plane_sync, "get_project_states", _fake_get_project_states)
|
||||||
|
|
||||||
registry_json = (
|
registry_json = (
|
||||||
f'[{{"plane_project_id": "{ENDURO_PLANE_ID}", "repo": "enduro-trails",'
|
f'[{{"plane_project_id": "{ENDURO_PLANE_ID}", "repo": "enduro-trails",'
|
||||||
f' "work_item_prefix": "ET", "name": "enduro-trails"}},'
|
f' "work_item_prefix": "ET", "name": "enduro-trails"}},'
|
||||||
@@ -60,6 +85,7 @@ def setup(monkeypatch):
|
|||||||
yield
|
yield
|
||||||
|
|
||||||
reload_projects()
|
reload_projects()
|
||||||
|
plane_sync.reload_project_states()
|
||||||
if os.path.exists(_test_db):
|
if os.path.exists(_test_db):
|
||||||
os.unlink(_test_db)
|
os.unlink(_test_db)
|
||||||
|
|
||||||
@@ -103,10 +129,9 @@ def test_fetch_sequence_id_missing_field_returns_none():
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
# Feature 1: pipeline starts on a status change to In Progress, not on creation.
|
# Feature 1: pipeline starts on a status change to In Progress, not on creation.
|
||||||
_IN_PROGRESS = "b873d9eb-993c-48cd-97ac-99a9b1623967"
|
# ORCH-39: in_progress UUID is project-specific; derive it from the project.
|
||||||
|
|
||||||
|
|
||||||
def _post(plane_id, plane_project_id=ORCH_PLANE_ID, name="A valid work item title"):
|
def _post(plane_id, plane_project_id=ORCH_PLANE_ID, name="A valid work item title"):
|
||||||
|
in_progress = _fake_get_project_states(plane_project_id)["in_progress"]
|
||||||
return client.post(
|
return client.post(
|
||||||
"/webhook/plane",
|
"/webhook/plane",
|
||||||
json={
|
json={
|
||||||
@@ -117,7 +142,7 @@ def _post(plane_id, plane_project_id=ORCH_PLANE_ID, name="A valid work item titl
|
|||||||
"name": name,
|
"name": name,
|
||||||
"description_stripped": "This is a sufficiently long description.",
|
"description_stripped": "This is a sufficiently long description.",
|
||||||
"project": plane_project_id,
|
"project": plane_project_id,
|
||||||
"state": {"id": _IN_PROGRESS, "name": "In Progress", "group": "started"},
|
"state": {"id": in_progress, "name": "In Progress", "group": "started"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
462
tests/test_orch10_states.py
Normal file
462
tests/test_orch10_states.py
Normal file
@@ -0,0 +1,462 @@
|
|||||||
|
"""ORCH-10: per-project Plane state resolution tests.
|
||||||
|
|
||||||
|
Verifies:
|
||||||
|
1. get_project_states(ET_PROJECT_ID) -> enduro-trails UUIDs (backward compat).
|
||||||
|
2. get_project_states(ORCH_PROJECT_ID) -> orchestrator UUIDs.
|
||||||
|
3. get_project_states falls back to _DEFAULT_STATES when the Plane API fails.
|
||||||
|
4. _STATES_CACHE is populated after a successful call and reload_project_states
|
||||||
|
evicts it (per-project and full flush).
|
||||||
|
5. stage_to_state() resolves per-project UUIDs for both projects.
|
||||||
|
6. Webhook handle_issue_updated recognises In Progress for BOTH projects
|
||||||
|
(ORCH-10 critical path: e331bfb3 for ORCH, b873d9eb for ET -> pipeline start).
|
||||||
|
7. Webhook handle_issue_updated recognises Approved/Rejected per project.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from unittest.mock import patch, MagicMock, AsyncMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Minimal env so src/config.py can import without a real .env file.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
os.environ.setdefault("ORCH_PLANE_API_URL", "http://plane.local")
|
||||||
|
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
|
||||||
|
os.environ.setdefault("ORCH_PLANE_WORKSPACE_SLUG", "test-ws")
|
||||||
|
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
|
||||||
|
os.environ.setdefault("ORCH_PLANE_WEBHOOK_SECRET", "")
|
||||||
|
os.environ.setdefault("ORCH_GITEA_WEBHOOK_SECRET", "")
|
||||||
|
|
||||||
|
_test_db = os.path.join(tempfile.gettempdir(), "test_orch10_states.db")
|
||||||
|
os.environ["ORCH_DB_PATH"] = _test_db
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Known UUIDs from the ТЗ (source of truth).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
ET_PROJECT_ID = "7a79f0a9-5278-49cd-9007-9a338f238f9c"
|
||||||
|
ORCH_PROJECT_ID = "8da6aa25-a60e-44d6-a1e2-d8ae59aa7d6a"
|
||||||
|
|
||||||
|
ET_STATES = {
|
||||||
|
"backlog": "113b24f6-cce8-4be9-9a22-a359b9cf0122",
|
||||||
|
"todo": "2c7d3df3-9eb9-419b-92b7-d7d560bcdd10",
|
||||||
|
"in_progress": "b873d9eb-993c-48cd-97ac-99a9b1623967",
|
||||||
|
"architecture": "3020bbb7-6122-4663-930c-0315ba8dfa3d",
|
||||||
|
"development": "9920609b-f140-4e46-ab95-89acda8412c8",
|
||||||
|
"review": "ba0d802c-5218-41d4-ab43-978b0ea123ed",
|
||||||
|
"testing": "7855d807-b1bf-42ef-8dae-6cde0df92d02",
|
||||||
|
"approved": "a519a341-dada-4a91-8910-7604f82b79c5",
|
||||||
|
"rejected": "ba958f3c-5db5-461d-8f82-89425e413b97",
|
||||||
|
"done": "381a2833-3c4e-4be5-bd0f-be84cb946ad8",
|
||||||
|
"cancelled": "b1cae7f9-961d-4889-a179-f3acea697d17",
|
||||||
|
"needs_input": "babf08a3-ff4d-41f3-a821-5491aa29a8ac",
|
||||||
|
"in_review": "38fb1f64-aa1e-48a3-92e0-0b109679046b",
|
||||||
|
"blocked": "6c4543f9-ac47-4ef7-ae0f-070020dc9920",
|
||||||
|
}
|
||||||
|
|
||||||
|
ORCH_STATES = {
|
||||||
|
"backlog": "2d5d42ff-e94d-4209-a664-8020c28c2a95",
|
||||||
|
"todo": "b5d3f512-4870-460f-bf6b-4ea560f00a6f",
|
||||||
|
"in_progress": "e331bfb3-e17e-4699-ba48-4abb89c21b7b",
|
||||||
|
"architecture": "795cc32f-5f5a-4244-be7b-9acffc92c7c0",
|
||||||
|
"development": "f5ed4705-5029-470d-89a9-54c3f0d211ee",
|
||||||
|
"review": "2026f3d9-0f43-4054-ab5f-3f9bae3308b8",
|
||||||
|
"testing": "81c5cd78-2993-4f2c-9e8c-2f52db3e5623",
|
||||||
|
"approved": "63f2c8fe-dcda-4ace-952f-dd88bd0118ff",
|
||||||
|
"rejected": "4c769e90-bf80-4a52-b97a-e1c84904bfc3",
|
||||||
|
"done": "3738cd3c-7610-4907-ba5e-26b9a248d9c0",
|
||||||
|
"cancelled": "59d1d210-8e3a-4a83-930a-cbc5dbf6ad85",
|
||||||
|
"needs_input": "99978b3f-72fe-46e3-8b9b-25ba02899fa0",
|
||||||
|
"in_review": "c52e99b9-31ae-4b31-be3f-9773eea7a747",
|
||||||
|
"blocked": "505f01a6-a12f-4121-aaa7-9c5dd009acc4",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _make_states_response(states_dict: dict) -> dict:
|
||||||
|
"""Build a fake Plane GET /states/ response."""
|
||||||
|
name_map = {v: k for k, v in {
|
||||||
|
"backlog": "Backlog",
|
||||||
|
"todo": "Todo",
|
||||||
|
"in_progress": "In Progress",
|
||||||
|
"architecture": "Architecture",
|
||||||
|
"development": "Development",
|
||||||
|
"review": "Review",
|
||||||
|
"testing": "Testing",
|
||||||
|
"approved": "Approved",
|
||||||
|
"rejected": "Rejected",
|
||||||
|
"done": "Done",
|
||||||
|
"cancelled": "Cancelled",
|
||||||
|
"needs_input": "Needs Input",
|
||||||
|
"in_review": "In Review",
|
||||||
|
"blocked": "Blocked",
|
||||||
|
}.items()}
|
||||||
|
logical_to_plane = {
|
||||||
|
"backlog": "Backlog",
|
||||||
|
"todo": "Todo",
|
||||||
|
"in_progress": "In Progress",
|
||||||
|
"architecture": "Architecture",
|
||||||
|
"development": "Development",
|
||||||
|
"review": "Review",
|
||||||
|
"testing": "Testing",
|
||||||
|
"approved": "Approved",
|
||||||
|
"rejected": "Rejected",
|
||||||
|
"done": "Done",
|
||||||
|
"cancelled": "Cancelled",
|
||||||
|
"needs_input": "Needs Input",
|
||||||
|
"in_review": "In Review",
|
||||||
|
"blocked": "Blocked",
|
||||||
|
}
|
||||||
|
results = [
|
||||||
|
{"id": uid, "name": logical_to_plane[key]}
|
||||||
|
for key, uid in states_dict.items()
|
||||||
|
if key in logical_to_plane
|
||||||
|
]
|
||||||
|
return {"results": results}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers to build fake httpx responses.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _fake_response(data: dict, status: int = 200):
|
||||||
|
m = MagicMock()
|
||||||
|
m.status_code = status
|
||||||
|
m.json.return_value = data
|
||||||
|
if status >= 400:
|
||||||
|
from httpx import HTTPStatusError, Request, Response
|
||||||
|
m.raise_for_status.side_effect = HTTPStatusError(
|
||||||
|
"error", request=MagicMock(), response=MagicMock()
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
m.raise_for_status.return_value = None
|
||||||
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixtures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def reset_states_cache():
|
||||||
|
"""Ensure the states cache is empty before each test."""
|
||||||
|
import src.plane_sync as ps
|
||||||
|
ps.reload_project_states()
|
||||||
|
yield
|
||||||
|
ps.reload_project_states()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 1 & 2. get_project_states returns correct UUIDs per project
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_get_project_states_enduro():
|
||||||
|
"""ET project -> enduro-trails UUIDs."""
|
||||||
|
import src.plane_sync as ps
|
||||||
|
with patch("src.plane_sync.httpx.get") as mock_get:
|
||||||
|
mock_get.return_value = _fake_response(_make_states_response(ET_STATES))
|
||||||
|
states = ps.get_project_states(ET_PROJECT_ID)
|
||||||
|
|
||||||
|
for key, expected_uuid in ET_STATES.items():
|
||||||
|
assert states[key] == expected_uuid, (
|
||||||
|
f"ET state '{key}': expected {expected_uuid}, got {states.get(key)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_project_states_orchestrator():
|
||||||
|
"""ORCH project -> orchestrator UUIDs."""
|
||||||
|
import src.plane_sync as ps
|
||||||
|
with patch("src.plane_sync.httpx.get") as mock_get:
|
||||||
|
mock_get.return_value = _fake_response(_make_states_response(ORCH_STATES))
|
||||||
|
states = ps.get_project_states(ORCH_PROJECT_ID)
|
||||||
|
|
||||||
|
for key, expected_uuid in ORCH_STATES.items():
|
||||||
|
assert states[key] == expected_uuid, (
|
||||||
|
f"ORCH state '{key}': expected {expected_uuid}, got {states.get(key)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_project_states_et_in_progress_uuid():
|
||||||
|
"""ET in_progress == b873d9eb (exact UUID from ТЗ)."""
|
||||||
|
import src.plane_sync as ps
|
||||||
|
with patch("src.plane_sync.httpx.get") as mock_get:
|
||||||
|
mock_get.return_value = _fake_response(_make_states_response(ET_STATES))
|
||||||
|
states = ps.get_project_states(ET_PROJECT_ID)
|
||||||
|
assert states["in_progress"] == "b873d9eb-993c-48cd-97ac-99a9b1623967"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_project_states_orch_in_progress_uuid():
|
||||||
|
"""ORCH in_progress == e331bfb3 (exact UUID from ТЗ) — the ORCH-10 blocker."""
|
||||||
|
import src.plane_sync as ps
|
||||||
|
with patch("src.plane_sync.httpx.get") as mock_get:
|
||||||
|
mock_get.return_value = _fake_response(_make_states_response(ORCH_STATES))
|
||||||
|
states = ps.get_project_states(ORCH_PROJECT_ID)
|
||||||
|
assert states["in_progress"] == "e331bfb3-e17e-4699-ba48-4abb89c21b7b"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 3. Fallback to _DEFAULT_STATES when API fails
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_get_project_states_api_error_fallback():
|
||||||
|
"""Network failure -> returns _DEFAULT_STATES (ET values)."""
|
||||||
|
import src.plane_sync as ps
|
||||||
|
with patch("src.plane_sync.httpx.get", side_effect=Exception("network error")):
|
||||||
|
states = ps.get_project_states(ORCH_PROJECT_ID)
|
||||||
|
# Should return _DEFAULT_STATES (ET values) as fallback.
|
||||||
|
assert states is ps._DEFAULT_STATES
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_project_states_non_200_fallback():
|
||||||
|
"""Non-2xx response -> returns _DEFAULT_STATES."""
|
||||||
|
import src.plane_sync as ps
|
||||||
|
with patch("src.plane_sync.httpx.get") as mock_get:
|
||||||
|
mock_get.return_value = _fake_response({}, status=500)
|
||||||
|
states = ps.get_project_states(ORCH_PROJECT_ID)
|
||||||
|
assert states is ps._DEFAULT_STATES
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_project_states_empty_response_fallback():
|
||||||
|
"""Empty results list -> returns _DEFAULT_STATES."""
|
||||||
|
import src.plane_sync as ps
|
||||||
|
with patch("src.plane_sync.httpx.get") as mock_get:
|
||||||
|
mock_get.return_value = _fake_response({"results": []})
|
||||||
|
states = ps.get_project_states(ORCH_PROJECT_ID)
|
||||||
|
assert states is ps._DEFAULT_STATES
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_project_states_none_project_id_fallback():
|
||||||
|
"""None project_id -> _DEFAULT_STATES immediately (no API call)."""
|
||||||
|
import src.plane_sync as ps
|
||||||
|
with patch("src.plane_sync.httpx.get") as mock_get:
|
||||||
|
states = ps.get_project_states(None)
|
||||||
|
mock_get.assert_not_called()
|
||||||
|
assert states is ps._DEFAULT_STATES
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 4. Caching & reload_project_states
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_get_project_states_caches_result():
|
||||||
|
"""Second call returns cached result without hitting the API again."""
|
||||||
|
import src.plane_sync as ps
|
||||||
|
with patch("src.plane_sync.httpx.get") as mock_get:
|
||||||
|
mock_get.return_value = _fake_response(_make_states_response(ET_STATES))
|
||||||
|
_ = ps.get_project_states(ET_PROJECT_ID)
|
||||||
|
_ = ps.get_project_states(ET_PROJECT_ID)
|
||||||
|
assert mock_get.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_reload_project_states_per_project():
|
||||||
|
"""reload_project_states(project_id) evicts only that project."""
|
||||||
|
import src.plane_sync as ps
|
||||||
|
with patch("src.plane_sync.httpx.get") as mock_get:
|
||||||
|
mock_get.return_value = _fake_response(_make_states_response(ET_STATES))
|
||||||
|
ps.get_project_states(ET_PROJECT_ID)
|
||||||
|
assert ET_PROJECT_ID in ps._STATES_CACHE
|
||||||
|
|
||||||
|
ps.reload_project_states(ET_PROJECT_ID)
|
||||||
|
assert ET_PROJECT_ID not in ps._STATES_CACHE
|
||||||
|
|
||||||
|
|
||||||
|
def test_reload_project_states_full_flush():
|
||||||
|
"""reload_project_states() with no args clears entire cache."""
|
||||||
|
import src.plane_sync as ps
|
||||||
|
with patch("src.plane_sync.httpx.get") as mock_get:
|
||||||
|
mock_get.return_value = _fake_response(_make_states_response(ET_STATES))
|
||||||
|
ps.get_project_states(ET_PROJECT_ID)
|
||||||
|
ps.reload_project_states()
|
||||||
|
assert ps._STATES_CACHE == {}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 5. stage_to_state() resolves per-project
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_stage_to_state_et_analysis():
|
||||||
|
"""ET analysis -> in_progress UUID b873d9eb."""
|
||||||
|
import src.plane_sync as ps
|
||||||
|
with patch("src.plane_sync.httpx.get") as mock_get:
|
||||||
|
mock_get.return_value = _fake_response(_make_states_response(ET_STATES))
|
||||||
|
uid = ps.stage_to_state("analysis", ET_PROJECT_ID)
|
||||||
|
assert uid == "b873d9eb-993c-48cd-97ac-99a9b1623967"
|
||||||
|
|
||||||
|
|
||||||
|
def test_stage_to_state_orch_analysis():
|
||||||
|
"""ORCH analysis -> in_progress UUID e331bfb3."""
|
||||||
|
import src.plane_sync as ps
|
||||||
|
with patch("src.plane_sync.httpx.get") as mock_get:
|
||||||
|
mock_get.return_value = _fake_response(_make_states_response(ORCH_STATES))
|
||||||
|
uid = ps.stage_to_state("analysis", ORCH_PROJECT_ID)
|
||||||
|
assert uid == "e331bfb3-e17e-4699-ba48-4abb89c21b7b"
|
||||||
|
|
||||||
|
|
||||||
|
def test_stage_to_state_unknown_stage():
|
||||||
|
"""Unknown stage -> None."""
|
||||||
|
import src.plane_sync as ps
|
||||||
|
with patch("src.plane_sync.httpx.get") as mock_get:
|
||||||
|
mock_get.return_value = _fake_response(_make_states_response(ET_STATES))
|
||||||
|
uid = ps.stage_to_state("nonexistent_stage", ET_PROJECT_ID)
|
||||||
|
assert uid is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_stage_to_state_orch_done():
|
||||||
|
"""ORCH done -> 3738cd3c."""
|
||||||
|
import src.plane_sync as ps
|
||||||
|
with patch("src.plane_sync.httpx.get") as mock_get:
|
||||||
|
mock_get.return_value = _fake_response(_make_states_response(ORCH_STATES))
|
||||||
|
uid = ps.stage_to_state("done", ORCH_PROJECT_ID)
|
||||||
|
assert uid == "3738cd3c-7610-4907-ba5e-26b9a248d9c0"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 6 & 7. Webhook handle_issue_updated — ORCH-10 critical path
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_webhook_in_progress_et_starts_pipeline():
|
||||||
|
"""ET In Progress (b873d9eb) -> handle_status_start called."""
|
||||||
|
from src.webhooks.plane import handle_issue_updated
|
||||||
|
import src.plane_sync as ps
|
||||||
|
|
||||||
|
et_states_resp = _make_states_response(ET_STATES)
|
||||||
|
with patch("src.plane_sync.httpx.get") as mock_httpx, \
|
||||||
|
patch("src.webhooks.plane.handle_status_start", new_callable=AsyncMock) as mock_start, \
|
||||||
|
patch("src.webhooks.plane.handle_verdict", new_callable=AsyncMock) as mock_verdict:
|
||||||
|
mock_httpx.return_value = _fake_response(et_states_resp)
|
||||||
|
data = {
|
||||||
|
"id": "et-issue-uuid",
|
||||||
|
"state": {"id": "b873d9eb-993c-48cd-97ac-99a9b1623967", "name": "In Progress"},
|
||||||
|
}
|
||||||
|
await handle_issue_updated(data, ET_PROJECT_ID)
|
||||||
|
|
||||||
|
mock_start.assert_called_once()
|
||||||
|
mock_verdict.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_webhook_in_progress_orch_starts_pipeline():
|
||||||
|
"""ORCH In Progress (e331bfb3) -> handle_status_start called.
|
||||||
|
|
||||||
|
This is the ORCH-10 blocker: previously the webhook compared against the
|
||||||
|
hardcoded ET UUID (b873d9eb) and the ORCH UUID (e331bfb3) was silently
|
||||||
|
ignored — the pipeline never started for ORCH tasks.
|
||||||
|
"""
|
||||||
|
from src.webhooks.plane import handle_issue_updated
|
||||||
|
import src.plane_sync as ps
|
||||||
|
|
||||||
|
orch_states_resp = _make_states_response(ORCH_STATES)
|
||||||
|
with patch("src.plane_sync.httpx.get") as mock_httpx, \
|
||||||
|
patch("src.webhooks.plane.handle_status_start", new_callable=AsyncMock) as mock_start, \
|
||||||
|
patch("src.webhooks.plane.handle_verdict", new_callable=AsyncMock) as mock_verdict:
|
||||||
|
mock_httpx.return_value = _fake_response(orch_states_resp)
|
||||||
|
data = {
|
||||||
|
"id": "orch-issue-uuid",
|
||||||
|
"state": {"id": "e331bfb3-e17e-4699-ba48-4abb89c21b7b", "name": "In Progress"},
|
||||||
|
}
|
||||||
|
await handle_issue_updated(data, ORCH_PROJECT_ID)
|
||||||
|
|
||||||
|
mock_start.assert_called_once()
|
||||||
|
mock_verdict.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_webhook_approved_orch():
|
||||||
|
"""ORCH Approved (63f2c8fe) -> handle_verdict(approved=True)."""
|
||||||
|
from src.webhooks.plane import handle_issue_updated
|
||||||
|
orch_states_resp = _make_states_response(ORCH_STATES)
|
||||||
|
with patch("src.plane_sync.httpx.get") as mock_httpx, \
|
||||||
|
patch("src.webhooks.plane.handle_status_start", new_callable=AsyncMock) as mock_start, \
|
||||||
|
patch("src.webhooks.plane.handle_verdict", new_callable=AsyncMock) as mock_verdict:
|
||||||
|
mock_httpx.return_value = _fake_response(orch_states_resp)
|
||||||
|
data = {
|
||||||
|
"id": "orch-issue-uuid",
|
||||||
|
"state": {"id": "63f2c8fe-dcda-4ace-952f-dd88bd0118ff", "name": "Approved"},
|
||||||
|
}
|
||||||
|
await handle_issue_updated(data, ORCH_PROJECT_ID)
|
||||||
|
|
||||||
|
mock_verdict.assert_called_once_with(data, ORCH_PROJECT_ID, approved=True)
|
||||||
|
mock_start.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_webhook_rejected_orch():
|
||||||
|
"""ORCH Rejected (4c769e90) -> handle_verdict(approved=False)."""
|
||||||
|
from src.webhooks.plane import handle_issue_updated
|
||||||
|
orch_states_resp = _make_states_response(ORCH_STATES)
|
||||||
|
with patch("src.plane_sync.httpx.get") as mock_httpx, \
|
||||||
|
patch("src.webhooks.plane.handle_status_start", new_callable=AsyncMock) as mock_start, \
|
||||||
|
patch("src.webhooks.plane.handle_verdict", new_callable=AsyncMock) as mock_verdict:
|
||||||
|
mock_httpx.return_value = _fake_response(orch_states_resp)
|
||||||
|
data = {
|
||||||
|
"id": "orch-issue-uuid",
|
||||||
|
"state": {"id": "4c769e90-bf80-4a52-b97a-e1c84904bfc3", "name": "Rejected"},
|
||||||
|
}
|
||||||
|
await handle_issue_updated(data, ORCH_PROJECT_ID)
|
||||||
|
|
||||||
|
mock_verdict.assert_called_once_with(data, ORCH_PROJECT_ID, approved=False)
|
||||||
|
mock_start.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_webhook_other_state_no_action():
|
||||||
|
"""A non-trigger state (e.g. 'Needs Input') -> no pipeline action."""
|
||||||
|
from src.webhooks.plane import handle_issue_updated
|
||||||
|
orch_states_resp = _make_states_response(ORCH_STATES)
|
||||||
|
with patch("src.plane_sync.httpx.get") as mock_httpx, \
|
||||||
|
patch("src.webhooks.plane.handle_status_start", new_callable=AsyncMock) as mock_start, \
|
||||||
|
patch("src.webhooks.plane.handle_verdict", new_callable=AsyncMock) as mock_verdict:
|
||||||
|
mock_httpx.return_value = _fake_response(orch_states_resp)
|
||||||
|
data = {
|
||||||
|
"id": "orch-issue-uuid",
|
||||||
|
"state": {"id": "99978b3f-72fe-46e3-8b9b-25ba02899fa0", "name": "Needs Input"},
|
||||||
|
}
|
||||||
|
await handle_issue_updated(data, ORCH_PROJECT_ID)
|
||||||
|
|
||||||
|
mock_start.assert_not_called()
|
||||||
|
mock_verdict.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_webhook_et_in_progress_not_confused_with_orch():
|
||||||
|
"""ET In Progress UUID does NOT trigger pipeline for ORCH project.
|
||||||
|
|
||||||
|
This guards against the reverse confusion: if somehow an ET UUID was sent
|
||||||
|
for an ORCH project event, it should NOT start the pipeline (wrong UUID).
|
||||||
|
"""
|
||||||
|
from src.webhooks.plane import handle_issue_updated
|
||||||
|
orch_states_resp = _make_states_response(ORCH_STATES)
|
||||||
|
with patch("src.plane_sync.httpx.get") as mock_httpx, \
|
||||||
|
patch("src.webhooks.plane.handle_status_start", new_callable=AsyncMock) as mock_start, \
|
||||||
|
patch("src.webhooks.plane.handle_verdict", new_callable=AsyncMock) as mock_verdict:
|
||||||
|
mock_httpx.return_value = _fake_response(orch_states_resp)
|
||||||
|
# Send ET's in_progress UUID for an ORCH project event.
|
||||||
|
data = {
|
||||||
|
"id": "orch-issue-uuid",
|
||||||
|
"state": {"id": "b873d9eb-993c-48cd-97ac-99a9b1623967", "name": "In Progress"},
|
||||||
|
}
|
||||||
|
await handle_issue_updated(data, ORCH_PROJECT_ID)
|
||||||
|
|
||||||
|
# Since ORCH in_progress is e331bfb3, ET's b873d9eb should NOT trigger start.
|
||||||
|
mock_start.assert_not_called()
|
||||||
|
mock_verdict.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 8. _DEFAULT_STATES / PLANE_STATES alias preserved
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_plane_states_alias_is_default_states():
|
||||||
|
"""PLANE_STATES is still exported and equals _DEFAULT_STATES (backward compat)."""
|
||||||
|
import src.plane_sync as ps
|
||||||
|
assert ps.PLANE_STATES is ps._DEFAULT_STATES
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_states_et_values():
|
||||||
|
"""_DEFAULT_STATES contains the original enduro-trails UUIDs."""
|
||||||
|
import src.plane_sync as ps
|
||||||
|
for key, expected in ET_STATES.items():
|
||||||
|
assert ps._DEFAULT_STATES[key] == expected, (
|
||||||
|
f"_DEFAULT_STATES['{key}']: expected {expected}, got {ps._DEFAULT_STATES.get(key)}"
|
||||||
|
)
|
||||||
@@ -33,11 +33,36 @@ from src.main import app # noqa: E402
|
|||||||
from src.db import init_db, get_db # noqa: E402
|
from src.db import init_db, get_db # noqa: E402
|
||||||
from src import projects as P # noqa: E402
|
from src import projects as P # noqa: E402
|
||||||
from src.projects import reload_projects # noqa: E402
|
from src.projects import reload_projects # noqa: E402
|
||||||
|
import src.plane_sync as plane_sync # noqa: E402
|
||||||
|
|
||||||
ORCH_PLANE_ID = "8da6aa25-a60e-44d6-a1e2-d8ae59aa7d6a"
|
ORCH_PLANE_ID = "8da6aa25-a60e-44d6-a1e2-d8ae59aa7d6a"
|
||||||
ENDURO_PLANE_ID = "7a79f0a9-5278-49cd-9007-9a338f238f9c"
|
ENDURO_PLANE_ID = "7a79f0a9-5278-49cd-9007-9a338f238f9c"
|
||||||
UNKNOWN_PLANE_ID = "deadbeef-0000-0000-0000-000000000000"
|
UNKNOWN_PLANE_ID = "deadbeef-0000-0000-0000-000000000000"
|
||||||
|
|
||||||
|
# ORCH-39: after ORCH-10 the webhook resolves Plane state UUIDs per-project via
|
||||||
|
# get_project_states(project_id). Hardcoding the enduro in_progress UUID for an
|
||||||
|
# ORCH-project payload no longer matches, so the pipeline never starts. We mock
|
||||||
|
# get_project_states with a deterministic per-project map (no network) and send
|
||||||
|
# each request with the UUID that matches its own project.
|
||||||
|
_PROJECT_STATES = {
|
||||||
|
ENDURO_PLANE_ID: {
|
||||||
|
"in_progress": "b873d9eb-993c-48cd-97ac-99a9b1623967",
|
||||||
|
"approved": "a519a341-dada-4a91-8910-7604f82b79c5",
|
||||||
|
"rejected": "ba958f3c-5db5-461d-8f82-89425e413b97",
|
||||||
|
},
|
||||||
|
ORCH_PLANE_ID: {
|
||||||
|
"in_progress": "e331bfb3-e17e-4699-ba48-4abb89c21b7b",
|
||||||
|
"approved": "63f2c8fe-dcda-4ace-952f-dd88bd0118ff",
|
||||||
|
"rejected": "4c769e90-bf80-4a52-b97a-e1c84904bfc3",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _fake_get_project_states(project_id):
|
||||||
|
"""Deterministic per-project state map; mirrors get_project_states' fallback
|
||||||
|
for unknown projects so the webhook still behaves sensibly."""
|
||||||
|
return _PROJECT_STATES.get(project_id, _PROJECT_STATES[ENDURO_PLANE_ID])
|
||||||
|
|
||||||
client = TestClient(app)
|
client = TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
@@ -57,6 +82,13 @@ def setup(monkeypatch):
|
|||||||
# focuses on the project filter, so bypass signature verification.
|
# focuses on the project filter, so bypass signature verification.
|
||||||
monkeypatch.setattr("src.webhooks.plane.verify_plane_signature", lambda body, sig: True)
|
monkeypatch.setattr("src.webhooks.plane.verify_plane_signature", lambda body, sig: True)
|
||||||
|
|
||||||
|
# ORCH-39: resolve Plane states deterministically per-project (no network)
|
||||||
|
# and start from a clean per-project cache so suites don't leak into each
|
||||||
|
# other. plane.py imports get_project_states locally from ..plane_sync, so
|
||||||
|
# patch it at the src.plane_sync source.
|
||||||
|
plane_sync.reload_project_states()
|
||||||
|
monkeypatch.setattr(plane_sync, "get_project_states", _fake_get_project_states)
|
||||||
|
|
||||||
registry_json = (
|
registry_json = (
|
||||||
f'[{{"plane_project_id": "{ENDURO_PLANE_ID}", "repo": "enduro-trails",'
|
f'[{{"plane_project_id": "{ENDURO_PLANE_ID}", "repo": "enduro-trails",'
|
||||||
f' "work_item_prefix": "ET", "name": "enduro-trails"}},'
|
f' "work_item_prefix": "ET", "name": "enduro-trails"}},'
|
||||||
@@ -69,6 +101,7 @@ def setup(monkeypatch):
|
|||||||
yield
|
yield
|
||||||
|
|
||||||
reload_projects() # restore from env
|
reload_projects() # restore from env
|
||||||
|
plane_sync.reload_project_states()
|
||||||
if os.path.exists(_test_db):
|
if os.path.exists(_test_db):
|
||||||
os.unlink(_test_db)
|
os.unlink(_test_db)
|
||||||
|
|
||||||
@@ -76,10 +109,10 @@ def setup(monkeypatch):
|
|||||||
# Feature 1: the pipeline now starts on a status change to In Progress (not on
|
# Feature 1: the pipeline now starts on a status change to In Progress (not on
|
||||||
# creation). _post_created drives that status-change event so these ORCH-6
|
# creation). _post_created drives that status-change event so these ORCH-6
|
||||||
# routing tests still exercise task creation through the new trigger.
|
# routing tests still exercise task creation through the new trigger.
|
||||||
_IN_PROGRESS = "b873d9eb-993c-48cd-97ac-99a9b1623967"
|
# ORCH-39: the in_progress UUID is now project-specific, so derive it from the
|
||||||
|
# project being posted to (matches get_project_states resolution above).
|
||||||
|
|
||||||
def _post_created(plane_project_id, plane_id="wi-1", name="A valid work item title"):
|
def _post_created(plane_project_id, plane_id="wi-1", name="A valid work item title"):
|
||||||
|
in_progress = _fake_get_project_states(plane_project_id)["in_progress"]
|
||||||
return client.post(
|
return client.post(
|
||||||
"/webhook/plane",
|
"/webhook/plane",
|
||||||
json={
|
json={
|
||||||
@@ -90,7 +123,7 @@ def _post_created(plane_project_id, plane_id="wi-1", name="A valid work item tit
|
|||||||
"name": name,
|
"name": name,
|
||||||
"description_stripped": "This is a sufficiently long description.",
|
"description_stripped": "This is a sufficiently long description.",
|
||||||
"project": plane_project_id,
|
"project": plane_project_id,
|
||||||
"state": {"id": _IN_PROGRESS, "name": "In Progress", "group": "started"},
|
"state": {"id": in_progress, "name": "In Progress", "group": "started"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user