diff --git a/__init__.py b/__init__.py index 9f098a1..01f0013 100644 --- a/__init__.py +++ b/__init__.py @@ -86,7 +86,12 @@ async def _issue(rest: str, session_id: str) -> str: from .api import build_data_router, build_view_router registry.register_router(build_view_router(), prefix="/plugins/github") - registry.register_router(build_data_router(cfg), prefix="/api/plugins/github") + # Pass a LIVE config getter when the host offers one (registry.live_config), + # so a repo/default_repo edit shows in the board with no server restart — a + # hot-reload can't re-mount this router, but reading config per request does. + # Older hosts (no live_config) fall back to the register-time snapshot. + get_cfg = registry.live_config if hasattr(registry, "live_config") else cfg + registry.register_router(build_data_router(get_cfg), prefix="/api/plugins/github") view = True except Exception: # noqa: BLE001 log.exception("[github] registering the board view failed") diff --git a/api.py b/api.py index 5c59ebd..76e85dd 100644 --- a/api.py +++ b/api.py @@ -105,25 +105,37 @@ async def _new_issue(): return router -def build_data_router(cfg: dict): +def build_data_router(cfg): """The board's DATA routes — mounted under the GATED ``/api/plugins/github`` prefix. - Reads the plugin's own configured repo picker (no host coupling). ``/issue`` reuses - the SAME gate-checked `file_issue` path as the `/issue` chat command, so the dialog - and the command can never diverge. + ``cfg`` is either the config dict OR a zero-arg callable returning it. Pass a callable + (e.g. ``registry.live_config``) so the board reflects config edits WITHOUT a server + restart: a hot-reload can't re-mount this router, but reading the config per request + picks up the freshly-saved repos/default_repo. A plain dict (tests, older host) is a + fixed snapshot. ``/issue`` reuses the SAME gate-checked `file_issue` path as the + `/issue` chat command, so the dialog and the command can never diverge. """ from fastapi import APIRouter, Body from .gh_issue import IssueRequest, effective_default_repo, file_issue, labels_for, resolve_repo + get_cfg = cfg if callable(cfg) else (lambda: cfg) + router = APIRouter() @router.get("/config") async def _config() -> dict: - repos = _repos(cfg) + current = get_cfg() + repos = _repos(current) + default = effective_default_repo(current.get("default_repo", ""), repos) + # The picker is built from `repos`. A very common config sets `default_repo` but + # leaves `repos` empty — without folding the default in, the picker has zero + # options and the board shows "nothing to select" even though a repo IS configured. + # Surface the resolved default as a selectable option (first), deduped. + selectable = [default, *repos] if default and default not in repos else repos return { - "repos": repos, - "default_repo": effective_default_repo(cfg.get("default_repo", ""), repos), + "repos": selectable, + "default_repo": default, "gh_available": gh_available(), } @@ -137,12 +149,13 @@ async def _prs(repo: str, state: str = "open") -> dict: @router.post("/issue") async def _create_issue(body: dict = Body(...)) -> dict: + current = get_cfg() kind = (body.get("kind") or "generic").lower() if kind not in ("bug", "feature", "generic"): kind = "generic" title = (body.get("title") or "").strip() issue_body = (body.get("body") or "").strip() - repo = resolve_repo(body.get("repo"), effective_default_repo(cfg.get("default_repo", ""), _repos(cfg))) + repo = resolve_repo(body.get("repo"), effective_default_repo(current.get("default_repo", ""), _repos(current))) labels = labels_for(kind, [str(x) for x in (body.get("labels") or [])]) dry_run = bool(body.get("dry_run")) if not title: diff --git a/tests/test_board_view.py b/tests/test_board_view.py index ce20285..3a4e732 100644 --- a/tests/test_board_view.py +++ b/tests/test_board_view.py @@ -97,6 +97,38 @@ def test_config_route_returns_repos_and_default(): assert isinstance(body["gh_available"], bool) +def test_config_folds_default_repo_into_picker_when_repos_empty(): + """default_repo set + repos empty (a common config) must still give the picker a + selectable option — otherwise the board shows "nothing to select" though a repo is + configured.""" + c = TestClient(_app({"repos": [], "default_repo": "o/solo"})) + body = c.get("/api/plugins/github/config").json() + assert body["repos"] == ["o/solo"] # default surfaced as the selectable option + assert body["default_repo"] == "o/solo" + + +def test_config_does_not_duplicate_default_already_in_repos(): + c = TestClient(_app({"repos": ["o/a", "o/b"], "default_repo": "o/b"})) + body = c.get("/api/plugins/github/config").json() + assert body["repos"] == ["o/a", "o/b"] # no dup; order preserved + assert body["default_repo"] == "o/b" + + +def test_data_router_reads_config_live_from_a_getter(): + """Given a config GETTER (callable), /config reflects edits per request — so a saved + repo shows in the board without a server restart (the mounted router can't re-mount).""" + from fastapi import FastAPI + + live = {"repos": [], "default_repo": ""} + app = FastAPI() + app.include_router(build_data_router(lambda: live), prefix="/api/plugins/github") + c = TestClient(app) + + assert c.get("/api/plugins/github/config").json()["repos"] == [] # nothing yet + live["repos"] = ["o/added"] # operator saves a repo (config reloaded under us) + assert c.get("/api/plugins/github/config").json()["repos"] == ["o/added"] # no restart + + def test_issues_route_proxies_fetch(): fake = AsyncMock(return_value=(0, '[{"number":3,"title":"X"}]', "")) with patch("ghplugin.api.run_gh", fake):