From 5327fb92c214f3419a8bfeca09a17813342a275c Mon Sep 17 00:00:00 2001 From: Josh Mabry Date: Tue, 23 Jun 2026 22:06:29 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20console=20board=20view=20=E2=80=94=20tw?= =?UTF-8?q?o-tab=20Issues/PRs=20panel=20(themed)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the plugin's own console view: a quick read of a project's board, modelled on protoMaker's github-view but rebuilt in our design system. - view.py: a self-contained, --pl-*-themed PAGE (vanilla JS, no host build) — two tabs (Issues / Pull Requests), a repo picker fed from the configured github.repos, an open/closed/all state filter, list rows (state dot, #number, title, author/date, label pills; PRs add draft/review/branch), and a "New issue" form. - api.py: two routers (the notes pattern) — the PAGE on the PUBLIC /plugins/github prefix (iframe-loadable) and the DATA routes on the GATED /api/plugins/github prefix (config/issues/prs/issue), fetched with the DS plugin-kit's bearer handshake. POST /issue reuses the SAME file_issue gate path as the /issue command, so they can't diverge. Data logic is in plain async fns (host-free testable); fastapi imports lazy. - __init__.py: register both routers when the host exposes register_router (guarded — an older host still loads tools + /issue). manifest: views: entry (placement: right). - tests: data fns + routes via FastAPI TestClient + register wiring + legacy-host degrade + page-references-the-gated-endpoints. requirements-dev adds fastapi + httpx. Co-Authored-By: Claude Opus 4.8 (1M context) --- PROTO.md | 13 ++- __init__.py | 24 +++++- api.py | 153 ++++++++++++++++++++++++++++++++++ protoagent.plugin.yaml | 6 ++ requirements-dev.txt | 6 +- tests/conftest.py | 8 ++ tests/test_board_view.py | 147 ++++++++++++++++++++++++++++++++ view.py | 176 +++++++++++++++++++++++++++++++++++++++ 8 files changed, 529 insertions(+), 4 deletions(-) create mode 100644 api.py create mode 100644 tests/test_board_view.py create mode 100644 view.py diff --git a/PROTO.md b/PROTO.md index 4080ecd..7de5026 100644 --- a/PROTO.md +++ b/PROTO.md @@ -37,9 +37,20 @@ gh_cli.py # vendored async `gh` runner (run_gh, check_gh_error, b read_tools.py # 8 read tools (6 ported core + read_file/repo_contents) write_tools.py # 8 write tools (create/edit/merge/close/comment/labels/assignees) — gated gh_issue.py # /issue chat command logic (user-only; gate-checked; configured repo) -tests/ # host-free pytest (gating + version coherence) +api.py # board view routers — public PAGE + gated data routes (config/issues/prs/issue) +view.py # board PAGE (HTML): two tabs (Issues/PRs) + repo picker + new-issue form, --pl-* themed +tests/ # host-free pytest (gating + version coherence + routes via TestClient) ``` +**Console board view (ADR 0026/0038/0042).** `register()` mounts two routers when the +host exposes `register_router` (guarded — degrade-safe): the PAGE on the PUBLIC +`/plugins/github` prefix (iframe-loadable; a page-load can't carry a bearer) and the +DATA routes on the GATED `/api/plugins/github` prefix (the page fetches them with the +DS plugin-kit's `apiFetch`, which attaches the operator bearer from the postMessage +handshake). The page is vanilla JS themed entirely from `--pl-*` tokens (no host build). +The manifest's `views:` entry points the console at `/plugins/github/view`. `POST /issue` +reuses the SAME `file_issue` gate path as the `/issue` command, so they can't diverge. + ## 4. The gating design — DO NOT BREAK IT `register()` registers **read tools unconditionally** and **write tools ONLY when diff --git a/__init__.py b/__init__.py index 689eb75..9f098a1 100644 --- a/__init__.py +++ b/__init__.py @@ -11,6 +11,12 @@ host provides it, reading the configured `default_repo`/`repos` for routing. On an older host without that seam, `/issue` is simply skipped — the tools still load. +And it serves its own console board view (two tabs: Issues / PRs) via two routers +(public PAGE + gated DATA, the notes pattern) when the host exposes `register_router`. + +Every host-coupled registration (chat command, routers) is `hasattr`-guarded so the +plugin degrades gracefully on an older host: the tools always load. + Host-only imports stay LAZY (none here) so the test suite imports the modules with no protoAgent host present. """ @@ -70,9 +76,25 @@ async def _issue(rest: str, session_id: str) -> str: except Exception: # noqa: BLE001 log.exception("[github] registering the /issue command failed") + # Console board view (ADR 0026/0038) — two routers, the notes pattern: the PAGE on + # the PUBLIC /plugins/github prefix (iframe-loadable), the DATA routes on the GATED + # /api/plugins/github prefix. Guarded so a host without register_router still loads + # the tools + /issue. The manifest's `views` entry points the console at /view. + view = False + if hasattr(registry, "register_router"): + try: + 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") + view = True + except Exception: # noqa: BLE001 + log.exception("[github] registering the board view failed") + log.info( - "[github] registered %d read tool(s)%s%s", + "[github] registered %d read tool(s)%s%s%s", n_read, f" + {n_write} write tool(s) (write enabled)" if write_enabled else " (read-only — github.write is false)", " + /issue command" if issue_cmd else "", + " + board view" if view else "", ) diff --git a/api.py b/api.py new file mode 100644 index 0000000..63d8f57 --- /dev/null +++ b/api.py @@ -0,0 +1,153 @@ +"""HTTP for the GitHub console board — the view PAGE + its gated data routes. + +Two routers, the notes-plugin pattern (ADR 0026/0038/0042): + - the view PAGE under the PUBLIC ``/plugins/github`` prefix (a browser iframe + page-load can't carry a bearer, so the page itself is public chrome); and + - the DATA routes under the GATED ``/api/plugins/github`` prefix (the operator + bearer gate), fetched from inside the loaded page with the postMessage handshake + token (the DS plugin-kit's ``apiFetch``). + +The data logic lives in plain async functions (``fetch_issues`` / ``fetch_prs``) so +the suite can test it host-free; the FastAPI imports stay lazy inside the build_* +functions (fastapi is the host's at runtime). +""" + +from __future__ import annotations + +import json +import shutil + +from .gh_cli import bad_repo, run_gh + +# The JSON fields we ask `gh` for — kept lean: enough for a board row + the detail link. +_ISSUE_FIELDS = "number,title,state,author,labels,url,createdAt,comments" +_PR_FIELDS = "number,title,state,author,labels,url,createdAt,isDraft,headRefName,reviewDecision" + + +def gh_available() -> bool: + """Whether the `gh` CLI is on PATH (the board shows a hint when it isn't).""" + return shutil.which("gh") is not None + + +def _norm_state(state: str) -> str | None: + """Normalise the state filter to what `gh` accepts, or None if invalid.""" + s = (state or "open").strip().lower() + return s if s in ("open", "closed", "merged", "all") else None + + +async def fetch_issues(repo: str, state: str = "open", limit: int = 30) -> dict: + """List issues for ``repo`` as ``{"items": [...]}`` (or ``{"error": "..."}``). + + Each item is the raw `gh issue list --json` row (number/title/state/author/ + labels/url/createdAt/comments). PRs are excluded — `gh issue list` already omits them. + """ + if err := bad_repo(repo): + return {"error": err} + norm = _norm_state(state) + if norm is None: + return {"error": f"Error: state must be open|closed|all (got {state!r})."} + capped = max(1, min(int(limit), 100)) + rc, out, serr = await run_gh( + ["issue", "list", "--repo", repo, "--state", norm, "--limit", str(capped), "--json", _ISSUE_FIELDS] + ) + if rc != 0: + return {"error": f"Error (gh exit {rc}): {serr[:300]}"} + try: + return {"items": json.loads(out or "[]")} + except json.JSONDecodeError: + return {"error": f"Error: could not parse gh output: {out[:200]}"} + + +async def fetch_prs(repo: str, state: str = "open", limit: int = 30) -> dict: + """List pull requests for ``repo`` as ``{"items": [...]}`` (or ``{"error": "..."}``). + + Each item is the raw `gh pr list --json` row (adds isDraft/headRefName/reviewDecision). + """ + if err := bad_repo(repo): + return {"error": err} + norm = _norm_state(state) + if norm is None: + return {"error": f"Error: state must be open|closed|merged|all (got {state!r})."} + capped = max(1, min(int(limit), 100)) + rc, out, serr = await run_gh( + ["pr", "list", "--repo", repo, "--state", norm, "--limit", str(capped), "--json", _PR_FIELDS] + ) + if rc != 0: + return {"error": f"Error (gh exit {rc}): {serr[:300]}"} + try: + return {"items": json.loads(out or "[]")} + except json.JSONDecodeError: + return {"error": f"Error: could not parse gh output: {out[:200]}"} + + +def _repos(cfg: dict) -> list[str]: + return [str(r).strip() for r in (cfg.get("repos") or []) if str(r).strip()] + + +def build_view_router(): + """The board PAGE — served under the PUBLIC ``/plugins/github`` prefix (ungated).""" + from fastapi import APIRouter + from fastapi.responses import HTMLResponse + + from .view import PAGE + + router = APIRouter() + + @router.get("/view") + async def _view(): + return HTMLResponse(PAGE) + + return router + + +def build_data_router(cfg: dict): + """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. + """ + from fastapi import APIRouter, Body + + from .gh_issue import IssueRequest, effective_default_repo, file_issue, labels_for, resolve_repo + + router = APIRouter() + + @router.get("/config") + async def _config() -> dict: + repos = _repos(cfg) + return { + "repos": repos, + "default_repo": effective_default_repo(cfg.get("default_repo", ""), repos), + "gh_available": gh_available(), + } + + @router.get("/issues") + async def _issues(repo: str, state: str = "open") -> dict: + return await fetch_issues(repo, state) + + @router.get("/prs") + async def _prs(repo: str, state: str = "open") -> dict: + return await fetch_prs(repo, state) + + @router.post("/issue") + async def _create_issue(body: dict = Body(...)) -> dict: + 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))) + labels = labels_for(kind, [str(x) for x in (body.get("labels") or [])]) + dry_run = bool(body.get("dry_run")) + if not title: + return {"ok": False, "error": "Title is required."} + if not repo: + return {"ok": False, "error": "No target repo — set one in Settings ▸ GitHub, or pick one."} + if bad_repo(repo): + return {"ok": False, "error": f"Repo must be 'owner/name' (got {repo!r})."} + return await file_issue( + IssueRequest(title=title, body=issue_body, kind=kind, repo=repo, labels=labels, dry_run=dry_run) + ) + + return router diff --git a/protoagent.plugin.yaml b/protoagent.plugin.yaml index 7ef0a40..4361758 100644 --- a/protoagent.plugin.yaml +++ b/protoagent.plugin.yaml @@ -31,6 +31,12 @@ settings: - {key: default_repo, label: "Default repo (owner/name)", type: string} - {key: repos, label: "Repo picker list", type: string_list} +# Console board view (ADR 0026) — a sandboxed iframe the plugin serves itself at +# /plugins/github/view (api.py). Two tabs (Issues / PRs) over the repo picker above. +# `placement: right` opens it in the right dock; views are drag-sortable in the console. +views: + - { id: github, label: GitHub, icon: Github, path: /plugins/github/view, placement: right } + # Auth: `gh` uses its own ambient auth (`gh auth login`), or GITHUB_TOKEN / GH_TOKEN # from the env if set. No plugin secret needed for public-repo reads. diff --git a/requirements-dev.txt b/requirements-dev.txt index 2643589..c29506b 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,6 +1,8 @@ -# Test-only deps. At runtime, langchain-core is provided by the host (protoAgent); -# the plugin adds NO runtime pip deps. These let the suite run standalone in CI. +# Test-only deps. At runtime, langchain-core + fastapi are provided by the host +# (protoAgent); the plugin adds NO runtime pip deps. These let the suite run standalone. langchain-core>=0.2 +fastapi>=0.110 # the board view's router (build_view_router/build_data_router) +httpx>=0.27 # powers fastapi/starlette TestClient (the route tests) pyyaml>=6 pytest>=8 pytest-asyncio>=0.23 # for the async-tool tests the team adds (asyncio_mode=auto) diff --git a/tests/conftest.py b/tests/conftest.py index 609d1bb..ecc17db 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -31,6 +31,7 @@ def __init__(self, config: dict | None = None): self.config = config or {} self.tools: list = [] self.chat_commands: dict = {} # token -> handler (the host's chat-command seam) + self.routers: list = [] # {"router", "prefix"} (the host's router seam) def register_tool(self, t): self.tools.append(t) @@ -38,10 +39,17 @@ def register_tool(self, t): def register_chat_command(self, name: str, handler): self.chat_commands[name] = handler + def register_router(self, router, prefix=None): + self.routers.append({"router": router, "prefix": prefix}) + @property def tool_names(self) -> list[str]: return [getattr(t, "name", getattr(t, "__name__", "?")) for t in self.tools] + @property + def router_prefixes(self) -> list[str]: + return [r["prefix"] for r in self.routers] + class _LegacyRegistry: """A host WITHOUT the chat-command seam (older protoAgent) — no diff --git a/tests/test_board_view.py b/tests/test_board_view.py new file mode 100644 index 0000000..dc70a5e --- /dev/null +++ b/tests/test_board_view.py @@ -0,0 +1,147 @@ +"""The GitHub board view — data functions, the gated data routes, and register wiring. + +The data logic (`fetch_issues`/`fetch_prs`) is tested directly with `run_gh` mocked; +the routes are tested through FastAPI's TestClient (mounted under their real prefixes, +the host-free terminal-plugin pattern). register() wires two routers when the host +exposes register_router, and the page references the right gated endpoints. +""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, patch + +from fastapi import FastAPI +from fastapi.testclient import TestClient +from ghplugin import register +from ghplugin.api import build_data_router, build_view_router, fetch_issues, fetch_prs, gh_available + +_CFG = {"repos": ["o/n", "o/m"], "default_repo": "o/n"} + + +def _app(cfg=None): + app = FastAPI() + app.include_router(build_view_router(), prefix="/plugins/github") + app.include_router(build_data_router(cfg or _CFG), prefix="/api/plugins/github") + return app + + +# --- data functions ---------------------------------------------------------- + + +async def test_fetch_issues_builds_argv_and_parses(): + fake = AsyncMock(return_value=(0, '[{"number":1,"title":"Bug","state":"OPEN"}]', "")) + with patch("ghplugin.api.run_gh", fake): + out = await fetch_issues("o/n", state="open") + assert out == {"items": [{"number": 1, "title": "Bug", "state": "OPEN"}]} + args = fake.call_args.args[0] + assert args[:5] == ["issue", "list", "--repo", "o/n", "--state"] + assert "--json" in args # asks for the structured row + + +async def test_fetch_issues_bad_repo_and_bad_state(): + fake = AsyncMock() + with patch("ghplugin.api.run_gh", fake): + assert "owner/name" in (await fetch_issues("nope"))["error"] + assert "state must be" in (await fetch_issues("o/n", state="weird"))["error"] + fake.assert_not_called() + + +async def test_fetch_issues_gh_failure(): + fake = AsyncMock(return_value=(1, "", "not found")) + with patch("ghplugin.api.run_gh", fake): + out = await fetch_issues("o/n") + assert out["error"].startswith("Error (gh exit 1)") + + +async def test_fetch_prs_builds_argv_and_parses(): + fake = AsyncMock(return_value=(0, '[{"number":5,"title":"PR","isDraft":true}]', "")) + with patch("ghplugin.api.run_gh", fake): + out = await fetch_prs("o/n", state="open") + assert out["items"][0]["number"] == 5 + assert fake.call_args.args[0][:2] == ["pr", "list"] + + +def test_gh_available_is_bool(): + assert isinstance(gh_available(), bool) + + +# --- routes (TestClient) ----------------------------------------------------- + + +def test_view_page_served(): + c = TestClient(_app()) + r = c.get("/plugins/github/view") + assert r.status_code == 200 and "GitHub" in r.text + + +def test_config_route_returns_repos_and_default(): + c = TestClient(_app()) + body = c.get("/api/plugins/github/config").json() + assert body["repos"] == ["o/n", "o/m"] + assert body["default_repo"] == "o/n" + assert isinstance(body["gh_available"], bool) + + +def test_issues_route_proxies_fetch(): + fake = AsyncMock(return_value=(0, '[{"number":3,"title":"X"}]', "")) + with patch("ghplugin.api.run_gh", fake): + body = TestClient(_app()).get("/api/plugins/github/issues", params={"repo": "o/n"}).json() + assert body["items"][0]["number"] == 3 + + +def test_prs_route_proxies_fetch(): + fake = AsyncMock(return_value=(0, "[]", "")) + with patch("ghplugin.api.run_gh", fake): + body = TestClient(_app()).get("/api/plugins/github/prs", params={"repo": "o/n", "state": "all"}).json() + assert body["items"] == [] + + +def test_create_issue_route_uses_gate_and_default_repo(): + """POST /issue filing goes through file_issue (gate + gh) and the configured repo.""" + fake = AsyncMock(return_value=(0, "https://github.com/o/n/issues/7", "")) + # A bug must clear the gate: a substantive body + a Problem AND a repro/steps section. + good_body = ( + "## Problem\nThe widget crashes on empty input and we should handle it gracefully " + "throughout the pipeline.\n## Steps to reproduce\nCall it with an empty string." + ) + with patch("ghplugin.gh_issue.run_gh", fake): + body = ( + TestClient(_app()) + .post("/api/plugins/github/issue", json={"title": "Crash on empty", "kind": "bug", "body": good_body}) + .json() + ) + assert body["ok"] and body["url"].endswith("/issues/7") + assert fake.call_args.args[0][:4] == ["issue", "create", "--repo", "o/n"] # default repo used + + +def test_create_issue_route_requires_title(): + body = TestClient(_app()).post("/api/plugins/github/issue", json={"title": ""}).json() + assert body["ok"] is False and "Title" in body["error"] + + +# --- register wiring --------------------------------------------------------- + + +def test_register_wires_both_routers(make_registry): + reg = make_registry(_CFG) + register(reg) + assert "/plugins/github" in reg.router_prefixes # public PAGE + assert "/api/plugins/github" in reg.router_prefixes # gated DATA + assert "github_get_pr" in reg.tool_names # tools still load + + +def test_legacy_host_without_register_router_still_loads_tools(make_legacy_registry): + reg = make_legacy_registry({}) + register(reg) # must not raise — no register_router on this host + assert "github_get_pr" in reg.tool_names + assert not hasattr(reg, "routers") + + +def test_page_references_gated_endpoints(): + """The page fetches its DATA from the gated /api/plugins/github routes.""" + from ghplugin.view import PAGE + + assert "/api/plugins/github/config" in PAGE + assert "/api/plugins/github/issues" in PAGE + assert "/api/plugins/github/prs" in PAGE + assert "/_ds/plugin-kit" in PAGE # themed via the DS kit diff --git a/view.py b/view.py new file mode 100644 index 0000000..ff1588f --- /dev/null +++ b/view.py @@ -0,0 +1,176 @@ +"""The GitHub board console view — a self-contained page served at +``/plugins/github/view`` (by api.py's view router) and iframed by the console. + +A quick read of a project's board: two tabs (Issues / Pull Requests) over a repo +picker sourced from the plugin's configured ``github.repos`` (the same registration +the ``/issue`` command uses), an open/closed/all state filter, and a "New issue" form +that posts through the SAME gate-checked path as ``/issue``. + +FOUR-RULES COMPLIANT (docs/how-to/build-a-plugin-view.md, the notes/chat_example +pattern): served on the PUBLIC path · DATA is the gated channel (the DS kit's +``apiFetch`` attaches the operator bearer from the postMessage handshake) · slug-aware +base (host window AND the fleet ``/agents/`` proxy) · links the DS plugin-kit so +the whole page is themed from the operator's live ``--pl-*`` tokens (no host build — +vanilla JS, git-installable per ADR 0038). +""" + +from __future__ import annotations + +PAGE = r""" + +GitHub + + +
+
+ +
+ + +
+ + + + +
+
+
+ + +
+ +
+ + + +
+
+
Loading…
+
+"""