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
47 changes: 47 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,52 @@
# Changelog

## v2.9.0

The review inbox stops sending you to GitHub. A PR waiting on you now shows its diff, gate
evidence, verify verdict, CI status and per-stage cost right in the dashboard, across every repo
the factory watches, with a merge button for the operator's own authority.

- **`src/merge-policy.ts`: an operator merge path.** `decideOperatorMerge(readiness, ci)` reuses
`checkReadiness` with the PR's own base branch, skipping `autoEligible`'s risk/path/size gates
entirely, since a human click is a different authority source than the `auto` policy. It shares
one merge primitive, `attemptMerge`, with the automated policy (a spy test proves the same
function serves both). `renderOperatorAuditComment` posts its own `**operator**` wording, kept
distinct from `**auto**`/`**dry-run**` so the audit trail always shows who approved.
- **`src/github.ts`.** `prDiff` (`gh pr diff`) and `prForIssue`, the one open PR that closes an
issue, shared by the triage check and the new review route. `mergeReadiness`'s
`changesRequestedStale` flag: a CHANGES_REQUESTED review against a commit the head has since
moved past no longer blocks the item, idea only, from assembler's unmerged fix branch, no code
copied.
- **`src/paths.ts`: `discoverRepos(FACTORY_HOME)`.** Every `<owner>/<repo>/factory.db` under the
factory's home directory becomes one inbox source, so a machine watching several repos gets one
inbox with a repo filter, not one dashboard per repo.
- **`dashboard/server.ts`.** `GET /api/inbox` aggregates across every discovered repo (or filters
to one with `?repo=`); `GET /api/issues/:n/review` returns the diff, CI, gate evidence, verify
verdict and per-stage cost for the PR that closes an issue; `POST /api/issues/:n/merge` runs
`decideOperatorMerge` and, on `outcome: "merge"`, calls the same `attemptMerge` the policy uses.
- **`dashboard/public/app.js`.** A repo filter on the inbox list; a review panel for `review-pr`
and `merge-dry-run` items (diff, CI status, gate line, verify findings, a per-stage cost table);
an "Approve and merge" button wired to the new merge route, separate from the generic
approve/revise/cancel actions, since neither of those inbox kinds carries a server-side
"approve" action.

Structural tests: every factory state or parked label yields one inbox item with at least one
action, or none for in-flight labels (`tests/inbox.test.ts`); one merge function serves both the
dashboard button and the automated policy (`tests/merge-policy-operator.test.ts`, a spy across
both call sites). Verified live against a real in-review PR
(`learnwithparam/splitbill-demo` issue #10 / PR #16): the review route returned the actual diff,
CI status, gate evidence and verify verdict from GitHub, unchanged from the fixture shape.
Playwright screenshots at 1440, 768 and 375 px in light and dark themes cover the inbox list, the
review panel and the repo filter, with no console or page errors at any size.

Not in v2.9.0:
- "Request changes" as its own review action: the existing `revise` action (with your note) fills
this role; a separate action would duplicate it.
- A CHANGES_REQUESTED-superseded check on the operator merge path beyond what `mergeReadiness`
already reports: `changesRequestedStale` is read, not re-derived, by `decideOperatorMerge`.
- Any change to the automated `merge.policy` behavior from v2.8.0: this release only adds a
second, human-triggered caller of the same merge primitive.

## v2.8.0

The merge-policy half of the lwp-website pilot: the factory can now tell whether an in-review PR
Expand Down
106 changes: 86 additions & 20 deletions dashboard/public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ const STAGES = ["triage", "plan", "build", "verify", "pr"];
const WAITING = new Set(["needs-info", "awaiting-approval", "needs-human", "failed"]);
const ACTIVE = new Set(["running", "verifying"]);
const ACTION_LABEL = { approve: "Approve plan", revise: "Request changes", answer: "Send answer", retry: "Retry", cancel: "Cancel run" };
const REVIEW_KINDS = new Set(["review-pr", "merge-dry-run"]);
const KIND_LABEL = { "approve-plan": "Plan to approve", "answer-question": "Question for you", "review-pr": "Pull request to review", "merge-dry-run": "Ready to merge", parked: "Needs a human", failed: "Failed", budget: "Over budget" };
const ICONS = {
inbox: "M3 13l3-8h12l3 8v6H3zM3 13h5l1 3h6l1-3h5",
line: "M4 6h10M4 12h16M4 18h7",
Expand All @@ -19,7 +21,7 @@ const ICONS = {
};
const NAV = [["inbox", "Inbox"], ["line", "Line"], ["runs", "Runs"], ["analytics", "Analytics"], ["agents", "Agents"]];

const state = { route: routeFromHash(location.hash), inbox: [], repo: "", selected: null, thread: null, filter: "all", data: {}, error: {} };
const state = { route: routeFromHash(location.hash), inbox: [], repo: "", repos: [], repoFilter: "", selected: null, thread: null, review: null, filter: "all", data: {}, error: {} };

function h(tag, attrs, ...kids) {
const el = document.createElement(tag);
Expand Down Expand Up @@ -68,7 +70,7 @@ const post = (path, body) => api(path, { method: "POST", headers: { "content-typ
/* ---- states shared by every view ---- */
const quiet = (title, hint, ...extra) =>
h("div", { class: "quiet-state" }, h("div", { class: "quiet-state-mark", "aria-hidden": "true" }, h("i"), h("i"), h("i")), h("strong", null, title), h("span", null, hint), ...extra);
const heading = (title, lede) => h("div", { class: "page-heading" }, h("div", null, h("h1", null, title), lede && h("p", { class: "lede" }, lede)));
const heading = (title, lede, ...extra) => h("div", { class: "page-heading" }, h("div", null, h("h1", null, title), lede && h("p", { class: "lede" }, lede)), ...extra);
const stateOr = (name, ready) => {
if (state.error[name]) return quiet("Could not load this view", state.error[name]);
if (!state.data[name]) return quiet("Loading", "Reading the factory's state.");
Expand Down Expand Up @@ -107,24 +109,65 @@ function lineView() {
}

/* ---- Inbox ---- */
async function selectItem(issue) {
state.selected = issue;
function isSelected(i) { return state.selected != null && state.selected.repo === i.repo && state.selected.issue === i.issue; }

async function selectItem(item) {
state.selected = { repo: item.repo, issue: item.issue };
state.thread = null;
state.review = null;
render();
try { state.thread = (await api(`/api/issues/${issue}/thread`)).issue; } catch (e) { state.thread = { error: e.message }; }
const q = `?repo=${encodeURIComponent(item.repo)}`;
const wantsReview = REVIEW_KINDS.has(item.kind);
const [thread, review] = await Promise.allSettled([api(`/api/issues/${item.issue}/thread${q}`), wantsReview ? api(`/api/issues/${item.issue}/review${q}`) : Promise.resolve(null)]);
state.thread = thread.status === "fulfilled" ? thread.value.issue : { error: thread.reason.message };
if (wantsReview) state.review = review.status === "fulfilled" ? review.value : { error: review.reason.message };
render();
}

async function actOn(item, action, text) {
if (action === "cancel" && !confirm(`Cancel the run for #${item.issue}? This closes the issue.`)) return;
try {
await post(`/api/inbox/${item.issue}/act`, { action, text });
await post(`/api/inbox/${item.issue}/act`, { action, text, repo: item.repo });
state.selected = null;
state.review = null;
await Promise.all([loadInbox(), loadView()]);
} catch (e) { alert(e.message); }
render();
}

async function mergeApprove(item) {
try {
const r = await post(`/api/issues/${item.issue}/merge`, { repo: item.repo });
if (r.decision.outcome !== "merge") { alert(`Not merged:\n${r.decision.refusals.map((x) => `- ${x.detail}`).join("\n")}`); return; }
state.selected = null;
state.review = null;
await Promise.all([loadInbox(), loadView()]);
} catch (e) { alert(e.message); }
render();
}

function actionLabel(item, a) { return REVIEW_KINDS.has(item.kind) && a === "cancel" ? "Close" : ACTION_LABEL[a]; }

function reviewPanel() {
const r = state.review;
if (!r) return quiet("Loading", "Reading the pull request.");
if (r.error) return quiet("Could not load the review", r.error);
const ciTone = r.ci.status === "passed" ? "ok" : r.ci.status === "failed" ? "bad" : "warn";
return h("div", { class: "review" },
h("dl", { class: "kv" },
h("dt", null, "Pull request"), h("dd", null, h("a", { href: r.pr.url, target: "_blank", rel: "noreferrer" }, `#${r.pr.number}`)),
h("dt", null, "CI"), h("dd", null, h("span", { class: "status", "data-tone": ciTone }, statusText(r.ci.status))),
r.gate ? [h("dt", null, "Gate"), h("dd", null, `${r.gate.status} 路 ${r.gate.line}`)] : null,
r.verify?.result ? [h("dt", null, "Verify"), h("dd", null, r.verify.result)] : null),
r.verify?.findings?.length ? h("div", null, h("h3", null, "Verify findings"),
h("ul", { class: "plain-list" }, r.verify.findings.map((f) => h("li", null, typeof f === "string" ? f : `${f.severity}: ${f.what}`)))) : null,
r.stages.length ? h("div", { class: "table-wrap" }, h("table", null,
h("thead", null, h("tr", null, ["Stage", "Agent", "Model", "Cost"].map((c, i) => h("th", { class: i === 3 ? "num" : "" }, c)))),
h("tbody", null, r.stages.map((s) => h("tr", null, h("td", null, s.stage), h("td", null, s.agent), h("td", null, s.model || "default"), h("td", { class: "num" }, s.usage_complete === 0 ? "Not reported" : money(s.cost_usd))))))) : null,
h("h3", { style: "margin-top:1rem" }, "Diff"),
h("pre", { class: "log" }, r.diff || "No diff available."));
}

function conversation(item) {
const t = state.thread;
const body = !t ? quiet("Loading", "Reading the issue thread.")
Expand All @@ -134,25 +177,38 @@ function conversation(item) {
.map((m) => h("div", { class: `msg${m.bot ? " bot" : ""}` }, h("header", null, m.bot ? "factory" : m.author), cleanBody(m.body))));
const needsText = item.actions.filter((a) => a === "revise" || a === "answer");
const box = needsText.length ? h("textarea", { class: "field-control", id: "composer", rows: "3", placeholder: item.kind === "answer-question" ? "Your answer" : "What should change?", "aria-label": "Your reply" }) : null;
const isReview = REVIEW_KINDS.has(item.kind);
return h("div", { class: "panel" },
h("div", { class: "panel-pad" }, h("h2", null, `#${item.issue} ${item.title}`), h("span", { class: "muted" }, `${item.label.replace("factory:", "").replace(/-/g, " ")} 路 waiting ${age(item.waitingSince)}`)),
h("div", { class: "panel-pad" }, h("h2", null, `#${item.issue} ${item.title}`), h("span", { class: "muted" }, `${item.repo} 路 ${item.label.replace("factory:", "").replace(/-/g, " ")} 路 waiting ${age(item.waitingSince)}`)),
isReview ? h("div", { class: "panel-pad" }, reviewPanel()) : null,
body,
h("div", { class: "composer" }, box, h("div", { class: "actions" }, item.actions.map((a) =>
h("button", { class: `btn${a === "approve" || a === "answer" ? " btn-primary" : a === "cancel" ? " btn-danger" : ""}`, type: "button",
onclick: () => { const text = box ? box.value : ""; if ((a === "revise" || a === "answer") && !text.trim()) { box.focus(); return; } actOn(item, a, text); } }, ACTION_LABEL[a])))));
h("div", { class: "composer" }, box, h("div", { class: "actions" },
isReview ? h("button", { class: "btn btn-primary", type: "button", onclick: () => mergeApprove(item) }, "Approve and merge") : null,
item.actions.map((a) =>
h("button", { class: `btn${a === "approve" || a === "answer" ? " btn-primary" : a === "cancel" ? " btn-danger" : ""}`, type: "button",
onclick: () => { const text = box ? box.value : ""; if ((a === "revise" || a === "answer") && !text.trim()) { box.focus(); return; } actOn(item, a, text); } }, actionLabel(item, a))))));
}

function repoFilterControl() {
if (state.repos.length <= 1) return null;
return h("select", { class: "field-control repo-filter", "aria-label": "Filter by repo",
onchange: (e) => { state.repoFilter = e.target.value; loadInbox().then(render); } },
h("option", { value: "", selected: state.repoFilter === "" }, "All repos"),
...state.repos.map((r) => h("option", { value: r, selected: state.repoFilter === r }, r)));
}

function inboxView() {
const items = state.inbox;
const item = items.find((i) => i.issue === state.selected);
return h("section", null, heading("Inbox", "Everything the factory is waiting on you for."),
const multiRepo = state.repos.length > 1;
const item = items.find(isSelected);
return h("section", null, heading("Inbox", "Everything the factory is waiting on you for.", repoFilterControl()),
!items.length ? quiet("Nothing is waiting on you", "The factory will list plans to approve and questions to answer here.")
: h("div", { class: "split", "data-open": String(Boolean(item)) },
h("div", { class: "list-col" }, h("div", { class: "list" }, items.map((i) =>
h("button", { class: "list-item", type: "button", "aria-current": String(i.issue === state.selected), onclick: () => selectItem(i.issue) },
h("span", { class: "kind" }, { "approve-plan": "Plan to approve", "answer-question": "Question for you", "review-pr": "Pull request to review", parked: "Needs a human", failed: "Failed" }[i.kind]),
h("button", { class: "list-item", type: "button", "aria-current": String(isSelected(i)), onclick: () => selectItem(i) },
h("span", { class: "kind" }, KIND_LABEL[i.kind], multiRepo ? h("span", { class: "muted repo-tag" }, ` 路 ${i.repo}`) : null),
h("strong", null, `#${i.issue} ${i.title}`), h("span", { class: "muted" }, `waiting ${age(i.waitingSince)}`), h("span", { class: "muted" }, i.ask.slice(0, 110)))))),
h("div", { class: "detail-col" }, item ? [h("button", { class: "btn back", type: "button", onclick: () => { state.selected = null; render(); } }, "Back to inbox"), conversation(item)] : quiet("Pick an item", "Its conversation and actions show up here."))));
h("div", { class: "detail-col" }, item ? [h("button", { class: "btn back", type: "button", onclick: () => { state.selected = null; state.review = null; render(); } }, "Back to inbox"), conversation(item)] : quiet("Pick an item", "Its conversation and actions show up here."))));
}

/* ---- Runs ---- */
Expand Down Expand Up @@ -285,11 +341,14 @@ function toggleTheme() {
try { localStorage.setItem("factory-theme", next); } catch {}
}

function go(view, issue) {
state.selected = issue ?? null;
function go(view, issue, repo = state.repo) {
state.selected = issue ? { repo, issue } : null;
if (location.hash !== `#/${view}`) location.hash = `#/${view}`;
else render();
if (issue) selectItem(issue);
if (issue) {
const item = state.inbox.find((i) => i.issue === issue && i.repo === repo);
if (item) selectItem(item);
}
}

function showLogin() {
Expand All @@ -300,8 +359,15 @@ function showLogin() {
}

async function loadInbox() {
try { const r = await api("/api/inbox"); state.inbox = r.items; state.repo = r.repo; state.error.inbox = null; } catch (e) { state.error.inbox = e.message; }
document.getElementById("repo-label").textContent = state.repo || "";
try {
const q = state.repoFilter ? `?repo=${encodeURIComponent(state.repoFilter)}` : "";
const r = await api(`/api/inbox${q}`);
state.inbox = r.items;
state.repo = r.repo;
state.repos = r.repos || [];
state.error.inbox = null;
} catch (e) { state.error.inbox = e.message; }
document.getElementById("repo-label").textContent = state.repo || (state.repos.length > 1 ? `${state.repos.length} repos` : "");
renderNav();
}

Expand Down
3 changes: 3 additions & 0 deletions dashboard/public/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ h2 { margin: 0 0 .75rem; font-size: 1rem; font-weight: 600; letter-spacing: -.01
.field-control { width: 100%; min-height: 2.5rem; border: 1px solid var(--border); border-radius: 8px; outline: none; background: var(--background); padding: .625rem .75rem; box-shadow: inset 0 1px 0 color-mix(in srgb, var(--foreground) 3%, transparent); transition: border-color .15s ease, box-shadow .15s ease; }
.field-control:focus { border-color: color-mix(in srgb, var(--primary) 60%, transparent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 14%, transparent); }
textarea.field-control { resize: vertical; min-height: 5rem; }
.repo-filter { width: auto; min-width: 12rem; }
.tabs { display: flex; gap: .25rem; flex-wrap: wrap; }
.tab { padding: .3rem .7rem; border: 1px solid transparent; border-radius: 999px; background: none; color: var(--muted-foreground); }
.tab[aria-pressed="true"] { border-color: var(--border); background: var(--surface); color: var(--foreground); }
Expand Down Expand Up @@ -198,6 +199,8 @@ tr[data-href]:hover { background: color-mix(in srgb, var(--muted) 50%, transpare
.kv dd { margin: 0; overflow-wrap: anywhere; }
pre.log { margin: 0; max-height: 22rem; overflow: auto; padding: .75rem; background: var(--background); border: 1px solid var(--border); border-radius: 8px; font: .75rem/1.1rem ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre-wrap; overflow-wrap: anywhere; }
@keyframes slide-in { from { transform: translateX(1.5rem); opacity: 0; } to { transform: none; opacity: 1; } }
.review { padding-bottom: 1.25rem; margin-bottom: 1.25rem; border-bottom: 1px solid var(--border); }
.review .kv { margin: .5rem 0 1rem; }

/* Empty, loading and error states */
.quiet-state { display: grid; justify-items: start; gap: .5rem; min-height: 11rem; align-content: center; color: var(--muted-foreground); }
Expand Down
Loading
Loading