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
13 changes: 12 additions & 1 deletion PROTO.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 23 additions & 1 deletion __init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""
Expand Down Expand Up @@ -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 "",
)
153 changes: 153 additions & 0 deletions api.py
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions protoagent.plugin.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
6 changes: 4 additions & 2 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
@@ -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)
8 changes: 8 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,17 +31,25 @@ 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)

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
Expand Down
Loading
Loading