Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion __init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
29 changes: 21 additions & 8 deletions api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
}

Expand All @@ -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:
Expand Down
32 changes: 32 additions & 0 deletions tests/test_board_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading