Operator capability to bring a NEW project online in one pass, fully
outside the runtime and the pipeline (src/** byte-exact, no kill-switch
needed — activation is an explicit human CLI run). Reference = the
orchestrator repo itself (ORCH-52b/c/d/e canons).
* onboarding/repo-skeleton/ — parametrized kit of a new repo: 6 agent
prompt templates per canon 52d/92 (5 ru + deployer en with the
shared-host guardrail frame), reviewer doc-gate (REQUEST_CHANGES),
CLAUDE.md passport, AGENTS.md, CONTRIBUTING.md, docs/ skeleton with
mandatory operations/INFRA.md, .env.example; {{NAME}} placeholders +
stdlib render, dictionary onboarding/placeholders.json (bijection
held by tests). Canon is NOT forked: docs/_templates + docs/_standards
are live-copied from the checkout at materialization time (BR-2/D3).
* scripts/onboard_project.py — plan (default, GET-only, zero mutations)
/ apply (idempotent ensure, no delete ops at all) / verify (registry
round-trip via the actual projects._parse_projects_json, all 22 state
names incl. fail-closed Confirm Deploy/STOP, labels, webhook, kit
completeness, unresolved-placeholder scan). Closed read-only src
import list (ADR D4); state groups fixed per ADR D5 (STOP→cancelled,
terminal groups only Done/Cancelled/STOP); Gitea webhook reuses the
single global ORCH_GITEA_WEBHOOK_SECRET (TR-6); initial push ONLY
into a freshly created empty repo (INV-4 untouched); never restarts
prod / never edits .env / deletes nothing (NFR-2); secrets masked
(NFR-3); Plane CE API gaps degrade to manual-step (fail-safe).
* docs/operations/ONBOARDING.md runbook + SETUP_WEBHOOKS.md generalized
per-repo; CLAUDE.md / docs/architecture/README.md / CHANGELOG.md
updated in the same PR (golden source).
* Anti-drift tests: test_onboarding_kit.py / test_onboarding_script.py
(mocked, no network) / test_onboarding_invariants.py (snapshots of
STAGE_TRANSITIONS/QG_CHECKS, closed CLI import list, reference
.openclaw/agents/ prompts untouched). Full regression: 1713 passed.
Refs: ORCH-009
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1091 lines
52 KiB
Python
1091 lines
52 KiB
Python
#!/usr/bin/env python3
|
||
"""onboard_project.py — операторский turnkey-CLI онбординга нового проекта (ORCH-009).
|
||
|
||
Разворачивает все слои подключения нового проекта к оркестратору одним проходом:
|
||
Plane-проект (22 статуса с точными каноническими именами + лейблы) → Gitea-репо
|
||
(+per-repo webhook) → материализация onboarding-kit (рендер плейсхолдеров +
|
||
live-copy канона docs/_templates|_standards) + initial push ТОЛЬКО в свежесозданный
|
||
пустой репо → merged-вывод ``ORCH_PROJECTS_JSON`` (round-trip через фактический
|
||
парсер реестра). Полный процесс — runbook ``docs/operations/ONBOARDING.md``.
|
||
|
||
Режимы (ADR-001 D11 ORCH-009):
|
||
plan — дефолт; только GET-пробы + полный план; НИ ОДНОЙ мутации (ни сети, ни диска).
|
||
apply — идемпотентный ensure: существующее → ``skipped(exists)``; delete-операций НЕТ.
|
||
verify — GET-пробы + локальные проверки (реестр, статусы, лейблы, webhook, полнота kit).
|
||
|
||
Гарантии безопасности (NFR-2/NFR-3):
|
||
* прод-контейнер оркестратора НИКОГДА не трогается (ни рестартов, ни остановок);
|
||
* ``.env`` НИКОГДА не правится (читается read-only); регистрация = операторский шаг;
|
||
* push возможен ТОЛЬКО в свежесозданный/пустой репо — в существующие никогда;
|
||
* ничего не удаляется; секреты в отчёте маскируются.
|
||
|
||
Запуск — из корня чекаута репо orchestrator (см. runbook):
|
||
python3 scripts/onboard_project.py plan --name "My Project" --repo my-project \\
|
||
--prefix MP --stack "Python 3.12 + FastAPI" --test-cmd "pytest -q" \\
|
||
--prod-port 8600 --staging-port 8601 --webhook-url https://<orch-host>/webhook/gitea
|
||
|
||
Exit-коды: 0 — чисто; 2 — есть manual-step / gap в verify; 1 — ошибка.
|
||
"""
|
||
|
||
import argparse
|
||
import dataclasses
|
||
import json
|
||
import logging
|
||
import os
|
||
import re
|
||
import secrets as _secrets
|
||
import subprocess
|
||
import sys
|
||
import tempfile
|
||
import urllib.parse
|
||
|
||
# Запуск из корня чекаута (паттерн scripts/staging_check.py): добавляем корень в
|
||
# sys.path, чтобы работали read-only импорты из src.
|
||
_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||
if _REPO_ROOT not in sys.path:
|
||
sys.path.insert(0, _REPO_ROOT)
|
||
|
||
# ── Закрытый список read-only импортов из src (ORCH-009 ADR-001 D4) ──────────
|
||
# Любой новый импорт из src — ТОЛЬКО через обновление ADR (контроль —
|
||
# tests/test_onboarding_invariants.py::test_tc21_cli_src_imports_stay_in_closed_list).
|
||
from src.config import settings # noqa: E402 # имена лейблов, URL/токены, реестр
|
||
from src.plane_sync import _PLANE_NAME_TO_KEY # noqa: E402 # точные имена 22 статусов (нулевой дрейф)
|
||
from src.projects import _parse_projects_json # noqa: E402 # round-trip валидация реестра
|
||
|
||
try: # httpx — существующая зависимость проекта; нужен только реальным клиентам
|
||
import httpx
|
||
except Exception: # pragma: no cover - тесты инжектируют фейки, httpx не обязателен
|
||
httpx = None
|
||
|
||
logger = logging.getLogger("onboard_project")
|
||
|
||
ONBOARDING_DIR = os.path.join(_REPO_ROOT, "onboarding")
|
||
SKELETON_DIR = os.path.join(ONBOARDING_DIR, "repo-skeleton")
|
||
PLACEHOLDERS_PATH = os.path.join(ONBOARDING_DIR, "placeholders.json")
|
||
RUNBOOK = "docs/operations/ONBOARDING.md"
|
||
|
||
# Live-copy канона (BR-2/D3): копируются из чекаута орка, в kit НЕ хранятся.
|
||
LIVE_COPY_DIRS = ("docs/_templates", "docs/_standards")
|
||
|
||
# Per-repo Gitea-webhook (формат docs/operations/SETUP_WEBHOOKS.md).
|
||
WEBHOOK_EVENTS = ["push", "pull_request", "status"]
|
||
|
||
# Синтаксис плейсхолдеров (D2): {{NAME}}, верхний регистр.
|
||
PLACEHOLDER_RE = re.compile(r"\{\{[A-Z][A-Z0-9_]*\}\}")
|
||
|
||
# Канонические группы статусов Plane (ADR-001 D5 ORCH-009). Код-критичные
|
||
# констрейнты: STOP — группа `cancelled` (fail-closed ветка отмены, ORCH-090);
|
||
# в терминальных группах (`completed`/`cancelled`) ТОЛЬКО Done/Cancelled/STOP —
|
||
# иначе terminal-detection (ORCH-068, {uuid→group}) ложно сочтёт живую задачу
|
||
# терминальной. Имена ключей — байт-в-байт из plane_sync._PLANE_NAME_TO_KEY.
|
||
STATE_GROUPS: dict[str, str] = {
|
||
"Backlog": "backlog",
|
||
"Todo": "unstarted",
|
||
"To Analyse": "unstarted",
|
||
"In Progress": "started",
|
||
"Analysis": "started",
|
||
"Architecture": "started",
|
||
"Development": "started",
|
||
"Code-Review": "started",
|
||
"Review": "started",
|
||
"Testing": "started",
|
||
"Awaiting Deploy": "started",
|
||
"Deploying": "started",
|
||
"Monitoring after Deploy": "started",
|
||
"Needs Input": "started",
|
||
"In Review": "started",
|
||
"Blocked": "started",
|
||
"Approved": "started",
|
||
"Confirm Deploy": "started",
|
||
"Rejected": "started", # reject = rework-петля, задача жива → НЕ cancelled
|
||
"Done": "completed",
|
||
"Cancelled": "cancelled",
|
||
"STOP": "cancelled",
|
||
}
|
||
|
||
# Статусы шагов отчёта (D11).
|
||
PLANNED = "planned"
|
||
CREATED = "created"
|
||
SKIPPED = "skipped(exists)"
|
||
MANUAL = "manual-step"
|
||
ERROR = "error"
|
||
OK = "ok"
|
||
GAP = "gap"
|
||
|
||
|
||
class ManualStep(Exception):
|
||
"""API-шаг недоступен (403/404/405/501/не реализовано в CE) → ручной пункт runbook."""
|
||
|
||
|
||
def label_names() -> list[str]:
|
||
"""Имена лейблов авто-режимов/багфикс-трека — из конфига (D4), не литералы."""
|
||
return [
|
||
settings.auto_approve_label,
|
||
settings.auto_deploy_label,
|
||
settings.bug_fast_track_label,
|
||
]
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# Отчёт
|
||
# --------------------------------------------------------------------------- #
|
||
|
||
@dataclasses.dataclass
|
||
class Step:
|
||
"""Один шаг плана/исполнения/верификации."""
|
||
|
||
id: str
|
||
description: str
|
||
status: str
|
||
detail: str = ""
|
||
|
||
|
||
@dataclasses.dataclass
|
||
class Report:
|
||
"""Итоговый отчёт прогона: шаги + операторские инструкции + exit-код."""
|
||
|
||
mode: str
|
||
steps: list = dataclasses.field(default_factory=list)
|
||
instructions: list = dataclasses.field(default_factory=list)
|
||
|
||
def add(self, step_id: str, description: str, status: str, detail: str = "") -> Step:
|
||
step = Step(step_id, description, status, detail)
|
||
self.steps.append(step)
|
||
logger.info("[%s] %s — %s%s", self.mode, step_id, status, f" ({detail})" if detail else "")
|
||
return step
|
||
|
||
@property
|
||
def exit_code(self) -> int:
|
||
statuses = {s.status for s in self.steps}
|
||
if ERROR in statuses:
|
||
return 1
|
||
if MANUAL in statuses or GAP in statuses:
|
||
return 2
|
||
return 0
|
||
|
||
def to_dict(self) -> dict:
|
||
totals = {"created": 0, "skipped": 0, "manual": 0, "planned": 0, "error": 0, "ok": 0, "gap": 0}
|
||
key_by_status = {
|
||
CREATED: "created", SKIPPED: "skipped", MANUAL: "manual",
|
||
PLANNED: "planned", ERROR: "error", OK: "ok", GAP: "gap",
|
||
}
|
||
for s in self.steps:
|
||
totals[key_by_status.get(s.status, "error")] += 1
|
||
return {
|
||
"mode": self.mode,
|
||
"steps": [dataclasses.asdict(s) for s in self.steps],
|
||
"totals": totals,
|
||
"instructions": list(self.instructions),
|
||
"exit_code": self.exit_code,
|
||
}
|
||
|
||
def render_text(self) -> str:
|
||
lines = [f"== onboarding report ({self.mode}) =="]
|
||
for s in self.steps:
|
||
lines.append(f" [{s.status:>15}] {s.id:<28} {s.description}")
|
||
if s.detail:
|
||
lines.append(f" {'':>17} ↳ {s.detail}")
|
||
t = self.to_dict()["totals"]
|
||
lines.append(
|
||
f"-- totals: created={t['created']} skipped={t['skipped']} "
|
||
f"manual={t['manual']} planned={t['planned']} ok={t['ok']} "
|
||
f"gap={t['gap']} error={t['error']}"
|
||
)
|
||
if self.instructions:
|
||
lines.append("-- операторские шаги:")
|
||
for i, instr in enumerate(self.instructions, 1):
|
||
lines.append(f" {i}. {instr}")
|
||
lines.append(f"-- exit code: {self.exit_code}")
|
||
return "\n".join(lines)
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# Чистое ядро: словарь, рендер, kit
|
||
# --------------------------------------------------------------------------- #
|
||
|
||
def load_placeholders() -> dict:
|
||
"""Словарь плейсхолдеров onboarding/placeholders.json (single source of truth)."""
|
||
with open(PLACEHOLDERS_PATH, encoding="utf-8") as f:
|
||
return json.load(f)
|
||
|
||
|
||
def render(text: str, params: dict) -> str:
|
||
"""Тупой проход str.replace по словарю (D2) — без шаблонизаторов и логики."""
|
||
for name, value in params.items():
|
||
text = text.replace("{{" + name + "}}", str(value))
|
||
return text
|
||
|
||
|
||
def find_unresolved(text: str) -> list[str]:
|
||
"""Все неразрешённые {{PLACEHOLDER}} в тексте (обязательный пост-скан, D2)."""
|
||
return sorted(set(PLACEHOLDER_RE.findall(text)))
|
||
|
||
|
||
def iter_skeleton_files() -> list[str]:
|
||
"""Относительные пути всех файлов repo-skeleton (отсортированы, детерминизм)."""
|
||
out: list[str] = []
|
||
for root, _dirs, files in os.walk(SKELETON_DIR):
|
||
for name in files:
|
||
full = os.path.join(root, name)
|
||
out.append(os.path.relpath(full, SKELETON_DIR))
|
||
return sorted(out)
|
||
|
||
|
||
def render_kit_in_memory(params: dict) -> dict:
|
||
"""Рендер всех файлов kit В ПАМЯТИ (plan-режим: ноль записей на диск, AC-8)."""
|
||
rendered: dict[str, str] = {}
|
||
for rel in iter_skeleton_files():
|
||
with open(os.path.join(SKELETON_DIR, rel), encoding="utf-8") as f:
|
||
rendered[rel.replace(os.sep, "/")] = render(f.read(), params)
|
||
return rendered
|
||
|
||
|
||
def materialize_kit(params: dict, dest_dir: str) -> list[str]:
|
||
"""Материализовать kit на диск: рендер скелета + live-copy канона (apply-only).
|
||
|
||
Возвращает список записанных относительных путей. Неразрешённые плейсхолдеры
|
||
после рендера → ValueError (PASS-условие AC-5). Существующие файлы в dest_dir
|
||
не перезаписываются (идемпотентность BR-9 при повторной материализации).
|
||
"""
|
||
written: list[str] = []
|
||
rendered = render_kit_in_memory(params)
|
||
problems = {rel: bad for rel, content in rendered.items() if (bad := find_unresolved(content))}
|
||
if problems:
|
||
raise ValueError(f"unresolved placeholders after render: {problems}")
|
||
|
||
for rel, content in rendered.items():
|
||
target = os.path.join(dest_dir, *rel.split("/"))
|
||
if os.path.exists(target):
|
||
continue # не перезаписываем существующее (BR-9)
|
||
os.makedirs(os.path.dirname(target), exist_ok=True)
|
||
with open(target, "w", encoding="utf-8") as f:
|
||
f.write(content)
|
||
written.append(rel)
|
||
|
||
# Live-copy канона байт-в-байт из чекаута орка (BR-2/D3).
|
||
for rel_dir in LIVE_COPY_DIRS:
|
||
src_dir = os.path.join(_REPO_ROOT, *rel_dir.split("/"))
|
||
if not os.path.isdir(src_dir):
|
||
raise FileNotFoundError(f"live canon dir missing in checkout: {rel_dir}")
|
||
for root, _dirs, files in os.walk(src_dir):
|
||
for name in files:
|
||
src_file = os.path.join(root, name)
|
||
rel = os.path.join(rel_dir, os.path.relpath(src_file, src_dir)).replace(os.sep, "/")
|
||
target = os.path.join(dest_dir, *rel.split("/"))
|
||
if os.path.exists(target):
|
||
continue
|
||
os.makedirs(os.path.dirname(target), exist_ok=True)
|
||
with open(src_file, encoding="utf-8") as fsrc, open(target, "w", encoding="utf-8") as fdst:
|
||
fdst.write(fsrc.read())
|
||
written.append(rel)
|
||
return written
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# Реестр (D7)
|
||
# --------------------------------------------------------------------------- #
|
||
|
||
def build_registry_entry(params: dict) -> dict:
|
||
"""Запись реестра нового проекта (контракт src/projects.py::ProjectConfig)."""
|
||
return {
|
||
"plane_project_id": str(params["PLANE_PROJECT_ID"]),
|
||
"repo": str(params["REPO"]),
|
||
"work_item_prefix": str(params["WORK_ITEM_PREFIX"]),
|
||
"name": str(params["PROJECT_NAME"]),
|
||
}
|
||
|
||
|
||
def merged_projects_json(entry: dict, existing_raw: str) -> tuple[str, str]:
|
||
"""(standalone-запись, полный merged-массив) с round-trip через фактический парсер.
|
||
|
||
D7: существующие записи verbatim + новая в конец; уже зарегистрированный проект
|
||
(тот же plane_project_id или repo) не дублируется. Несоответствие round-trip →
|
||
RuntimeError (никогда не отдаём оператору строку, которую парсер отвергнет).
|
||
"""
|
||
try:
|
||
existing = json.loads(existing_raw) if existing_raw and existing_raw.strip() else []
|
||
except (ValueError, TypeError):
|
||
existing = []
|
||
if not isinstance(existing, list):
|
||
existing = []
|
||
|
||
already = any(
|
||
isinstance(item, dict)
|
||
and (
|
||
item.get("plane_project_id") == entry["plane_project_id"]
|
||
or item.get("repo") == entry["repo"]
|
||
)
|
||
for item in existing
|
||
)
|
||
merged_list = list(existing) if already else list(existing) + [entry]
|
||
merged = json.dumps(merged_list, ensure_ascii=False)
|
||
|
||
parsed = _parse_projects_json(merged)
|
||
if parsed is None:
|
||
raise RuntimeError("merged ORCH_PROJECTS_JSON failed the registry parser round-trip")
|
||
wanted = [p for p in parsed if p.plane_project_id == entry["plane_project_id"] or p.repo == entry["repo"]]
|
||
if not already:
|
||
if not wanted:
|
||
raise RuntimeError("new registry entry lost in the round-trip")
|
||
got = wanted[0]
|
||
if (
|
||
got.repo != entry["repo"]
|
||
or got.work_item_prefix != entry["work_item_prefix"]
|
||
or got.name != entry["name"]
|
||
):
|
||
raise RuntimeError("registry round-trip distorted the new entry fields")
|
||
if len(parsed) < len([i for i in existing if isinstance(i, dict)]):
|
||
raise RuntimeError("registry round-trip lost existing entries")
|
||
|
||
return json.dumps(entry, ensure_ascii=False), merged
|
||
|
||
|
||
def read_existing_registry(env_file: str | None = None) -> str:
|
||
"""Существующие записи реестра: env (settings) > --env-file/.env (read-only)."""
|
||
raw = getattr(settings, "projects_json", "") or ""
|
||
if raw.strip():
|
||
return raw
|
||
path = env_file or os.path.join(_REPO_ROOT, ".env")
|
||
if os.path.isfile(path):
|
||
try:
|
||
with open(path, encoding="utf-8") as f: # read-only: .env НИКОГДА не пишем (NFR-2)
|
||
for line in f:
|
||
line = line.strip()
|
||
if line.startswith("ORCH_PROJECTS_JSON="):
|
||
return line.split("=", 1)[1]
|
||
except OSError as e:
|
||
logger.warning("cannot read %s: %s", path, e)
|
||
return ""
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# Тонкие клиенты — единственные точки сети (инжектируются, в тестах фейки)
|
||
# --------------------------------------------------------------------------- #
|
||
|
||
class PlaneClient:
|
||
"""Тонкий клиент Plane API (паттерн URL — src/plane_sync.py)."""
|
||
|
||
def __init__(self, base_url: str, token: str, workspace: str):
|
||
self.base = f"{base_url.rstrip('/')}/api/v1"
|
||
self.headers = {"X-API-Key": token}
|
||
self.ws = workspace
|
||
|
||
def _get(self, url: str):
|
||
resp = httpx.get(url, headers=self.headers, timeout=15)
|
||
if resp.status_code == 200:
|
||
return resp.json()
|
||
if resp.status_code in (403, 404, 405, 501):
|
||
return None
|
||
resp.raise_for_status()
|
||
return None
|
||
|
||
def _post(self, url: str, payload: dict) -> dict:
|
||
resp = httpx.post(url, headers=self.headers, json=payload, timeout=20)
|
||
if resp.status_code in (200, 201):
|
||
return resp.json()
|
||
if resp.status_code in (401, 403, 404, 405, 501):
|
||
raise ManualStep(f"Plane API refused POST ({resp.status_code}): {url}")
|
||
resp.raise_for_status()
|
||
raise ManualStep(f"Plane API unexpected status {resp.status_code}")
|
||
|
||
def get_project(self, project_id: str):
|
||
if not project_id:
|
||
return None
|
||
return self._get(f"{self.base}/workspaces/{self.ws}/projects/{project_id}/")
|
||
|
||
def find_project_by_identifier(self, identifier: str):
|
||
data = self._get(f"{self.base}/workspaces/{self.ws}/projects/")
|
||
results = data.get("results", data) if isinstance(data, dict) else data
|
||
if not isinstance(results, list):
|
||
return None
|
||
for item in results:
|
||
if isinstance(item, dict) and item.get("identifier") == identifier:
|
||
return item
|
||
return None
|
||
|
||
def list_states(self, project_id: str):
|
||
data = self._get(f"{self.base}/workspaces/{self.ws}/projects/{project_id}/states/")
|
||
if data is None:
|
||
return None
|
||
return data.get("results", data) if isinstance(data, dict) else data
|
||
|
||
def list_labels(self, project_id: str):
|
||
data = self._get(f"{self.base}/workspaces/{self.ws}/projects/{project_id}/labels/")
|
||
if data is None:
|
||
return None
|
||
return data.get("results", data) if isinstance(data, dict) else data
|
||
|
||
def create_project(self, name: str, identifier: str) -> dict:
|
||
return self._post(
|
||
f"{self.base}/workspaces/{self.ws}/projects/",
|
||
{"name": name, "identifier": identifier},
|
||
)
|
||
|
||
def create_state(self, project_id: str, name: str, group: str) -> dict:
|
||
return self._post(
|
||
f"{self.base}/workspaces/{self.ws}/projects/{project_id}/states/",
|
||
{"name": name, "group": group},
|
||
)
|
||
|
||
def create_label(self, project_id: str, name: str) -> dict:
|
||
return self._post(
|
||
f"{self.base}/workspaces/{self.ws}/projects/{project_id}/labels/",
|
||
{"name": name},
|
||
)
|
||
|
||
|
||
class GiteaClient:
|
||
"""Тонкий клиент Gitea API (паттерн URL — src/gitea.py, src/merge_gate.py)."""
|
||
|
||
def __init__(self, base_url: str, token: str):
|
||
self.base = f"{base_url.rstrip('/')}/api/v1"
|
||
self.headers = {"Authorization": f"token {token}"}
|
||
|
||
def _get(self, url: str):
|
||
resp = httpx.get(url, headers=self.headers, timeout=15)
|
||
if resp.status_code == 200:
|
||
return resp.json()
|
||
if resp.status_code == 404:
|
||
return None
|
||
if resp.status_code in (401, 403, 405, 501):
|
||
return None
|
||
resp.raise_for_status()
|
||
return None
|
||
|
||
def _post(self, url: str, payload: dict) -> dict:
|
||
resp = httpx.post(url, headers=self.headers, json=payload, timeout=20)
|
||
if resp.status_code in (200, 201):
|
||
return resp.json()
|
||
if resp.status_code in (401, 403, 404, 405, 409, 501):
|
||
raise ManualStep(f"Gitea API refused POST ({resp.status_code}): {url}")
|
||
resp.raise_for_status()
|
||
raise ManualStep(f"Gitea API unexpected status {resp.status_code}")
|
||
|
||
def get_repo(self, owner: str, repo: str):
|
||
return self._get(f"{self.base}/repos/{owner}/{repo}")
|
||
|
||
def list_hooks(self, owner: str, repo: str):
|
||
return self._get(f"{self.base}/repos/{owner}/{repo}/hooks") or []
|
||
|
||
def create_repo(self, owner: str, name: str, description: str = "") -> dict:
|
||
# auto_init=False: репо рождается ПУСТЫМ; `main` создаёт initial push (D6).
|
||
payload = {"name": name, "description": description, "auto_init": False, "private": False}
|
||
try:
|
||
return self._post(f"{self.base}/orgs/{owner}/repos", payload)
|
||
except ManualStep:
|
||
# owner — не организация → личный namespace токена / admin-маршрут.
|
||
return self._post(f"{self.base}/user/repos", payload)
|
||
|
||
def create_hook(self, owner: str, repo: str, url: str, secret: str, events: list) -> dict:
|
||
return self._post(
|
||
f"{self.base}/repos/{owner}/{repo}/hooks",
|
||
{
|
||
"type": "gitea",
|
||
"active": True,
|
||
"branch_filter": "*",
|
||
"events": list(events),
|
||
"config": {"url": url, "content_type": "json", "secret": secret},
|
||
},
|
||
)
|
||
|
||
def get_file_text(self, owner: str, repo: str, path: str):
|
||
data = self._get(
|
||
f"{self.base}/repos/{owner}/{repo}/raw/{urllib.parse.quote(path)}?ref=main"
|
||
)
|
||
return data if isinstance(data, str) else None
|
||
|
||
def list_dir(self, owner: str, repo: str, path: str):
|
||
data = self._get(
|
||
f"{self.base}/repos/{owner}/{repo}/contents/{urllib.parse.quote(path)}?ref=main"
|
||
)
|
||
if not isinstance(data, list):
|
||
return None
|
||
return sorted(item.get("name", "") for item in data if isinstance(item, dict))
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# git: initial push ТОЛЬКО в свежесозданный пустой репо (D6)
|
||
# --------------------------------------------------------------------------- #
|
||
|
||
def default_git_runner(cmd: list, cwd: str) -> int:
|
||
"""Единственная точка subprocess: git в каталоге материализации kit."""
|
||
masked = [re.sub(r"://[^@/]+@", "://***@", part) for part in cmd]
|
||
logger.info("git: %s (cwd=%s)", " ".join(masked), cwd)
|
||
return subprocess.run(cmd, cwd=cwd, check=False).returncode # noqa: S603
|
||
|
||
|
||
def _push_url(gitea_url: str, token: str, owner: str, repo: str) -> str:
|
||
parts = urllib.parse.urlsplit(gitea_url)
|
||
netloc = f"oauth2:{token}@{parts.netloc}" if token else parts.netloc
|
||
return urllib.parse.urlunsplit(
|
||
(parts.scheme, netloc, f"{parts.path.rstrip('/')}/{owner}/{repo}.git", "", "")
|
||
)
|
||
|
||
|
||
def initial_push(workdir: str, owner: str, repo: str, git_runner) -> bool:
|
||
"""git init/commit/push материализованного kit в пустой репо. True = успех."""
|
||
url = _push_url(settings.gitea_url, settings.gitea_token, owner, repo)
|
||
commands = [
|
||
["git", "init", "-q", "-b", "main"],
|
||
["git", "add", "-A"],
|
||
[
|
||
"git",
|
||
"-c", "user.email=onboarding@orchestrator.local",
|
||
"-c", "user.name=orchestrator-onboarding",
|
||
"commit", "-q", "-m", "feat: onboarding skeleton (ORCH-009 kit)",
|
||
],
|
||
["git", "remote", "add", "origin", url],
|
||
["git", "push", "-q", "-u", "origin", "main"],
|
||
]
|
||
for cmd in commands:
|
||
rc = git_runner(cmd, workdir)
|
||
if rc not in (0, None):
|
||
logger.error("git step failed (rc=%s): %s", rc, cmd[0:2])
|
||
return False
|
||
return True
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# Наблюдение текущего состояния (GET-only) и чистый план
|
||
# --------------------------------------------------------------------------- #
|
||
|
||
@dataclasses.dataclass
|
||
class Observed:
|
||
"""Снимок текущего состояния внешних систем (только GET-пробы)."""
|
||
|
||
project: dict | None = None
|
||
states: list | None = None
|
||
labels: list | None = None
|
||
repo: dict | None = None
|
||
hooks: list = dataclasses.field(default_factory=list)
|
||
|
||
|
||
def observe(params: dict, plane, gitea) -> Observed:
|
||
"""GET-пробы Plane/Gitea; ошибки чтения → None (план пометит шаг)."""
|
||
obs = Observed()
|
||
pid = str(params.get("PLANE_PROJECT_ID") or "")
|
||
try:
|
||
if pid and not pid.startswith("<"):
|
||
obs.project = plane.get_project(pid)
|
||
if obs.project is None:
|
||
obs.project = plane.find_project_by_identifier(params["WORK_ITEM_PREFIX"])
|
||
except Exception as e: # GET-проба не должна валить прогон
|
||
logger.warning("plane project probe failed: %s", e)
|
||
project_id = (obs.project or {}).get("id") or pid
|
||
if obs.project is not None and project_id:
|
||
try:
|
||
obs.states = plane.list_states(project_id)
|
||
except Exception as e:
|
||
logger.warning("plane states probe failed: %s", e)
|
||
try:
|
||
obs.labels = plane.list_labels(project_id)
|
||
except Exception as e:
|
||
logger.warning("plane labels probe failed: %s", e)
|
||
try:
|
||
obs.repo = gitea.get_repo(params["GITEA_OWNER"], params["REPO"])
|
||
if obs.repo is not None:
|
||
obs.hooks = gitea.list_hooks(params["GITEA_OWNER"], params["REPO"]) or []
|
||
except Exception as e:
|
||
logger.warning("gitea probe failed: %s", e)
|
||
return obs
|
||
|
||
|
||
def _existing_names(items, key: str = "name") -> set:
|
||
return {
|
||
str(item.get(key, "")).strip()
|
||
for item in (items or [])
|
||
if isinstance(item, dict)
|
||
}
|
||
|
||
|
||
def _hook_exists(hooks: list, webhook_url: str) -> bool:
|
||
for hook in hooks or []:
|
||
config = hook.get("config", {}) if isinstance(hook, dict) else {}
|
||
if config.get("url") == webhook_url:
|
||
return True
|
||
return False
|
||
|
||
|
||
def build_plan(params: dict, observed: Observed, webhook_url: str) -> list:
|
||
"""Чистая функция: упорядоченный план шагов закрытого списка BR-1 (без I/O)."""
|
||
steps: list[Step] = []
|
||
|
||
def add(step_id, description, status, detail=""):
|
||
steps.append(Step(step_id, description, status, detail))
|
||
|
||
# 1. Plane: проект
|
||
if observed.project is not None:
|
||
add("plane.project", f"Plane-проект «{params['PROJECT_NAME']}»", SKIPPED,
|
||
f"id={observed.project.get('id', '?')}")
|
||
else:
|
||
add("plane.project", f"Plane-проект «{params['PROJECT_NAME']}»", PLANNED,
|
||
f"identifier={params['WORK_ITEM_PREFIX']}")
|
||
|
||
# 2. Plane: 22 статуса с точными именами (источник — _PLANE_NAME_TO_KEY, D5)
|
||
existing_states = _existing_names(observed.states)
|
||
for name in _PLANE_NAME_TO_KEY: # порядок словаря = канонический порядок кода
|
||
group = STATE_GROUPS[name]
|
||
if name in existing_states:
|
||
add(f"plane.state:{name}", f"статус «{name}»", SKIPPED, f"group={group}")
|
||
else:
|
||
add(f"plane.state:{name}", f"статус «{name}»", PLANNED, f"group={group}")
|
||
|
||
# 3. Plane: лейблы (имена из конфига, D4)
|
||
existing_labels = _existing_names(observed.labels)
|
||
for label in label_names():
|
||
status = SKIPPED if label in existing_labels else PLANNED
|
||
add(f"plane.label:{label}", f"лейбл «{label}»", status)
|
||
|
||
# Заведомо ручные шаги Plane (D5): UI-only / уже существующее.
|
||
add("plane.board-order", "порядок статусов на доске (drag-and-drop)", MANUAL,
|
||
f"UI-only шаг — см. {RUNBOOK}")
|
||
add("plane.workspace-webhook", "workspace-webhook Plane", MANUAL,
|
||
f"уже существует (общий на workspace) — только проверка, см. {RUNBOOK}")
|
||
|
||
# 4. Gitea: репо
|
||
if observed.repo is not None:
|
||
add("gitea.repo", f"Gitea-репо {params['GITEA_OWNER']}/{params['REPO']}", SKIPPED,
|
||
f"empty={observed.repo.get('empty')}")
|
||
else:
|
||
add("gitea.repo", f"Gitea-репо {params['GITEA_OWNER']}/{params['REPO']}", PLANNED,
|
||
"auto_init=false (пустой; main создаст initial push)")
|
||
|
||
# 5. Gitea: per-repo webhook
|
||
if _hook_exists(observed.hooks, webhook_url):
|
||
add("gitea.webhook", "per-repo webhook", SKIPPED, f"url={webhook_url}")
|
||
else:
|
||
add("gitea.webhook", "per-repo webhook", PLANNED,
|
||
f"url={webhook_url}; events={'/'.join(WEBHOOK_EVENTS)}; "
|
||
"HMAC secret — глобальный из env, в гит не попадает")
|
||
|
||
# 6. Kit: материализация + initial push (только пустой/свежий репо, D6)
|
||
repo_exists = observed.repo is not None
|
||
repo_empty = bool((observed.repo or {}).get("empty"))
|
||
if not repo_exists or repo_empty:
|
||
add("kit.materialize", "материализация kit (рендер + live-copy канона)", PLANNED,
|
||
f"{len(iter_skeleton_files())} файлов скелета + {'/'.join(LIVE_COPY_DIRS)}")
|
||
add("kit.push", "initial push kit в свежесозданный пустой репо", PLANNED,
|
||
"git init/commit/push -> main (единственный разрешённый push)")
|
||
else:
|
||
add("kit.materialize", "материализация kit", MANUAL,
|
||
f"репо непустой — kit-файлы НИКОГДА не пушатся поверх существующего контента; см. {RUNBOOK}")
|
||
add("kit.push", "initial push kit", MANUAL,
|
||
f"репо непустой — push запрещён (BR-9); см. {RUNBOOK}")
|
||
|
||
# 7. Реестр: merged-вывод (применение env + рестарт — операторский шаг, D7)
|
||
add("registry.emit", "запись реестра ORCH_PROJECTS_JSON (merged-вывод)", PLANNED,
|
||
"применение строки в .env + управляемый рестарт — ОПЕРАТОРСКИЙ шаг")
|
||
return steps
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# Режимы
|
||
# --------------------------------------------------------------------------- #
|
||
|
||
def _registry_instructions(report: Report, params: dict, env_file: str | None) -> None:
|
||
"""Standalone + merged вывод реестра в инструкции отчёта (D7)."""
|
||
try:
|
||
entry = build_registry_entry(params)
|
||
standalone, merged = merged_projects_json(entry, read_existing_registry(env_file))
|
||
report.instructions.append(
|
||
"Добавь/обнови строку в .env оркестратора (полный merged-массив, атомарно): "
|
||
f"ORCH_PROJECTS_JSON={merged}"
|
||
)
|
||
report.instructions.append(f"Standalone-запись нового проекта (справочно): {standalone}")
|
||
report.instructions.append(
|
||
"После правки .env выполни УПРАВЛЯЕМЫЙ рестарт оркестратора (операторский шаг, "
|
||
f"групповое окно для всех проектов) — см. {RUNBOOK}."
|
||
)
|
||
except (RuntimeError, KeyError, ValueError) as e:
|
||
report.add("registry.emit", "запись реестра", ERROR, str(e))
|
||
|
||
|
||
def run_plan(params: dict, plane, gitea, webhook_url: str, webhook_secret: str | None = None) -> Report:
|
||
"""Режим plan: GET-пробы + полный план; НИ ОДНОЙ мутации (сеть/диск) — AC-8."""
|
||
report = Report(mode="plan")
|
||
observed = observe(params, plane, gitea)
|
||
for step in build_plan(params, observed, webhook_url):
|
||
report.steps.append(step)
|
||
# Рендер-проверка строго в памяти: ловим неразрешённые плейсхолдеры ДО apply.
|
||
rendered = render_kit_in_memory(params)
|
||
problems = {rel: bad for rel, content in rendered.items() if (bad := find_unresolved(content))}
|
||
if problems:
|
||
report.add("kit.render-check", "скан неразрешённых плейсхолдеров", ERROR, str(problems))
|
||
else:
|
||
report.add("kit.render-check", "скан неразрешённых плейсхолдеров", OK,
|
||
f"{len(rendered)} файлов отрендерено в памяти, чисто")
|
||
_registry_instructions(report, params, None)
|
||
if webhook_secret is None and not settings.gitea_webhook_secret:
|
||
report.instructions.append(
|
||
"ORCH_GITEA_WEBHOOK_SECRET отсутствует в env — apply сгенерирует и выведет его "
|
||
"для .env (секрет в гит не попадает)."
|
||
)
|
||
return report
|
||
|
||
|
||
def run_apply(
|
||
params: dict,
|
||
plane,
|
||
gitea,
|
||
webhook_url: str,
|
||
git_runner=default_git_runner,
|
||
workdir: str | None = None,
|
||
webhook_secret: str | None = None,
|
||
env_file: str | None = None,
|
||
) -> Report:
|
||
"""Режим apply: идемпотентный ensure (BR-9). Существующее → skipped; delete нет."""
|
||
report = Report(mode="apply")
|
||
observed = observe(params, plane, gitea)
|
||
|
||
# 1. Plane: проект
|
||
project = observed.project
|
||
if project is not None:
|
||
report.add("plane.project", f"Plane-проект «{params['PROJECT_NAME']}»", SKIPPED,
|
||
f"id={project.get('id', '?')}")
|
||
else:
|
||
try:
|
||
project = plane.create_project(params["PROJECT_NAME"], params["WORK_ITEM_PREFIX"])
|
||
report.add("plane.project", f"Plane-проект «{params['PROJECT_NAME']}»", CREATED,
|
||
f"id={project.get('id', '?')}")
|
||
except ManualStep as e:
|
||
report.add("plane.project", f"Plane-проект «{params['PROJECT_NAME']}»", MANUAL,
|
||
f"{e}; создай проект в UI и перезапусти apply с --plane-project-id; см. {RUNBOOK}")
|
||
except Exception as e:
|
||
report.add("plane.project", "Plane-проект", ERROR, str(e))
|
||
|
||
project_id = (project or {}).get("id") or str(params.get("PLANE_PROJECT_ID") or "")
|
||
if project_id and not str(project_id).startswith("<"):
|
||
params = dict(params, PLANE_PROJECT_ID=str(project_id))
|
||
|
||
# 2. Plane: статусы (точные имена + группы, D5)
|
||
existing_states = _existing_names(observed.states)
|
||
if project is None:
|
||
for name in _PLANE_NAME_TO_KEY:
|
||
report.add(f"plane.state:{name}", f"статус «{name}»", MANUAL,
|
||
f"нет проекта — создай статусы вручную по таблице runbook; см. {RUNBOOK}")
|
||
else:
|
||
for name in _PLANE_NAME_TO_KEY:
|
||
group = STATE_GROUPS[name]
|
||
if name in existing_states:
|
||
report.add(f"plane.state:{name}", f"статус «{name}»", SKIPPED, f"group={group}")
|
||
continue
|
||
try:
|
||
plane.create_state(project_id, name, group)
|
||
report.add(f"plane.state:{name}", f"статус «{name}»", CREATED, f"group={group}")
|
||
except ManualStep as e:
|
||
report.add(f"plane.state:{name}", f"статус «{name}»", MANUAL,
|
||
f"{e}; создай вручную (group={group}); см. {RUNBOOK}")
|
||
except Exception as e:
|
||
report.add(f"plane.state:{name}", f"статус «{name}»", ERROR, str(e))
|
||
|
||
# 3. Plane: лейблы
|
||
existing_labels = _existing_names(observed.labels)
|
||
if project is None:
|
||
for label in label_names():
|
||
report.add(f"plane.label:{label}", f"лейбл «{label}»", MANUAL,
|
||
f"нет проекта — создай лейбл вручную; см. {RUNBOOK}")
|
||
else:
|
||
for label in label_names():
|
||
if label in existing_labels:
|
||
report.add(f"plane.label:{label}", f"лейбл «{label}»", SKIPPED)
|
||
continue
|
||
try:
|
||
plane.create_label(project_id, label)
|
||
report.add(f"plane.label:{label}", f"лейбл «{label}»", CREATED)
|
||
except ManualStep as e:
|
||
report.add(f"plane.label:{label}", f"лейбл «{label}»", MANUAL,
|
||
f"{e}; создай вручную; см. {RUNBOOK}")
|
||
except Exception as e:
|
||
report.add(f"plane.label:{label}", f"лейбл «{label}»", ERROR, str(e))
|
||
|
||
# Заведомо ручные шаги Plane (D5).
|
||
report.add("plane.board-order", "порядок статусов на доске (drag-and-drop)", MANUAL,
|
||
f"UI-only шаг — см. {RUNBOOK}")
|
||
report.add("plane.workspace-webhook", "workspace-webhook Plane", MANUAL,
|
||
f"уже существует (общий на workspace) — только проверка, см. {RUNBOOK}")
|
||
|
||
# 4. Gitea: репо
|
||
repo = observed.repo
|
||
freshly_created = False
|
||
if repo is not None:
|
||
report.add("gitea.repo", f"Gitea-репо {params['GITEA_OWNER']}/{params['REPO']}", SKIPPED,
|
||
f"empty={repo.get('empty')}")
|
||
else:
|
||
try:
|
||
repo = gitea.create_repo(params["GITEA_OWNER"], params["REPO"],
|
||
str(params.get("PROJECT_DESCRIPTION", "")))
|
||
freshly_created = True
|
||
report.add("gitea.repo", f"Gitea-репо {params['GITEA_OWNER']}/{params['REPO']}",
|
||
CREATED, "auto_init=false (пустой)")
|
||
except ManualStep as e:
|
||
report.add("gitea.repo", "Gitea-репо", MANUAL, f"{e}; см. {RUNBOOK}")
|
||
except Exception as e:
|
||
report.add("gitea.repo", "Gitea-репо", ERROR, str(e))
|
||
|
||
# 5. Gitea: webhook (секрет переиспользуется из env — D6; в отчёте маскируется)
|
||
secret = webhook_secret if webhook_secret is not None else settings.gitea_webhook_secret
|
||
generated = False
|
||
if not secret:
|
||
secret = _secrets.token_hex(20)
|
||
generated = True
|
||
if repo is None:
|
||
report.add("gitea.webhook", "per-repo webhook", MANUAL,
|
||
f"нет репо — создай webhook вручную; см. {RUNBOOK}")
|
||
elif _hook_exists(observed.hooks, webhook_url):
|
||
report.add("gitea.webhook", "per-repo webhook", SKIPPED, f"url={webhook_url}")
|
||
else:
|
||
try:
|
||
gitea.create_hook(params["GITEA_OWNER"], params["REPO"], webhook_url, secret,
|
||
WEBHOOK_EVENTS)
|
||
report.add("gitea.webhook", "per-repo webhook", CREATED,
|
||
f"url={webhook_url}; events={'/'.join(WEBHOOK_EVENTS)}; secret=***")
|
||
except ManualStep as e:
|
||
report.add("gitea.webhook", "per-repo webhook", MANUAL, f"{e}; см. {RUNBOOK}")
|
||
except Exception as e:
|
||
report.add("gitea.webhook", "per-repo webhook", ERROR, str(e))
|
||
if generated:
|
||
report.instructions.append(
|
||
"Сгенерирован новый HMAC-секрет webhook — добавь в .env оркестратора строку: "
|
||
f"ORCH_GITEA_WEBHOOK_SECRET={secret} (в гит НЕ коммитить)."
|
||
)
|
||
|
||
# 6. Kit: материализация + initial push ТОЛЬКО в пустой/свежесозданный репо (D6)
|
||
repo_empty = freshly_created or bool((repo or {}).get("empty"))
|
||
if repo is not None and repo_empty:
|
||
try:
|
||
dest = workdir or tempfile.mkdtemp(prefix="onboard-kit-")
|
||
written = materialize_kit(params, dest)
|
||
report.add("kit.materialize", "материализация kit", CREATED,
|
||
f"{len(written)} файлов -> {dest}")
|
||
if initial_push(dest, params["GITEA_OWNER"], params["REPO"], git_runner):
|
||
report.add("kit.push", "initial push kit в пустой репо", CREATED,
|
||
"git push origin main (единственный разрешённый push)")
|
||
else:
|
||
report.add("kit.push", "initial push kit", ERROR,
|
||
f"git-команда вернула ненулевой код; материализованный kit остался в {dest}")
|
||
except (ValueError, FileNotFoundError) as e:
|
||
report.add("kit.materialize", "материализация kit", ERROR, str(e))
|
||
elif repo is not None:
|
||
report.add("kit.materialize", "материализация kit", MANUAL,
|
||
f"репо непустой — kit поверх существующего контента не пушится (BR-9); см. {RUNBOOK}")
|
||
report.add("kit.push", "initial push kit", MANUAL,
|
||
f"репо непустой — push запрещён (BR-9); см. {RUNBOOK}")
|
||
else:
|
||
report.add("kit.materialize", "материализация kit", MANUAL,
|
||
f"нет репо — повтори apply после создания репо; см. {RUNBOOK}")
|
||
report.add("kit.push", "initial push kit", MANUAL, f"нет репо; см. {RUNBOOK}")
|
||
|
||
# 7. Реестр (вывод инструкций; .env скрипт НЕ правит — NFR-2)
|
||
report.add("registry.emit", "запись реестра ORCH_PROJECTS_JSON", CREATED,
|
||
"merged-массив выведен в инструкции; применение env + рестарт — операторский шаг")
|
||
_registry_instructions(report, params, env_file)
|
||
return report
|
||
|
||
|
||
# Полнота kit в verify: ключевые файлы, обязанные лежать в репо (FR-5).
|
||
VERIFY_KIT_FILES = (
|
||
".openclaw/agents/analyst.md",
|
||
".openclaw/agents/architect.md",
|
||
".openclaw/agents/developer.md",
|
||
".openclaw/agents/reviewer.md",
|
||
".openclaw/agents/tester.md",
|
||
".openclaw/agents/deployer.md",
|
||
"CLAUDE.md",
|
||
"AGENTS.md",
|
||
"CONTRIBUTING.md",
|
||
"README.md",
|
||
"CHANGELOG.md",
|
||
".env.example",
|
||
"docs/operations/INFRA.md",
|
||
)
|
||
|
||
|
||
def run_verify(
|
||
params: dict,
|
||
plane,
|
||
gitea,
|
||
webhook_url: str,
|
||
projects_raw: str | None = None,
|
||
env_file: str | None = None,
|
||
) -> Report:
|
||
"""Режим verify: GET-пробы + локальные проверки (FR-5). Ничего не мутирует."""
|
||
report = Report(mode="verify")
|
||
observed = observe(params, plane, gitea)
|
||
|
||
# 1. Реестр: round-trip фактическим парсером + наличие записи проекта.
|
||
raw = projects_raw if projects_raw is not None else read_existing_registry(env_file)
|
||
parsed = _parse_projects_json(raw or "")
|
||
entry = None
|
||
for p in parsed or []:
|
||
if p.repo == params["REPO"] or p.plane_project_id == str(params.get("PLANE_PROJECT_ID")):
|
||
entry = p
|
||
break
|
||
if entry is None:
|
||
report.add("verify.registry", "запись реестра ORCH_PROJECTS_JSON", GAP,
|
||
"проект НЕ зарегистрирован (создано, но не зарегистрировано — см. runbook: "
|
||
"env + управляемый рестарт)")
|
||
elif (
|
||
entry.work_item_prefix != params["WORK_ITEM_PREFIX"]
|
||
or entry.repo != params["REPO"]
|
||
):
|
||
report.add("verify.registry", "запись реестра", GAP,
|
||
f"поля записи расходятся с параметрами: {entry}")
|
||
else:
|
||
report.add("verify.registry", "запись реестра ORCH_PROJECTS_JSON", OK,
|
||
f"prefix={entry.work_item_prefix}, repo={entry.repo}")
|
||
|
||
# 2. Статусы: все 22 канонических имени (включая fail-closed Confirm Deploy/STOP).
|
||
if observed.project is None or observed.states is None:
|
||
report.add("verify.plane.states", "статусы Plane-проекта", GAP,
|
||
"проект/статусы не читаются через API")
|
||
else:
|
||
existing = _existing_names(observed.states)
|
||
missing = [name for name in _PLANE_NAME_TO_KEY if name not in existing]
|
||
if missing:
|
||
report.add("verify.plane.states", "статусы Plane-проекта", GAP,
|
||
f"отсутствуют: {', '.join(missing)}")
|
||
else:
|
||
report.add("verify.plane.states", "статусы Plane-проекта", OK,
|
||
f"все {len(_PLANE_NAME_TO_KEY)} канонических имени на месте")
|
||
|
||
# 3. Лейблы.
|
||
if observed.labels is None:
|
||
report.add("verify.plane.labels", "лейблы Plane-проекта", GAP, "лейблы не читаются")
|
||
else:
|
||
existing = _existing_names(observed.labels)
|
||
missing = [label for label in label_names() if label not in existing]
|
||
if missing:
|
||
report.add("verify.plane.labels", "лейблы Plane-проекта", GAP,
|
||
f"отсутствуют: {', '.join(missing)}")
|
||
else:
|
||
report.add("verify.plane.labels", "лейблы Plane-проекта", OK,
|
||
", ".join(label_names()))
|
||
|
||
# 4. Gitea-webhook.
|
||
if observed.repo is None:
|
||
report.add("verify.gitea.repo", "Gitea-репо", GAP, "репо не найден")
|
||
report.add("verify.gitea.webhook", "per-repo webhook", GAP, "репо не найден")
|
||
else:
|
||
report.add("verify.gitea.repo", "Gitea-репо", OK,
|
||
f"{params['GITEA_OWNER']}/{params['REPO']}")
|
||
active_hook = any(
|
||
isinstance(h, dict)
|
||
and h.get("config", {}).get("url") == webhook_url
|
||
and h.get("active", False)
|
||
for h in observed.hooks
|
||
)
|
||
if active_hook:
|
||
report.add("verify.gitea.webhook", "per-repo webhook", OK,
|
||
f"url={webhook_url}, active")
|
||
else:
|
||
report.add("verify.gitea.webhook", "per-repo webhook", GAP,
|
||
f"активный webhook с url={webhook_url} не найден")
|
||
|
||
# 5. Полнота kit в репо + скан неразрешённых плейсхолдеров.
|
||
if observed.repo is not None:
|
||
missing_files = []
|
||
unresolved: dict[str, list] = {}
|
||
for rel in VERIFY_KIT_FILES:
|
||
content = gitea.get_file_text(params["GITEA_OWNER"], params["REPO"], rel)
|
||
if content is None:
|
||
missing_files.append(rel)
|
||
continue
|
||
bad = find_unresolved(content)
|
||
if bad:
|
||
unresolved[rel] = bad
|
||
if missing_files:
|
||
report.add("verify.kit.files", "kit-файлы в репо", GAP,
|
||
f"отсутствуют: {', '.join(missing_files)}")
|
||
elif unresolved:
|
||
report.add("verify.kit.files", "kit-файлы в репо", GAP,
|
||
f"неразрешённые плейсхолдеры: {unresolved}")
|
||
else:
|
||
report.add("verify.kit.files", "kit-файлы в репо", OK,
|
||
f"{len(VERIFY_KIT_FILES)} ключевых файлов на месте, плейсхолдеров нет")
|
||
|
||
templates = gitea.list_dir(params["GITEA_OWNER"], params["REPO"], "docs/_templates") or []
|
||
standards = gitea.list_dir(params["GITEA_OWNER"], params["REPO"], "docs/_standards") or []
|
||
if len(templates) >= 16 and len(standards) >= 3:
|
||
report.add("verify.kit.canon", "live-copy канона в репо", OK,
|
||
f"_templates={len(templates)}, _standards={len(standards)}")
|
||
else:
|
||
report.add("verify.kit.canon", "live-copy канона в репо", GAP,
|
||
f"_templates={len(templates)} (>=16?), _standards={len(standards)} (>=3?)")
|
||
|
||
# 6. Workspace-webhook Plane: только команда проверки (Ф-6 — существует, не создаём).
|
||
report.add("verify.plane.workspace-webhook", "workspace-webhook Plane", MANUAL,
|
||
f"проверь вручную командой из {RUNBOOK} (создан на уровне workspace)")
|
||
return report
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# CLI
|
||
# --------------------------------------------------------------------------- #
|
||
|
||
def build_params(args: argparse.Namespace) -> dict:
|
||
"""Параметры рендера из аргументов CLI (ключи — словарь placeholders.json)."""
|
||
return {
|
||
"PROJECT_NAME": args.name,
|
||
"PROJECT_DESCRIPTION": args.description,
|
||
"REPO": args.repo,
|
||
"GITEA_OWNER": args.gitea_owner,
|
||
"WORK_ITEM_PREFIX": args.prefix,
|
||
"PLANE_PROJECT_ID": args.plane_project_id or "<assigned-on-apply>",
|
||
"STACK": args.stack,
|
||
"TEST_CMD": args.test_cmd,
|
||
"PROD_PORT": str(args.prod_port),
|
||
"STAGING_PORT": str(args.staging_port),
|
||
}
|
||
|
||
|
||
def main(argv: list | None = None) -> int:
|
||
parser = argparse.ArgumentParser(
|
||
description="Turnkey-онбординг нового проекта в оркестратор (ORCH-009). "
|
||
f"Полный процесс — {RUNBOOK}.",
|
||
)
|
||
parser.add_argument("mode", nargs="?", default="plan", choices=("plan", "apply", "verify"),
|
||
help="режим: plan (дефолт, dry-run) / apply / verify")
|
||
parser.add_argument("--name", required=True, help="человекочитаемое имя проекта")
|
||
parser.add_argument("--description", default="", help="1–2 фразы «зачем проект»")
|
||
parser.add_argument("--repo", required=True, help="имя Gitea-репо")
|
||
parser.add_argument("--gitea-owner", default=getattr(settings, "gitea_owner", "admin"),
|
||
help="owner/org репо в Gitea")
|
||
parser.add_argument("--prefix", required=True, help="префикс work-item (identifier Plane)")
|
||
parser.add_argument("--plane-project-id", default="",
|
||
help="UUID существующего Plane-проекта (если уже создан)")
|
||
parser.add_argument("--stack", required=True, help="стек проекта (описательно)")
|
||
parser.add_argument("--test-cmd", required=True, help="команда тестов проекта")
|
||
parser.add_argument("--prod-port", required=True, help="порт прод-контура проекта")
|
||
parser.add_argument("--staging-port", required=True, help="порт staging-контура проекта")
|
||
parser.add_argument("--webhook-url", required=True,
|
||
help="внешний URL приёмника Gitea-webhook оркестратора "
|
||
"(https://<host>/webhook/gitea)")
|
||
parser.add_argument("--env-file", default=None,
|
||
help="файл .env с текущим ORCH_PROJECTS_JSON (read-only; дефолт .env корня)")
|
||
parser.add_argument("--json", action="store_true", help="печатать отчёт в JSON")
|
||
args = parser.parse_args(argv)
|
||
|
||
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
|
||
params = build_params(args)
|
||
|
||
plane = PlaneClient(settings.plane_api_url, settings.plane_api_token,
|
||
settings.plane_workspace_slug)
|
||
gitea = GiteaClient(settings.gitea_url, settings.gitea_token)
|
||
|
||
if args.mode == "plan":
|
||
report = run_plan(params, plane, gitea, webhook_url=args.webhook_url)
|
||
elif args.mode == "apply":
|
||
report = run_apply(params, plane, gitea, webhook_url=args.webhook_url,
|
||
env_file=args.env_file)
|
||
else:
|
||
report = run_verify(params, plane, gitea, webhook_url=args.webhook_url,
|
||
env_file=args.env_file)
|
||
|
||
print(json.dumps(report.to_dict(), ensure_ascii=False, indent=2) if args.json
|
||
else report.render_text())
|
||
return report.exit_code
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|