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"?(p|div|ul|ol|pre)[^>]*>", "\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 "" 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
-
-""",
- "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
"
- 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>
"
-
-
-# --------------------------------------------------------------------------
-# _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
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