@popcorn245/cornfield 0.11.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/core/config.js +3 -2
- package/explorer/explorer.css +49 -4
- package/explorer/forge-panel.js +116 -20
- package/explorer/index.html +9 -0
- package/explorer/layers.js +2 -0
- package/explorer/render.js +8 -0
- package/explorer/runs-map.js +135 -0
- package/explorer/runs-model.js +146 -0
- package/explorer/runs-view.js +101 -0
- package/explorer/state.js +6 -0
- package/package.json +1 -1
- package/server/agent-runs.js +290 -10
- package/server/db.js +26 -0
- package/server/forge-forgejo.js +7 -0
- package/server/forge-github.js +5 -0
- package/server/forge.js +5 -0
- package/server/server.js +2 -0
package/server/agent-runs.js
CHANGED
|
@@ -28,6 +28,19 @@
|
|
|
28
28
|
* Every attempt past step 1 is a row in `actions` (`agent-request`), refusals included. The audit never holds a brief, a token's secret or a
|
|
29
29
|
* credential: the request's shape, the brief's length, and the token's id and label.
|
|
30
30
|
*
|
|
31
|
+
* ## On the map (issue #82)
|
|
32
|
+
*
|
|
33
|
+
* Each run in the API carries `nodes` (the ids of the map's nodes it is changing), `nodesFrom`, `collidesWith` and a `timeline`:
|
|
34
|
+
*
|
|
35
|
+
* - **nodes** come from the files the run has changed once it has a branch or a trusted pull request: the pull request's files, or a
|
|
36
|
+
* comparison of the branch with the base (`forge.compare`), read once for each head sha (`run_reads`) and mapped with `nodesForFile` against the checkout's
|
|
37
|
+
* map each time they are asked for. Before the first push: the request's own `nodes` and the `[[mentions]]` in the opening post of what the run is about
|
|
38
|
+
* - **collidesWith** lists the other OPEN runs that share a node with this one (an open run only: a finished or quiet run marks nothing)
|
|
39
|
+
* - `agents.refuseCollisions` (off unless set) refuses a new request that names a node an open run is already changing (409 `collision`), in the
|
|
40
|
+
* transaction that takes the place for it, right after the cap check. Only the nodes a request names are checked: its `nodes`
|
|
41
|
+
* - the **timeline** is the request, the runner's reports, pushes, the pull request, its checks, how it ended, a stop request, and quiet: the first
|
|
42
|
+
* and last are worked out from the row, the rest are kept as they are seen (`run_events`, one row for each, so a second look adds none)
|
|
43
|
+
*
|
|
31
44
|
* ## What counts as open
|
|
32
45
|
*
|
|
33
46
|
* A run is **open** unless it is finished or quiet (`statusOf`): finished is the runner's report `done`, `failed` or `stopped`, or its pull
|
|
@@ -62,10 +75,14 @@
|
|
|
62
75
|
* checks: string | null, last_activity: string }} RunRow
|
|
63
76
|
*/
|
|
64
77
|
|
|
78
|
+
import fs from 'node:fs';
|
|
79
|
+
import path from 'node:path';
|
|
65
80
|
import { ForgeError, isMaintainer } from './forge.js';
|
|
66
81
|
import { createRequest, findRunId, formatRunComment, runAuthors, runBlocksOf, runIdFromBranch, validateReport, validateRequest, MAX_NOTE_CHARS } from '../core/agent-contract.js';
|
|
67
82
|
import { quietAfterMs } from '../core/config.js';
|
|
68
|
-
import {
|
|
83
|
+
import { nodesForFile, parseMentions, resolveMentions } from '../core/mentions.js';
|
|
84
|
+
import { readTopology } from '../core/validate.js';
|
|
85
|
+
import { findPullProposal, listPullProposals, readPullProposal } from './proposal-pulls.js';
|
|
69
86
|
import { Refused, countAttempt, throttle } from './throttle.js';
|
|
70
87
|
|
|
71
88
|
/** Requests one person may make in a minute (a ceiling of its own; every other action is counted by `ATTEMPTS_PER_MINUTE` and `PER_MINUTE`) */
|
|
@@ -81,6 +98,15 @@ export const FOLLOW_FLOOR_MS = 48 * 3_600_000;
|
|
|
81
98
|
export const RESERVATION_MS = 10 * 60_000;
|
|
82
99
|
/** The runs a listing answers */
|
|
83
100
|
export const LIST_LIMIT = 200;
|
|
101
|
+
/** The most events a run's timeline holds */
|
|
102
|
+
export const TIMELINE_LIMIT = 100;
|
|
103
|
+
/** How much of a request's brief the API shows (the Runs view groups runs by it) */
|
|
104
|
+
export const BRIEF_SHOWN = 2000;
|
|
105
|
+
/** The most runs whose subject's opening post is read for mentions in one look, and how long a reading is kept */
|
|
106
|
+
export const MAX_MENTION_READS = 10;
|
|
107
|
+
/** The most runs whose changed files are read in one look */
|
|
108
|
+
export const MAX_FILE_READS = 10;
|
|
109
|
+
export const MENTIONS_KEEP_MS = 3_600_000;
|
|
84
110
|
/** The queued requests a runner's listing answers at most, oldest first */
|
|
85
111
|
export const QUEUE_LIMIT = 50;
|
|
86
112
|
/** The runs that are over once the runner has said so */
|
|
@@ -147,7 +173,7 @@ const subjectWords = (refs) => [refs.issue != null && `issue #${refs.issue}`, re
|
|
|
147
173
|
* @param {number} nowMs
|
|
148
174
|
* @param {number} quietMs
|
|
149
175
|
*/
|
|
150
|
-
export function publicRun(row, nowMs, quietMs) {
|
|
176
|
+
export function publicRun(row, nowMs, quietMs, extra = {}) {
|
|
151
177
|
const { status, open } = statusOf(row, nowMs, quietMs);
|
|
152
178
|
let limits = null;
|
|
153
179
|
try { limits = JSON.parse(row.request).limits ?? null; } catch { limits = null; }
|
|
@@ -162,9 +188,48 @@ export function publicRun(row, nowMs, quietMs) {
|
|
|
162
188
|
stopRequested: row.stop_requested_at ? { at: row.stop_requested_at, by: row.stop_requested_by } : null,
|
|
163
189
|
pull: row.pull != null ? { number: row.pull, state: row.pull_state, url: row.pull_url, by: row.pull_by, trusted: row.pull_trusted === 1, via: row.linked_via } : null,
|
|
164
190
|
branch: row.branch, headSha: row.head_sha, checks: row.checks, lastActivity: row.last_activity,
|
|
191
|
+
...extra,
|
|
165
192
|
};
|
|
166
193
|
}
|
|
167
194
|
|
|
195
|
+
/**
|
|
196
|
+
* The run's story in order: the request first, what was seen (`events`, oldest first), and "quiet" last when the run is. Pure.
|
|
197
|
+
* @param {RunRow} row
|
|
198
|
+
* @param {{ at: string, kind: string, actor: string | null, ref: string | null, note: string | null, url: string | null }[]} events
|
|
199
|
+
* @param {{ status: string }} now `statusOf`'s answer
|
|
200
|
+
* @param {number} quietMs
|
|
201
|
+
*/
|
|
202
|
+
export function timelineOf(row, events, now, quietMs) {
|
|
203
|
+
const out = [{ at: row.requested_at, kind: 'requested', actor: row.requested_by, ref: row.profile, note: null, url: row.comment_url }];
|
|
204
|
+
out.push(...events);
|
|
205
|
+
if (now.status === 'quiet') out.push({ at: iso(Date.parse(row.last_activity) + quietMs), kind: 'quiet', actor: null, ref: null, note: null, url: null });
|
|
206
|
+
return out;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* The ids of the nodes a proposal touches: what it adds (and their parents), changes and removes, and both ends of the edges it adds,
|
|
211
|
+
* changes and removes. Anything that is not a proposal gives none. Pure.
|
|
212
|
+
* @param {any} proposal
|
|
213
|
+
* @returns {string[]}
|
|
214
|
+
*/
|
|
215
|
+
export function proposalNodeIds(proposal) {
|
|
216
|
+
const out = new Set();
|
|
217
|
+
const list = (v) => (Array.isArray(v) ? v : []);
|
|
218
|
+
const id = (v) => { if (typeof v === 'string' && v !== '') out.add(v); };
|
|
219
|
+
const blocks = proposal && typeof proposal === 'object' ? proposal : {};
|
|
220
|
+
for (const op of ['add', 'change', 'remove']) {
|
|
221
|
+
for (const n of list(blocks.nodes?.[op])) { id(n?.id); if (op === 'add') id(n?.parent); }
|
|
222
|
+
for (const e of list(blocks.edges?.[op])) { id(e?.from); id(e?.to); }
|
|
223
|
+
}
|
|
224
|
+
return [...out];
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** The ids a request names, the ones the map knows when it is known */
|
|
228
|
+
function requestNodes(request, known) {
|
|
229
|
+
const ids = Array.isArray(request?.nodes) ? request.nodes.filter((n) => typeof n === 'string') : [];
|
|
230
|
+
return known ? ids.filter((id) => known.has(id)) : ids;
|
|
231
|
+
}
|
|
232
|
+
|
|
168
233
|
/**
|
|
169
234
|
* @typedef {object} RequestContext
|
|
170
235
|
* @property {() => import('./forge.js').Forge | Promise<import('./forge.js').Forge>} forge the forge as the person (may throw a ForgeError `unauthorized`)
|
|
@@ -195,6 +260,7 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
|
|
|
195
260
|
const runnerSet = new Set((Array.isArray(agents.runners) ? agents.runners : []).map(lower));
|
|
196
261
|
const authorSet = new Set((Array.isArray(agents.authors) ? agents.authors : []).map(lower));
|
|
197
262
|
const quietMs = quietAfterMs(config);
|
|
263
|
+
const refuseCollisions = agents.refuseCollisions === true;
|
|
198
264
|
/** How requests travel from now on (`agents.transport`); a run keeps the transport it was asked for with */
|
|
199
265
|
const transport = /** @type {'forge' | 'push' | 'pull'} */ (agents.transport ?? 'forge');
|
|
200
266
|
const log = (what) => console.error(`[cornfield] agent runs: ${what}`);
|
|
@@ -208,6 +274,87 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
|
|
|
208
274
|
}
|
|
209
275
|
};
|
|
210
276
|
|
|
277
|
+
// -------------------------------------------------------------------------
|
|
278
|
+
// Nodes and collisions
|
|
279
|
+
// -------------------------------------------------------------------------
|
|
280
|
+
|
|
281
|
+
/** The checkout's map, read again when the file changes (a sync): `{ nodes, ids }`, empty when there is none */
|
|
282
|
+
let mapCache = { stamp: '', nodes: /** @type {{ id: string, file?: string }[]} */ ([]), ids: /** @type {Set<string>} */ (new Set()) };
|
|
283
|
+
function theMap() {
|
|
284
|
+
let stamp = '';
|
|
285
|
+
try { stamp = String(fs.statSync(path.join(config.root, config.topology)).mtimeMs); } catch { stamp = ''; }
|
|
286
|
+
if (stamp === mapCache.stamp) return mapCache;
|
|
287
|
+
let nodes = [];
|
|
288
|
+
try { nodes = readTopology(config)?.nodes ?? []; } catch { nodes = []; }
|
|
289
|
+
nodes = Array.isArray(nodes) ? nodes.filter((n) => n && typeof n.id === 'string') : [];
|
|
290
|
+
mapCache = { stamp, nodes, ids: new Set(nodes.map((n) => n.id)) };
|
|
291
|
+
return mapCache;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const readOf = (db, runId, source) => {
|
|
295
|
+
const r = /** @type {{ sha: string, data: string } | undefined} */ (db.prepare('SELECT sha, data FROM run_reads WHERE run_id = ? AND source = ?').get(runId, source));
|
|
296
|
+
if (!r) return null;
|
|
297
|
+
try { return { sha: r.sha, data: JSON.parse(r.data) }; } catch { return null; }
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* The nodes a run is changing: from the files at its head when they were read, else (before the first push, or before the files
|
|
302
|
+
* were read) what the request names and what the opening post of its subject mentions.
|
|
303
|
+
* @returns {{ nodes: string[], from: 'files' | 'request' }}
|
|
304
|
+
*/
|
|
305
|
+
function nodesOfRow(db, row) {
|
|
306
|
+
const map = theMap();
|
|
307
|
+
if (row.head_sha) {
|
|
308
|
+
const files = readOf(db, row.run_id, 'files');
|
|
309
|
+
// `null` at this sha: the forge could not list the files (see readNodes), so what the run is about stands in, below
|
|
310
|
+
if (files && files.sha === row.head_sha && Array.isArray(files.data)) {
|
|
311
|
+
const ids = new Set();
|
|
312
|
+
for (const f of files.data) {
|
|
313
|
+
for (const p of [f.path, f.previousPath]) if (typeof p === 'string') for (const n of nodesForFile(p, map.nodes)) ids.add(n.id);
|
|
314
|
+
}
|
|
315
|
+
return { nodes: [...ids], from: 'files' };
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
let request = null;
|
|
319
|
+
try { request = JSON.parse(row.request); } catch { request = null; }
|
|
320
|
+
const ids = new Set(requestNodes(request, map.ids.size ? map.ids : null));
|
|
321
|
+
const mentions = readOf(db, row.run_id, 'mentions');
|
|
322
|
+
if (mentions && Array.isArray(mentions.data)) for (const id of resolveMentions(mentions.data, map.nodes).ids) ids.add(id);
|
|
323
|
+
return { nodes: [...ids], from: 'request' };
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** What the runs add to their public form: the nodes, the open runs they share a node with, and the timeline */
|
|
327
|
+
function extrasFor(db, rows, at) {
|
|
328
|
+
const open = openRows(db, at);
|
|
329
|
+
const openIds = new Set(open.map((r) => r.run_id));
|
|
330
|
+
const nodesOf = new Map();
|
|
331
|
+
for (const r of new Map([...open, ...rows].map((x) => [x.run_id, x])).values()) nodesOf.set(r.run_id, nodesOfRow(db, r));
|
|
332
|
+
const events = new Map();
|
|
333
|
+
if (rows.length) {
|
|
334
|
+
const ask = db.prepare('SELECT at, kind, actor, ref, note, url FROM run_events WHERE run_id = ? ORDER BY at, rowid LIMIT ?');
|
|
335
|
+
for (const r of rows) events.set(r.run_id, ask.all(r.run_id, TIMELINE_LIMIT));
|
|
336
|
+
}
|
|
337
|
+
return new Map(rows.map((r) => {
|
|
338
|
+
const mine = nodesOf.get(r.run_id);
|
|
339
|
+
const collidesWith = openIds.has(r.run_id)
|
|
340
|
+
? open.filter((o) => o.run_id !== r.run_id && nodesOf.get(o.run_id).nodes.some((id) => mine.nodes.includes(id))).map((o) => o.run_id)
|
|
341
|
+
: [];
|
|
342
|
+
let brief = '';
|
|
343
|
+
try { brief = String(JSON.parse(r.request).brief ?? '').slice(0, BRIEF_SHOWN); } catch { brief = ''; }
|
|
344
|
+
return [r.run_id, { brief, nodes: mine.nodes, nodesFrom: mine.from, collidesWith, timeline: timelineOf(r, events.get(r.run_id) ?? [], statusOf(r, at, quietMs), quietMs) }];
|
|
345
|
+
}));
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** Writes what happened to a run, once: the key says what, so a second look at the same thing adds nothing */
|
|
349
|
+
function keepEvent(db, runId, ev) {
|
|
350
|
+
// A forge's answer may lack a field (a pull request with no `createdAt`): the timeline then says when cornfield saw it, and a value
|
|
351
|
+
// that is not text is left out. One odd answer must never throw out of the look, or every run's state in it would be lost
|
|
352
|
+
const text = (v) => (typeof v === 'string' && v !== '' ? v : v == null ? null : typeof v === 'number' ? String(v) : null);
|
|
353
|
+
const at = typeof ev.at === 'string' && !Number.isNaN(Date.parse(ev.at)) ? ev.at : iso(now());
|
|
354
|
+
db.prepare('INSERT OR IGNORE INTO run_events (run_id, key, at, kind, actor, ref, note, url) VALUES (?, ?, ?, ?, ?, ?, ?, ?)')
|
|
355
|
+
.run(runId, String(ev.key), at, ev.kind, text(ev.actor), text(ev.ref), text(ev.note), text(ev.url));
|
|
356
|
+
}
|
|
357
|
+
|
|
211
358
|
/**
|
|
212
359
|
* The runs of this repo that are open as of now (the cap and the one-per-subject rule count these). A reservation whose comment never got
|
|
213
360
|
* posted (a crash between the reservation and the post) holds its place for `RESERVATION_MS` only, whenever cornfield last restarted.
|
|
@@ -218,7 +365,7 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
|
|
|
218
365
|
}
|
|
219
366
|
|
|
220
367
|
/** Takes a place for a run: the cap and the one-per-subject rule are checked and the row inserted in one transaction. Throws a `Refused`. */
|
|
221
|
-
function reserve(db, request, { target, who, via, force, trigger = false }) {
|
|
368
|
+
function reserve(db, request, { target, who, via, force, trigger = false, derived = null }) {
|
|
222
369
|
const at = now();
|
|
223
370
|
const mine = subjectKeys(request.refs);
|
|
224
371
|
db.exec('BEGIN IMMEDIATE');
|
|
@@ -229,6 +376,18 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
|
|
|
229
376
|
if (open.length >= cap) {
|
|
230
377
|
throw new Refused(409, `${repo} already has ${open.length} open run${open.length === 1 ? '' : 's'}, the most cornfield asks for (agents.maxOpen is ${cap}): ${open.map((r) => r.run_id).join(', ')}. Wait for one to finish or go quiet, or stop one.`, 'cap-reached');
|
|
231
378
|
}
|
|
379
|
+
if (refuseCollisions) {
|
|
380
|
+
const map = theMap();
|
|
381
|
+
// What the request names; when it names nothing (the dashboard's button), what its subject is about, read before the transaction
|
|
382
|
+
const wanted = [...new Set([...requestNodes(request, map.ids.size ? map.ids : null), ...resolveMentions(derived || [], map.nodes).ids])];
|
|
383
|
+
for (const r of open) {
|
|
384
|
+
const theirs = nodesOfRow(db, r).nodes;
|
|
385
|
+
const shared = wanted.filter((id) => theirs.includes(id));
|
|
386
|
+
if (shared.length) {
|
|
387
|
+
throw new Refused(409, `${shared.length === 1 ? 'The node' : 'The nodes'} ${shared.join(', ')} ${shared.length === 1 ? 'is' : 'are'} already being changed by the open run ${r.run_id} (agents.refuseCollisions is on). Wait for it to finish or go quiet, or stop it, or ask for a request that leaves ${shared.length === 1 ? 'it' : 'them'} out.`, 'collision');
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
232
391
|
if (!force) {
|
|
233
392
|
const clash = open.find((r) => (r.profile ?? null) === (request.profile ?? null)
|
|
234
393
|
&& subjectKeys({ issue: r.issue ?? undefined, thread: r.thread ?? undefined, proposal: r.proposal ?? undefined }).some((k) => mine.includes(k)));
|
|
@@ -322,10 +481,11 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
|
|
|
322
481
|
|
|
323
482
|
// Where the request goes: the thread it came from, else the open spec PR of the proposal, else the issue
|
|
324
483
|
let number;
|
|
484
|
+
let entry = null;
|
|
325
485
|
if (made.refs.thread != null) number = made.refs.thread;
|
|
326
486
|
else if (made.refs.proposal != null) {
|
|
327
487
|
const listed = await listPullProposals(forge, folder, { hooks: null });
|
|
328
|
-
|
|
488
|
+
entry = listed.entries.find((e) => e.slug === made.refs.proposal) ?? null;
|
|
329
489
|
if (!entry) throw new Refused(404, listed.problem ? `The open spec pull requests could not be read: ${listed.problem}` : `No open spec pull request holds the proposal ${made.refs.proposal}, so there is nowhere to post the request.`, 'not-found');
|
|
330
490
|
number = entry.pull.number;
|
|
331
491
|
made = build(me.login, number);
|
|
@@ -359,14 +519,27 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
|
|
|
359
519
|
const why = await ctx.stillAllowed();
|
|
360
520
|
if (why) throw new Refused(403, why, 'forbidden');
|
|
361
521
|
}
|
|
522
|
+
// With collisions refused, a request that names no node on the map is checked by what its subject is about (spec 0005 D9), as the
|
|
523
|
+
// map would mark it before its first push: the opening post's mentions and the proposal's nodes. Read now, outside the transaction
|
|
524
|
+
let derived = null;
|
|
525
|
+
if (refuseCollisions && !requestNodes(made, theMap().ids.size ? theMap().ids : null).length) {
|
|
526
|
+
try {
|
|
527
|
+
derived = await subjectRefs(forge, made.refs, entry);
|
|
528
|
+
} catch (err) {
|
|
529
|
+
throw new Refused(503, `What this request is about could not be read, so it cannot be checked for collisions (agents.refuseCollisions is on): ${words(err)}`, 'unavailable');
|
|
530
|
+
}
|
|
531
|
+
}
|
|
362
532
|
const db = await auth.store();
|
|
363
|
-
reserve(db, made, { target: number, who, via, force: force === true, trigger: Boolean(made.trigger) });
|
|
533
|
+
reserve(db, made, { target: number, who, via, force: force === true, trigger: Boolean(made.trigger), derived });
|
|
534
|
+
// What was read is what the map marks for this run until it pushes: kept, so the overlay has it now and the poll need not read it again
|
|
535
|
+
if (derived) db.prepare('INSERT OR REPLACE INTO run_reads (run_id, source, sha, data, at) VALUES (?, ?, ?, ?, ?)').run(made.runId, 'mentions', '', JSON.stringify(derived), iso(now()));
|
|
364
536
|
let posted = null;
|
|
365
537
|
if (comment !== null) {
|
|
366
538
|
try {
|
|
367
539
|
posted = await forge.comment(number, comment);
|
|
368
540
|
} catch (err) {
|
|
369
541
|
db.prepare('DELETE FROM runs WHERE run_id = ?').run(made.runId);
|
|
542
|
+
db.prepare('DELETE FROM run_reads WHERE run_id = ?').run(made.runId);
|
|
370
543
|
throw err;
|
|
371
544
|
}
|
|
372
545
|
db.prepare('UPDATE runs SET comment_id = ?, comment_url = ? WHERE run_id = ?').run(posted.id, posted.url ?? null, made.runId);
|
|
@@ -376,7 +549,7 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
|
|
|
376
549
|
hooks?.emit('agent.requested', { request: made, actor: me.login });
|
|
377
550
|
try { onAsk?.(); } catch { /* the poll is a convenience */ }
|
|
378
551
|
const row = /** @type {RunRow} */ (db.prepare('SELECT * FROM runs WHERE run_id = ?').get(made.runId));
|
|
379
|
-
return { run: publicRun(row, now(), quietMs), request: made, comment: posted ? { id: posted.id, url: posted.url ?? null, number } : null };
|
|
552
|
+
return { run: publicRun(row, now(), quietMs, extrasFor(db, [row], now()).get(row.run_id)), request: made, comment: posted ? { id: posted.id, url: posted.url ?? null, number } : null };
|
|
380
553
|
} catch (err) {
|
|
381
554
|
if (status === null) {
|
|
382
555
|
const out = describe(err);
|
|
@@ -447,6 +620,7 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
|
|
|
447
620
|
db.exec('BEGIN IMMEDIATE');
|
|
448
621
|
try {
|
|
449
622
|
db.prepare('UPDATE runs SET stop_requested_at = ?, stop_requested_by = ?, last_activity = MAX(last_activity, ?) WHERE run_id = ?').run(stamp, me.login, stamp, row.run_id);
|
|
623
|
+
keepEvent(db, row.run_id, { key: posted ? `stop:${posted.id}` : `stop:cornfield:${stamp}`, at: stamp, kind: 'stop-requested', actor: me.login, note: note || null, url: posted ? posted.url ?? null : null });
|
|
450
624
|
if (posted) {
|
|
451
625
|
db.prepare('INSERT OR IGNORE INTO run_seen (run_id, key, at) VALUES (?, ?, ?)').run(row.run_id, `c:${posted.id}:${row.run_id}:stop-request:0`, stamp);
|
|
452
626
|
db.prepare('INSERT OR IGNORE INTO run_seen (run_id, key, at) VALUES (?, ?, ?)').run(row.run_id, `ev:stop:${posted.id}:c:${posted.id}:${row.run_id}:stop-request:0`, stamp);
|
|
@@ -468,7 +642,7 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
|
|
|
468
642
|
hooks?.emit('run.state', { ...runPayload(after, me.login), state: 'stopped' });
|
|
469
643
|
hooks?.emit('run.finished', { ...runPayload(after, me.login), state: 'stopped' });
|
|
470
644
|
}
|
|
471
|
-
return { run: publicRun(after, now(), quietMs), comment: posted ? { id: posted.id, url: posted.url ?? null } : null };
|
|
645
|
+
return { run: publicRun(after, now(), quietMs, extrasFor(db, [after], now()).get(after.run_id)), comment: posted ? { id: posted.id, url: posted.url ?? null } : null };
|
|
472
646
|
} catch (err) {
|
|
473
647
|
if (!done) {
|
|
474
648
|
const out = describe(err);
|
|
@@ -497,6 +671,7 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
|
|
|
497
671
|
const rows = /** @type {RunRow[]} */ (db.prepare(`SELECT * FROM runs WHERE repo = ? AND ${POSTED} ORDER BY requested_at DESC, run_id DESC LIMIT ?`).all(repo, limit));
|
|
498
672
|
// The dashboard's notice for triggers that did nothing (server/triggers.js writes a `trigger-skipped` row each time): the last day's, by reason
|
|
499
673
|
const skipped = /** @type {{ at: string, answer: string | null }[]} */ (db.prepare("SELECT at, answer FROM actions WHERE action = 'trigger-skipped' AND (target = ? OR target LIKE ?) AND at > ? ORDER BY id DESC LIMIT 200").all(repo, `${repo}#%`, iso(at - 24 * 3_600_000)));
|
|
674
|
+
const extras = extrasFor(db, rows, at);
|
|
500
675
|
const reasons = {};
|
|
501
676
|
for (const r of skipped) {
|
|
502
677
|
let why = 'error';
|
|
@@ -504,8 +679,9 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
|
|
|
504
679
|
reasons[why] = (reasons[why] || 0) + 1;
|
|
505
680
|
}
|
|
506
681
|
return {
|
|
507
|
-
runs: rows.map((r) => publicRun(r, at, quietMs)), open: openRows(db, at).length, maxOpen: cap, profiles, limits: agents.limits || {}, quietAfter: agents.quietAfter, runners: [...runnerSet].length,
|
|
682
|
+
runs: rows.map((r) => publicRun(r, at, quietMs, extras.get(r.run_id))), open: openRows(db, at).length, maxOpen: cap, profiles, limits: agents.limits || {}, quietAfter: agents.quietAfter, runners: [...runnerSet].length,
|
|
508
683
|
triggerSkipped: skipped.length ? { count: skipped.length, last: skipped[0].at, reasons } : null,
|
|
684
|
+
refuseCollisions,
|
|
509
685
|
};
|
|
510
686
|
}
|
|
511
687
|
|
|
@@ -513,7 +689,7 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
|
|
|
513
689
|
async function get(runId) {
|
|
514
690
|
const db = await auth.store();
|
|
515
691
|
const row = typeof runId === 'string' ? /** @type {RunRow | undefined} */ (db.prepare(`SELECT * FROM runs WHERE run_id = ? AND repo = ? AND ${POSTED}`).get(runId, repo)) : undefined;
|
|
516
|
-
return row ? publicRun(row, now(), quietMs) : null;
|
|
692
|
+
return row ? publicRun(row, now(), quietMs, extrasFor(db, [row], now()).get(row.run_id)) : null;
|
|
517
693
|
}
|
|
518
694
|
|
|
519
695
|
/** The runs a follow looks at: posted, not over for good (a trusted pull request merged or closed), and active within the follow window */
|
|
@@ -545,6 +721,92 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
|
|
|
545
721
|
return following;
|
|
546
722
|
}
|
|
547
723
|
|
|
724
|
+
/**
|
|
725
|
+
* What a run is about, as references the map resolves (spec 0005 D9: "before the first push, the nodes come from what the request was
|
|
726
|
+
* about: the proposal, or the thread's mentions"): the `[[mentions]]` in the opening post of its thread, issue or spec PR, and for a
|
|
727
|
+
* proposal the ids of every node it touches (what it adds, changes and removes, the parents of what it adds, and the ends of the edges it
|
|
728
|
+
* adds, changes and removes). Asks the forge; throws what it throws. `entry`: the spec PR's listing when the caller has it already.
|
|
729
|
+
* @returns {Promise<string[]>}
|
|
730
|
+
*/
|
|
731
|
+
async function subjectRefs(forge, refs, entry = null) {
|
|
732
|
+
const out = [];
|
|
733
|
+
const add = (r) => { if (typeof r === 'string' && r !== '' && !out.includes(r)) out.push(r); };
|
|
734
|
+
if (refs?.proposal != null && refs.pull != null) {
|
|
735
|
+
const pull = await forge.pull(refs.pull);
|
|
736
|
+
parseMentions(pull.body ?? '').refs.forEach(add);
|
|
737
|
+
const found = entry ?? await findPullProposal(forge, folder, refs.proposal, refs.pull);
|
|
738
|
+
if (found) {
|
|
739
|
+
let proposal = null;
|
|
740
|
+
try { proposal = JSON.parse(await readPullProposal(forge, folder, found)); } catch (err) { if (!(err instanceof SyntaxError)) throw err; }
|
|
741
|
+
for (const id of proposalNodeIds(proposal)) add(id);
|
|
742
|
+
}
|
|
743
|
+
} else if (refs?.thread != null || refs?.issue != null) {
|
|
744
|
+
const n = refs.thread ?? refs.issue;
|
|
745
|
+
let item;
|
|
746
|
+
try {
|
|
747
|
+
item = await forge.issue(n);
|
|
748
|
+
} catch (err) {
|
|
749
|
+
// A thread that is a pull request is not an issue to the forge: ask for it as one
|
|
750
|
+
if (!(err instanceof ForgeError && err.code === 'not-found')) throw err;
|
|
751
|
+
item = await forge.pull(n);
|
|
752
|
+
}
|
|
753
|
+
parseMentions(item.body ?? '').refs.forEach(add);
|
|
754
|
+
}
|
|
755
|
+
return out;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/**
|
|
759
|
+
* What the map needs from the forge, kept in `run_reads` so it is asked once: for a run with a head (a trusted pull request, or a branch)
|
|
760
|
+
* the files it changes, read again only when the head moved; for a run with no head yet, the `[[mentions]]` in the opening post of what it is about.
|
|
761
|
+
* A failure is logged and tried again at the next look. At most `MAX_FILE_READS` and `MAX_MENTION_READS` a look.
|
|
762
|
+
*/
|
|
763
|
+
async function readNodes(forge, db, rows, at) {
|
|
764
|
+
const save = db.prepare('INSERT OR REPLACE INTO run_reads (run_id, source, sha, data, at) VALUES (?, ?, ?, ?, ?)');
|
|
765
|
+
let files = 0;
|
|
766
|
+
let mentions = 0;
|
|
767
|
+
let base = null;
|
|
768
|
+
for (const row of rows) {
|
|
769
|
+
if (!statusOf(row, at, quietMs).open) continue;
|
|
770
|
+
if (row.head_sha) {
|
|
771
|
+
const had = readOf(db, row.run_id, 'files');
|
|
772
|
+
// Files listed at this head: they say the nodes. Listed as unknowable (`null`): what the run is about stands in, read below
|
|
773
|
+
if (had && had.sha === row.head_sha && Array.isArray(had.data)) continue;
|
|
774
|
+
if (!(had && had.sha === row.head_sha) && files < MAX_FILE_READS) {
|
|
775
|
+
files += 1;
|
|
776
|
+
try {
|
|
777
|
+
let list;
|
|
778
|
+
if (row.pull != null && row.pull_trusted === 1) list = await forge.pullFiles(row.pull);
|
|
779
|
+
else {
|
|
780
|
+
base ??= config.forge.baseBranch || await forge.defaultBranch();
|
|
781
|
+
list = await forge.compare(base, row.head_sha);
|
|
782
|
+
}
|
|
783
|
+
save.run(row.run_id, 'files', row.head_sha, JSON.stringify(list.map((f) => ({ path: f.path, previousPath: f.previousPath ?? null, status: f.status }))), iso(at));
|
|
784
|
+
} catch (err) {
|
|
785
|
+
// A forge that cannot list a comparison's files (an older Forgejo) will not at this head either: say so once, keep `null` for this
|
|
786
|
+
// sha so it is not asked again until the next push, and the run keeps the nodes of what it is about. Anything else is tried again.
|
|
787
|
+
if (err instanceof ForgeError && err.code === 'invalid') save.run(row.run_id, 'files', row.head_sha, 'null', iso(at));
|
|
788
|
+
log(`the files of ${row.run_id} could not be read: ${words(err)}`);
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
const read = readOf(db, row.run_id, 'files');
|
|
792
|
+
if (!(read && read.sha === row.head_sha && read.data === null)) continue; // only an unknowable list falls through to the subject
|
|
793
|
+
}
|
|
794
|
+
const had = readOf(db, row.run_id, 'mentions');
|
|
795
|
+
const stamp = /** @type {{ at: string } | undefined} */ (db.prepare("SELECT at FROM run_reads WHERE run_id = ? AND source = 'mentions'").get(row.run_id));
|
|
796
|
+
if (had && stamp && at - Date.parse(stamp.at) < MENTIONS_KEEP_MS) continue;
|
|
797
|
+
if (mentions >= MAX_MENTION_READS) continue;
|
|
798
|
+
let refs = null;
|
|
799
|
+
try { refs = JSON.parse(row.request).refs; } catch { refs = null; }
|
|
800
|
+
if (!(refs?.proposal != null && refs.pull != null) && refs?.thread == null && refs?.issue == null) continue;
|
|
801
|
+
mentions += 1;
|
|
802
|
+
try {
|
|
803
|
+
save.run(row.run_id, 'mentions', '', JSON.stringify(await subjectRefs(forge, refs)), iso(at));
|
|
804
|
+
} catch (err) {
|
|
805
|
+
log(`what ${row.run_id} is about could not be read for its nodes: ${words(err)}`);
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
|
|
548
810
|
async function followRuns(forge) {
|
|
549
811
|
const db = await auth.store();
|
|
550
812
|
const at = now();
|
|
@@ -559,6 +821,9 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
|
|
|
559
821
|
const events = [];
|
|
560
822
|
/** @type {Map<string, string>} the cause of the latest change of each run, for the key of its finished event */
|
|
561
823
|
const cause = new Map();
|
|
824
|
+
/** @type {{ runId: string, key: string, at: string, kind: string, actor?: string | null, ref?: string | null, note?: string | null, url?: string | null }[]} what happened, for the timelines */
|
|
825
|
+
const happened = [];
|
|
826
|
+
const happen = (row, ev) => { happened.push({ runId: row.run_id, ...ev }); };
|
|
562
827
|
/** @type {{ login: string | null, request: object, answer: object, target: number }[]} audit rows to write after */
|
|
563
828
|
const audits = [];
|
|
564
829
|
|
|
@@ -631,6 +896,7 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
|
|
|
631
896
|
const firstLink = row.pull == null;
|
|
632
897
|
const state = pr.state === 'merged' ? 'merged' : pr.state === 'closed' ? 'closed' : 'open';
|
|
633
898
|
const earlier = row.pull_state;
|
|
899
|
+
const pushedBefore = row.head_sha;
|
|
634
900
|
let trusted = row.pull_trusted === 1;
|
|
635
901
|
if (firstLink) {
|
|
636
902
|
try { trusted = await trustedAuthor(pr.author?.login ?? null); } catch (err) { log(`who opened #${pr.number} could not be asked: ${words(err)}`); continue; }
|
|
@@ -639,6 +905,8 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
|
|
|
639
905
|
// A stranger's branch is not the run's: only a trusted pull request says where the run pushes
|
|
640
906
|
if (trusted) Object.assign(row, { branch: pr.head.ref || null, head_sha: pr.head.sha || null });
|
|
641
907
|
dirty.add(row.run_id);
|
|
908
|
+
if (trusted && firstLink) happen(row, { key: `pull:${pr.number}:opened`, at: pr.createdAt, kind: 'pull-opened', actor: pr.author?.login ?? null, ref: `#${pr.number}`, url: pr.url ?? null });
|
|
909
|
+
if (trusted && pr.head.sha && pr.head.sha !== pushedBefore) happen(row, { key: `push:${pr.head.sha}`, at: iso(at), kind: 'push', actor: pr.author?.login ?? null, ref: pr.head.sha, note: pr.head.ref || null });
|
|
642
910
|
if (trusted) touch(row, pr.updatedAt);
|
|
643
911
|
if (firstLink) {
|
|
644
912
|
out.linked += 1;
|
|
@@ -647,6 +915,7 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
|
|
|
647
915
|
if (trusted) queue(row, 'run.state', `ev:linked:${pr.number}`, pr.author?.login ?? null, 'working');
|
|
648
916
|
}
|
|
649
917
|
if (trusted && state !== 'open' && earlier !== state) {
|
|
918
|
+
happen(row, { key: `pull:${pr.number}:${state}`, at: (state === 'merged' && pr.mergedAt) || pr.updatedAt || iso(at), kind: state, actor: pr.author?.login ?? null, ref: `#${pr.number}`, url: pr.url ?? null });
|
|
650
919
|
cause.set(row.run_id, `pull:${pr.number}:${state}`);
|
|
651
920
|
queue(row, 'run.state', `ev:pull:${pr.number}:${state}`, pr.author?.login ?? null, state);
|
|
652
921
|
}
|
|
@@ -730,11 +999,13 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
|
|
|
730
999
|
touch(row, when);
|
|
731
1000
|
out.reports += 1;
|
|
732
1001
|
cause.set(row.run_id, key);
|
|
1002
|
+
happen(row, { key: `report:${key}`, at: when, kind: r.state === 'claimed' ? 'claimed' : 'report', actor: login, ref: r.state, note: r.note ?? null, url: r.url ?? null });
|
|
733
1003
|
if (r.state === 'claimed') queue(row, 'run.claimed', 'ev:claimed', login, 'claimed');
|
|
734
1004
|
if (r.state !== previous) queue(row, 'run.state', `ev:state:${id}:${key}`, login, r.state);
|
|
735
1005
|
} else {
|
|
736
1006
|
if (!row.stop_requested_at) Object.assign(row, { stop_requested_at: when, stop_requested_by: login });
|
|
737
1007
|
touch(row, when);
|
|
1008
|
+
happen(row, { key: `stop:${id}`, at: when, kind: 'stop-requested', actor: login, note: block.value.note ?? null });
|
|
738
1009
|
queue(row, 'run.stopRequested', `ev:stop:${id}:${key}`, login, 'stop-requested');
|
|
739
1010
|
}
|
|
740
1011
|
}
|
|
@@ -745,7 +1016,11 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
|
|
|
745
1016
|
try {
|
|
746
1017
|
const checks = await forge.checks(row.head_sha);
|
|
747
1018
|
const summary = !checks.length ? 'none' : checks.some((c) => c.state === 'failure') ? 'failure' : checks.some((c) => c.state === 'queued' || c.state === 'running') ? 'running' : checks.some((c) => c.state === 'success') ? 'success' : 'none';
|
|
748
|
-
if (summary !== row.checks) {
|
|
1019
|
+
if (summary !== row.checks) {
|
|
1020
|
+
row.checks = summary;
|
|
1021
|
+
dirty.add(row.run_id);
|
|
1022
|
+
happen(row, { key: `checks:${row.head_sha}:${summary}`, at: iso(at), kind: 'checks', ref: summary });
|
|
1023
|
+
}
|
|
749
1024
|
} catch (err) {
|
|
750
1025
|
log(`the checks of #${row.pull} could not be read: ${words(err)}`);
|
|
751
1026
|
}
|
|
@@ -760,6 +1035,7 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
|
|
|
760
1035
|
if (b.name !== row.branch || b.sha !== row.head_sha) {
|
|
761
1036
|
Object.assign(row, { branch: b.name, head_sha: b.sha });
|
|
762
1037
|
touch(row, iso(at)); // a push: the forge does not say when, so now
|
|
1038
|
+
happen(row, { key: `push:${b.sha}`, at: iso(at), kind: 'push', ref: b.sha, note: b.name });
|
|
763
1039
|
}
|
|
764
1040
|
}
|
|
765
1041
|
} catch (err) {
|
|
@@ -767,6 +1043,9 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
|
|
|
767
1043
|
}
|
|
768
1044
|
}
|
|
769
1045
|
|
|
1046
|
+
// ---- the files each run has changed (once for each head sha), and what its subject mentions before its first push -----------------
|
|
1047
|
+
await readNodes(forge, db, [...cur.values()], at);
|
|
1048
|
+
|
|
770
1049
|
// ---- kept first, then announced ------------------------------------------------------------------------------------------------
|
|
771
1050
|
for (const row of cur.values()) {
|
|
772
1051
|
const now2 = statusOf(row, at, quietMs);
|
|
@@ -791,6 +1070,7 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
|
|
|
791
1070
|
r.pull, r.pull_state, r.pull_url, r.pull_by, r.pull_trusted, r.linked_via, r.branch, r.head_sha, r.checks, r.last_activity, id);
|
|
792
1071
|
}
|
|
793
1072
|
for (const e of events) if (mark.run(e.runId, e.key, iso(at)).changes === 1) fresh.push(e);
|
|
1073
|
+
for (const ev of happened) keepEvent(db, ev.runId, ev);
|
|
794
1074
|
db.exec('COMMIT');
|
|
795
1075
|
} catch (err) {
|
|
796
1076
|
try { db.exec('ROLLBACK'); } catch { /* nothing was begun */ }
|
package/server/db.js
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
* it and when anything last happened (server/agent-runs.js, migration 7); review_tokens also keep a scope
|
|
20
20
|
* (review, agent, or runner: migration 8, which also gives a run its transport and the runner token that claimed it)
|
|
21
21
|
* run_seen what has been seen of each run (a comment judged, a hook event sent), so a restart sends and judges nothing twice
|
|
22
|
+
* run_events what happened to each run, in order, for its timeline; run_reads what was read to work out its nodes (migration 9, issue #82)
|
|
22
23
|
* sessions also keep a forge refresh token, the access token's expiry and when `session.expiring` was sent (migration 5)
|
|
23
24
|
*
|
|
24
25
|
* The schema is a list of numbered migrations (`MIGRATIONS`); the file's `PRAGMA user_version` is the number of
|
|
@@ -232,6 +233,31 @@ export const MIGRATIONS = Object.freeze([
|
|
|
232
233
|
ALTER TABLE review_tokens_new RENAME TO review_tokens;
|
|
233
234
|
CREATE INDEX review_tokens_user_id ON review_tokens (user_id);
|
|
234
235
|
`,
|
|
236
|
+
// 9: runs on the map (server/agent-runs.js, issue #82): what happened to each run in order (the timeline), and what was read to
|
|
237
|
+
// work out the nodes it changes, kept by head sha so the forge is asked once per push
|
|
238
|
+
`
|
|
239
|
+
CREATE TABLE run_events (
|
|
240
|
+
run_id TEXT NOT NULL,
|
|
241
|
+
key TEXT NOT NULL, -- what happened, once: 'report:<comment>:<n>', 'push:<sha>', 'pull:<n>:opened', 'checks:<sha>:<summary>' ...
|
|
242
|
+
at TEXT NOT NULL, -- when it happened (the forge's time where it says, else when cornfield saw it)
|
|
243
|
+
kind TEXT NOT NULL CHECK (kind IN ('claimed', 'report', 'stop-requested', 'push', 'pull-opened', 'checks', 'merged', 'closed')),
|
|
244
|
+
actor TEXT,
|
|
245
|
+
ref TEXT, -- the report's state, the sha pushed, the pull request number, the checks summary
|
|
246
|
+
note TEXT, -- the runner's words, as written: the page escapes them
|
|
247
|
+
url TEXT,
|
|
248
|
+
PRIMARY KEY (run_id, key)
|
|
249
|
+
) STRICT;
|
|
250
|
+
CREATE INDEX run_events_at ON run_events (run_id, at);
|
|
251
|
+
|
|
252
|
+
CREATE TABLE run_reads (
|
|
253
|
+
run_id TEXT NOT NULL,
|
|
254
|
+
source TEXT NOT NULL CHECK (source IN ('files', 'mentions')), -- the files a branch changes at sha, or the [[mentions]] of what the run is about
|
|
255
|
+
sha TEXT NOT NULL DEFAULT '',
|
|
256
|
+
data TEXT NOT NULL CHECK (json_valid(data)),
|
|
257
|
+
at TEXT NOT NULL,
|
|
258
|
+
PRIMARY KEY (run_id, source)
|
|
259
|
+
) STRICT;
|
|
260
|
+
`,
|
|
235
261
|
]);
|
|
236
262
|
|
|
237
263
|
/** The version a file is at: its `user_version` */
|
package/server/forge-forgejo.js
CHANGED
|
@@ -240,6 +240,13 @@ export function createForgejoForge(forge, { token = null, fetch, apiUrl, timeout
|
|
|
240
240
|
return (await client.list(`repos/${repo}/pulls/${n}/files?limit=${PER_PAGE}`, { what: `the files of ${pullWhat(n)}` })).map(toFile);
|
|
241
241
|
},
|
|
242
242
|
|
|
243
|
+
async compare(base, head) {
|
|
244
|
+
const { body } = await client.request('GET', `repos/${repo}/compare/${segments(base)}...${segments(head)}`, { what: `the files of ${head} against ${base} in ${forge.repo}` });
|
|
245
|
+
// Forgejo's comparison lists commits; only a version that also lists files can say which nodes a branch touches
|
|
246
|
+
if (!Array.isArray(body?.files)) throw new ForgeError('This Forgejo does not list the files of a comparison.', { code: 'invalid', forge: NAME });
|
|
247
|
+
return body.files.map(toFile);
|
|
248
|
+
},
|
|
249
|
+
|
|
243
250
|
async pullCommits(n) {
|
|
244
251
|
return (await client.list(`repos/${repo}/pulls/${n}/commits?limit=${PER_PAGE}`, { what: `the commits of ${pullWhat(n)}` })).map(toCommit);
|
|
245
252
|
},
|
package/server/forge-github.js
CHANGED
|
@@ -233,6 +233,11 @@ export function createGitHubForge(forge, { token = null, fetch, apiUrl, timeoutM
|
|
|
233
233
|
return (await client.list(`repos/${repo}/pulls/${n}/files?per_page=${PER_PAGE}`, { what: `the files of ${pullWhat(n)}` })).map(toFile);
|
|
234
234
|
},
|
|
235
235
|
|
|
236
|
+
async compare(base, head) {
|
|
237
|
+
const { body } = await client.request('GET', `repos/${repo}/compare/${segments(base)}...${segments(head)}`, { what: `the files of ${head} against ${base} in ${forge.repo}` });
|
|
238
|
+
return (Array.isArray(body?.files) ? body.files : []).map(toFile);
|
|
239
|
+
},
|
|
240
|
+
|
|
236
241
|
async pullCommits(n) {
|
|
237
242
|
return (await client.list(`repos/${repo}/pulls/${n}/commits?per_page=${PER_PAGE}`, { what: `the commits of ${pullWhat(n)}` })).map(toCommit);
|
|
238
243
|
},
|
package/server/forge.js
CHANGED
|
@@ -86,6 +86,7 @@
|
|
|
86
86
|
* createIssue(issue: { title: string, body: string, labels?: string[] }): Promise<Issue>,
|
|
87
87
|
* pullFiles(n: number): Promise<PullFile[]>,
|
|
88
88
|
* pullCommits(n: number): Promise<PullCommit[]>,
|
|
89
|
+
* compare(base: string, head: string): Promise<PullFile[]>, // the files a branch changes against another (optional: a forge that cannot list them throws `invalid`)
|
|
89
90
|
* comments(n: number, opts?: { since?: string }): Promise<Comment[]>,
|
|
90
91
|
* comment(n: number, body: string): Promise<Comment>,
|
|
91
92
|
* checks(ref: string): Promise<Check[]>,
|
|
@@ -280,6 +281,10 @@ export function wrap(a, forge) {
|
|
|
280
281
|
},
|
|
281
282
|
pullFiles: async (n) => a.pullFiles(number(n)),
|
|
282
283
|
pullCommits: async (n) => a.pullCommits(number(n)),
|
|
284
|
+
compare: async (base, head) => {
|
|
285
|
+
if (typeof a.compare !== 'function') throw invalid('This forge does not list the files of a comparison.');
|
|
286
|
+
return a.compare(text(base, 'The base branch'), text(head, 'The branch'));
|
|
287
|
+
},
|
|
283
288
|
comments: async (n, opts) => {
|
|
284
289
|
if (opts !== undefined && (opts === null || typeof opts !== 'object' || Array.isArray(opts))) throw invalid('The options are an object.');
|
|
285
290
|
const since = opts && opts.since;
|
package/server/server.js
CHANGED
|
@@ -123,6 +123,8 @@ export function clientConfig(config, signIn = false) {
|
|
|
123
123
|
// Threads (docs/threads.md): the idea label, or false when `threads: false` turns the Threads tab off
|
|
124
124
|
threads: config.threads === false ? false : { label: ideaLabel(config) },
|
|
125
125
|
sources: browserSources(config),
|
|
126
|
+
// Agent runs are followed (and marked on the map) where there is a forge and a sign-in (docs/agents.md)
|
|
127
|
+
...(config.forge && signIn ? { agents: { runs: true } } : {}),
|
|
126
128
|
signIn,
|
|
127
129
|
};
|
|
128
130
|
}
|