diff --git a/.console/log.md b/.console/log.md index cff2737d5..cc339ad82 100644 --- a/.console/log.md +++ b/.console/log.md @@ -1,3 +1,59 @@ +## 2026-08-19 — council: a health probe must not be able to throw + +The Forgejo row I added to `dependency_check` called `response.json()` +unguarded. A 200 carrying non-JSON — a reverse-proxy error page, a login +interstitial — would raise out of a function whose entire job is to *report* +health, taking the whole dependency report down over one row. The Plane probe +it replaced never parsed a body, so I introduced the failure mode while +replacing something that did not have it. + +Guarded, and it returns unhealthy rather than healthy: something answering on +that URL that is not the API means the fleet has no board. Five tests cover the +probe, including the non-JSON path. + +## 2026-08-19 — stale custodian exclusion caught by CI, not by me + +The Plane deletion PR went red on `custodian-doctor --strict`: +`audit.exclude_paths.D11: glob 'src/operations_center/adapters/plane/**' +matches no files (stale exclusion?)`. Correct — and exactly the residue the +deletion should have swept. + +Local/CI gap worth remembering: the pinned custodian in `.venv` reports that as +a WARN and exits 0; CI installs `.[dev]` fresh and its `--strict` treats the +same warning as fatal. This is the one gate where running the exact CI command +locally still produced a green CI would not give. + +## 2026-08-19 — the Plane adapter is deleted + +Point 3 of the migration. `adapters/plane` (382 lines) and its 1,068 lines of +tests are gone; `PlaneSettings`, `Settings.plane` and `plane_token()` with +them. `board_backend` narrows to `Literal["forgejo"]`, and a config still +naming the retired backend gets an explanation rather than "Input should be +'forgejo'", which would read as a typo. + +`dependency_check` traded its Plane service row for a Forgejo one — the board +is the one service whose absence stops everything, so a dependency report +without it would be blind where it matters most. `--create-plane-tasks` becomes +`--create-board-tasks`; it always went through `make_board_client` and was +never Plane-specific, only Plane-named. + +The board-seam ratchet is retargeted rather than retired: the reason a caller +must not name a concrete client never depended on which client it was, so it +now guards `ForgejoClient`, with the setup wizard as the single allowlisted +direct constructor. + +Fallout worth recording: 30 unit tests broke, all fixtures describing a +Plane-shaped settings object. Fixing them exposed a real gap — `settings.forgejo` +raised AttributeError on a stub lacking the attribute instead of the explained +"no `forgejo:` settings block" error sitting right below it. Both factory paths +use `getattr` now. + +Measurement note, fourth instance this session: a bare `python -c` from a +worktree resolves `operations_center` through the editable install to the MAIN +checkout, so my first check of the new validator reported "no error" against +code that did not have it. pytest is fine (pyproject sets `pythonpath`); bare +python needs PYTHONPATH. + ## 2026-08-19 — council round 2: unset is not the same as misconfigured #520 again, and the reviewer was right again. `egress_proxy_hostport` returned diff --git a/.custodian/config.yaml b/.custodian/config.yaml index 199dc56fc..71491baf0 100644 --- a/.custodian/config.yaml +++ b/.custodian/config.yaml @@ -467,7 +467,6 @@ audit: # PR-quality + reporting adapters that wrap GitHub APIs share patterns. - src/operations_center/adapters/pr_quality.py - "src/operations_center/adapters/reporting/**" - - "src/operations_center/adapters/plane/**" # Spec-director phase orchestrators are a CRUD family by design. - "src/operations_center/spec_author/**" # Routing rules / decision rules / proposer / planning / lifecycle — diff --git a/scripts/operations-center.sh b/scripts/operations-center.sh index 45b378bf4..a6db673f9 100755 --- a/scripts/operations-center.sh +++ b/scripts/operations-center.sh @@ -216,7 +216,7 @@ Usage: scripts/operations-center.sh watch --role review scripts/operations-center.sh watch-stop --role goal scripts/operations-center.sh run --task-id TASK-123 - scripts/operations-center.sh dependency-check [--create-plane-tasks] + scripts/operations-center.sh dependency-check [--create-board-tasks] scripts/operations-center.sh janitor scripts/operations-center.sh dev-up scripts/operations-center.sh dev-down diff --git a/src/operations_center/adapters/board/__init__.py b/src/operations_center/adapters/board/__init__.py index ae93607ff..bc417990b 100644 --- a/src/operations_center/adapters/board/__init__.py +++ b/src/operations_center/adapters/board/__init__.py @@ -2,12 +2,16 @@ # Copyright (C) 2026 ProtocolWarden """The board seam: what the fleet needs from a task board, and one place to build it. -OC's board is Plane today and will not be. Replacing it is currently a 37-file -change, not because the surface is large — it is eleven operations — but because -every one of those files imports `PlaneClient` by name, constructs it from the -same four settings fields, and type-hints against the concrete class. Ten of them -have independently hand-rolled the identical `_make_plane_client()` helper, which -is the clearest possible evidence that the missing piece is a shared one. +OC's board was Plane, and replacing it was a 37-file change — not because the +surface is large (it is eleven operations) but because every one of those files +imported `PlaneClient` by name, constructed it from the same four settings +fields, and type-hinted against the concrete class. Ten had independently +hand-rolled the identical `_make_plane_client()` helper, which was the clearest +possible evidence that the missing piece was a shared one. + +The migration finished on 2026-08-18 and the Plane adapter is gone. What remains +is the property that made it finishable: callers name this module, not a +backend. This module is that piece: @@ -21,7 +25,7 @@ migration should be boring and reviewable, and any behaviour change should be its own commit. -Nothing outside ``adapters/`` should import `PlaneClient` directly. +Nothing outside ``adapters/`` should import a concrete client directly. ``tests/unit/adapters/test_board_seam.py`` enforces that against a shrinking allowlist, so the boundary tightens instead of eroding. """ @@ -92,7 +96,7 @@ def make_board_client(settings: Any) -> BoardClient: """Build the configured board client. The one place that names a concrete backend. Every caller that used to - construct `PlaneClient` from `settings.plane.*` calls this instead, so + construct a concrete client from its settings block calls this instead, so pointing the fleet at a different board is a change here and nowhere else. Kept byte-compatible with the ten hand-rolled `_make_plane_client()` helpers @@ -100,40 +104,24 @@ def make_board_client(settings: Any) -> BoardClient: change behaviour. """ backend = _backend_name(settings) + _reject_retired(backend) - if backend == "forgejo": - from operations_center.adapters.forgejo import ForgejoClient - - cfg = settings.forgejo - if cfg is None: - raise RuntimeError( - "board_backend is 'forgejo' but no `forgejo:` settings block is " - "configured — refusing to fall back to Plane, because a silent " - "fallback would point the fleet at the board it is migrating off" - ) - return ForgejoClient( - base_url=cfg.base_url, - api_token=settings.forgejo_token(), - owner=cfg.owner, - repo=cfg.repo, - ) - - if backend != "plane": - raise RuntimeError(f"unknown board_backend {backend!r} (plane, forgejo)") + if backend != "forgejo": + raise RuntimeError(f"unknown board_backend {backend!r} (forgejo)") - from operations_center.adapters.plane import PlaneClient + from operations_center.adapters.forgejo import ForgejoClient - board = settings.plane - if board is None: + cfg = getattr(settings, "forgejo", None) + if cfg is None: raise RuntimeError( - "board_backend is 'plane' but no `plane:` settings block is " + "board_backend is 'forgejo' but no `forgejo:` settings block is " "configured — the fleet has no board to talk to" ) - return PlaneClient( - base_url=board.base_url, - api_token=settings.plane_token(), - workspace_slug=board.workspace_slug, - project_id=board.project_id, + return ForgejoClient( + base_url=cfg.base_url, + api_token=settings.forgejo_token(), + owner=cfg.owner, + repo=cfg.repo, ) @@ -147,8 +135,23 @@ def _backend_name(settings: Any) -> str: validates this field as a str, so a non-string here means "nothing configured this", not "someone chose backend 42". """ - backend = getattr(settings, "board_backend", "plane") - return backend if isinstance(backend, str) else "plane" + backend = getattr(settings, "board_backend", "forgejo") + return backend if isinstance(backend, str) else "forgejo" + + +def _reject_retired(backend: str) -> None: + """Answer an old config honestly. + + "unknown board_backend 'plane'" reads as a typo. Plane was a real backend + until the 2026-08-18 cutover, so an operator whose config still says it is + asking a reasonable question and deserves the actual answer. + """ + if backend == "plane": + raise RuntimeError( + "board_backend 'plane' was removed — the Plane adapter is gone as of " + "the 2026-08-18 Forgejo cutover. Set `board_backend: forgejo` and a " + "`forgejo:` block (see config/operations_center.example.yaml)." + ) def board_project_id(settings: Any) -> str: @@ -165,23 +168,15 @@ def board_project_id(settings: Any) -> str: `owner/repo`. """ backend = _backend_name(settings) + _reject_retired(backend) - if backend == "forgejo": - cfg = settings.forgejo - if cfg is None: - raise RuntimeError( - "board_backend is 'forgejo' but no `forgejo:` settings block is " - "configured — the fleet has no board to talk to" - ) - return f"{cfg.owner}/{cfg.repo}" - - if backend != "plane": - raise RuntimeError(f"unknown board_backend {backend!r} (plane, forgejo)") + if backend != "forgejo": + raise RuntimeError(f"unknown board_backend {backend!r} (forgejo)") - board = settings.plane - if board is None: + cfg = getattr(settings, "forgejo", None) + if cfg is None: raise RuntimeError( - "board_backend is 'plane' but no `plane:` settings block is " + "board_backend is 'forgejo' but no `forgejo:` settings block is " "configured — the fleet has no board to talk to" ) - return board.project_id + return f"{cfg.owner}/{cfg.repo}" diff --git a/src/operations_center/adapters/plane/__init__.py b/src/operations_center/adapters/plane/__init__.py deleted file mode 100644 index 6b58553e3..000000000 --- a/src/operations_center/adapters/plane/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-or-later -# Copyright (C) 2026 ProtocolWarden -from operations_center.adapters.plane.client import PlaneClient - -__all__ = ["PlaneClient"] diff --git a/src/operations_center/adapters/plane/client.py b/src/operations_center/adapters/plane/client.py deleted file mode 100644 index eafda010b..000000000 --- a/src/operations_center/adapters/plane/client.py +++ /dev/null @@ -1,377 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-or-later -# Copyright (C) 2026 ProtocolWarden -from __future__ import annotations - -import html -import re -import time -from datetime import UTC, datetime -from typing import Any, cast - -import httpx - -from operations_center.application.task_parser import TaskParser -from operations_center.domain.models import BoardTask - - -class PlaneClient: - def __init__(self, base_url: str, api_token: str, workspace_slug: str, project_id: str) -> None: - self.base_url = base_url.rstrip("/") - self.workspace_slug = workspace_slug - self.project_id = project_id - self.task_parser = TaskParser() - self._states_cache: list[dict[str, Any]] | None = None - self._labels_cache: list[dict[str, Any]] | None = None - self._client = httpx.Client( - base_url=self.base_url, - headers={"X-API-Key": api_token, "Content-Type": "application/json"}, - timeout=30.0, - ) - - def close(self) -> None: - self._client.close() - - def fetch_issue(self, task_id: str) -> dict[str, Any]: - url = f"/api/v1/workspaces/{self.workspace_slug}/projects/{self.project_id}/work-items/{task_id}/" - response = self._request("GET", url, params={"expand": "state"}) - response.raise_for_status() - payload = response.json() - if isinstance(payload, dict): - return self._hydrate_issue_labels(payload) - return payload - - def fetch_project(self) -> dict[str, Any]: - url = f"/api/v1/workspaces/{self.workspace_slug}/projects/{self.project_id}/" - response = self._request("GET", url) - response.raise_for_status() - return response.json() - - def list_issues(self) -> list[dict[str, Any]]: - url = f"/api/v1/workspaces/{self.workspace_slug}/projects/{self.project_id}/work-items/" - response = self._request("GET", url, params={"expand": "state"}) - response.raise_for_status() - payload = response.json() - if isinstance(payload, list): - return [self._hydrate_issue_labels(item) for item in payload if isinstance(item, dict)] - if isinstance(payload, dict): - results = payload.get("results") - if isinstance(results, list): - return [ - self._hydrate_issue_labels(item) for item in results if isinstance(item, dict) - ] - return [] - - def list_states(self) -> list[dict[str, Any]]: - if self._states_cache is not None: - return list(self._states_cache) - url = f"/api/v1/workspaces/{self.workspace_slug}/projects/{self.project_id}/states/" - response = self._request("GET", url) - response.raise_for_status() - payload = response.json() - if isinstance(payload, list): - self._states_cache = [item for item in payload if isinstance(item, dict)] - return list(self._states_cache) - if isinstance(payload, dict): - results = payload.get("results") - if isinstance(results, list): - self._states_cache = [item for item in results if isinstance(item, dict)] - return list(self._states_cache) - return [] - - def list_labels(self, *, force_refresh: bool = False) -> list[dict[str, Any]]: - if self._labels_cache is not None and not force_refresh: - return list(self._labels_cache) - url = f"/api/v1/workspaces/{self.workspace_slug}/projects/{self.project_id}/labels/" - response = self._request("GET", url) - response.raise_for_status() - payload = response.json() - if isinstance(payload, list): - self._labels_cache = [item for item in payload if isinstance(item, dict)] - return list(self._labels_cache) - if isinstance(payload, dict): - results = payload.get("results") - if isinstance(results, list): - self._labels_cache = [item for item in results if isinstance(item, dict)] - return list(self._labels_cache) - return [] - - def list_comments(self, task_id: str) -> list[dict[str, Any]]: - url = f"/api/v1/workspaces/{self.workspace_slug}/projects/{self.project_id}/work-items/{task_id}/comments/" - response = self._request("GET", url) - response.raise_for_status() - payload = response.json() - if isinstance(payload, list): - return [item for item in payload if isinstance(item, dict)] - if isinstance(payload, dict): - results = payload.get("results") - if isinstance(results, list): - return [item for item in results if isinstance(item, dict)] - return [] - - def to_board_task(self, issue: dict[str, Any]) -> BoardTask: - """Build the legacy Plane compatibility shape from a Plane work item.""" - - description = self._issue_description_text(issue) - label_names = [ - label.get("name", "") for label in issue.get("labels", []) if isinstance(label, dict) - ] - parsed_body = self.task_parser.parse(description, labels=label_names) - metadata = parsed_body.execution_metadata - state = issue.get("state") - status_value = ( - state.get("name", "Unknown") if isinstance(state, dict) else str(state or "Unknown") - ) - return BoardTask( - task_id=str(issue["id"]), - project_id=str(issue.get("project_id", self.project_id)), - title=issue.get("name", "Untitled"), - description=description, - status=status_value, - labels=label_names, - repo_key=str(metadata["repo"]), - base_branch=str(metadata["base_branch"]), - execution_mode=cast("Any", metadata.get("mode", "goal")), - allowed_paths=[ - str(path) for path in cast(list[object], metadata.get("allowed_paths") or []) - ], - validation_profile=( - str(metadata.get("validation_profile")) - if metadata.get("validation_profile") - else None - ), - open_pr=bool(metadata.get("open_pr", False)), - goal_text=parsed_body.goal_text, - constraints_text=parsed_body.constraints_text, - ) - - def transition_issue(self, task_id: str, state: str) -> None: - url = f"/api/v1/workspaces/{self.workspace_slug}/projects/{self.project_id}/work-items/{task_id}/" - state_value = self._resolve_state_value(state) - today = datetime.now(UTC).date().isoformat() - payload: dict[str, Any] = {"state": state_value} - if state == "Running": - payload["start_date"] = today - elif state in ("Done", "Review", "In Review", "Blocked"): - payload["target_date"] = today - response = self._request("PATCH", url, json=payload) - response.raise_for_status() - - def create_issue( - self, - *, - name: str, - description: str, - state: str | None = None, - label_names: list[str] | None = None, - ) -> dict[str, Any]: - url = f"/api/v1/workspaces/{self.workspace_slug}/projects/{self.project_id}/work-items/" - payload: dict[str, Any] = { - "name": name, - "description_stripped": description, - "description_html": self._render_text_html(description), - } - if state: - payload["state"] = self._resolve_state_value(state) - if label_names: - payload["labels"] = self._ensure_label_ids(label_names) - response = self._request("POST", url, json=payload) - response.raise_for_status() - return response.json() - - def set_priority(self, task_id: str, priority: str) -> None: - """Set a work item's priority. - - Added because callers were reaching through `_client` to PATCH this URL - themselves — the board surface was missing an operation the fleet - genuinely performs, and the gap leaked Plane's private internals and URL - shape into an entrypoint. - """ - url = ( - f"/api/v1/workspaces/{self.workspace_slug}/projects/{self.project_id}" - f"/work-items/{task_id}/" - ) - response = self._request("PATCH", url, json={"priority": priority}) - response.raise_for_status() - - def update_issue_description(self, task_id: str, description: str) -> None: - """Replace the description of an existing work item.""" - url = f"/api/v1/workspaces/{self.workspace_slug}/projects/{self.project_id}/work-items/{task_id}/" - payload: dict[str, Any] = { - "description_stripped": description, - "description_html": self._render_text_html(description), - } - response = self._request("PATCH", url, json=payload) - response.raise_for_status() - - def update_issue_labels(self, task_id: str, label_names: list[str]) -> None: - """Replace the label set on an existing work item.""" - url = f"/api/v1/workspaces/{self.workspace_slug}/projects/{self.project_id}/work-items/{task_id}/" - label_ids = self._ensure_label_ids(label_names) - response = self._request("PATCH", url, json={"labels": label_ids}) - response.raise_for_status() - - def comment_issue(self, task_id: str, comment_markdown: str) -> None: - url = ( - f"/api/v1/workspaces/{self.workspace_slug}/projects/{self.project_id}/" - f"work-items/{task_id}/comments/" - ) - response = self._request( - "POST", url, json={"comment_html": self._render_comment_html(comment_markdown)} - ) - response.raise_for_status() - - def _resolve_state_value(self, state: str) -> str: - state_value: str = state - for item in self.list_states(): - if str(item.get("name", "")).strip().lower() == state.strip().lower(): - state_value = str(item["id"]) - break - return state_value - - def _ensure_label_ids(self, label_names: list[str]) -> list[str]: - existing = { - str(item.get("name", "")).strip().lower(): str(item["id"]) - for item in self.list_labels() - if item.get("id") and item.get("name") - } - ids: list[str] = [] - for label_name in label_names: - normalized = label_name.strip().lower() - if not normalized: - continue - label_id = existing.get(normalized) - if label_id is None: - created = self._create_label(label_name.strip()) - label_id = str(created["id"]) - existing[normalized] = label_id - ids.append(label_id) - return ids - - def _create_label(self, label_name: str) -> dict[str, Any]: - url = f"/api/v1/workspaces/{self.workspace_slug}/projects/{self.project_id}/labels/" - response = self._request("POST", url, json={"name": label_name}) - response.raise_for_status() - created = response.json() - if self._labels_cache is not None and isinstance(created, dict): - self._labels_cache.append(created) - return created - - def _hydrate_issue_labels(self, issue: dict[str, Any]) -> dict[str, Any]: - raw_labels = issue.get("labels") - if not isinstance(raw_labels, list) or not raw_labels: - return issue - if all(isinstance(label, dict) for label in raw_labels): - return issue - - def label_map(*, force_refresh: bool = False) -> dict[str, Any]: - return { - str(label.get("id")): label - for label in self.list_labels(force_refresh=force_refresh) - if isinstance(label, dict) and label.get("id") - } - - by_id = label_map() - unresolved = [ - str(raw) for raw in raw_labels if not isinstance(raw, dict) and str(raw) not in by_id - ] - if unresolved: - by_id = label_map(force_refresh=True) - hydrated: list[Any] = [] - for raw in raw_labels: - if isinstance(raw, dict): - hydrated.append(raw) - else: - mapped = by_id.get(str(raw)) - hydrated.append(mapped or raw) - issue["labels"] = hydrated - return issue - - def _request(self, method: str, url: str, **kwargs: Any) -> httpx.Response: - """Execute an HTTP request with retry on 429, transient 5xx, and connection errors. - - Retries up to 3 times (4 total attempts). Connection-level failures - (ConnectError, TimeoutException) and 502/503/504 responses are retried - with linear backoff. 429 responses honour the Retry-After header. - Duplicate side-effects (e.g. duplicate comments from a retried POST) are - acceptable — a missed board transition is far more damaging than a - duplicate comment. - """ - attempts = 4 - for attempt in range(1, attempts + 1): - try: - response = self._client.request(method, url, **kwargs) - except (httpx.ConnectError, httpx.TimeoutException, httpx.RemoteProtocolError): - if attempt == attempts: - raise - time.sleep(attempt * 2) - continue - if response.status_code == 429: - if attempt == attempts: - return response - retry_after_header = response.headers.get("Retry-After", "").strip() - retry_after = ( - int(retry_after_header) if retry_after_header.isdigit() else attempt * 2 - ) - time.sleep(retry_after) - continue - # Retry on transient gateway / server errors regardless of HTTP method. - if response.status_code in (502, 503, 504) and attempt < attempts: - time.sleep(attempt * 2) - continue - return response - raise RuntimeError("unreachable") - - @staticmethod - def _render_comment_html(comment_markdown: str) -> str: - lines = [line.strip() for line in comment_markdown.splitlines() if line.strip()] - if not lines: - return "

(no summary)

" - - header = html.escape(lines[0]) - items: list[str] = [] - for line in lines[1:]: - if line.startswith("- "): - items.append(f"
  • {html.escape(line[2:])}
  • ") - - if items: - return f"

    {header}

    " - return f"

    {header}

    " - - @staticmethod - def _render_text_html(text: str) -> str: - blocks = [block.strip() for block in text.split("\n\n") if block.strip()] - if not blocks: - return "

    " - rendered: list[str] = [] - for block in blocks: - lines = [html.escape(line) for line in block.splitlines()] - rendered.append(f"

    {'
    '.join(lines)}

    ") - return "".join(rendered) - - @staticmethod - def _issue_description_text(issue: dict[str, Any]) -> str: - raw = issue.get("description") or issue.get("description_stripped") - if isinstance(raw, str) and raw.strip(): - return raw - html_body = issue.get("description_html") - if isinstance(html_body, str) and html_body.strip(): - return PlaneClient._html_to_task_text(html_body) - return "" - - @staticmethod - def _html_to_task_text(html_body: str) -> str: - text = html.unescape(html_body) - text = re.sub( - r"]*>\s*(.*?)\s*", - lambda m: f"\n## {m.group(1)}\n", - text, - flags=re.I | re.S, - ) - text = re.sub( - r"]*>\s*(.*?)\s*", lambda m: f"- {m.group(1)}\n", text, flags=re.I | re.S - ) - text = re.sub(r"", "\n", text, flags=re.I) - text = re.sub(r"]*>", "\n", text, flags=re.I) - text = re.sub(r"<[^>]+>", "", text) - text = re.sub(r"\n{3,}", "\n\n", text) - return text.strip() diff --git a/src/operations_center/config/settings.py b/src/operations_center/config/settings.py index 5dd87c543..5807fbdd7 100644 --- a/src/operations_center/config/settings.py +++ b/src/operations_center/config/settings.py @@ -14,13 +14,6 @@ from operations_center.execution.models import ExecutionControlSettings -class PlaneSettings(BaseModel): - base_url: str - api_token_env: str - workspace_slug: str - project_id: str - - class ForgejoSettings(BaseModel): """Self-hosted Forgejo, used as the board (issues) and later the forge. @@ -683,7 +676,25 @@ def label_trust_allows(self, *identities: str | None) -> bool: class Settings(BaseModel): - plane: PlaneSettings | None = None + @model_validator(mode="before") + @classmethod + def _explain_retired_board_backend(cls, data): + """Answer a config left on the retired backend. + + Narrowing `board_backend` to a single-value Literal makes pydantic + reject `plane` with "Input should be 'forgejo'", which reads as a typo. + It was a real backend until the 2026-08-18 Forgejo cutover, so an + operator whose config still names it is asking a reasonable question. + """ + if isinstance(data, dict) and data.get("board_backend") == "plane": + raise ValueError( + "board_backend 'plane' was removed at the 2026-08-18 Forgejo " + "cutover — the Plane adapter is gone. Set " + "`board_backend: forgejo` and add a `forgejo:` block; see " + "config/operations_center.example.yaml." + ) + return data + # Which forge the PR/review surface talks to. Independent of board_backend: # the board cut over first (#516); review moves only after `audit` exists on # Forgejo Actions (docs/specs/forgejo-pr-adapter.md, B4). Flipping this is @@ -692,7 +703,7 @@ class Settings(BaseModel): # Which board the fleet talks to. Explicit rather than inferred from whether # `forgejo:` is configured — repointing the board is not something that # should happen as a side effect of adding a config block. - board_backend: Literal["plane", "forgejo"] = "plane" + board_backend: Literal["forgejo"] = "forgejo" forgejo: ForgejoSettings | None = None git: GitSettings team_executor: TeamExecutorSettings = Field(default_factory=TeamExecutorSettings) @@ -826,14 +837,6 @@ class Settings(BaseModel): # exit blocks. False disables the gate entirely → prior (pre-gate) behavior. pre_pr_custodian_gate: bool = True - def plane_token(self) -> str: - if self.plane is None: - raise RuntimeError( - "board_backend is 'plane' but no `plane:` settings block is " - "configured — the fleet has no board to talk to" - ) - return os.environ[self.plane.api_token_env] - def forgejo_token(self) -> str: """The Forgejo API token. diff --git a/src/operations_center/entrypoints/maintenance/dependency_check.py b/src/operations_center/entrypoints/maintenance/dependency_check.py index 2c3466a32..82dbd4b00 100644 --- a/src/operations_center/entrypoints/maintenance/dependency_check.py +++ b/src/operations_center/entrypoints/maintenance/dependency_check.py @@ -75,12 +75,6 @@ def fetch_npm_latest(package_name: str) -> str | None: return None -def plane_latest_from_env(env: dict[str, str]) -> tuple[str | None, str | None]: - pinned = normalize_version(env.get("OPERATIONS_CENTER_PLANE_VERSION")) - setup_url = env.get("OPERATIONS_CENTER_PLANE_SETUP_URL") or None - return pinned, setup_url - - def executor_backend_status(module: str) -> tuple[bool, str | None]: """Return ``(importable, distribution version)`` for an execute backend module. @@ -106,39 +100,61 @@ def executor_backend_status(module: str) -> tuple[bool, str | None]: return True, None -def current_plane_health(settings: Settings) -> bool: - if settings.plane is None: - return False +def current_board_status(settings: Settings) -> tuple[str | None, bool]: + """Version and reachability of the Forgejo instance serving the board. + + Replaces the Plane service row this module used to carry. `/api/v1/version` + needs no auth and answers both questions at once, so a failure here is + exactly what an operator wants to see in a dependency report: the board is + the one service whose absence stops everything. + """ + cfg = getattr(settings, "forgejo", None) + if cfg is None: + return None, False try: - response = httpx.get(settings.plane.base_url, timeout=10.0) + response = httpx.get(f"{cfg.base_url.rstrip('/')}/api/v1/version", timeout=10.0) except httpx.HTTPError: - return False - return response.status_code < 500 + return None, False + if response.status_code >= 400: + return None, False + try: + payload = response.json() + except ValueError: + # Something answered on that URL, but it is not Forgejo's API — a + # reverse-proxy error page, a login interstitial, a captive portal. + # Letting the decode error escape would take down the whole dependency + # report over one row, and this function exists to *report* health, not + # to have it. "Up" would also be wrong: the fleet cannot use that as a + # board. + return None, False + if not isinstance(payload, dict): + return None, False + return normalize_version(str(payload.get("version") or "").strip()), True def collect_dependency_statuses(settings: Settings, env: dict[str, str]) -> list[DependencyStatus]: statuses: list[DependencyStatus] = [] - plane_pinned, _ = plane_latest_from_env(env) - plane_latest = fetch_github_latest_release("makeplane", "plane") - plane_notes: list[str] = [] - plane_healthy = current_plane_health(settings) - if not plane_healthy: - plane_notes.append("Plane base URL is not reachable.") - if plane_pinned and plane_latest and plane_pinned != plane_latest: - plane_notes.append( - f"Pinned release {plane_pinned} differs from upstream latest {plane_latest}." + board_version, board_healthy = current_board_status(settings) + board_notes: list[str] = [] + if not board_healthy: + board_notes.append( + "Forgejo did not answer /api/v1/version — unreachable, or not " + "the API. The fleet has no board." ) statuses.append( DependencyStatus( - key="plane", - label="Plane", + key="forgejo", + label="Forgejo (board)", kind="service", - installed_version=None, - pinned_version=plane_pinned, - upstream_latest=plane_latest, - healthy=plane_healthy, - notes=plane_notes, + installed_version=board_version, + pinned_version=None, + # Forgejo publishes releases on Codeberg, not the GitHub releases + # API the other rows use. Reporting None is honest; inventing a + # second fetcher for one row is not this change. + upstream_latest=None, + healthy=board_healthy, + notes=board_notes, ) ) @@ -301,7 +317,9 @@ def main() -> None: description="Check pinned tool versions against installed state and upstream latest versions" ) parser.add_argument("--config", required=True) - parser.add_argument("--create-plane-tasks", action="store_true") + # Renamed from --create-plane-tasks: it always went through + # make_board_client and was never Plane-specific, only Plane-named. + parser.add_argument("--create-board-tasks", action="store_true") args = parser.parse_args() settings = load_settings(args.config) @@ -315,7 +333,7 @@ def main() -> None: statuses = collect_dependency_statuses(settings, env) created_task_ids: list[str] = [] - if args.create_plane_tasks: + if args.create_board_tasks: client = make_board_client(settings) try: for status in actionable_statuses(statuses): diff --git a/tests/maintenance/test_orphan_branch_check.py b/tests/maintenance/test_orphan_branch_check.py index ce887a2d0..cec9f0ad7 100644 --- a/tests/maintenance/test_orphan_branch_check.py +++ b/tests/maintenance/test_orphan_branch_check.py @@ -500,10 +500,8 @@ def test_emit_plane_task_updates_existing_issue() -> None: age_hours=50.0, ) settings = _make_settings() - settings.plane = MagicMock(base_url="http://plane", workspace_slug="ws", project_id="proj") - settings.plane_token = MagicMock(return_value="token") - plane = MagicMock() - plane.list_issues.return_value = [ + board = MagicMock() + board.list_issues.return_value = [ { "id": "task-123", "name": "Orphan branch: MyRepo/feat/old (1 commits ahead)", @@ -512,14 +510,20 @@ def test_emit_plane_task_updates_existing_issue() -> None: } ] - with patch("operations_center.adapters.plane.PlaneClient", return_value=plane): + # Patched at the seam, not at a concrete client: `_emit_plane_task` calls + # `make_board_client`, so this stays true whichever backend is configured. + # It previously patched `adapters.plane.PlaneClient`, which the Forgejo + # cutover deleted. + with patch( + "operations_center.adapters.board.make_board_client", return_value=board + ): from operations_center.entrypoints.maintenance.orphan_branch_check import _emit_plane_task _emit_plane_task(settings, orphan) - plane.create_issue.assert_not_called() - plane.update_issue_description.assert_called_once() - plane.update_issue_labels.assert_called_once_with("task-123", ["orphan-branch", "repo:MyRepo"]) + board.create_issue.assert_not_called() + board.update_issue_description.assert_called_once() + board.update_issue_labels.assert_called_once_with("task-123", ["orphan-branch", "repo:MyRepo"]) # ── scan() integration-level ────────────────────────────────────────────────── diff --git a/tests/test_aider_local_adapter.py b/tests/test_aider_local_adapter.py index 0cf2d4a46..43e0208d7 100644 --- a/tests/test_aider_local_adapter.py +++ b/tests/test_aider_local_adapter.py @@ -292,19 +292,12 @@ def test_factory_registers_aider_local(tmp_path: Path) -> None: from operations_center.backends.factory import CanonicalBackendRegistry from operations_center.config.settings import ( GitSettings, - PlaneSettings, Settings, TeamExecutorSettings, ) from operations_center.contracts.enums import BackendName settings = Settings( - plane=PlaneSettings( - base_url="http://plane.local", - api_token_env="PLANE_TOKEN", - workspace_slug="eng", - project_id="proj-1", - ), git=GitSettings(), team_executor=TeamExecutorSettings(), repos={}, diff --git a/tests/test_dependency_check.py b/tests/test_dependency_check.py index 0cf028c98..9d9bef0ee 100644 --- a/tests/test_dependency_check.py +++ b/tests/test_dependency_check.py @@ -76,3 +76,67 @@ def test_dependency_task_description_uses_default_repo_and_context() -> None: assert "base_branch: main" in description assert "mode: goal" in description assert "dependency: codex" in description + + +# ── board health probe ─────────────────────────────────────────────────────── + + +class _Resp: + def __init__(self, status_code, payload=None, raises=False): + self.status_code = status_code + self._payload = payload + self._raises = raises + + def json(self): + if self._raises: + raise ValueError("Expecting value: line 1 column 1 (char 0)") + return self._payload + + +class _Settings: + class forgejo: + base_url = "http://forge.local" + + +def _probe(monkeypatch, response): + from operations_center.entrypoints.maintenance import dependency_check + + monkeypatch.setattr( + dependency_check.httpx, "get", lambda *a, **k: response, raising=True + ) + return dependency_check.current_board_status(_Settings()) + + +def test_board_status_reports_version_and_health(monkeypatch): + assert _probe(monkeypatch, _Resp(200, {"version": "13.0.5+gitea-1.22.0"})) == ( + "13.0.5", + True, + ) + + +def test_board_status_survives_a_non_json_body(monkeypatch): + """A 200 carrying HTML — a proxy interstitial, a login page. + + The decode error must not escape: this function is how the dependency + report *learns* the board is unusable, so raising here would take down the + whole report over one row. And "healthy" would be the wrong answer, because + the fleet cannot use that as a board. + """ + assert _probe(monkeypatch, _Resp(200, raises=True)) == (None, False) + + +def test_board_status_rejects_a_non_object_payload(monkeypatch): + assert _probe(monkeypatch, _Resp(200, ["not", "an", "object"])) == (None, False) + + +def test_board_status_reports_http_errors_as_unhealthy(monkeypatch): + assert _probe(monkeypatch, _Resp(503)) == (None, False) + + +def test_board_status_without_a_forgejo_block_is_unhealthy(monkeypatch): + from operations_center.entrypoints.maintenance import dependency_check + + class _NoBoard: + forgejo = None + + assert dependency_check.current_board_status(_NoBoard()) == (None, False) diff --git a/tests/test_execution_controls.py b/tests/test_execution_controls.py index 9747c68c5..4ab901b3f 100644 --- a/tests/test_execution_controls.py +++ b/tests/test_execution_controls.py @@ -324,18 +324,11 @@ def test_backend_cap_settings_pydantic_default(): def test_settings_backend_caps_default_empty(): from operations_center.config.settings import ( GitSettings, - PlaneSettings, Settings, TeamExecutorSettings, ) s = Settings( - plane=PlaneSettings( - base_url="http://x", - api_token_env="X", - workspace_slug="w", - project_id="p", - ), git=GitSettings(), team_executor=TeamExecutorSettings(), repos={}, diff --git a/tests/test_plane_client_http.py b/tests/test_plane_client_http.py deleted file mode 100644 index be6336c0a..000000000 --- a/tests/test_plane_client_http.py +++ /dev/null @@ -1,318 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-or-later -# Copyright (C) 2026 ProtocolWarden -import json - -import httpx - -from operations_center.adapters.plane import PlaneClient - - -def test_plane_comment_and_state_update_flow() -> None: - calls: list[tuple[str, str, dict[str, object] | None, dict[str, str]]] = [] - - def handler(request: httpx.Request) -> httpx.Response: - payload = json.loads(request.content.decode()) if request.content else None - calls.append((request.method, str(request.url), payload, dict(request.headers))) - - if request.method == "GET" and "/states/" in str(request.url): - return httpx.Response(200, json={"results": []}) - if request.method == "GET": - return httpx.Response( - 200, - json={ - "id": "TASK-1", - "project_id": "proj", - "name": "Task", - "description": """## Execution -repo: repo_a -base_branch: main -mode: goal - -## Goal -Do thing. -""", - "state": {"name": "Ready for AI"}, - "labels": [], - }, - ) - return httpx.Response(200, json={"ok": True}) - - transport = httpx.MockTransport(handler) - client = PlaneClient("http://plane.local", "token", "ws", "proj") - client._client = httpx.Client( # type: ignore[attr-defined] - transport=transport, - base_url="http://plane.local", - headers={"X-API-Key": "token", "Content-Type": "application/json"}, - ) - - try: - issue = client.fetch_issue("TASK-1") - task = client.to_board_task(issue) - client.transition_issue(task.task_id, "Running") - client.comment_issue(task.task_id, "Result\n- success: true\n- run_id: abc") - finally: - client.close() - - assert any("/work-items/TASK-1/" in url for _, url, _, _ in calls) - patch_call = next(payload for method, _, payload, _ in calls if method == "PATCH") - assert patch_call.get("state") == "Running" - assert "start_date" in patch_call - - post_call = next(payload for method, _, payload, _ in calls if method == "POST") - assert isinstance(post_call, dict) - assert "comment_html" in post_call - assert "
    • success: true
    • run_id: abc
    " in str(post_call["comment_html"]) - - for _, _, _, headers in calls: - assert headers.get("x-api-key") == "token" - - -def test_plane_fetch_project_uses_workspace_and_project_path() -> None: - calls: list[tuple[str, str]] = [] - - def handler(request: httpx.Request) -> httpx.Response: - calls.append((request.method, str(request.url))) - return httpx.Response(200, json={"id": "proj", "name": "Engineering"}) - - transport = httpx.MockTransport(handler) - client = PlaneClient("http://plane.local", "token", "ws", "proj") - client._client = httpx.Client( # type: ignore[attr-defined] - transport=transport, - base_url="http://plane.local", - headers={"X-API-Key": "token", "Content-Type": "application/json"}, - ) - - try: - project = client.fetch_project() - finally: - client.close() - - assert project["name"] == "Engineering" - assert calls == [("GET", "http://plane.local/api/v1/workspaces/ws/projects/proj/")] - - -def test_plane_list_issues_supports_paginated_results() -> None: - calls: list[tuple[str, str]] = [] - - def handler(request: httpx.Request) -> httpx.Response: - calls.append((request.method, str(request.url))) - return httpx.Response( - 200, - json={ - "results": [ - {"id": "TASK-1", "state": {"name": "Ready for AI"}}, - {"id": "TASK-2", "state": {"name": "Backlog"}}, - ] - }, - ) - - transport = httpx.MockTransport(handler) - client = PlaneClient("http://plane.local", "token", "ws", "proj") - client._client = httpx.Client( # type: ignore[attr-defined] - transport=transport, - base_url="http://plane.local", - headers={"X-API-Key": "token", "Content-Type": "application/json"}, - ) - - try: - issues = client.list_issues() - finally: - client.close() - - assert [issue["id"] for issue in issues] == ["TASK-1", "TASK-2"] - assert calls == [ - ("GET", "http://plane.local/api/v1/workspaces/ws/projects/proj/work-items/?expand=state") - ] - - -def test_plane_fetch_issue_hydrates_label_ids_to_label_objects() -> None: - def handler(request: httpx.Request) -> httpx.Response: - url = str(request.url) - if url.endswith("/labels/"): - return httpx.Response( - 200, json={"results": [{"id": "LABEL-1", "name": "task-kind: improve"}]} - ) - return httpx.Response( - 200, - json={ - "id": "TASK-1", - "project_id": "proj", - "name": "Task", - "description": """## Execution -repo: repo_a -base_branch: main -mode: goal - -## Goal -Do thing. -""", - "state": {"name": "Ready for AI"}, - "labels": ["LABEL-1"], - }, - ) - - transport = httpx.MockTransport(handler) - client = PlaneClient("http://plane.local", "token", "ws", "proj") - client._client = httpx.Client( # type: ignore[attr-defined] - transport=transport, - base_url="http://plane.local", - headers={"X-API-Key": "token", "Content-Type": "application/json"}, - ) - - try: - issue = client.fetch_issue("TASK-1") - finally: - client.close() - - assert issue["labels"] == [{"id": "LABEL-1", "name": "task-kind: improve"}] - - -def test_plane_fetch_issue_refreshes_label_cache_for_unknown_label_ids() -> None: - label_calls = {"count": 0} - - def handler(request: httpx.Request) -> httpx.Response: - url = str(request.url) - if url.endswith("/labels/"): - label_calls["count"] += 1 - if label_calls["count"] == 1: - return httpx.Response( - 200, json={"results": [{"id": "LABEL-OLD", "name": "task-kind: goal"}]} - ) - return httpx.Response( - 200, json={"results": [{"id": "LABEL-NEW", "name": "task-kind: test"}]} - ) - return httpx.Response( - 200, - json={ - "id": "TASK-2", - "project_id": "proj", - "name": "Task", - "state": {"name": "Ready for AI"}, - "labels": ["LABEL-NEW"], - }, - ) - - transport = httpx.MockTransport(handler) - client = PlaneClient("http://plane.local", "token", "ws", "proj") - client._client = httpx.Client( # type: ignore[attr-defined] - transport=transport, - base_url="http://plane.local", - headers={"X-API-Key": "token", "Content-Type": "application/json"}, - ) - - try: - assert client.list_labels() == [{"id": "LABEL-OLD", "name": "task-kind: goal"}] - issue = client.fetch_issue("TASK-2") - finally: - client.close() - - assert issue["labels"] == [{"id": "LABEL-NEW", "name": "task-kind: test"}] - assert label_calls["count"] == 2 - - -def test_plane_create_issue_ensures_labels_and_state() -> None: - calls: list[tuple[str, str, dict[str, object] | None]] = [] - - def handler(request: httpx.Request) -> httpx.Response: - payload = json.loads(request.content.decode()) if request.content else None - calls.append((request.method, str(request.url), payload)) - url = str(request.url) - if request.method == "GET" and "/states/" in url: - return httpx.Response( - 200, json={"results": [{"id": "STATE-1", "name": "Ready for AI"}]} - ) - if request.method == "GET" and "/labels/" in url: - return httpx.Response(200, json={"results": []}) - if request.method == "POST" and url.endswith("/labels/"): - assert payload == {"name": "task-kind: goal"} or payload == { - "name": "source: improve-worker" - } - return httpx.Response( - 201, - json={ - "id": f"LABEL-{len([c for c in calls if c[1].endswith('/labels/') and c[0] == 'POST'])}" - }, - ) - if request.method == "POST" and url.endswith("/work-items/"): - return httpx.Response(201, json={"id": "TASK-NEW", "name": payload["name"]}) - raise AssertionError(f"Unexpected call: {request.method} {url}") - - transport = httpx.MockTransport(handler) - client = PlaneClient("http://plane.local", "token", "ws", "proj") - client._client = httpx.Client( # type: ignore[attr-defined] - transport=transport, - base_url="http://plane.local", - headers={"X-API-Key": "token", "Content-Type": "application/json"}, - ) - - try: - created = client.create_issue( - name="Follow-up", - description="## Goal\nDo thing.", - state="Ready for AI", - label_names=["task-kind: goal", "source: improve-worker"], - ) - finally: - client.close() - - assert created["id"] == "TASK-NEW" - work_item_payload = next( - payload - for method, url, payload in calls - if method == "POST" and url.endswith("/work-items/") - ) - assert work_item_payload["state"] == "STATE-1" - assert work_item_payload["labels"] == ["LABEL-1", "LABEL-2"] - assert "description_html" in work_item_payload - - -def test_plane_list_comments_supports_paginated_results() -> None: - def handler(request: httpx.Request) -> httpx.Response: - return httpx.Response( - 200, json={"results": [{"id": "C-1", "comment_html": "

    Hello

    "}]} - ) - - transport = httpx.MockTransport(handler) - client = PlaneClient("http://plane.local", "token", "ws", "proj") - client._client = httpx.Client( # type: ignore[attr-defined] - transport=transport, - base_url="http://plane.local", - headers={"X-API-Key": "token", "Content-Type": "application/json"}, - ) - - try: - comments = client.list_comments("TASK-1") - finally: - client.close() - - assert comments == [{"id": "C-1", "comment_html": "

    Hello

    "}] - - -def test_plane_task_parses_from_description_html_when_plain_text_missing() -> None: - client = PlaneClient("http://plane.local", "token", "ws", "proj") - issue = { - "id": "TASK-9", - "project_id": "proj", - "name": "Task", - "description_html": """ -

    Execution

    -

    repo: repo_a -
    base_branch: main -
    mode: goal

    -

    Goal

    -

    Do thing.

    -

    Constraints

    -
    • Keep tests green.
    -""", - "state": {"name": "Ready for AI"}, - "labels": [], - } - try: - task = client.to_board_task(issue) - finally: - client.close() - - assert task.repo_key == "repo_a" - assert task.base_branch == "main" - assert task.goal_text == "Do thing." - assert task.constraints_text == "- Keep tests green." diff --git a/tests/unit/adapters/plane/__init__.py b/tests/unit/adapters/plane/__init__.py deleted file mode 100644 index 2c86026d6..000000000 --- a/tests/unit/adapters/plane/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-or-later -# Copyright (C) 2026 ProtocolWarden diff --git a/tests/unit/adapters/plane/test_client_cov.py b/tests/unit/adapters/plane/test_client_cov.py deleted file mode 100644 index 9118386f2..000000000 --- a/tests/unit/adapters/plane/test_client_cov.py +++ /dev/null @@ -1,750 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-or-later -# Copyright (C) 2026 ProtocolWarden -from __future__ import annotations - -from typing import Any -from unittest.mock import MagicMock - -import httpx -import pytest - -from operations_center.adapters.plane.client import PlaneClient - - -class FakeResponse: - """Minimal stand-in for httpx.Response.""" - - def __init__( - self, - *, - json_data: Any = None, - status_code: int = 200, - headers: dict[str, str] | None = None, - raise_error: bool = False, - ) -> None: - self._json = json_data - self.status_code = status_code - self.headers = headers or {} - self._raise_error = raise_error - self.raise_called = False - - def json(self) -> Any: - return self._json - - def raise_for_status(self) -> None: - self.raise_called = True - if self._raise_error: - raise httpx.HTTPStatusError("boom", request=MagicMock(), response=MagicMock()) - - -@pytest.fixture -def client() -> PlaneClient: - """Construct a PlaneClient with its httpx client replaced by a mock.""" - c = PlaneClient( - base_url="https://plane.example.com/", - api_token="tok", - workspace_slug="ws", - project_id="proj", - ) - # Replace the real httpx client so no network or sleeps occur. - c._client = MagicMock() - return c - - -def _make_request_returning(client: PlaneClient, response: FakeResponse) -> list[dict[str, Any]]: - """Patch ``_request`` to return ``response`` and record calls.""" - calls: list[dict[str, Any]] = [] - - def fake_request(method: str, url: str, **kwargs: Any) -> FakeResponse: - calls.append({"method": method, "url": url, "kwargs": kwargs}) - return response - - client._request = fake_request # type: ignore[method-assign] - return calls - - -# -------------------------------------------------------------------------- -# __init__ / close -# -------------------------------------------------------------------------- - - -def test_init_strips_trailing_slash_and_sets_fields() -> None: - c = PlaneClient( - base_url="https://x.test/", - api_token="tok", - workspace_slug="ws", - project_id="proj", - ) - assert c.base_url == "https://x.test" - assert c.workspace_slug == "ws" - assert c.project_id == "proj" - assert c._states_cache is None - assert c._labels_cache is None - - -def test_close_delegates_to_client(client: PlaneClient) -> None: - result = client.close() - assert result is None - assert client._client.close.call_count == 1 - client._client.close.assert_called_once_with() - - -# -------------------------------------------------------------------------- -# fetch_issue / fetch_project -# -------------------------------------------------------------------------- - - -def test_fetch_issue_hydrates_dict(client: PlaneClient) -> None: - resp = FakeResponse(json_data={"id": "1", "labels": [{"name": "a"}]}) - calls = _make_request_returning(client, resp) - out = client.fetch_issue("T1") - assert out["id"] == "1" - assert calls[0]["method"] == "GET" - assert "work-items/T1/" in calls[0]["url"] - assert calls[0]["kwargs"]["params"] == {"expand": "state"} - assert resp.raise_called - - -def test_fetch_issue_non_dict_payload_returned_as_is(client: PlaneClient) -> None: - resp = FakeResponse(json_data=["not-a-dict"]) - _make_request_returning(client, resp) - assert client.fetch_issue("T1") == ["not-a-dict"] - - -def test_fetch_project(client: PlaneClient) -> None: - resp = FakeResponse(json_data={"name": "p"}) - calls = _make_request_returning(client, resp) - assert client.fetch_project() == {"name": "p"} - assert calls[0]["url"].endswith("projects/proj/") - - -# -------------------------------------------------------------------------- -# list_issues -# -------------------------------------------------------------------------- - - -def test_list_issues_list_payload(client: PlaneClient) -> None: - resp = FakeResponse(json_data=[{"id": "1"}, "skip", {"id": "2"}]) - _make_request_returning(client, resp) - out = client.list_issues() - assert [i["id"] for i in out] == ["1", "2"] - - -def test_list_issues_paginated_dict_payload(client: PlaneClient) -> None: - resp = FakeResponse(json_data={"results": [{"id": "9"}, 5]}) - _make_request_returning(client, resp) - out = client.list_issues() - assert [i["id"] for i in out] == ["9"] - - -def test_list_issues_unexpected_payload_returns_empty(client: PlaneClient) -> None: - resp = FakeResponse(json_data={"no_results": True}) - _make_request_returning(client, resp) - assert client.list_issues() == [] - - -def test_list_issues_results_not_a_list(client: PlaneClient) -> None: - resp = FakeResponse(json_data={"results": "nope"}) - _make_request_returning(client, resp) - assert client.list_issues() == [] - - -# -------------------------------------------------------------------------- -# list_states (with caching) -# -------------------------------------------------------------------------- - - -def test_list_states_list_payload_and_cache(client: PlaneClient) -> None: - resp = FakeResponse(json_data=[{"id": "s1", "name": "Todo"}, 7]) - calls = _make_request_returning(client, resp) - out = client.list_states() - assert out == [{"id": "s1", "name": "Todo"}] - # Second call uses cache, no new request. - out2 = client.list_states() - assert out2 == out - assert out2 is not out # returns a copy - assert len(calls) == 1 - - -def test_list_states_paginated(client: PlaneClient) -> None: - resp = FakeResponse(json_data={"results": [{"id": "s2"}, "x"]}) - _make_request_returning(client, resp) - assert client.list_states() == [{"id": "s2"}] - - -def test_list_states_unexpected_returns_empty_and_no_cache(client: PlaneClient) -> None: - resp = FakeResponse(json_data="weird") - _make_request_returning(client, resp) - assert client.list_states() == [] - assert client._states_cache is None - - -def test_list_states_dict_without_list_results(client: PlaneClient) -> None: - resp = FakeResponse(json_data={"results": 123}) - _make_request_returning(client, resp) - assert client.list_states() == [] - - -# -------------------------------------------------------------------------- -# list_labels (caching + force_refresh) -# -------------------------------------------------------------------------- - - -def test_list_labels_list_and_cache_and_force_refresh(client: PlaneClient) -> None: - resp = FakeResponse(json_data=[{"id": "l1", "name": "bug"}]) - calls = _make_request_returning(client, resp) - assert client.list_labels() == [{"id": "l1", "name": "bug"}] - # Cached. - client.list_labels() - assert len(calls) == 1 - # force_refresh bypasses cache. - client.list_labels(force_refresh=True) - assert len(calls) == 2 - - -def test_list_labels_paginated(client: PlaneClient) -> None: - resp = FakeResponse(json_data={"results": [{"id": "l2"}, None]}) - _make_request_returning(client, resp) - assert client.list_labels() == [{"id": "l2"}] - - -def test_list_labels_unexpected_returns_empty(client: PlaneClient) -> None: - resp = FakeResponse(json_data=42) - _make_request_returning(client, resp) - assert client.list_labels() == [] - - -def test_list_labels_dict_results_not_list(client: PlaneClient) -> None: - resp = FakeResponse(json_data={"results": {}}) - _make_request_returning(client, resp) - assert client.list_labels() == [] - - -# -------------------------------------------------------------------------- -# list_comments -# -------------------------------------------------------------------------- - - -def test_list_comments_list(client: PlaneClient) -> None: - resp = FakeResponse(json_data=[{"c": 1}, "x"]) - _make_request_returning(client, resp) - assert client.list_comments("T1") == [{"c": 1}] - - -def test_list_comments_paginated(client: PlaneClient) -> None: - resp = FakeResponse(json_data={"results": [{"c": 2}, 0]}) - _make_request_returning(client, resp) - assert client.list_comments("T1") == [{"c": 2}] - - -def test_list_comments_unexpected(client: PlaneClient) -> None: - resp = FakeResponse(json_data="nope") - _make_request_returning(client, resp) - assert client.list_comments("T1") == [] - - -def test_list_comments_dict_results_not_list(client: PlaneClient) -> None: - resp = FakeResponse(json_data={"results": 1}) - _make_request_returning(client, resp) - assert client.list_comments("T1") == [] - - -# -------------------------------------------------------------------------- -# to_board_task -# -------------------------------------------------------------------------- - - -def _exec_description(extra: str = "") -> str: - return ( - "## Goal\nDo the thing.\n\n## Execution\nrepo: myrepo\nbase_branch: main\n" + extra + "\n" - ) - - -def test_to_board_task_full(client: PlaneClient) -> None: - issue = { - "id": "ID1", - "project_id": "P9", - "name": "My Task", - "description": _exec_description("mode: goal\nopen_pr: true\n"), - "labels": [{"name": "x"}, {"name": "y"}, "bad"], - "state": {"name": "Running"}, - } - bt = client.to_board_task(issue) - assert bt.task_id == "ID1" - assert bt.project_id == "P9" - assert bt.title == "My Task" - assert bt.status == "Running" - assert bt.labels == ["x", "y"] - assert bt.repo_key == "myrepo" - assert bt.base_branch == "main" - assert bt.open_pr is True - assert bt.goal_text == "Do the thing." - - -def test_to_board_task_defaults_and_state_string(client: PlaneClient) -> None: - issue = { - "id": 5, - "description": _exec_description(), - "state": "CustomState", - } - bt = client.to_board_task(issue) - assert bt.task_id == "5" - assert bt.project_id == "proj" # falls back to client.project_id - assert bt.title == "Untitled" - assert bt.status == "CustomState" - assert bt.allowed_paths == [] - assert bt.validation_profile is None - assert bt.open_pr is False - - -def test_to_board_task_state_none_unknown(client: PlaneClient) -> None: - issue = {"id": "z", "description": _exec_description(), "state": None} - bt = client.to_board_task(issue) - assert bt.status == "Unknown" - - -def test_to_board_task_with_validation_profile_and_paths(client: PlaneClient) -> None: - desc = _exec_description("validation_profile: strict\nallowed_paths:\n - a/b\n - c/d\n") - issue = {"id": "1", "description": desc, "state": {"name": "Todo"}} - bt = client.to_board_task(issue) - assert bt.validation_profile == "strict" - assert bt.allowed_paths == ["a/b", "c/d"] - - -# -------------------------------------------------------------------------- -# transition_issue -# -------------------------------------------------------------------------- - - -def test_transition_issue_running_sets_start_date(client: PlaneClient) -> None: - client._states_cache = [{"id": "SID", "name": "Running"}] - resp = FakeResponse(json_data={}) - calls = _make_request_returning(client, resp) - client.transition_issue("T1", "Running") - body = calls[0]["kwargs"]["json"] - assert body["state"] == "SID" - assert "start_date" in body - assert "target_date" not in body - assert calls[0]["method"] == "PATCH" - - -@pytest.mark.parametrize("state", ["Done", "Review", "In Review", "Blocked"]) -def test_transition_issue_terminal_sets_target_date(client: PlaneClient, state: str) -> None: - client._states_cache = [] # no resolution -> keeps raw name - resp = FakeResponse(json_data={}) - calls = _make_request_returning(client, resp) - client.transition_issue("T1", state) - body = calls[0]["kwargs"]["json"] - assert body["state"] == state - assert "target_date" in body - assert "start_date" not in body - - -def test_transition_issue_other_state_no_dates(client: PlaneClient) -> None: - client._states_cache = [] - resp = FakeResponse(json_data={}) - calls = _make_request_returning(client, resp) - client.transition_issue("T1", "Todo") - body = calls[0]["kwargs"]["json"] - assert body == {"state": "Todo"} - - -# -------------------------------------------------------------------------- -# create_issue -# -------------------------------------------------------------------------- - - -def test_create_issue_minimal(client: PlaneClient) -> None: - resp = FakeResponse(json_data={"id": "new"}) - calls = _make_request_returning(client, resp) - out = client.create_issue(name="N", description="hello world") - assert out == {"id": "new"} - body = calls[0]["kwargs"]["json"] - assert body["name"] == "N" - assert body["description_stripped"] == "hello world" - assert body["description_html"] == "

    hello world

    " - assert "state" not in body - assert "labels" not in body - - -def test_create_issue_with_state_and_labels(client: PlaneClient) -> None: - client._states_cache = [{"id": "SID", "name": "Todo"}] - client._labels_cache = [{"id": "LID", "name": "bug"}] - resp = FakeResponse(json_data={"id": "new"}) - calls = _make_request_returning(client, resp) - client.create_issue(name="N", description="d", state="Todo", label_names=["bug"]) - body = calls[0]["kwargs"]["json"] - assert body["state"] == "SID" - assert body["labels"] == ["LID"] - - -# -------------------------------------------------------------------------- -# update_issue_description / update_issue_labels -# -------------------------------------------------------------------------- - - -def test_update_issue_description(client: PlaneClient) -> None: - resp = FakeResponse(json_data={}) - calls = _make_request_returning(client, resp) - client.update_issue_description("T1", "para one\n\npara two") - body = calls[0]["kwargs"]["json"] - assert body["description_stripped"] == "para one\n\npara two" - assert body["description_html"] == "

    para one

    para two

    " - assert calls[0]["method"] == "PATCH" - - -def test_update_issue_labels(client: PlaneClient) -> None: - client._labels_cache = [{"id": "LID", "name": "bug"}] - resp = FakeResponse(json_data={}) - calls = _make_request_returning(client, resp) - client.update_issue_labels("T1", ["bug"]) - assert calls[0]["kwargs"]["json"] == {"labels": ["LID"]} - - -# -------------------------------------------------------------------------- -# comment_issue -# -------------------------------------------------------------------------- - - -def test_comment_issue(client: PlaneClient) -> None: - resp = FakeResponse(json_data={}) - calls = _make_request_returning(client, resp) - client.comment_issue("T1", "Summary line\n- item") - body = calls[0]["kwargs"]["json"] - assert body["comment_html"] == "

    Summary line

    • item
    " - assert calls[0]["method"] == "POST" - - -# -------------------------------------------------------------------------- -# _resolve_state_value -# -------------------------------------------------------------------------- - - -def test_resolve_state_value_match_case_insensitive(client: PlaneClient) -> None: - client._states_cache = [{"id": "SID", "name": "In Progress"}] - assert client._resolve_state_value(" in progress ") == "SID" - - -def test_resolve_state_value_no_match_returns_original(client: PlaneClient) -> None: - client._states_cache = [{"id": "SID", "name": "Todo"}] - assert client._resolve_state_value("Nonexistent") == "Nonexistent" - - -# -------------------------------------------------------------------------- -# _ensure_label_ids / _create_label -# -------------------------------------------------------------------------- - - -def test_ensure_label_ids_existing_only(client: PlaneClient) -> None: - client._labels_cache = [ - {"id": "L1", "name": "Bug"}, - {"id": "L2", "name": "feature"}, - {"id": None, "name": "ignored"}, # filtered out (no id) - {"id": "L3", "name": ""}, # filtered out (no name) - ] - ids = client._ensure_label_ids(["bug", "FEATURE", " ", ""]) - assert ids == ["L1", "L2"] - - -def test_ensure_label_ids_creates_missing(client: PlaneClient) -> None: - client._labels_cache = [] - resp = FakeResponse(json_data={"id": "NEW", "name": "shiny"}) - calls = _make_request_returning(client, resp) - ids = client._ensure_label_ids(["shiny", "shiny"]) - # Created once, reused on the second occurrence. - assert ids == ["NEW", "NEW"] - create_calls = [c for c in calls if c["method"] == "POST"] - assert len(create_calls) == 1 - assert create_calls[0]["kwargs"]["json"] == {"name": "shiny"} - # Cache was appended to. - assert {"id": "NEW", "name": "shiny"} in client._labels_cache - - -def test_create_label_no_cache_append_when_cache_none(client: PlaneClient) -> None: - client._labels_cache = None - resp = FakeResponse(json_data={"id": "NEW", "name": "x"}) - _make_request_returning(client, resp) - out = client._create_label("x") - assert out == {"id": "NEW", "name": "x"} - assert client._labels_cache is None - - -def test_create_label_non_dict_response_not_appended(client: PlaneClient) -> None: - client._labels_cache = [] - resp = FakeResponse(json_data="not-a-dict") - _make_request_returning(client, resp) - out = client._create_label("x") - assert out == "not-a-dict" - assert client._labels_cache == [] - - -# -------------------------------------------------------------------------- -# _hydrate_issue_labels -# -------------------------------------------------------------------------- - - -def test_hydrate_labels_empty_or_missing(client: PlaneClient) -> None: - assert client._hydrate_issue_labels({"labels": []}) == {"labels": []} - assert client._hydrate_issue_labels({"labels": "x"}) == {"labels": "x"} - assert client._hydrate_issue_labels({}) == {} - - -def test_hydrate_labels_already_dicts(client: PlaneClient) -> None: - issue = {"labels": [{"id": "1"}, {"id": "2"}]} - assert client._hydrate_issue_labels(issue) is issue - - -def test_hydrate_labels_resolves_ids_from_cache(client: PlaneClient) -> None: - client._labels_cache = [{"id": "L1", "name": "bug"}, {"id": "L2", "name": "feat"}] - issue = {"labels": ["L1", "L2"]} - out = client._hydrate_issue_labels(issue) - assert out["labels"] == [ - {"id": "L1", "name": "bug"}, - {"id": "L2", "name": "feat"}, - ] - - -def test_hydrate_labels_force_refresh_for_unresolved(client: PlaneClient) -> None: - # First label_map call (cache) misses L9 -> triggers force_refresh fetch. - client._labels_cache = [{"id": "L1", "name": "bug"}] - resp = FakeResponse(json_data=[{"id": "L1", "name": "bug"}, {"id": "L9", "name": "new"}]) - _make_request_returning(client, resp) - issue = {"labels": ["L1", "L9", "missing"]} - out = client._hydrate_issue_labels(issue) - assert out["labels"][0] == {"id": "L1", "name": "bug"} - assert out["labels"][1] == {"id": "L9", "name": "new"} - # Still unresolved -> raw kept. - assert out["labels"][2] == "missing" - - -def test_hydrate_labels_mixed_dict_and_id(client: PlaneClient) -> None: - client._labels_cache = [{"id": "L1", "name": "bug"}] - issue = {"labels": [{"id": "X", "name": "inline"}, "L1"]} - out = client._hydrate_issue_labels(issue) - assert out["labels"][0] == {"id": "X", "name": "inline"} - assert out["labels"][1] == {"id": "L1", "name": "bug"} - - -# -------------------------------------------------------------------------- -# _request — retry logic -# -------------------------------------------------------------------------- - - -def _real_request_client() -> PlaneClient: - c = PlaneClient( - base_url="https://plane.example.com", - api_token="tok", - workspace_slug="ws", - project_id="proj", - ) - c._client = MagicMock() - return c - - -def test_request_success_first_try(monkeypatch: pytest.MonkeyPatch) -> None: - c = _real_request_client() - ok = MagicMock(status_code=200) - c._client.request.return_value = ok - slept: list[float] = [] - monkeypatch.setattr( - "operations_center.adapters.plane.client.time.sleep", lambda s: slept.append(s) - ) - assert c._request("GET", "/x") is ok - assert slept == [] - c._client.request.assert_called_once() - - -def test_request_retries_on_connect_error_then_succeeds( - monkeypatch: pytest.MonkeyPatch, -) -> None: - c = _real_request_client() - ok = MagicMock(status_code=200) - c._client.request.side_effect = [httpx.ConnectError("nope"), ok] - slept: list[float] = [] - monkeypatch.setattr( - "operations_center.adapters.plane.client.time.sleep", lambda s: slept.append(s) - ) - assert c._request("GET", "/x") is ok - assert slept == [2] # attempt 1 backoff - - -def test_request_connect_error_exhausts_and_raises( - monkeypatch: pytest.MonkeyPatch, -) -> None: - c = _real_request_client() - c._client.request.side_effect = httpx.TimeoutException("timeout") - monkeypatch.setattr("operations_center.adapters.plane.client.time.sleep", lambda s: None) - with pytest.raises(httpx.TimeoutException): - c._request("GET", "/x") - assert c._client.request.call_count == 4 - - -def test_request_429_with_retry_after_header(monkeypatch: pytest.MonkeyPatch) -> None: - c = _real_request_client() - r429 = MagicMock(status_code=429, headers={"Retry-After": "7"}) - ok = MagicMock(status_code=200) - c._client.request.side_effect = [r429, ok] - slept: list[float] = [] - monkeypatch.setattr( - "operations_center.adapters.plane.client.time.sleep", lambda s: slept.append(s) - ) - assert c._request("GET", "/x") is ok - assert slept == [7] - - -def test_request_429_non_numeric_retry_after_uses_backoff( - monkeypatch: pytest.MonkeyPatch, -) -> None: - c = _real_request_client() - r429 = MagicMock(status_code=429, headers={"Retry-After": "soon"}) - ok = MagicMock(status_code=200) - c._client.request.side_effect = [r429, ok] - slept: list[float] = [] - monkeypatch.setattr( - "operations_center.adapters.plane.client.time.sleep", lambda s: slept.append(s) - ) - assert c._request("GET", "/x") is ok - assert slept == [2] # attempt 1 * 2 - - -def test_request_429_exhausts_returns_last_response( - monkeypatch: pytest.MonkeyPatch, -) -> None: - c = _real_request_client() - r429 = MagicMock(status_code=429, headers={}) - c._client.request.return_value = r429 - monkeypatch.setattr("operations_center.adapters.plane.client.time.sleep", lambda s: None) - assert c._request("GET", "/x") is r429 - assert c._client.request.call_count == 4 - - -def test_request_5xx_retried_then_succeeds(monkeypatch: pytest.MonkeyPatch) -> None: - c = _real_request_client() - r503 = MagicMock(status_code=503, headers={}) - ok = MagicMock(status_code=200) - c._client.request.side_effect = [r503, ok] - slept: list[float] = [] - monkeypatch.setattr( - "operations_center.adapters.plane.client.time.sleep", lambda s: slept.append(s) - ) - assert c._request("GET", "/x") is ok - assert slept == [2] - - -def test_request_5xx_exhausts_returns_response(monkeypatch: pytest.MonkeyPatch) -> None: - c = _real_request_client() - r502 = MagicMock(status_code=502, headers={}) - c._client.request.return_value = r502 - monkeypatch.setattr("operations_center.adapters.plane.client.time.sleep", lambda s: None) - # On the final attempt, the 5xx branch is skipped and the response returns. - assert c._request("GET", "/x") is r502 - assert c._client.request.call_count == 4 - - -def test_request_non_retryable_4xx_returned_immediately( - monkeypatch: pytest.MonkeyPatch, -) -> None: - c = _real_request_client() - r404 = MagicMock(status_code=404, headers={}) - c._client.request.return_value = r404 - monkeypatch.setattr("operations_center.adapters.plane.client.time.sleep", lambda s: None) - assert c._request("GET", "/x") is r404 - c._client.request.assert_called_once() - - -# -------------------------------------------------------------------------- -# _render_comment_html -# -------------------------------------------------------------------------- - - -def test_render_comment_html_empty() -> None: - assert PlaneClient._render_comment_html(" \n ") == "

    (no summary)

    " - - -def test_render_comment_html_header_only() -> None: - assert PlaneClient._render_comment_html("Just a header") == "

    Just a header

    " - - -def test_render_comment_html_with_items_and_escaping() -> None: - out = PlaneClient._render_comment_html("Head \n- a & b\n ignored\n- c") - assert out == "

    Head <x>

    • a & b
    • c
    " - - -# -------------------------------------------------------------------------- -# _render_text_html -# -------------------------------------------------------------------------- - - -def test_render_text_html_empty() -> None: - assert PlaneClient._render_text_html(" ") == "

    " - - -def test_render_text_html_multiline_blocks() -> None: - out = PlaneClient._render_text_html("line1\nline<2>\n\nblock2") - assert out == "

    line1
    line<2>

    block2

    " - - -# -------------------------------------------------------------------------- -# _issue_description_text -# -------------------------------------------------------------------------- - - -def test_issue_description_text_prefers_description() -> None: - assert PlaneClient._issue_description_text({"description": "raw"}) == "raw" - - -def test_issue_description_text_uses_stripped() -> None: - assert PlaneClient._issue_description_text({"description_stripped": "stripped"}) == "stripped" - - -def test_issue_description_text_falls_back_to_html() -> None: - out = PlaneClient._issue_description_text( - {"description": " ", "description_html": "

    hi

    "} - ) - assert out == "hi" - - -def test_issue_description_text_empty() -> None: - assert PlaneClient._issue_description_text({}) == "" - assert PlaneClient._issue_description_text({"description_html": " "}) == "" - - -# -------------------------------------------------------------------------- -# _html_to_task_text -# -------------------------------------------------------------------------- - - -def test_html_to_task_text_headings_lists_breaks() -> None: - html_body = ( - "

    Title

    • one
    • two

    first
    second

    boxed
    " - ) - out = PlaneClient._html_to_task_text(html_body) - assert "## Title" in out - assert "- one" in out - assert "- two" in out - assert "first" in out - assert "second" in out - assert "boxed" in out - assert "<" not in out # all tags stripped - - -def test_html_to_task_text_unescapes_entities() -> None: - out = PlaneClient._html_to_task_text("

    a & b

    ") - assert out == "a & b" - - -def test_html_to_task_text_collapses_blank_lines() -> None: - out = PlaneClient._html_to_task_text("

    a

    b

    ") - assert "\n\n\n" not in out - - -# -------------------------------------------------------------------------- -# raise_for_status propagation -# -------------------------------------------------------------------------- - - -def test_fetch_issue_raises_on_http_error(client: PlaneClient) -> None: - resp = FakeResponse(json_data={"id": "1"}, raise_error=True) - _make_request_returning(client, resp) - with pytest.raises(httpx.HTTPStatusError): - client.fetch_issue("T1") diff --git a/tests/unit/adapters/test_board_backend_selection.py b/tests/unit/adapters/test_board_backend_selection.py index 9a02f776a..a5e6c18ab 100644 --- a/tests/unit/adapters/test_board_backend_selection.py +++ b/tests/unit/adapters/test_board_backend_selection.py @@ -2,15 +2,11 @@ # Copyright (C) 2026 ProtocolWarden """The factory must choose a board backend deliberately, and never by accident. -`make_board_client` is the one place a concrete board is named. Two properties -matter more than the happy path: - -* **Default is Plane.** Adding a `forgejo:` config block must not repoint the - fleet's board. A switch that happens as a side effect of writing config is a - switch nobody decided to make. -* **No silent fallback.** Asking for Forgejo without configuration must fail, - not quietly return Plane — falling back would point the fleet at the very - board it is migrating off, and the symptom would be a board that looks fine. +`make_board_client` is the one place a concrete board is named. Plane was +removed at the 2026-08-18 cutover, so the properties worth pinning changed: +the default is now Forgejo, an unconfigured Forgejo must fail rather than fall +back to anything, and a config still naming the retired backend must be told +so plainly instead of being reported as a typo. """ from __future__ import annotations @@ -20,12 +16,6 @@ from operations_center.adapters.board import make_board_client -class _Plane: - base_url = "http://plane.local" - workspace_slug = "ws" - project_id = "proj" - - class _Forgejo: base_url = "http://forge.local" owner = "protocolwarden" @@ -35,35 +25,29 @@ class _Forgejo: class _Settings: """Minimal stand-in — the factory only touches these attributes.""" - def __init__(self, backend="plane", forgejo=None): - self.plane = _Plane() - self.board_backend = backend - self.forgejo = forgejo + _UNSET = object() - def plane_token(self): - return "plane-tok" + def __init__(self, backend="forgejo", forgejo=_UNSET): + self.board_backend = backend + # `None` must stay None — it is the case under test. Defaulting it away + # made test_forgejo_without_config assert against a configured board. + self.forgejo = _Forgejo() if forgejo is _Settings._UNSET else forgejo def forgejo_token(self): return "forge-tok" -def test_defaults_to_plane(): - from operations_center.adapters.plane import PlaneClient - - client = make_board_client(_Settings()) - assert isinstance(client, PlaneClient) - client.close() +def test_defaults_to_forgejo(): + from operations_center.adapters.forgejo import ForgejoClient + class _NoBackendField: + forgejo = _Forgejo() -def test_configuring_forgejo_alone_does_not_switch_the_board(): - """Config presence is not consent. Only board_backend switches the board.""" - from operations_center.adapters.plane import PlaneClient + def forgejo_token(self): + return "forge-tok" - client = make_board_client(_Settings(backend="plane", forgejo=_Forgejo())) - assert isinstance(client, PlaneClient), ( - "adding a forgejo: block silently repointed the board — that switch must " - "be an explicit decision, not a side effect of writing config" - ) + client = make_board_client(_NoBackendField()) + assert isinstance(client, ForgejoClient) client.close() @@ -78,7 +62,7 @@ def test_selects_forgejo_when_asked(): def test_forgejo_without_config_fails_rather_than_falling_back(): - """A silent fallback would point the fleet at the board it is leaving.""" + """A board the fleet cannot reach must be an error, not an empty queue.""" with pytest.raises(RuntimeError, match="no `forgejo:` settings block"): make_board_client(_Settings(backend="forgejo", forgejo=None)) @@ -89,6 +73,12 @@ def test_unknown_backend_is_refused(): make_board_client(_Settings(backend="gitea")) +def test_the_retired_backend_says_it_was_removed(): + """An old config asking for Plane gets the reason, not "unknown".""" + with pytest.raises(RuntimeError, match="was removed"): + make_board_client(_Settings(backend="plane")) + + def test_settings_model_accepts_a_forgejo_block(): """The real Settings model, not the stub — the config shape must round-trip.""" from operations_center.config.settings import ForgejoSettings @@ -102,13 +92,11 @@ def test_settings_model_accepts_a_forgejo_block(): assert cfg.owner == "protocolwarden" -def test_forgejo_token_explains_itself_when_unconfigured(): - """The error names the cause; 'KeyError: None' would not.""" +def test_settings_default_backend_is_forgejo(): from operations_center.config.settings import Settings fields = Settings.model_fields assert "board_backend" in fields assert "forgejo" in fields - assert fields["board_backend"].default == "plane", ( - "the default backend must remain Plane until cutover" - ) + assert fields["board_backend"].default == "forgejo" + assert "plane" not in fields, "the retired backend is still a settings field" diff --git a/tests/unit/adapters/test_board_seam.py b/tests/unit/adapters/test_board_seam.py index a5685ae46..7f5de50fd 100644 --- a/tests/unit/adapters/test_board_seam.py +++ b/tests/unit/adapters/test_board_seam.py @@ -1,21 +1,18 @@ # SPDX-License-Identifier: AGPL-3.0-or-later # Copyright (C) 2026 ProtocolWarden -"""Hold the board seam, and make the remaining coupling shrink rather than drift. - -OC's board is Plane today and will not be. What makes replacing it expensive is -not the surface — eleven operations — but that callers name `PlaneClient` -directly, construct it from the same four settings fields, and type-hint against -the concrete class. Ten of them had independently hand-rolled the identical -`_make_plane_client()` helper. - -`operations_center.adapters.board` is the seam: a `BoardClient` protocol and one -`make_board_client()` factory. These tests do two jobs: - -* pin the seam itself — the protocol matches what the fleet actually calls, and - the concrete client still satisfies it; -* ratchet the migration — `STILL_IMPORTING_PLANE` is the accepted remainder, and - it may only shrink. A new file reaching past the boundary fails here, which is - the difference between a boundary and a suggestion. +"""Hold the board seam, now that there is one backend behind it. + +The seam was built to make replacing Plane a one-place change, and it did that: +37 files named `PlaneClient` directly, then 2, then none, and the adapter is +gone. What it protects from here is the same boundary aimed at the live +backend — because the reason a caller must not name a concrete client has +nothing to do with *which* client it is. + +* the protocol matches what the fleet actually calls, and the concrete client + still satisfies it; +* the factory is the one place a backend is named, and it refuses to guess; +* nothing outside ``adapters/`` imports the concrete client, with one + allowlisted exception whose reason is recorded below. """ from __future__ import annotations @@ -27,40 +24,36 @@ SRC = pathlib.Path(__file__).resolve().parents[3] / "src" / "operations_center" -#: Files that name `PlaneClient` on purpose, and should keep doing so. +#: Files that construct a concrete board client on purpose. #: -#: This began as a burn-down list of 37 unmigrated callers. It is now empty of -#: migration work — every caller goes through the seam. What remains are two -#: files that exercise Plane *specifically*; routing them through -#: `make_board_client` would delete the thing they test. +#: The setup wizard validates credentials the operator has just typed, before a +#: `Settings` object exists for `make_board_client` to build from. That is a +#: real reason, not a shortcut — and it is the only one. This began as a +#: 37-entry burn-down list against `PlaneClient`; Plane is gone, so the same +#: boundary now guards `ForgejoClient`. #: -#: Adding to this set is not a way to avoid migrating. A new entry needs a reason -#: of the same kind: "this tests Plane itself", not "this was easier". -#: Empty. The setup wizard was the last entry: it constructs a board client -#: directly because it validates credentials the operator has just typed, before -#: any Settings object exists for `make_board_client` to build from. That reason -#: still holds — but since the wizard now onboards operators onto Forgejo, the -#: client it constructs is `ForgejoClient`, and nothing imports `PlaneClient` -#: outside `adapters/` any more. -PLANE_SPECIFIC_BY_DESIGN: set[str] = set() - -#: Kept as the old name so the ratchet tests below read unchanged. -STILL_IMPORTING_PLANE = PLANE_SPECIFIC_BY_DESIGN - -_IMPORTS_PLANE = re.compile( - r"^[ \t]*from operations_center\.adapters\.plane(?:\.client)? import PlaneClient", +#: Adding an entry needs a reason of the same kind: "this validates config that +#: does not exist yet", not "this was easier". +CONSTRUCTS_DIRECTLY_BY_DESIGN = { + "entrypoints/setup/main.py", +} + +_IMPORTS_CONCRETE = re.compile( + r"^[ \t]*from operations_center\.adapters\.forgejo(?:\.client)? import .*ForgejoClient", re.M, ) def _importers() -> set[str]: - """Files outside adapters/ that import the concrete client.""" + """Files outside adapters/ that import the concrete board client.""" found = set() for path in SRC.rglob("*.py"): rel = path.relative_to(SRC).as_posix() if rel.startswith("adapters/") or "__pycache__" in rel: continue - if _IMPORTS_PLANE.search(path.read_text(encoding="utf-8", errors="replace")): + text = path.read_text(encoding="utf-8", errors="replace") + # The PR-side client lives in the same package and is a different seam. + if _IMPORTS_CONCRETE.search(text) and "ForgejoPRClient" not in text: found.add(rel) return found @@ -89,15 +82,15 @@ def _declared_operations(proto: type) -> set[str]: def test_the_concrete_client_satisfies_the_protocol(): - """PlaneClient must remain usable as a BoardClient. + """ForgejoClient must remain usable as a BoardClient. If it stops, callers type-hinting the protocol are lying about what they accept, and the seam is decorative. """ - from operations_center.adapters.plane import PlaneClient + from operations_center.adapters.forgejo import ForgejoClient - missing = sorted(op for op in BOARD_OPERATIONS if not hasattr(PlaneClient, op)) - assert not missing, f"PlaneClient no longer provides: {missing}" + missing = sorted(op for op in BOARD_OPERATIONS if not hasattr(ForgejoClient, op)) + assert not missing, f"ForgejoClient no longer provides: {missing}" def test_protocol_declares_every_operation_the_fleet_calls(): @@ -105,8 +98,8 @@ def test_protocol_declares_every_operation_the_fleet_calls(): A protocol missing an operation pushes callers back to the concrete class — which is exactly what happened with `set_priority`: it was absent, so - triage_scan reached through the adapter's private httpx client to PATCH - Plane's URL directly. + triage_scan reached through the adapter's private httpx client to PATCH the + board's URL directly. """ from operations_center.adapters.board import BoardClient @@ -115,6 +108,15 @@ def test_protocol_declares_every_operation_the_fleet_calls(): assert not missing, f"BoardClient does not declare: {missing}" +# ── construction ───────────────────────────────────────────────────────────── + + +class _Forgejo: + base_url = "http://forge.local" + owner = "protocolwarden" + repo = "board" + + def test_factory_builds_from_settings_without_naming_a_backend(monkeypatch): """make_board_client is the one place a concrete backend is named.""" from operations_center.adapters import board @@ -126,39 +128,33 @@ def __init__(self, **kw): captured.update(kw) monkeypatch.setattr( - "operations_center.adapters.plane.PlaneClient", _Fake, raising=False + "operations_center.adapters.forgejo.ForgejoClient", _Fake, raising=False ) - class _Board: - base_url = "http://board.local" - workspace_slug = "ws" - project_id = "proj" - class _Settings: - plane = _Board() + board_backend = "forgejo" + forgejo = _Forgejo() - def plane_token(self): + def forgejo_token(self): return "tok" board.make_board_client(_Settings()) assert captured == { - "base_url": "http://board.local", + "base_url": "http://forge.local", "api_token": "tok", - "workspace_slug": "ws", - "project_id": "proj", + "owner": "protocolwarden", + "repo": "board", }, "the factory changed the construction contract the callers relied on" def test_factory_tolerates_a_settings_double(monkeypatch): """A MagicMock settings object must still build the default backend. - `getattr(settings, "board_backend", "plane")` looks like it defaults, but a + `getattr(settings, "board_backend", ...)` looks like it defaults, but a MagicMock answers every attribute, so the default is unreachable and the - factory raised "unknown board_backend ". That is not a - hypothetical: it broke - `tests/maintenance/test_orphan_branch_check.py::test_emit_plane_task_updates_existing_issue` - from #509 until now, and went unnoticed because CI runs `tests/unit` and - never `tests/maintenance/`. + factory raised "unknown board_backend ". That is not + hypothetical: it broke test_orphan_branch_check from #509 until #513, and + went unnoticed because CI runs `tests/unit` and never `tests/maintenance/`. """ from unittest.mock import MagicMock @@ -171,13 +167,26 @@ def __init__(self, **kw): built.update(kw) monkeypatch.setattr( - "operations_center.adapters.plane.PlaneClient", _Fake, raising=False + "operations_center.adapters.forgejo.ForgejoClient", _Fake, raising=False ) board.make_board_client(MagicMock()) assert built, "a settings double no longer builds the default backend" +def test_factory_refuses_forgejo_without_a_config_block(): + """No silent fallback: a board the fleet cannot reach must be an error, not + an empty-looking queue.""" + from operations_center.adapters import board + + class _Settings: + board_backend = "forgejo" + forgejo = None + + with pytest.raises(RuntimeError, match="no `forgejo:` settings block"): + board.make_board_client(_Settings()) + + def test_factory_still_rejects_a_real_unknown_backend(): """Tolerating a mock must not tolerate a typo in the config.""" from operations_center.adapters import board @@ -189,68 +198,45 @@ class _Settings: board.make_board_client(_Settings()) -def test_factory_refuses_plane_backend_without_a_plane_block(): - """`plane` is optional in Settings since the Forgejo cutover. +def test_asking_for_plane_says_it_was_removed(): + """A config left on the retired backend deserves a straight answer. - A config that says board_backend: plane but carries no plane block must fail - loudly at construction — the same contract the forgejo branch has — because - a board the fleet cannot reach looks like an empty queue, not an error. + "unknown board_backend 'plane'" would read as a typo. It was a real backend + until the 2026-08-18 cutover, and an operator with an old config is asking a + reasonable question. """ from operations_center.adapters import board class _Settings: board_backend = "plane" - plane = None - with pytest.raises(RuntimeError, match="no `plane:` settings block"): + with pytest.raises(RuntimeError, match="removed"): board.make_board_client(_Settings()) -class _ForgejoBlock: - owner = "Operations_Center_Admin" - repo = "board" +# ── the project id ─────────────────────────────────────────────────────────── -class _PlaneBlock: - project_id = "proj-uuid" - - -def test_board_project_id_follows_the_forgejo_backend(): - """Forgejo's natural identifier is the board repo itself.""" +def test_board_project_id_comes_from_the_active_backend(): from operations_center.adapters.board import board_project_id class _Settings: board_backend = "forgejo" - forgejo = _ForgejoBlock() + forgejo = _Forgejo() - assert board_project_id(_Settings()) == "Operations_Center_Admin/board" + assert board_project_id(_Settings()) == "protocolwarden/board" -def test_board_project_id_follows_the_plane_backend(): +def test_board_project_id_fails_loudly_without_a_config_block(): + """#516's concern: this sits on the dispatch path, so an AttributeError here + means a correctly-configured-looking fleet executes nothing.""" from operations_center.adapters.board import board_project_id class _Settings: - board_backend = "plane" - plane = _PlaneBlock() - - assert board_project_id(_Settings()) == "proj-uuid" - - -@pytest.mark.parametrize("backend", ["plane", "forgejo"]) -def test_board_project_id_fails_loudly_without_the_active_block(backend): - """The council's #516 concern: `settings.plane.project_id` sat on the - dispatch path, so a Forgejo-only config (exactly what the example now - recommends) raised AttributeError before any task could execute. The id - must come from the active backend, and a missing block must be a loud - RuntimeError, not an AttributeError.""" - from operations_center.adapters.board import board_project_id - - class _Settings: - board_backend = backend - plane = None + board_backend = "forgejo" forgejo = None - with pytest.raises(RuntimeError, match="settings block"): + with pytest.raises(RuntimeError, match="no `forgejo:` settings block"): board_project_id(_Settings()) @@ -261,19 +247,25 @@ def test_board_project_id_tolerates_a_settings_double(): from operations_center.adapters.board import board_project_id settings = MagicMock() - settings.plane.project_id = "proj-uuid" - assert board_project_id(settings) == "proj-uuid" + settings.forgejo.owner = "o" + settings.forgejo.repo = "r" + assert board_project_id(settings) == "o/r" + +# ── the boundary ───────────────────────────────────────────────────────────── -# ── the ratchet ────────────────────────────────────────────────────────────── +def test_no_file_reaches_past_the_boundary(): + """Callers depend on `BoardClient`, never on the concrete class. -def test_no_new_file_reaches_past_the_boundary(): - """The accepted remainder may shrink, never grow.""" + This is the ratchet that took Plane from 37 importers to zero, aimed now at + the backend that actually exists. The reason it existed never depended on + which backend it was. + """ actual = _importers() - added = sorted(actual - STILL_IMPORTING_PLANE) + added = sorted(actual - CONSTRUCTS_DIRECTLY_BY_DESIGN) assert not added, ( - f"{len(added)} file(s) import PlaneClient directly without being on the " + f"{len(added)} file(s) import ForgejoClient directly without being on the " f"accepted list: {added}. Use " "`from operations_center.adapters.board import BoardClient, make_board_client` " "instead — the point of the seam is that swapping the board is one change, " @@ -282,58 +274,58 @@ def test_no_new_file_reaches_past_the_boundary(): def test_allowlist_has_no_stale_entries(): - """A migrated file must be struck off, so the list measures real remaining work.""" + """A file that stops constructing directly must be struck off, so the list + measures real remaining coupling.""" actual = _importers() - stale = sorted(STILL_IMPORTING_PLANE - actual) + stale = sorted(CONSTRUCTS_DIRECTLY_BY_DESIGN - actual) assert not stale, ( - f"{len(stale)} file(s) no longer import PlaneClient but are still listed: " - f"{stale}. Remove them from STILL_IMPORTING_PLANE." + f"{len(stale)} file(s) no longer import ForgejoClient but are still " + f"listed: {stale}. Remove them from CONSTRUCTS_DIRECTLY_BY_DESIGN." ) -@pytest.mark.parametrize("migrated", [ - "entrypoints/maintenance/board_unblock.py", - "entrypoints/maintenance/board_unblock_task.py", - "entrypoints/maintenance/triage_scan.py", - "entrypoints/board_worker/main.py", - "entrypoints/pr_review_watcher/main.py", - "entrypoints/proposer/main.py", - "entrypoints/spec_hygiene/main.py", - "propagation/plane_adapter.py", - "scheduled_tasks/runner.py", - "priority_scans.py", -]) -def test_migrated_files_stay_migrated(migrated): - """Pin this slice so it cannot quietly regress.""" - text = (SRC / migrated).read_text(encoding="utf-8") - assert "PlaneClient" not in text, f"{migrated} names PlaneClient again" - assert "adapters.board" in text, f"{migrated} no longer uses the seam" - - def test_the_hand_rolled_factories_are_gone(): - """Ten copies of the same constructor was the evidence the seam was missing.""" - remaining = [ - p.relative_to(SRC).as_posix() - for p in SRC.rglob("*.py") - if "__pycache__" not in p.as_posix() - and re.search(r"def _(?:make_)?plane_client\b", p.read_text(encoding="utf-8", errors="replace")) - and re.search(r"PlaneClient\(", p.read_text(encoding="utf-8", errors="replace")) - ] + """Ten copies of the same constructor was the evidence the seam was missing. + + Kept pointed at the current backend so the pattern cannot grow back under a + new name. + """ + remaining = [] + for p in SRC.rglob("*.py"): + rel = p.relative_to(SRC).as_posix() + if rel.startswith("adapters/") or "__pycache__" in rel: + continue + text = p.read_text(encoding="utf-8", errors="replace") + if re.search(r"def _(?:make_)?(?:board|forgejo)_client\b", text) and re.search( + r"ForgejoClient\(", text + ): + remaining.append(rel) assert not remaining, ( - f"{len(remaining)} file(s) still hand-roll the client constructor: {remaining}" + f"{len(remaining)} file(s) hand-roll the client constructor: {remaining}" ) -def test_the_migration_is_finished(): - """No caller should be left to migrate. +def test_the_retired_backend_is_actually_gone(): + """The adapter package, not just its callers. - The seam existed to make swapping the board a one-place change. That is only - true once every caller goes through it — a seam with stragglers still forces - a per-caller change at cutover, which is the cost it was built to remove. + Leaving 382 lines of unreachable client behind would be a second source of + truth about how the fleet talks to a board — one nothing exercises, and so + one nothing keeps honest. """ - actual = _importers() - unmigrated = sorted(actual - PLANE_SPECIFIC_BY_DESIGN) - assert not unmigrated, ( - f"{len(unmigrated)} caller(s) still import PlaneClient without a " - f"design reason: {unmigrated}" + assert not (SRC / "adapters" / "plane").exists(), ( + "adapters/plane is back; the board backend is Forgejo" + ) + # Deliberately importers, not every mention: several docstrings narrate the + # migration ("callers imported PlaneClient by name..."), and that history is + # why the seam exists. What must not come back is a live dependency. + importers = sorted( + p.relative_to(SRC).as_posix() + for p in SRC.rglob("*.py") + if "__pycache__" not in p.as_posix() + and re.search( + r"^[ \t]*(from operations_center\.adapters\.plane|import operations_center\.adapters\.plane)", + p.read_text(encoding="utf-8", errors="replace"), + re.M, + ) ) + assert not importers, f"files still import the removed adapter: {importers}" diff --git a/tests/unit/entrypoints/board_worker/test_dispatch_cov.py b/tests/unit/entrypoints/board_worker/test_dispatch_cov.py index 061c38f6b..ad65ad0d6 100644 --- a/tests/unit/entrypoints/board_worker/test_dispatch_cov.py +++ b/tests/unit/entrypoints/board_worker/test_dispatch_cov.py @@ -16,7 +16,8 @@ def _make_settings(repos=None): return SimpleNamespace( repos=repos or {}, - plane=SimpleNamespace(project_id="proj-1"), + board_backend="forgejo", + forgejo=SimpleNamespace(owner="protocolwarden", repo="board"), team_executor=SimpleNamespace(timeout_seconds=900), ) diff --git a/tests/unit/entrypoints/board_worker/test_spec_author_cov.py b/tests/unit/entrypoints/board_worker/test_spec_author_cov.py index 37b84663b..c86ae808c 100644 --- a/tests/unit/entrypoints/board_worker/test_spec_author_cov.py +++ b/tests/unit/entrypoints/board_worker/test_spec_author_cov.py @@ -16,9 +16,10 @@ # ── Helpers ─────────────────────────────────────────────────────────────────── -def _make_settings(repos=None, project_id="proj-1"): +def _make_settings(repos=None): return SimpleNamespace( - plane=SimpleNamespace(project_id=project_id), + board_backend="forgejo", + forgejo=SimpleNamespace(owner="protocolwarden", repo="board"), repos=repos or {}, ) diff --git a/tests/unit/test_capability_ownership.py b/tests/unit/test_capability_ownership.py index 9c0b18960..31e442135 100644 --- a/tests/unit/test_capability_ownership.py +++ b/tests/unit/test_capability_ownership.py @@ -364,15 +364,7 @@ def test_require_capability_owner_default_is_true(): def test_require_capability_owner_true_on_constructed_settings(): # Belt-and-suspenders: a fully constructed Settings carries the True default. - from operations_center.config.settings import GitSettings, PlaneSettings, Settings - - s = Settings( - plane=PlaneSettings( - base_url="http://x", - api_token_env="T", - workspace_slug="w", - project_id="p", - ), - git=GitSettings(), - ) + from operations_center.config.settings import GitSettings, Settings + + s = Settings(git=GitSettings()) assert s.require_capability_owner is True