pr-reviewer/static/index.html
Adam Moussa 46cd856fb3
Add pr-reviewer local PR review tool
A single-user local dashboard that pulls open PRs from the
Sea-Haven-Industries org, reviews each with a Fireworks model in the
BLOCK/FIX/NIT/QUESTION format, and posts the review to GitHub as the
token owner. Runs only on localhost; secrets stay in a gitignored .env
and never reach the browser.

Structured as an app/ package plus a static/ frontend so the module
imports and static mount resolve. HTTP uses httpx2 (the runtime lib
starlette's TestClient now prefers), pinned in requirements.txt.

Includes a stdlib-only pytest suite (network mocked, no extra test
deps so CI needs only pytest) with a skip-by-default live Fireworks
test, and CI wired to the org ci-python-app reusable workflow to lint
app and tests and run the mocked suite fully offline. Dependabot covers
pip and github-actions.
2026-07-01 13:48:10 -04:00

286 lines
12 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>PR Review Desk</title>
<style>
:root {
--bg: #12141a;
--panel: #191c24;
--panel-2: #1f2330;
--line: #2a2f3d;
--ink: #e7e9ef;
--ink-dim: #9aa2b4;
--accent: #6ea8fe;
--block: #ff6b6b;
--fix: #f7b955;
--nit: #7bd88f;
--question: #b39 dff;
--question: #b39dff;
--mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, monospace;
--sans: "Inter", system-ui, -apple-system, sans-serif;
}
* { box-sizing: border-box; }
body {
margin: 0; background: var(--bg); color: var(--ink);
font-family: var(--sans); font-size: 14px; line-height: 1.5;
}
header {
display: flex; align-items: baseline; gap: 16px;
padding: 14px 20px; border-bottom: 1px solid var(--line);
background: var(--panel);
}
header h1 { font-size: 15px; margin: 0; letter-spacing: .02em; font-weight: 600; }
header .meta { font-family: var(--mono); font-size: 12px; color: var(--ink-dim); }
header .spacer { flex: 1; }
button {
font-family: var(--sans); font-size: 13px; cursor: pointer;
background: var(--panel-2); color: var(--ink);
border: 1px solid var(--line); border-radius: 6px; padding: 6px 12px;
}
button:hover { border-color: var(--accent); }
button:disabled { opacity: .5; cursor: not-allowed; }
button.primary { background: var(--accent); color: #0b0e14; border-color: var(--accent); font-weight: 600; }
button.danger { border-color: var(--block); }
.layout { display: grid; grid-template-columns: 340px 1fr; height: calc(100vh - 51px); }
.queue { border-right: 1px solid var(--line); overflow-y: auto; background: var(--panel); }
.queue-item {
padding: 12px 16px; border-bottom: 1px solid var(--line); cursor: pointer;
}
.queue-item:hover { background: var(--panel-2); }
.queue-item.active { background: var(--panel-2); border-left: 2px solid var(--accent); padding-left: 14px; }
.queue-item .repo { font-family: var(--mono); font-size: 11px; color: var(--ink-dim); }
.queue-item .title { font-weight: 500; margin: 2px 0; }
.queue-item .sub { font-size: 12px; color: var(--ink-dim); display: flex; gap: 8px; align-items: center; }
.badge { font-size: 10px; font-family: var(--mono); padding: 1px 6px; border-radius: 4px; border: 1px solid var(--line); }
.badge.reviewed { color: var(--nit); border-color: var(--nit); }
.badge.mention { color: var(--accent); border-color: var(--accent); }
.detail { overflow-y: auto; padding: 24px 28px; }
.detail h2 { font-size: 17px; margin: 0 0 4px; }
.detail .prlink { font-family: var(--mono); font-size: 12px; color: var(--accent); text-decoration: none; }
.empty { color: var(--ink-dim); padding: 60px; text-align: center; }
.toolbar { display: flex; gap: 8px; margin: 16px 0; flex-wrap: wrap; align-items: center; }
.rec { font-family: var(--mono); font-size: 12px; padding: 4px 10px; border-radius: 6px; border: 1px solid var(--line); }
.rec.APPROVE { color: var(--nit); border-color: var(--nit); }
.rec.REQUEST_CHANGES { color: var(--block); border-color: var(--block); }
.rec.COMMENT { color: var(--fix); border-color: var(--fix); }
.summary { background: var(--panel); border: 1px solid var(--line); border-left: 3px solid var(--accent);
border-radius: 8px; padding: 14px 16px; margin: 8px 0 16px; }
.cat { margin: 14px 0; }
.cat h3 { font-family: var(--mono); font-size: 12px; letter-spacing: .08em; margin: 0 0 6px; }
.cat.block h3 { color: var(--block); }
.cat.fix h3 { color: var(--fix); }
.cat.nit h3 { color: var(--nit); }
.cat.question h3 { color: var(--question); }
.cat ul { margin: 0; padding-left: 18px; }
.cat li { margin: 4px 0; }
.cat code { font-family: var(--mono); font-size: 12px; background: var(--panel-2); padding: 1px 5px; border-radius: 4px; }
textarea {
width: 100%; min-height: 70px; background: var(--panel); color: var(--ink);
border: 1px solid var(--line); border-radius: 6px; padding: 10px;
font-family: var(--sans); font-size: 13px; resize: vertical;
}
.bodyprev {
background: var(--panel); border: 1px solid var(--line); border-radius: 8px;
padding: 14px 16px; font-family: var(--mono); font-size: 12px; white-space: pre-wrap;
color: var(--ink-dim); margin-top: 8px;
}
select {
background: var(--panel-2); color: var(--ink); border: 1px solid var(--line);
border-radius: 6px; padding: 6px 10px; font-family: var(--mono); font-size: 12px;
}
.spin { color: var(--ink-dim); font-style: italic; }
.toast { position: fixed; bottom: 20px; right: 20px; background: var(--panel-2);
border: 1px solid var(--accent); border-radius: 8px; padding: 12px 16px; }
.toast.err { border-color: var(--block); }
label.small { font-size: 12px; color: var(--ink-dim); display: block; margin: 14px 0 4px; }
</style>
</head>
<body>
<header>
<h1>PR Review Desk</h1>
<span class="meta" id="cfgMeta">loading config...</span>
<span class="spacer"></span>
<button id="refreshBtn">Refresh queue</button>
</header>
<div class="layout">
<div class="queue" id="queue"><div class="empty">Click Refresh to load PRs.</div></div>
<div class="detail" id="detail"><div class="empty">Select a PR to review.</div></div>
</div>
<div id="toastHost"></div>
<script>
const state = { prs: [], me: "", active: null, reviews: {} };
function key(pr){ return `${pr.owner}/${pr.repo}#${pr.number}`; }
async function j(url, opts){
const r = await fetch(url, opts);
if(!r.ok){ const t = await r.text(); throw new Error(t); }
return r.json();
}
function toast(msg, err){
const host = document.getElementById("toastHost");
const el = document.createElement("div");
el.className = "toast" + (err ? " err" : "");
el.textContent = msg;
host.appendChild(el);
setTimeout(()=>el.remove(), 4000);
}
async function loadConfig(){
const c = await j("/api/config");
document.getElementById("cfgMeta").textContent =
`${c.org} | model: ${c.model.split('/').pop()} | mentions: ${c.mention_authors.join(', ')||'none'}`;
state.mentions = c.mention_authors;
}
async function refresh(){
const q = document.getElementById("queue");
q.innerHTML = '<div class="empty spin">Loading PRs...</div>';
try {
const data = await j("/api/prs");
state.prs = data.prs; state.me = data.me;
renderQueue();
} catch(e){ q.innerHTML = `<div class="empty">Failed: ${e.message}</div>`; }
}
function renderQueue(){
const q = document.getElementById("queue");
if(!state.prs.length){ q.innerHTML = '<div class="empty">No PRs match the filter.</div>'; return; }
q.innerHTML = "";
state.prs.forEach(pr => {
const k = key(pr);
const div = document.createElement("div");
div.className = "queue-item" + (state.active===k ? " active":"");
const mention = (state.mentions||[]).includes((pr.author||"").toLowerCase());
div.innerHTML = `
<div class="repo">${pr.owner}/${pr.repo} #${pr.number}</div>
<div class="title">${escapeHtml(pr.title)}</div>
<div class="sub">
<span>@${escapeHtml(pr.author)}</span>
${mention ? '<span class="badge mention">will @mention</span>':''}
${state.reviews[k] ? '<span class="badge reviewed">reviewed</span>':''}
</div>`;
div.onclick = ()=>selectPR(k);
q.appendChild(div);
});
}
function findPR(k){ return state.prs.find(p=>key(p)===k); }
async function selectPR(k){
state.active = k; renderQueue();
const pr = findPR(k);
const d = document.getElementById("detail");
if(state.reviews[k]){ renderReview(pr, state.reviews[k]); return; }
d.innerHTML = `
<h2>${escapeHtml(pr.title)}</h2>
<a class="prlink" href="${pr.url}" target="_blank">${pr.owner}/${pr.repo} #${pr.number}</a>
<div class="toolbar"><button class="primary" id="runBtn">Run review</button></div>`;
document.getElementById("runBtn").onclick = ()=>runReview(pr);
}
async function runReview(pr){
const d = document.getElementById("detail");
d.querySelector(".toolbar").innerHTML = '<span class="spin">Reviewing with Fireworks...</span>';
try {
const res = await j("/api/review", {
method:"POST", headers:{"Content-Type":"application/json"},
body: JSON.stringify(pr)
});
state.reviews[key(pr)] = res.review;
renderQueue();
renderReview(pr, res.review);
} catch(e){
toast("Review failed: "+e.message, true);
selectPR(key(pr));
}
}
function renderReview(pr, rv){
const d = document.getElementById("detail");
const cats = [["block","BLOCK"],["fix","FIX"],["nit","NIT"],["question","QUESTION"]];
let catHtml = "";
cats.forEach(([k,label])=>{
const items = (rv[k]||[]).filter(Boolean);
if(!items.length) return;
catHtml += `<div class="cat ${k}"><h3>${label}</h3><ul>${
items.map(i=>`<li>${mdInline(i)}</li>`).join("")}</ul></div>`;
});
const rec = rv.recommended_event || "COMMENT";
d.innerHTML = `
<h2>${escapeHtml(pr.title)}</h2>
<a class="prlink" href="${pr.url}" target="_blank">${pr.owner}/${pr.repo} #${pr.number}</a>
<div class="toolbar">
<span class="rec ${rec}">recommends ${rec}</span>
<button id="rerunBtn">Re-run</button>
</div>
<div class="summary">${mdInline(rv.summary||"")}</div>
${catHtml}
${rv.overall ? `<div class="cat"><h3 style="color:var(--ink-dim)">OVERALL</h3><div>${mdInline(rv.overall)}</div></div>`:""}
<label class="small">Request a revision (tell the model what to change about this review)</label>
<textarea id="notes" placeholder="e.g. The auth check on line 40 is fine, drop that BLOCK. Be harsher on missing tests."></textarea>
<div class="toolbar"><button id="reviseBtn">Request revision</button></div>
<label class="small">Body that will be posted (editable)</label>
<textarea id="bodyEdit" style="min-height:160px;font-family:var(--mono)">${escapeHtml(rv._body_markdown||"")}</textarea>
<div class="toolbar">
<select id="eventSel">
<option value="COMMENT"${rec==="COMMENT"?" selected":""}>COMMENT</option>
<option value="APPROVE"${rec==="APPROVE"?" selected":""}>APPROVE</option>
<option value="REQUEST_CHANGES"${rec==="REQUEST_CHANGES"?" selected":""}>REQUEST_CHANGES</option>
</select>
<button class="primary" id="postBtn">Post review to GitHub</button>
</div>`;
document.getElementById("rerunBtn").onclick = ()=>runReview(pr);
document.getElementById("reviseBtn").onclick = ()=>revise(pr);
document.getElementById("postBtn").onclick = ()=>postReview(pr);
}
async function revise(pr){
const notes = document.getElementById("notes").value.trim();
if(!notes){ toast("Enter revision notes first.", true); return; }
const btn = document.getElementById("reviseBtn");
btn.disabled = true; btn.textContent = "Revising...";
try {
const res = await j("/api/revise", {
method:"POST", headers:{"Content-Type":"application/json"},
body: JSON.stringify({...pr, notes})
});
state.reviews[key(pr)] = res.review;
renderReview(pr, res.review);
toast("Review revised.");
} catch(e){ toast("Revision failed: "+e.message, true); btn.disabled=false; btn.textContent="Request revision"; }
}
async function postReview(pr){
const body = document.getElementById("bodyEdit").value;
const event = document.getElementById("eventSel").value;
if(!confirm(`Post a ${event} review to ${pr.owner}/${pr.repo}#${pr.number} as @${state.me}?`)) return;
const btn = document.getElementById("postBtn");
btn.disabled = true; btn.textContent = "Posting...";
try {
const res = await j("/api/post", {
method:"POST", headers:{"Content-Type":"application/json"},
body: JSON.stringify({owner:pr.owner, repo:pr.repo, number:pr.number, body, event})
});
toast("Posted. Opening on GitHub...");
if(res.html_url) window.open(res.html_url, "_blank");
btn.textContent = "Posted";
} catch(e){ toast("Post failed: "+e.message, true); btn.disabled=false; btn.textContent="Post review to GitHub"; }
}
function escapeHtml(s){ return (s||"").replace(/[&<>"']/g, c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c])); }
function mdInline(s){ return escapeHtml(s).replace(/`([^`]+)`/g, '<code>$1</code>'); }
document.getElementById("refreshBtn").onclick = refresh;
loadConfig();
</script>
</body>
</html>