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
23 changes: 14 additions & 9 deletions PROTO.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,19 +37,24 @@ 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)
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
api.py # routers — public PAGES (view/new-issue) + gated data routes (config/issues/prs/issue)
view.py # PAGE = read-only board (Issues/PRs tabs + repo picker); NEW_ISSUE_PAGE = file-an-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
**Console surfaces (ADR 0026/0038/0042/0057).** `register()` mounts two routers when the
host exposes `register_router` (guarded — degrade-safe): the PAGES 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.
DATA routes on the GATED `/api/plugins/github` prefix (pages fetch them with the DS
plugin-kit's `apiFetch`, which attaches the operator bearer from the postMessage
handshake). Pages are vanilla JS themed entirely from `--pl-*` tokens (no host build).
Two manifest `views`, three surfaces, deliberately split:
- **`/view`** — the READ-ONLY board (Issues/PRs tabs + repo picker + state filter). Right
dock + a ⌘K morph (`palette: inline`). No writes — it's a viewer.
- **`/new-issue`** — the compact file-an-issue form. A util-bar widget pill (`utility`,
opens its dialog) AND a distinct ⌘K page (`palette: { path }`). `POST /issue` reuses the
SAME `file_issue` gate path as the `/issue` command, so they can't diverge.
Filing lives in the widget + palette, NOT the board (keep the board read-only).

## 4. The gating design — DO NOT BREAK IT

Expand Down
9 changes: 7 additions & 2 deletions api.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,18 +85,23 @@ def _repos(cfg: dict) -> list[str]:


def build_view_router():
"""The board PAGE — served under the PUBLIC ``/plugins/github`` prefix (ungated)."""
"""The PAGES — served under the PUBLIC ``/plugins/github`` prefix (ungated): the
read-only board (``/view``) and the compact file-an-issue form (``/new-issue``)."""
from fastapi import APIRouter
from fastapi.responses import HTMLResponse

from .view import PAGE
from .view import NEW_ISSUE_PAGE, PAGE

router = APIRouter()

@router.get("/view")
async def _view():
return HTMLResponse(PAGE)

@router.get("/new-issue")
async def _new_issue():
return HTMLResponse(NEW_ISSUE_PAGE)

return router


Expand Down
13 changes: 9 additions & 4 deletions protoagent.plugin.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,16 @@ 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.
# Console views (ADR 0026/0057) — sandboxed iframes the plugin serves itself (api.py).
# - the READ-ONLY board (Issues/PRs tabs over the repo picker): right dock + a ⌘K morph
# (`palette: inline` opens the board in the palette body). `placement: right` is just a
# default — views are drag-sortable in the console.
# - FILE AN ISSUE (the compact form): a util-bar widget pill (`utility`, opens its dialog)
# AND a distinct ⌘K palette page (`palette: { path }`). Filing lives here + the palette,
# NOT in the board — the board is a viewer.
views:
- { id: github, label: GitHub, icon: Github, path: /plugins/github/view, placement: right }
- { id: github, label: GitHub, icon: Github, path: /plugins/github/view, placement: right, palette: inline }
- { id: github-new-issue, label: New issue, icon: Bug, path: /plugins/github/new-issue, utility: { info: "File a GitHub issue" }, palette: { path: /plugins/github/new-issue } }

# 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
39 changes: 37 additions & 2 deletions tests/test_board_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,21 @@ def test_view_page_served():
assert r.status_code == 200 and "GitHub" in r.text


def test_new_issue_page_served():
r = TestClient(_app()).get("/plugins/github/new-issue")
assert r.status_code == 200 and "New issue" in r.text


def test_board_page_is_read_only():
"""The board is a viewer — it must NOT POST to /issue (filing lives in the widget/palette)."""
from ghplugin.view import PAGE

# The board must not POST to the create-issue endpoint. Match it exactly with its
# closing quote, since "/api/plugins/github/issue" is a substring of ".../issues".
assert '/api/plugins/github/issue"' not in PAGE # no create-issue from the board
assert "/api/plugins/github/issues" in PAGE and "/api/plugins/github/prs" in PAGE # reads only


def test_config_route_returns_repos_and_default():
c = TestClient(_app())
body = c.get("/api/plugins/github/config").json()
Expand Down Expand Up @@ -138,10 +153,30 @@ def test_legacy_host_without_register_router_still_loads_tools(make_legacy_regis


def test_page_references_gated_endpoints():
"""The page fetches its DATA from the gated /api/plugins/github routes."""
from ghplugin.view import PAGE
"""The pages fetch their DATA from the gated /api/plugins/github routes."""
from ghplugin.view import NEW_ISSUE_PAGE, 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
# The new-issue page posts to the gated create route + is kit-themed.
assert "/api/plugins/github/issue" in NEW_ISSUE_PAGE
assert "/_ds/plugin-kit" in NEW_ISSUE_PAGE


def test_manifest_declares_board_and_widget_views():
"""Two views: the read-only board (right dock + ⌘K) and the file-an-issue widget
(util-bar pill + ⌘K palette page)."""
from pathlib import Path

import yaml

root = Path(__file__).resolve().parent.parent
views = {v["id"]: v for v in yaml.safe_load((root / "protoagent.plugin.yaml").read_text())["views"]}
board, new_issue = views["github"], views["github-new-issue"]
assert board["placement"] == "right" and board["palette"] == "inline"
assert board["path"] == "/plugins/github/view"
assert new_issue["utility"]["info"] # a util-bar pill with hover info
assert new_issue["palette"]["path"] == "/plugins/github/new-issue" # distinct ⌘K page
assert new_issue["path"] == "/plugins/github/new-issue"
146 changes: 85 additions & 61 deletions view.py
Original file line number Diff line number Diff line change
@@ -1,33 +1,34 @@
"""The GitHub board console viewa self-contained page served at
``/plugins/github/view`` (by api.py's view router) and iframed by the console.
"""The GitHub console pagesserved at ``/plugins/github/*`` (by api.py) and iframed
by the console. Two pages, two surfaces:

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``.
- ``PAGE`` (``/view``) — the **read-only board**: a quick look at a project's board,
two tabs (Issues / Pull Requests) over a repo picker (the configured ``github.repos``)
+ an open/closed/all filter. No writes — it's a viewer. Declared in the manifest as a
right-dock view AND a ⌘K palette morph (``palette: inline``).
- ``NEW_ISSUE_PAGE`` (``/new-issue``) — the compact **file-an-issue** form (title / kind /
repo / body → the gate-checked ``POST /issue``). Declared as a util-bar widget
(``utility``) AND a distinct ⌘K palette page (``palette: { path }``) — so filing lives
in the util bar + the palette, NOT in the board (the "view is read-only" split).

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/<slug>`` 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).
FOUR-RULES COMPLIANT (docs/how-to/build-a-plugin-view.md): 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 proxy); links the
DS plugin-kit so the page is themed from the operator's live ``--pl-*`` tokens. Vanilla
JS, no host build (ADR 0038).
"""

from __future__ import annotations

# --- the read-only board -----------------------------------------------------
PAGE = r"""<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>GitHub</title>
<script>
"use strict";
// Slug-aware base, computed FIRST (the kit's own assets load before the kit exists).
window.__base = location.pathname.split("/plugins/")[0];
document.write('<link rel="stylesheet" href="' + window.__base + '/_ds/plugin-kit.css">');
</script>
<style>
/* Layout only — colours/type come from plugin-kit.css's --pl-* tokens (re-skinned to
the operator's live theme by plugin-kit.js on the handshake). */
*{box-sizing:border-box}
html,body{margin:0;height:100%;background:var(--pl-color-bg-raised);color:var(--pl-color-fg);
font-family:var(--pl-font-sans,ui-sans-serif,system-ui,sans-serif);font-size:13px}
Expand All @@ -52,13 +53,6 @@
.meta{margin-top:3px;font-size:11px;color:var(--pl-color-fg-muted);display:flex;gap:6px;flex-wrap:wrap;align-items:center}
.pill{font-size:10px;padding:1px 7px;border-radius:999px;border:1px solid var(--pl-color-border)}
.empty,.hint{padding:24px 14px;text-align:center;color:var(--pl-color-fg-muted)}
#panel{display:none;border-bottom:1px solid var(--pl-color-border);padding:10px;background:var(--pl-color-bg)}
#panel.on{display:block}
#panel input,#panel textarea,#panel select{width:100%;margin:4px 0;background:var(--pl-color-bg-raised);
color:var(--pl-color-fg);border:1px solid var(--pl-color-border);border-radius:var(--pl-radius,8px);padding:6px;font-size:12px}
#panel textarea{min-height:120px;resize:vertical;font-family:var(--pl-font-mono,ui-monospace,Menlo,monospace)}
.prow{display:flex;gap:6px;align-items:center}
#res{font-size:11px;margin-top:4px;color:var(--pl-color-fg-muted);white-space:pre-wrap}
</style></head><body>
<div id="wrap">
<div class="bar">
Expand All @@ -69,38 +63,22 @@
</div>
<select id="state" title="State"><option value="open">Open</option><option value="closed">Closed</option><option value="all">All</option></select>
<span class="spacer"></span>
<button class="pl-btn pl-btn--sm" id="new" type="button">New issue</button>
<button class="pl-btn pl-btn--sm" id="refresh" type="button" title="Refresh">↻</button>
</div>
<div id="panel">
<div class="prow">
<input id="f-title" placeholder="Issue title" />
<select id="f-kind" style="max-width:130px"><option value="generic">Generic</option><option value="bug">Bug</option><option value="feature">Feature</option></select>
</div>
<textarea id="f-body" placeholder="## Problem&#10;What's wrong or what you want, and why it matters.&#10;&#10;## Acceptance&#10;How we'll know it's done."></textarea>
<div class="prow">
<button class="pl-btn pl-btn--sm" id="f-submit" type="button">File issue</button>
<button class="pl-btn pl-btn--sm" id="f-cancel" type="button">Cancel</button>
<span id="res"></span>
</div>
</div>
<div id="list"><div class="hint">Loading…</div></div>
</div>
<script type="module">
"use strict";
// plugin-kit.js is an ES module; dynamic import is the no-build way to load it with a
// slug-aware URL. Older host with no /_ds route → tokenless same-origin shim (local dev).
let kit;
try { kit = await import(window.__base + "/_ds/plugin-kit.js"); }
catch (e) { kit = { initPluginView(cb){ cb && cb(); }, apiFetch:(p,i)=>fetch(window.__base+p,i) }; }

const $ = (id) => document.getElementById(id);
let tab = "issues", cfg = { repos: [], default_repo: "", gh_available: true };
let tab = "issues";
const esc = (s) => String(s == null ? "" : s).replace(/[&<>"]/g, (c) => ({ "&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;" }[c]));
const fmtDate = (iso) => { try { return new Date(iso).toLocaleDateString(undefined,{month:"short",day:"numeric",year:"numeric"}); } catch(e){ return ""; } };

function setTab(t){ tab = t; $("t-issues").setAttribute("aria-selected", t==="issues"); $("t-prs").setAttribute("aria-selected", t==="prs"); load(); }

function labelPills(labels){ return (labels||[]).slice(0,5).map(l => '<span class="pill">'+esc(l.name||l)+'</span>').join(" "); }

function issueRow(it){
Expand All @@ -124,8 +102,7 @@
}

async function load(){
const repo = $("repo").value;
const list = $("list");
const repo = $("repo").value, list = $("list");
if(!repo){ list.innerHTML = '<div class="hint">No repositories configured. Add some under <b>Settings ▸ GitHub</b> (github.repos).</div>'; return; }
list.innerHTML = '<div class="hint">Loading…</div>';
const path = (tab==="issues"?"/api/plugins/github/issues":"/api/plugins/github/prs")
Expand All @@ -139,22 +116,8 @@
} catch(e){ list.innerHTML = '<div class="empty">Failed to load — is the agent reachable?</div>'; }
}

async function submitIssue(){
const repo = $("repo").value, title = $("f-title").value.trim();
if(!title){ $("res").textContent = "Title is required."; return; }
$("res").textContent = "Filing…";
try {
const r = await kit.apiFetch("/api/plugins/github/issue", {
method:"POST", headers:{"Content-Type":"application/json"},
body: JSON.stringify({ repo, title, body: $("f-body").value, kind: $("f-kind").value })
}).then(x => x.json());
if(r.ok && r.url){ $("res").textContent = "Filed ✓"; $("f-title").value=""; $("f-body").value=""; $("panel").classList.remove("on"); if(tab==="issues") load(); }
else if(r.missing){ $("res").textContent = "Missing: "+r.missing.join("; "); }
else { $("res").textContent = r.error || "Could not file."; }
} catch(e){ $("res").textContent = "Request failed."; }
}

async function boot(){
let cfg = { repos: [], default_repo: "" };
try { cfg = await kit.apiFetch("/api/plugins/github/config").then(r => r.json()); } catch(e){}
const sel = $("repo");
sel.innerHTML = (cfg.repos||[]).map(r => '<option value="'+esc(r)+'">'+esc(r)+'</option>').join("");
Expand All @@ -165,12 +128,73 @@
$("t-issues").onclick = () => setTab("issues");
$("t-prs").onclick = () => setTab("prs");
$("repo").onchange = load; $("state").onchange = load; $("refresh").onclick = load;
$("new").onclick = () => { $("panel").classList.toggle("on"); $("res").textContent=""; if($("panel").classList.contains("on")) $("f-title").focus(); };
$("f-cancel").onclick = () => $("panel").classList.remove("on");
$("f-submit").onclick = submitIssue;
kit.initPluginView(boot);
boot();
</script></body></html>"""


# --- the compact "file an issue" page (util-bar widget + ⌘K palette) ---------
NEW_ISSUE_PAGE = r"""<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>New issue</title>
<script>
"use strict";
window.__base = location.pathname.split("/plugins/")[0];
document.write('<link rel="stylesheet" href="' + window.__base + '/_ds/plugin-kit.css">');
</script>
<style>
*{box-sizing:border-box}
html,body{margin:0;height:100%;background:var(--pl-color-bg-raised);color:var(--pl-color-fg);
font-family:var(--pl-font-sans,ui-sans-serif,system-ui,sans-serif);font-size:13px}
#wrap{display:flex;flex-direction:column;gap:6px;padding:12px;height:100%;min-height:0}
.row{display:flex;gap:6px;align-items:center}
input,textarea,select{width:100%;background:var(--pl-color-bg);color:var(--pl-color-fg);
border:1px solid var(--pl-color-border);border-radius:var(--pl-radius,8px);padding:7px;font-size:12px}
textarea{flex:1;min-height:140px;resize:vertical;font-family:var(--pl-font-mono,ui-monospace,Menlo,monospace)}
#res{font-size:11px;color:var(--pl-color-fg-muted);white-space:pre-wrap;min-height:16px}
</style></head><body>
<div id="wrap">
<div class="row">
<select id="repo" title="Repository"></select>
<select id="kind" style="max-width:130px"><option value="generic">Generic</option><option value="bug">Bug</option><option value="feature">Feature</option></select>
</div>
<input id="title" placeholder="Issue title" autofocus />
<textarea id="body" placeholder="## Problem&#10;What's wrong or what you want, and why it matters.&#10;&#10;## Acceptance&#10;How we'll know it's done."></textarea>
<div class="row">
<button class="pl-btn pl-btn--sm" id="submit" type="button">File issue</button>
<span class="spacer" style="flex:1"></span>
<span id="res"></span>
</div>
</div>
<script type="module">
"use strict";
let kit;
try { kit = await import(window.__base + "/_ds/plugin-kit.js"); }
catch (e) { kit = { initPluginView(cb){ cb && cb(); }, apiFetch:(p,i)=>fetch(window.__base+p,i) }; }
const $ = (id) => document.getElementById(id);
const esc = (s) => String(s == null ? "" : s).replace(/[&<>"]/g, (c) => ({ "&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;" }[c]));

// The kit owns the handshake (bearer + theme, incl. live re-themes); re-boot when the
// token first arrives on a gated instance (the immediate boot() covers tokenless local).
async function boot(){
let cfg = { repos: [], default_repo: "" };
try { cfg = await kit.apiFetch("/api/plugins/github/config").then(r => r.json()); } catch(e){}
$("repo").innerHTML = (cfg.repos||[]).map(r => '<option value="'+esc(r)+'">'+esc(r)+'</option>').join("");
if(cfg.default_repo){ $("repo").value = cfg.default_repo; }
}
async function submit(){
const title = $("title").value.trim();
if(!title){ $("res").textContent = "Title is required."; return; }
$("res").textContent = "Filing…";
try {
const r = await kit.apiFetch("/api/plugins/github/issue", {
method:"POST", headers:{"Content-Type":"application/json"},
body: JSON.stringify({ repo: $("repo").value, title, body: $("body").value, kind: $("kind").value })
}).then(x => x.json());
if(r.ok && r.url){ $("res").innerHTML = 'Filed ✓ <a href="'+esc(r.url)+'" target="_blank" rel="noreferrer">view</a>'; $("title").value=""; $("body").value=""; }
else if(r.missing){ $("res").textContent = "Missing: "+r.missing.join("; "); }
else { $("res").textContent = r.error || "Could not file."; }
} catch(e){ $("res").textContent = "Request failed."; }
}
$("submit").onclick = submit;
kit.initPluginView(boot);
boot();
</script></body></html>"""
Loading