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
2 changes: 1 addition & 1 deletion protoagent.plugin.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
# Keep `version` in lockstep with pyproject.toml (tests/test_version.py asserts it).
id: github
name: GitHub (read/write tools)
version: 0.1.3
version: 0.1.4
description: >-
Read AND write GitHub tools over the `gh` CLI, with PER-AGENT write gating. The
read tools (PRs, issues, diffs, CI, repo files/contents) are always on; the write
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "github-plugin"
version = "0.1.3"
version = "0.1.4"
description = "Read/write GitHub tools for protoAgent over the gh CLI, with per-agent write gating."
requires-python = ">=3.11"

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 @@ -99,6 +99,38 @@ def test_pages_boot_once():
assert "boot();" not in page # …and NOT also called directly


def test_pages_boot_is_idempotent():
"""#15 — the kit's ``initPluginView`` callback fires on the initial init AND on every
re-theme (plus the handshake re-send), so ``boot`` must guard itself to run exactly once.
'Boot once' at the call site (#13) wasn't enough — the single callback fires repeatedly,
each run rebuilding the picker + re-loading → the list flicker/thrash returned."""
from ghplugin.view import NEW_ISSUE_PAGE, PAGE

for page in (PAGE, NEW_ISSUE_PAGE):
assert "if (booted) return;" in page # the callback is guarded, not just called once


def test_board_renders_comment_count_not_the_array():
"""#16 — ``gh issue list --json comments`` returns an ARRAY of comment objects; the board
must render its COUNT (``.length``), not ``String(array)`` which shows '[object Object]'."""
from ghplugin.view import PAGE

assert "it.comments.length" in PAGE # the count, not the raw array
assert "esc(it.comments)" not in PAGE # the old object-stringifying render is gone
assert "💬" not in PAGE # emoji swapped for a Lucide icon
assert '<svg class="ico"' in PAGE # inline Lucide icon markup present


def test_board_heading_uses_icon_actions():
"""#16 follow-up + heading tightening — the refresh action is a Lucide icon (not the raw
glyph) and the pull-request tab is the compact 'PRs'."""
from ghplugin.view import PAGE

assert "↻" not in PAGE # raw refresh glyph replaced by the Lucide refresh-cw icon
assert "ICON.refresh" in PAGE # …set from the shared icon set
assert ">PRs<" in PAGE # tightened tab label


def test_config_route_returns_repos_and_default():
c = TestClient(_app())
body = c.get("/api/plugins/github/config").json()
Expand Down
41 changes: 31 additions & 10 deletions view.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,16 @@
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;height:100%;min-height:0}
.bar{display:flex;align-items:center;gap:8px;padding:8px 10px;flex-wrap:wrap;
.bar{display:flex;align-items:center;gap:6px;padding:6px 8px;flex-wrap:wrap;
border-bottom:var(--pl-border-width,1px) solid var(--pl-color-border)}
.tabs{display:inline-flex;border:1px solid var(--pl-color-border);border-radius:var(--pl-radius,8px);overflow:hidden}
.tab{padding:5px 12px;cursor:pointer;background:transparent;border:0;color:var(--pl-color-fg-muted);font-size:12px}
.tab{padding:4px 11px;cursor:pointer;background:transparent;border:0;color:var(--pl-color-fg-muted);
font-size:12px;line-height:1.6;white-space:nowrap}
.tab[aria-selected="true"]{background:var(--pl-color-bg);color:var(--pl-color-fg);font-weight:600}
select{background:var(--pl-color-bg);color:var(--pl-color-fg);border:1px solid var(--pl-color-border);
border-radius:var(--pl-radius,8px);padding:4px 6px;font-size:12px;max-width:240px}
border-radius:var(--pl-radius,8px);padding:4px 6px;font-size:12px;max-width:220px}
.ico{width:1em;height:1em;flex:none;vertical-align:-0.15em}
#refresh{display:inline-flex;align-items:center;justify-content:center;padding:4px 7px;color:var(--pl-color-fg-muted)}
.spacer{flex:1}
#list{flex:1;min-height:0;overflow:auto;padding:4px 0}
.row{display:block;text-decoration:none;color:inherit;padding:9px 12px;
Expand All @@ -51,19 +54,20 @@
.title{font-weight:600;line-height:1.35}
.num{color:var(--pl-color-fg-muted);font-weight:400}
.meta{margin-top:3px;font-size:11px;color:var(--pl-color-fg-muted);display:flex;gap:6px;flex-wrap:wrap;align-items:center}
.cmt{display:inline-flex;align-items:center;gap:3px}
.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)}
</style></head><body>
<div id="wrap">
<div class="bar">
<select id="repo" title="Repository"></select>
<div class="tabs" role="tablist">
<button class="tab" id="t-issues" role="tab" aria-selected="true">Issues</button>
<button class="tab" id="t-prs" role="tab" aria-selected="false">Pull Requests</button>
<button class="tab" id="t-issues" role="tab" aria-selected="true" title="Issues">Issues</button>
<button class="tab" id="t-prs" role="tab" aria-selected="false" title="Pull Requests">PRs</button>
</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="refresh" type="button" title="Refresh">↻</button>
<button class="pl-btn pl-btn--sm" id="refresh" type="button" title="Refresh" aria-label="Refresh"></button>
</div>
<div id="list"><div class="hint">Loading…</div></div>
</div>
Expand All @@ -77,17 +81,26 @@
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 ""; } };
// Inline Lucide (v0.468) SVGs — themed via currentColor, no runtime dep / kit icon API.
const svg = (paths) => '<svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">'+paths+'</svg>';
const ICON = {
comment: svg('<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>'),
refresh: svg('<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/>'),
};

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){
const st = (it.state||"").toLowerCase();
// `gh issue list --json comments` returns an ARRAY of comment objects, not a count —
// rendering it directly was the "[object Object]" bug (#16). Show its length.
const cc = Array.isArray(it.comments) ? it.comments.length : (Number(it.comments)||0);
return '<a class="row" href="'+esc(it.url)+'" target="_blank" rel="noreferrer">'
+ '<div class="top"><span class="dot '+(st==="closed"?"closed":"open")+'"></span>'
+ '<span class="title">'+esc(it.title)+' <span class="num">#'+esc(it.number)+'</span></span></div>'
+ '<div class="meta"><span>'+esc((it.author&&it.author.login)||"?")+'</span><span>'+fmtDate(it.createdAt)+'</span>'
+ (it.comments?('<span>💬 '+esc(it.comments)+'</span>'):'')+' '+labelPills(it.labels)+'</div></a>';
+ (cc?('<span class="cmt" title="'+esc(cc)+' comment'+(cc===1?'':'s')+'">'+ICON.comment+esc(cc)+'</span>'):'')+' '+labelPills(it.labels)+'</div></a>';
}
function prRow(it){
const merged = (it.state||"").toLowerCase()==="merged", draft = !!it.isDraft;
Expand Down Expand Up @@ -122,7 +135,10 @@
} catch(e){ if(my===loadSeq) list.innerHTML = '<div class="empty">Failed to load — is the agent reachable?</div>'; }
}

let booted = false;
async function boot(){
if (booted) return; // the kit re-fires this on every re-theme + the handshake re-send;
booted = true; // build the picker and first-load EXACTLY once, or the list thrashes (#15).
let cfg = { repos: [], default_repo: "" };
try { cfg = await kit.apiFetch("/api/plugins/github/config").then(r => r.json()); } catch(e){}
const sel = $("repo");
Expand All @@ -131,12 +147,14 @@
load();
}

$("refresh").innerHTML = ICON.refresh;
$("t-issues").onclick = () => setTab("issues");
$("t-prs").onclick = () => setTab("prs");
$("repo").onchange = load; $("state").onchange = load; $("refresh").onclick = load;
// Boot ONCE — via the kit so it runs after the theme/auth handshake (the fallback kit calls
// it immediately). A second direct boot() here caused two overlapping config+load sequences
// → the list flicker/thrash on mount (#13).
// Boot via the kit so it runs after the theme/auth handshake (the fallback kit calls it
// immediately). initPluginView's callback fires on the initial init AND every re-theme, so
// boot() itself is guarded (booted) to run once — that's what actually kills the mount
// thrash; a second direct boot() call was removed in #13.
kit.initPluginView(boot);
</script></body></html>"""

Expand Down Expand Up @@ -182,7 +200,10 @@
const $ = (id) => document.getElementById(id);
const esc = (s) => String(s == null ? "" : s).replace(/[&<>"]/g, (c) => ({ "&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;" }[c]));

let booted = false;
async function boot(){
if (booted) return; // kit re-fires on every re-theme; populate the picker once so it never
booted = true; // clobbers an in-progress repo selection (#15).
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("");
Expand Down
Loading