mirror of
https://github.com/Sea-Haven-Industries/pr-reviewer.git
synced 2026-09-30 08:03:17 +00:00
Add Expand all / Collapse all controls and a Dependabot-only filter to the top of the queue sidebar. Render the dependency-risk assessment in the detail view (update-type and risk badges, packages, reasons, title and description notes) reusing the post, revise, and auto-merge controls, and show a risk chip on Dependabot rows in the queue. Reviews without a "_kind" fall back to the code-review layout.
515 lines
23 KiB
HTML
515 lines
23 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-col { display: flex; flex-direction: column; border-right: 1px solid var(--line); overflow: hidden; background: var(--panel); }
|
|
.queue-tools { display: flex; gap: 6px; padding: 8px 10px; border-bottom: 1px solid var(--line); background: var(--panel-2); }
|
|
.queue-tools button { font-size: 12px; padding: 4px 9px; }
|
|
.queue-tools button.on { background: var(--accent); color: #0b0e14; border-color: var(--accent); font-weight: 600; }
|
|
.queue { overflow-y: auto; background: var(--panel); flex: 1; }
|
|
.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, .badge.ready { color: var(--nit); border-color: var(--nit); }
|
|
.badge.reviewing { color: var(--fix); border-color: var(--fix); }
|
|
.badge.error { color: var(--block); border-color: var(--block); }
|
|
.badge.closed { color: var(--ink-dim); border-color: var(--line); }
|
|
.badge.mention { color: var(--accent); border-color: var(--accent); }
|
|
.queue-item.closed { opacity: .55; }
|
|
.repo-group { border-bottom: 1px solid var(--line); }
|
|
.repo-header {
|
|
display: flex; align-items: center; gap: 8px; width: 100%;
|
|
padding: 9px 14px; background: var(--panel-2); border: none; border-radius: 0;
|
|
color: var(--ink); font-family: var(--mono); font-size: 12px; cursor: pointer;
|
|
text-align: left; border-bottom: 1px solid var(--line);
|
|
}
|
|
.repo-header:hover { color: var(--accent); }
|
|
.repo-header .chev { width: 12px; color: var(--ink-dim); }
|
|
.repo-header .repo-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
.repo-header .repo-count {
|
|
color: var(--ink-dim); background: var(--bg); border: 1px solid var(--line);
|
|
border-radius: 10px; padding: 0 7px; font-size: 11px;
|
|
}
|
|
.queue-item { padding-left: 22px; }
|
|
.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); }
|
|
.rec.dep-safe, .rec.dep-low_risk { color: var(--nit); border-color: var(--nit); }
|
|
.rec.dep-risky { color: var(--fix); border-color: var(--fix); }
|
|
.rec.dep-breaking { color: var(--block); border-color: var(--block); }
|
|
.badge.dep-safe, .badge.dep-low_risk { color: var(--nit); border-color: var(--nit); }
|
|
.badge.dep-risky { color: var(--fix); border-color: var(--fix); }
|
|
.badge.dep-breaking { color: var(--block); border-color: var(--block); }
|
|
.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="meta" id="hbMeta"></span>
|
|
<span class="spacer"></span>
|
|
<span class="meta" id="syncMeta"></span>
|
|
<button id="refreshBtn">Refresh now</button>
|
|
</header>
|
|
|
|
<div class="layout">
|
|
<div class="queue-col">
|
|
<div class="queue-tools">
|
|
<button id="expandAllBtn">Expand all</button>
|
|
<button id="collapseAllBtn">Collapse all</button>
|
|
<button id="depFilterBtn">Dependabot only</button>
|
|
</div>
|
|
<div class="queue" id="queue"><div class="empty spin">Loading queue...</div></div>
|
|
</div>
|
|
<div class="detail" id="detail"><div class="empty">Select a PR. Reviews are prepared automatically in the background.</div></div>
|
|
</div>
|
|
|
|
<div id="toastHost"></div>
|
|
|
|
<script>
|
|
const state = { items: [], byKey: {}, me: "", active: null, mentions: [], shownKey: null, pollMs: 8000, depFilter: false };
|
|
|
|
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;
|
|
}
|
|
|
|
// The reviewer identity (for the post confirmation) comes from /api/prs, which
|
|
// also returns the authenticated login. Best-effort, non-blocking.
|
|
async function loadMe(){
|
|
try { const data = await j("/api/prs"); state.me = data.me || ""; } catch(e){ /* ignore */ }
|
|
}
|
|
|
|
// Show which handbook commit the reviews are grounded in. Hidden when disabled.
|
|
async function updateHandbook(){
|
|
const el = document.getElementById("hbMeta");
|
|
try {
|
|
const h = await j("/api/handbook");
|
|
if(!h.enabled || !h.head_sha){ el.textContent = ""; return; }
|
|
let ago = "";
|
|
if(h.distilled_at){
|
|
const hrs = Math.round((Date.now()/1000 - h.distilled_at)/3600);
|
|
ago = hrs <= 0 ? " (just now)" : ` (${hrs}h ago)`;
|
|
}
|
|
el.textContent = `| handbook @ ${h.head_sha.slice(0,7)}${ago}`;
|
|
} catch(e){ el.textContent = ""; }
|
|
}
|
|
|
|
// Force an immediate background poll cycle, then refresh the view shortly after.
|
|
async function refreshNow(){
|
|
try { await fetch("/api/refresh", {method:"POST"}); toast("Refresh scheduled."); }
|
|
catch(e){ toast("Could not schedule refresh: "+e.message, true); }
|
|
setTimeout(poll, 1500);
|
|
}
|
|
|
|
// Poll the cached reviews and repaint the queue. Runs on a timer.
|
|
async function poll(){
|
|
let data;
|
|
try { data = await j("/api/reviews"); }
|
|
catch(e){
|
|
document.getElementById("syncMeta").textContent = "sync failed";
|
|
return;
|
|
}
|
|
state.items = data.reviews || [];
|
|
state.byKey = {};
|
|
state.items.forEach(it => { state.byKey[key(it)] = it; });
|
|
if(data.poll_interval){ document.getElementById("cfgMeta").dataset.poll = data.poll_interval; }
|
|
const now = new Date();
|
|
document.getElementById("syncMeta").textContent =
|
|
"synced " + now.toLocaleTimeString();
|
|
updateHandbook();
|
|
renderQueue();
|
|
// Upgrade a placeholder to the full review once it becomes ready.
|
|
if(state.active){
|
|
const it = state.byKey[state.active];
|
|
if(it && it.status === "ready" && it.review && state.shownKey !== state.active){
|
|
renderReview(it, it.review);
|
|
} else if(it && it.status !== "ready" && state.shownKey === state.active){
|
|
// it went back to reviewing/error after being shown
|
|
state.shownKey = null; selectPR(state.active);
|
|
}
|
|
}
|
|
}
|
|
|
|
const STATUS_LABEL = { reviewing: "reviewing", ready: "ready", error: "error", closed: "closed" };
|
|
|
|
const COLLAPSE_KEY = "pr-reviewer:collapsed:v1";
|
|
function loadCollapsed(){
|
|
try { return new Set(JSON.parse(localStorage.getItem(COLLAPSE_KEY) || "[]")); }
|
|
catch(e){ return new Set(); }
|
|
}
|
|
function saveCollapsed(set, repoKeys){
|
|
// Prune repos that no longer exist so the stored set can't grow unbounded.
|
|
const pruned = [...set].filter(r => repoKeys.has(r));
|
|
localStorage.setItem(COLLAPSE_KEY, JSON.stringify(pruned));
|
|
}
|
|
|
|
// PR age for oldest->newest ordering; fall back to updated_at if created_at absent.
|
|
function prTime(it){ return Date.parse(it.created_at || it.updated_at || "") || 0; }
|
|
|
|
function isDependabot(it){ return (it.author || "").toLowerCase().startsWith("dependabot"); }
|
|
function visibleItems(){ return state.depFilter ? state.items.filter(isDependabot) : state.items; }
|
|
function currentRepoKeys(){ return new Set(visibleItems().map(it => `${it.owner}/${it.repo}`)); }
|
|
|
|
function expandAll(){ localStorage.setItem(COLLAPSE_KEY, "[]"); renderQueue(); }
|
|
function collapseAll(){ localStorage.setItem(COLLAPSE_KEY, JSON.stringify([...currentRepoKeys()])); renderQueue(); }
|
|
function toggleDepFilter(){
|
|
state.depFilter = !state.depFilter;
|
|
document.getElementById("depFilterBtn").classList.toggle("on", state.depFilter);
|
|
renderQueue();
|
|
}
|
|
|
|
function queueItem(it){
|
|
const k = key(it);
|
|
const div = document.createElement("div");
|
|
div.className = "queue-item" + (state.active===k ? " active":"") + (it.status==="closed" ? " closed":"");
|
|
const mention = (state.mentions||[]).includes((it.author||"").toLowerCase());
|
|
const statusBadge = `<span class="badge ${it.status}">${STATUS_LABEL[it.status]||it.status}</span>`;
|
|
const rv = it.review;
|
|
const depBadge = (rv && rv._kind === "dependabot" && rv.assessment)
|
|
? `<span class="badge dep-${rv.assessment}">${escapeHtml(String(rv.assessment).replace("_"," "))}</span>`
|
|
: "";
|
|
div.innerHTML = `
|
|
<div class="repo">#${it.number}</div>
|
|
<div class="title">${escapeHtml(it.title||"(no title)")}</div>
|
|
<div class="sub">
|
|
<span>@${escapeHtml(it.author||"")}</span>
|
|
${mention ? '<span class="badge mention">will @mention</span>':''}
|
|
${depBadge}
|
|
${statusBadge}
|
|
</div>`;
|
|
div.onclick = ()=>selectPR(k);
|
|
return div;
|
|
}
|
|
|
|
function renderQueue(){
|
|
const q = document.getElementById("queue");
|
|
const items = visibleItems();
|
|
if(!items.length){
|
|
q.innerHTML = `<div class="empty">${state.depFilter
|
|
? "No Dependabot PRs in the queue."
|
|
: "No PRs in the queue yet. The worker reviews new PRs automatically."}</div>`;
|
|
return;
|
|
}
|
|
|
|
// Group by owner/repo.
|
|
const groups = {};
|
|
items.forEach(it => {
|
|
const g = `${it.owner}/${it.repo}`;
|
|
(groups[g] = groups[g] || []).push(it);
|
|
});
|
|
const repoKeys = new Set(Object.keys(groups));
|
|
const collapsed = loadCollapsed();
|
|
saveCollapsed(collapsed, repoKeys);
|
|
|
|
// Repo groups ordered by their oldest PR; PRs within each group oldest->newest.
|
|
const orderedRepos = Object.keys(groups).sort(
|
|
(a, b) => Math.min(...groups[a].map(prTime)) - Math.min(...groups[b].map(prTime))
|
|
);
|
|
|
|
q.innerHTML = "";
|
|
orderedRepos.forEach(repo => {
|
|
const items = groups[repo].sort((a, b) => prTime(a) - prTime(b));
|
|
const isCollapsed = collapsed.has(repo);
|
|
const section = document.createElement("div");
|
|
section.className = "repo-group";
|
|
|
|
const header = document.createElement("button");
|
|
header.className = "repo-header";
|
|
header.setAttribute("aria-expanded", String(!isCollapsed));
|
|
header.innerHTML = `<span class="chev">${isCollapsed ? "▸" : "▾"}</span>` +
|
|
`<span class="repo-name">${escapeHtml(repo)}</span>` +
|
|
`<span class="repo-count">${items.length}</span>`;
|
|
header.onclick = () => {
|
|
const c = loadCollapsed();
|
|
c.has(repo) ? c.delete(repo) : c.add(repo);
|
|
saveCollapsed(c, repoKeys);
|
|
renderQueue();
|
|
};
|
|
section.appendChild(header);
|
|
|
|
if(!isCollapsed){ items.forEach(it => section.appendChild(queueItem(it))); }
|
|
q.appendChild(section);
|
|
});
|
|
}
|
|
|
|
function selectPR(k){
|
|
state.active = k; renderQueue();
|
|
const it = state.byKey[k];
|
|
if(!it) return;
|
|
const d = document.getElementById("detail");
|
|
if(it.status === "ready" && it.review){ renderReview(it, it.review); return; }
|
|
state.shownKey = null;
|
|
let inner;
|
|
if(it.status === "reviewing"){
|
|
inner = '<span class="spin">Reviewing with Fireworks in the background...</span>';
|
|
} else if(it.status === "error"){
|
|
inner = `<div class="cat block"><h3>REVIEW FAILED</h3><div>${escapeHtml(it.error||"unknown error")} (attempt ${it.attempts||0})</div></div>
|
|
<div class="toolbar"><button class="primary" id="runBtn">Retry now</button></div>`;
|
|
} else if(it.status === "closed"){
|
|
inner = '<div class="spin">This PR left the queue (merged or closed).</div>';
|
|
} else {
|
|
inner = '<div class="toolbar"><button class="primary" id="runBtn">Run review now</button></div>';
|
|
}
|
|
d.innerHTML = `
|
|
<h2>${escapeHtml(it.title||"(no title)")}</h2>
|
|
<a class="prlink" href="${it.url||"#"}" target="_blank">${it.owner}/${it.repo} #${it.number}</a>
|
|
<div style="margin-top:16px">${inner}</div>`;
|
|
const runBtn = document.getElementById("runBtn");
|
|
if(runBtn) runBtn.onclick = ()=>runReview(it);
|
|
}
|
|
|
|
async function runReview(pr){
|
|
const d = document.getElementById("detail");
|
|
d.querySelector(".toolbar") && (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({owner:pr.owner, repo:pr.repo, number:pr.number, title:pr.title||"", author:pr.author||""})
|
|
});
|
|
renderReview(pr, res.review);
|
|
poll();
|
|
} catch(e){
|
|
toast("Review failed: "+e.message, true);
|
|
selectPR(key(pr));
|
|
}
|
|
}
|
|
|
|
function renderReview(pr, rv){
|
|
const d = document.getElementById("detail");
|
|
state.shownKey = key(pr);
|
|
const rec = rv.recommended_event || "COMMENT";
|
|
const isDep = rv._kind === "dependabot";
|
|
let headline, middle;
|
|
if(isDep){
|
|
const asmt = String(rv.assessment || "unknown");
|
|
const ut = String(rv.update_type || "unknown");
|
|
headline = `<span class="rec dep-${asmt}">${escapeHtml(asmt.replace("_"," "))}</span>` +
|
|
`<span class="badge">update: ${escapeHtml(ut)}</span>` +
|
|
`<button id="rerunBtn">Re-run</button>`;
|
|
const pkgs = (rv.packages||[]).filter(p=>p && typeof p==="object");
|
|
const pkgHtml = pkgs.length ? `<div class="cat"><h3>PACKAGES</h3><ul>${
|
|
pkgs.map(p=>`<li><code>${escapeHtml(p.name||"?")}</code> ${escapeHtml(String(p.from||"?"))} → ${escapeHtml(String(p.to||"?"))}</li>`).join("")}</ul></div>` : "";
|
|
const reasons = (rv.reasons||[]).filter(Boolean);
|
|
const reasonHtml = reasons.length ? `<div class="cat"><h3>ASSESSMENT</h3><ul>${
|
|
reasons.map(r=>`<li>${mdInline(r)}</li>`).join("")}</ul></div>` : "";
|
|
const notes = (rv.title_desc_notes||[]).filter(Boolean);
|
|
const noteHtml = notes.length ? `<div class="cat question"><h3>TITLE / DESCRIPTION</h3><ul>${
|
|
notes.map(n=>`<li>${mdInline(n)}</li>`).join("")}</ul></div>` : "";
|
|
middle = pkgHtml + reasonHtml + noteHtml;
|
|
} else {
|
|
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>`;
|
|
});
|
|
headline = `<span class="rec ${rec}">recommends ${rec}</span>` +
|
|
`<button id="rerunBtn">Re-run</button>`;
|
|
middle = catHtml +
|
|
(rv.overall ? `<div class="cat"><h3 style="color:var(--ink-dim)">OVERALL</h3><div>${mdInline(rv.overall)}</div></div>` : "");
|
|
}
|
|
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">${headline}</div>
|
|
<div class="summary">${mdInline(rv.summary||"")}</div>
|
|
${middle}
|
|
|
|
<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>
|
|
|
|
<label class="small">Auto-merge (merges automatically once required checks pass)</label>
|
|
<div class="toolbar">
|
|
<select id="mergeMethod">
|
|
<option value="SQUASH">Squash</option>
|
|
<option value="MERGE">Merge</option>
|
|
<option value="REBASE">Rebase</option>
|
|
</select>
|
|
<button id="automergeBtn">Enable auto-merge</button>
|
|
</div>`;
|
|
document.getElementById("rerunBtn").onclick = ()=>runReview(pr);
|
|
document.getElementById("reviseBtn").onclick = ()=>revise(pr);
|
|
document.getElementById("postBtn").onclick = ()=>postReview(pr);
|
|
document.getElementById("automergeBtn").onclick = ()=>automerge(pr);
|
|
}
|
|
|
|
async function automerge(pr){
|
|
if(!pr.node_id){ toast("No node id for this PR yet; refresh the queue.", true); return; }
|
|
const method = document.getElementById("mergeMethod").value;
|
|
if(!confirm(`Enable ${method} auto-merge for ${pr.owner}/${pr.repo}#${pr.number}? It will merge automatically once required checks pass.`)) return;
|
|
const btn = document.getElementById("automergeBtn");
|
|
btn.disabled = true; btn.textContent = "Enabling...";
|
|
try {
|
|
await j("/api/automerge", {
|
|
method:"POST", headers:{"Content-Type":"application/json"},
|
|
body: JSON.stringify({owner:pr.owner, repo:pr.repo, number:pr.number, node_id:pr.node_id, method})
|
|
});
|
|
toast("Auto-merge enabled.");
|
|
btn.textContent = "Auto-merge enabled";
|
|
} catch(e){ toast("Auto-merge failed: "+e.message, true); btn.disabled=false; btn.textContent="Enable auto-merge"; }
|
|
}
|
|
|
|
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({owner:pr.owner, repo:pr.repo, number:pr.number, title:pr.title||"", author:pr.author||"", notes})
|
|
});
|
|
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=>({"&":"&","<":"<",">":">",'"':""","'":"'"}[c])); }
|
|
function mdInline(s){ return escapeHtml(s).replace(/`([^`]+)`/g, '<code>$1</code>'); }
|
|
|
|
document.getElementById("refreshBtn").onclick = refreshNow;
|
|
document.getElementById("expandAllBtn").onclick = expandAll;
|
|
document.getElementById("collapseAllBtn").onclick = collapseAll;
|
|
document.getElementById("depFilterBtn").onclick = toggleDepFilter;
|
|
loadConfig();
|
|
loadMe();
|
|
poll();
|
|
setInterval(poll, state.pollMs);
|
|
</script>
|
|
</body>
|
|
</html>
|