Files
orchestrator/scripts/onboard_project.py
claude-bot dc1cb87818 feat(onboarding): turnkey project onboarding — kit + CLI + runbook (ORCH-009)
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>
2026-06-10 16:08:43 +03:00

1091 lines
52 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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="12 фразы «зачем проект»")
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())