Batches page: list every batch, including those sent back before review
GET /api/batches lists batches newest first with state, reason, notes and per-state function counts; filters by state and author, pages with before/limit. The batch detail adds notes, times and file paths. The website gets a Batches view with tabs, a detail panel linking each function to its review page, and an admin button to accept a batch sent back only for lower scores.
This commit is contained in:
parent
dce889f0b2
commit
6472a15a17
4 changed files with 208 additions and 5 deletions
|
|
@ -544,6 +544,56 @@ export function createApp(ctx) {
|
|||
return guided(r);
|
||||
});
|
||||
|
||||
// Every batch, newest first: the website's Batches page, so batches sent back at a gate are visible too.
|
||||
router.on('GET', '/api/batches', ({ req, query }) => {
|
||||
auth(req);
|
||||
const where = [];
|
||||
const args = [];
|
||||
const states = (query.get('state') ?? '').split(',').filter(Boolean);
|
||||
if (states.length) {
|
||||
where.push(`b.state IN (${states.map(() => '?').join(',')})`);
|
||||
args.push(...states);
|
||||
}
|
||||
if (query.get('author')) {
|
||||
where.push('u.name = ? COLLATE NOCASE');
|
||||
args.push(query.get('author'));
|
||||
}
|
||||
if (query.get('before')) {
|
||||
where.push('b.id < ?');
|
||||
args.push(intParam(query.get('before')));
|
||||
}
|
||||
const limit = Math.min(200, Math.max(1, intParam(query.get('limit') ?? '50')));
|
||||
const rows = store.all(
|
||||
`SELECT b.id, b.kind, b.state, b.created_at, b.finished_at, b.merged_commit, b.notes, b.result_json, u.name AS author
|
||||
FROM batches b JOIN users u ON u.id = b.user_id ${where.length ? `WHERE ${where.join(' AND ')}` : ''} ORDER BY b.id DESC LIMIT ?`,
|
||||
...args,
|
||||
limit,
|
||||
);
|
||||
const counts = new Map(rows.map((b) => [b.id, {}]));
|
||||
if (rows.length) {
|
||||
const ids = rows.map((b) => b.id);
|
||||
for (const c of store.all(`SELECT batch_id, state, COUNT(*) AS n FROM attempts WHERE batch_id IN (${ids.map(() => '?').join(',')}) GROUP BY batch_id, state`, ...ids)) {
|
||||
counts.get(c.batch_id)[c.state] = c.n;
|
||||
}
|
||||
}
|
||||
return rows.map((b) => {
|
||||
const result = b.result_json ? JSON.parse(b.result_json) : {};
|
||||
return {
|
||||
id: b.id,
|
||||
kind: b.kind,
|
||||
author: b.author,
|
||||
state: b.state,
|
||||
createdAt: b.created_at,
|
||||
finishedAt: b.finished_at,
|
||||
mergedCommit: b.merged_commit,
|
||||
notes: b.notes,
|
||||
returned: result.returned ?? null,
|
||||
mergeFailure: result.mergeFailure ?? null,
|
||||
functions: counts.get(b.id),
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
router.on('GET', '/api/batches/:id', ({ req, params }) => {
|
||||
auth(req);
|
||||
const b = store.get('SELECT b.*, u.name AS author FROM batches b JOIN users u ON u.id = b.user_id WHERE b.id = ?', intParam(params.id));
|
||||
|
|
@ -561,6 +611,10 @@ export function createApp(ctx) {
|
|||
...(b.kind === 'maintenance' && { result: result }),
|
||||
baseCommit: b.base_commit,
|
||||
mergedCommit: b.merged_commit,
|
||||
notes: b.notes,
|
||||
createdAt: b.created_at,
|
||||
finishedAt: b.finished_at,
|
||||
files: Object.keys(JSON.parse(b.files_json)),
|
||||
returned: result.returned ?? null,
|
||||
mergeFailure: result.mergeFailure ?? null,
|
||||
changedHeaders: result.changedHeaders ?? [],
|
||||
|
|
|
|||
|
|
@ -151,7 +151,7 @@ const TASK_STATES = {
|
|||
|
||||
// --- router
|
||||
|
||||
const VIEWS = { home: null, guide: null, login: null, account: 'contributor', review: 'reviewer', arcade: 'contributor', admin: 'admin' };
|
||||
const VIEWS = { home: null, guide: null, login: null, account: 'contributor', review: 'reviewer', batches: 'contributor', arcade: 'contributor', admin: 'admin' };
|
||||
let currentView = null;
|
||||
|
||||
function route() {
|
||||
|
|
@ -164,11 +164,15 @@ function route() {
|
|||
const first = currentView !== view;
|
||||
currentView = view;
|
||||
if (first) window.scrollTo({ top: 0 });
|
||||
const load = { home: loadHome, review: loadReview, arcade: loadArcade, admin: loadAdmin, account: loadAccount }[view];
|
||||
const load = { home: loadHome, review: loadReview, batches: loadBatches, arcade: loadArcade, admin: loadAdmin, account: loadAccount }[view];
|
||||
if (load) load().catch((e) => toast(e.message));
|
||||
// Deep link into the review panel: #/review/<attemptId>
|
||||
// Deep links: #/review/<attemptId>, #/batches/<batchId>
|
||||
const parts = location.hash.replace(/^#\/?/, '').split('/');
|
||||
if (view === 'review' && parts[1]) openAttempt(Number(parts[1])).catch((e) => toast(e.message));
|
||||
if (view === 'batches') {
|
||||
if (parts[1]) openBatch(Number(parts[1])).catch((e) => toast(e.message));
|
||||
else $('batch-detail').hidden = true;
|
||||
}
|
||||
}
|
||||
window.addEventListener('hashchange', route);
|
||||
|
||||
|
|
@ -1124,6 +1128,106 @@ function renderQueue() {
|
|||
}
|
||||
}
|
||||
|
||||
// --- batches
|
||||
|
||||
let batchStates = '';
|
||||
let batchRows = [];
|
||||
|
||||
const BATCH_STATES = {
|
||||
queued: 'Waiting for the worker.',
|
||||
verifying: 'The worker is checking and compiling it.',
|
||||
open: 'Checked; its functions are in peer review or waiting to merge.',
|
||||
merging: 'Being merged onto main.',
|
||||
merged: 'On main.',
|
||||
returned: 'Sent back to its author: see why below.',
|
||||
superseded: 'Replaced by another candidate.',
|
||||
};
|
||||
|
||||
async function loadBatches({ more = false } = {}) {
|
||||
const q = new URLSearchParams({ limit: '50' });
|
||||
if (batchStates) q.set('state', batchStates);
|
||||
const author = $('batches-author').value.trim();
|
||||
if (author) q.set('author', author);
|
||||
if (more && batchRows.length) q.set('before', String(batchRows[batchRows.length - 1].id));
|
||||
const rows = await api(`/api/batches?${q}`);
|
||||
batchRows = more ? [...batchRows, ...rows] : rows;
|
||||
renderBatches(rows.length === 50);
|
||||
}
|
||||
|
||||
function batchSummary(b) {
|
||||
const f = Object.entries(b.functions ?? {}).map(([s, n]) => `${n} ${s.replace('_', ' ')}`);
|
||||
return f.length ? f.join(', ') : b.kind === 'maintenance' ? 'maintenance' : 'no functions';
|
||||
}
|
||||
|
||||
function renderBatches(hasMore) {
|
||||
$('batches-count').textContent = `${batchRows.length} shown`;
|
||||
$('batches-more').hidden = !hasMore;
|
||||
if (!batchRows.length) return $('batches-list').replaceChildren(el('p', 'No batches.', 'muted'));
|
||||
$('batches-list').replaceChildren(
|
||||
table(
|
||||
['Batch', 'Author', 'State', 'Functions', 'Why', 'Submitted'],
|
||||
batchRows.map((b) => {
|
||||
const link = el('a', `#${b.id}`);
|
||||
link.href = `#/batches/${b.id}`;
|
||||
const state = el('span', b.state, b.state === 'returned' ? 'warn' : '');
|
||||
state.title = BATCH_STATES[b.state] ?? '';
|
||||
return [link, b.author, state, batchSummary(b), b.returned ?? b.mergeFailure ?? '', ago(b.createdAt)];
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
for (const t of document.querySelectorAll('#batches-tabs .tab')) {
|
||||
t.addEventListener('click', () => {
|
||||
batchStates = t.dataset.states;
|
||||
for (const x of document.querySelectorAll('#batches-tabs .tab')) x.classList.toggle('active', x === t);
|
||||
loadBatches().catch((e) => toast(e.message));
|
||||
});
|
||||
}
|
||||
$('batches-author').addEventListener('change', () => loadBatches().catch((e) => toast(e.message)));
|
||||
$('batches-more').addEventListener('click', () => loadBatches({ more: true }).catch((e) => toast(e.message)));
|
||||
|
||||
async function openBatch(id) {
|
||||
const b = await api(`/api/batches/${id}`);
|
||||
const box = $('batch-detail');
|
||||
box.hidden = false;
|
||||
const head = el('div', null, 'plate-head');
|
||||
head.append(el('h2', `Batch #${b.id} · ${b.author}`), el('span', `${b.kind} · ${b.state}`, b.state === 'returned' ? 'warn' : 'hint'));
|
||||
const meta = el('p', `Submitted ${date(b.createdAt)}${b.finishedAt ? ` · finished ${date(b.finishedAt)}` : ''} · base ${b.baseCommit.slice(0, 10)}${b.mergedCommit ? ` · merged as ${b.mergedCommit.slice(0, 10)}` : ''}`, 'muted');
|
||||
const parts = [head, meta];
|
||||
if (b.returned || b.mergeFailure) parts.push(el('p', b.returned ?? b.mergeFailure, 'warn'));
|
||||
if (b.notes) parts.push(el('p', `Notes: ${b.notes}`));
|
||||
if (b.files?.length) parts.push(el('p', `Files: ${b.files.join(', ')}`, 'hint'));
|
||||
const fnRows = b.functions.map((f) => {
|
||||
const link = el('a', `${f.symbol} ${f.name ?? ''}`);
|
||||
link.href = `#/review/${f.attemptId}`;
|
||||
return [link, f.state.replace('_', ' '), f.score === null ? '' : `${(f.score * 100).toFixed(1)}%`, f.summary ?? ''];
|
||||
});
|
||||
parts.push(fnRows.length ? table(['Function', 'State', 'Bytes', 'Result'], fnRows) : el('p', 'No function tasks (a maintenance batch).', 'muted'));
|
||||
if (b.kind === 'maintenance' && b.result) {
|
||||
const pre = el('pre', JSON.stringify(b.result, null, 1));
|
||||
pre.className = 'secret';
|
||||
parts.push(pre);
|
||||
}
|
||||
if (atLeast('admin') && b.state === 'returned' && b.functions.some((f) => /matched before/.test(f.summary ?? ''))) {
|
||||
const accept = button('Accept lower scores and merge', async () => {
|
||||
const comment = prompt(`Merge batch #${b.id} although it lowers the score of functions that matched before? Every function of it is accepted without peer review. Reason:`);
|
||||
if (comment === null) return;
|
||||
try {
|
||||
await post(`/api/admin/batches/${b.id}/accept-regressions`, { comment });
|
||||
toast(`Batch #${b.id} accepted; it merges next`);
|
||||
openBatch(b.id);
|
||||
loadBatches();
|
||||
} catch (e) {
|
||||
toast(e.message);
|
||||
}
|
||||
}, 'danger');
|
||||
parts.push(accept);
|
||||
}
|
||||
box.replaceChildren(...parts);
|
||||
box.scrollIntoView({ behavior: reducedMotion() ? 'auto' : 'smooth', block: 'start' });
|
||||
}
|
||||
|
||||
async function openAttempt(id) {
|
||||
const a = await api(`/api/attempts/${id}`);
|
||||
currentAttempt = a;
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
<meta name="color-scheme" content="dark light">
|
||||
<title>Clank Together</title>
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="/style.css?v=auto-approve-20261025">
|
||||
<link rel="stylesheet" href="/style.css?v=batches-20261027">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
|
|
@ -30,6 +30,7 @@
|
|||
<a href="#/" data-route="home">Home</a>
|
||||
<a href="#/guide" data-route="guide">Get started</a>
|
||||
<a href="#/review" data-route="review" data-role="reviewer" hidden>Review</a>
|
||||
<a href="#/batches" data-route="batches" data-role="contributor" hidden>Batches</a>
|
||||
<a href="#/arcade" data-route="arcade" data-role="contributor" hidden>Arcade</a>
|
||||
<a href="#/admin" data-route="admin" data-role="admin" hidden>Admin</a>
|
||||
<a href="#/account" data-route="account" data-role="contributor" id="account-link" hidden>Account<span class="nav-dot" id="account-dot" hidden></span></a>
|
||||
|
|
@ -290,6 +291,28 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Batches -->
|
||||
<div class="view" data-view="batches" hidden>
|
||||
<section class="plate">
|
||||
<div class="plate-head">
|
||||
<h2>Batches</h2>
|
||||
<span class="hint" id="batches-count"></span>
|
||||
</div>
|
||||
<p class="muted">Every submitted batch, including the ones the server sent back before peer review (static gate, comments and strings, global variables, compile errors, regressions). Pick one for its functions and why each came back.</p>
|
||||
<div class="tabs small" role="tablist" id="batches-tabs">
|
||||
<button type="button" class="tab active" data-states="">All</button>
|
||||
<button type="button" class="tab" data-states="queued,verifying">Checking</button>
|
||||
<button type="button" class="tab" data-states="open,merging">In review</button>
|
||||
<button type="button" class="tab" data-states="returned">Sent back</button>
|
||||
<button type="button" class="tab" data-states="merged">Merged</button>
|
||||
</div>
|
||||
<input id="batches-author" type="search" placeholder="Author…" aria-label="Filter by author">
|
||||
<div id="batches-list"></div>
|
||||
<button type="button" id="batches-more" class="ghost" hidden>Older batches</button>
|
||||
</section>
|
||||
<section class="plate" id="batch-detail" hidden></section>
|
||||
</div>
|
||||
|
||||
<!-- Arcade -->
|
||||
<div class="view" data-view="arcade" hidden>
|
||||
<section class="plate arcade-head">
|
||||
|
|
@ -389,6 +412,6 @@
|
|||
<div class="hazard thin" aria-hidden="true"></div>
|
||||
<p>Clank Together · source is open: <code id="clone-cmd">git clone https://clank.rodecomp.online/git/clank/game-decomp.git</code></p>
|
||||
</footer>
|
||||
<script src="/app.js?v=auto-approve-20261025"></script>
|
||||
<script src="/app.js?v=batches-20261027"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -1885,3 +1885,25 @@ test('global rules reject undefined data in code and maintenance batches, includ
|
|||
ctx.regions.release(ctx.accounts.byName('globalguard'), { kind: 'unit', key: unit });
|
||||
}
|
||||
});
|
||||
|
||||
test('the batch list shows every batch, also those sent back before review, filtered by state and author and paged', async () => {
|
||||
const token = await activeUser('blister');
|
||||
const lister = ctx.accounts.byName('blister');
|
||||
const head = ctx.repo.head();
|
||||
const task = ctx.store.get('SELECT id FROM tasks LIMIT 1').id;
|
||||
const batch = (state, result) => Number(ctx.store.run(`INSERT INTO batches (user_id, base_commit, files_json, state, result_json, created_at) VALUES (?, ?, '{}', ?, ?, ?)`, lister.id, head, state, result ? JSON.stringify(result) : null, now()).lastInsertRowid);
|
||||
const sentBack = batch('returned', { returned: '3 of 6 functions need work' });
|
||||
ctx.store.run(`INSERT INTO attempts (task_id, user_id, base_commit, files_json, state, batch_id, created_at) VALUES (?, ?, ?, '{}', 'needs_cleanup', ?, ?)`, task, lister.id, head, sentBack, now());
|
||||
const merged = batch('merged');
|
||||
|
||||
assert.equal((await call('GET', '/api/batches')).status, 401);
|
||||
const mine = (await call('GET', '/api/batches?author=BLISTER', null, token)).body;
|
||||
assert.deepEqual(mine.map((b) => b.id), [merged, sentBack], 'newest first, author matched without case');
|
||||
const back = mine.find((b) => b.id === sentBack);
|
||||
assert.equal(back.returned, '3 of 6 functions need work');
|
||||
assert.deepEqual(back.functions, { needs_cleanup: 1 });
|
||||
assert.deepEqual((await call('GET', '/api/batches?state=returned&author=blister', null, token)).body.map((b) => b.id), [sentBack]);
|
||||
assert.deepEqual((await call('GET', `/api/batches?author=blister&before=${merged}`, null, token)).body.map((b) => b.id), [sentBack]);
|
||||
assert.deepEqual((await call('GET', '/api/batches?author=blister&limit=1', null, token)).body.map((b) => b.id), [merged]);
|
||||
ctx.store.run(`UPDATE attempts SET state = 'rejected' WHERE batch_id = ?`, sentBack);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue