@popcorn245/cornfield 0.7.0 → 0.9.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/README.md +1 -0
- package/core/agent-contract.js +5 -2
- package/core/agent-contract.schema.json +9 -9
- package/core/config.js +94 -11
- package/core/mentions.js +239 -0
- package/explorer/explorer.css +12 -1
- package/explorer/forge-panel-model.js +88 -21
- package/explorer/forge-panel.js +512 -19
- package/explorer/index.html +46 -0
- package/explorer/panels.js +24 -0
- package/explorer/review-tokens-model.js +5 -1
- package/explorer/review-tokens.js +11 -7
- package/explorer/runs-model.js +107 -0
- package/explorer/state.js +7 -0
- package/package.json +1 -1
- package/server/agent-api.js +227 -0
- package/server/agent-runs.js +769 -0
- package/server/db.js +68 -0
- package/server/forge-actions.js +106 -5
- package/server/forge-forgejo.js +33 -5
- package/server/forge-github.js +12 -4
- package/server/forge-routes.js +33 -1
- package/server/forge.js +24 -7
- package/server/hooks.js +100 -1
- package/server/proposal-pulls.js +3 -3
- package/server/review-api.js +26 -12
- package/server/review-tokens.js +42 -25
- package/server/server.js +32 -8
- package/server/spec-poll.js +71 -14
- package/server/threads.js +275 -0
package/server/db.js
CHANGED
|
@@ -12,6 +12,12 @@
|
|
|
12
12
|
* hook_seen the (pull request, head, proposal) triples whose `spec.ready` event was sent, so a restart sends none twice;
|
|
13
13
|
* a row with `pr = 0` says the repo has been looked at once (server/hooks.js)
|
|
14
14
|
* landings the spec PRs an approval is waiting to land (`proposals.approveLands`), so a restart picks them up (server/lander.js)
|
|
15
|
+
* thread_seen the (repo, issue, comment) triples whose thread.* hook event was sent, and each thread's last-seen time, so a
|
|
16
|
+
* restart sends none twice and a look asks the forge only for what is new (server/hooks.js, server/threads.js);
|
|
17
|
+
* a row with `issue = 0` says the repo's threads have been looked at once
|
|
18
|
+
* runs the agent runs cornfield asked for: the request, where it was posted, the latest report, the pull request found for
|
|
19
|
+
* it and when anything last happened (server/agent-runs.js, migration 7); review_tokens also keep a scope
|
|
20
|
+
* run_seen what has been seen of each run (a comment judged, a hook event sent), so a restart sends and judges nothing twice
|
|
15
21
|
* sessions also keep a forge refresh token, the access token's expiry and when `session.expiring` was sent (migration 5)
|
|
16
22
|
*
|
|
17
23
|
* The schema is a list of numbered migrations (`MIGRATIONS`); the file's `PRAGMA user_version` is the number of
|
|
@@ -133,6 +139,68 @@ export const MIGRATIONS = Object.freeze([
|
|
|
133
139
|
ALTER TABLE sessions ADD COLUMN token_expires_at TEXT; -- when the access token stops working; NULL when it does not expire
|
|
134
140
|
ALTER TABLE sessions ADD COLUMN expiring_sent_at TEXT; -- when the \`session.expiring\` hook event was sent; NULL before
|
|
135
141
|
`,
|
|
142
|
+
// 6: threads (server/threads.js, server/hooks.js): which comments of a thread have been announced. The thread itself is the
|
|
143
|
+
// forge's (an issue's comments, or a spec PR's conversation); this only remembers what was told, never what was said
|
|
144
|
+
`
|
|
145
|
+
CREATE TABLE thread_seen (
|
|
146
|
+
repo TEXT NOT NULL, -- 'owner/name'
|
|
147
|
+
issue INTEGER NOT NULL, -- the issue or pull request number of the thread; 0 with comment '' says the repo has been looked at once
|
|
148
|
+
comment TEXT NOT NULL, -- '' the thread itself (opened); a comment's id (message); 'link:<id>' or 'link:body' (linked); 'pr:<n>' a spec PR that names the thread
|
|
149
|
+
stamp TEXT NOT NULL DEFAULT '', -- the forge's own time: the comment's last edit, or (on the '' row) the thread's last update when it was looked at
|
|
150
|
+
at TEXT NOT NULL, -- when cornfield first saw it
|
|
151
|
+
PRIMARY KEY (repo, issue, comment)
|
|
152
|
+
) STRICT;
|
|
153
|
+
`,
|
|
154
|
+
// 7: agent runs (server/agent-runs.js, spec 0005): a table of the runs cornfield asked for, what it has seen of them, and a scope on
|
|
155
|
+
// the API tokens so one store holds review tokens and agent tokens
|
|
156
|
+
`
|
|
157
|
+
ALTER TABLE review_tokens ADD COLUMN scope TEXT NOT NULL DEFAULT 'review' CHECK (scope IN ('review', 'agent')); -- what the token may call: POST /api/reviews, or POST /api/agent-requests
|
|
158
|
+
|
|
159
|
+
CREATE TABLE runs (
|
|
160
|
+
run_id TEXT PRIMARY KEY, -- run-<ulid>, minted with the request
|
|
161
|
+
repo TEXT NOT NULL, -- 'owner/name'
|
|
162
|
+
kind TEXT NOT NULL,
|
|
163
|
+
profile TEXT,
|
|
164
|
+
issue INTEGER, -- what the run is about (at least one of the three)
|
|
165
|
+
proposal TEXT,
|
|
166
|
+
thread INTEGER,
|
|
167
|
+
target INTEGER NOT NULL, -- the issue or pull request the request, and the stop, were posted on
|
|
168
|
+
comment_id TEXT NOT NULL DEFAULT '', -- the request comment; '' while the post is in flight (the row already counts as open)
|
|
169
|
+
comment_url TEXT,
|
|
170
|
+
request TEXT NOT NULL CHECK (json_valid(request)), -- the request as posted: no credential in it, ever
|
|
171
|
+
requested_by TEXT NOT NULL,
|
|
172
|
+
requested_by_id TEXT NOT NULL,
|
|
173
|
+
requested_at TEXT NOT NULL,
|
|
174
|
+
via TEXT NOT NULL DEFAULT 'dashboard', -- 'dashboard' | 'api'
|
|
175
|
+
state TEXT NOT NULL DEFAULT 'requested' CHECK (state IN ('requested', 'claimed', 'working', 'done', 'failed', 'stopped')), -- the runner's latest report
|
|
176
|
+
reported_at TEXT,
|
|
177
|
+
reported_by TEXT,
|
|
178
|
+
note TEXT,
|
|
179
|
+
url TEXT,
|
|
180
|
+
tokens INTEGER,
|
|
181
|
+
cost REAL,
|
|
182
|
+
stop_requested_at TEXT,
|
|
183
|
+
stop_requested_by TEXT,
|
|
184
|
+
pull INTEGER, -- the pull request that carries the run's id
|
|
185
|
+
pull_state TEXT CHECK (pull_state IS NULL OR pull_state IN ('open', 'merged', 'closed')),
|
|
186
|
+
pull_url TEXT,
|
|
187
|
+
pull_by TEXT,
|
|
188
|
+
pull_trusted INTEGER NOT NULL DEFAULT 0, -- 1 when its author is a runner, a listed agent author or a maintainer: only then does it move the state
|
|
189
|
+
linked_via TEXT CHECK (linked_via IS NULL OR linked_via IN ('body', 'commit', 'branch')),
|
|
190
|
+
branch TEXT,
|
|
191
|
+
head_sha TEXT,
|
|
192
|
+
checks TEXT CHECK (checks IS NULL OR checks IN ('success', 'failure', 'running', 'none')),
|
|
193
|
+
last_activity TEXT NOT NULL -- the latest report, push or pull request activity (or the request itself)
|
|
194
|
+
) STRICT;
|
|
195
|
+
CREATE INDEX runs_repo ON runs (repo, requested_at);
|
|
196
|
+
|
|
197
|
+
CREATE TABLE run_seen (
|
|
198
|
+
run_id TEXT NOT NULL, -- '' for what belongs to no run (a pull request's commits already read)
|
|
199
|
+
key TEXT NOT NULL, -- 'c:<comment>:<n>' a block judged, 'ev:…' a hook event sent, 'commits:<pull>@<sha>'
|
|
200
|
+
at TEXT NOT NULL,
|
|
201
|
+
PRIMARY KEY (run_id, key)
|
|
202
|
+
) STRICT;
|
|
203
|
+
`,
|
|
136
204
|
]);
|
|
137
205
|
|
|
138
206
|
/** The version a file is at: its `user_version` */
|
package/server/forge-actions.js
CHANGED
|
@@ -37,11 +37,17 @@
|
|
|
37
37
|
* (server/db.js), begun before anything is asked of the forge and finished with the answer after (`auth.begin`,
|
|
38
38
|
* `auth.finish`). Without an audit to write to nothing is done.
|
|
39
39
|
*
|
|
40
|
+
* One more write is not about a pull request: **a new idea** (docs/threads.md), `POST /api/forge/threads { title, body }`, which opens an issue
|
|
41
|
+
* with the threads label as the signed-in maintainer (`serveIdea`, below). It goes through the same doors, in the same order:
|
|
42
|
+
* POST, signed in, the flood ceiling, an audit row begun, same origin, JSON (here up to 32 KB), not twice, the permission asked of
|
|
43
|
+
* the forge now. The row keeps the title and the text's length, never the text. It only adds: an issue, and on Forgejo the
|
|
44
|
+
* threads label first when the repo lacks it (the answer, and so the row, says `labelsMade`).
|
|
45
|
+
*
|
|
40
46
|
* A pull request lands by the config's `forge.mergeMethod` (`merge`, `squash` or `rebase`; `merge` when unset), the way
|
|
41
47
|
* the repo lands its pull requests; the forge refuses a method the repo does not allow, in its own words.
|
|
42
48
|
*/
|
|
43
49
|
|
|
44
|
-
import { ForgeError, RERUN_STATES, canRerun, isMaintainer } from './forge.js';
|
|
50
|
+
import { ForgeError, RERUN_STATES, canRerun, isMaintainer, hasAdapter } from './forge.js';
|
|
45
51
|
import { sameOrigin } from './proposals.js';
|
|
46
52
|
import { planSpec, planBuild, startSpec, openBuildIssue } from './pipeline.js';
|
|
47
53
|
import { PROPOSAL_FILE } from './files.js';
|
|
@@ -50,19 +56,22 @@ import { Refused, throttle, countAttempt } from './throttle.js';
|
|
|
50
56
|
// The limits live in server/throttle.js (reviews share them); these are re-exported for the callers that always had them here
|
|
51
57
|
export { COOLDOWN_MS, PER_MINUTE, ATTEMPTS_PER_MINUTE, countAttempt } from './throttle.js';
|
|
52
58
|
import { proposalPayload, landedPayload } from './hooks.js';
|
|
59
|
+
import { ideaInput, createIdea, threadsOff } from './threads.js';
|
|
53
60
|
|
|
54
61
|
/** A request body is a sha and a check's id */
|
|
55
62
|
const MAX_BODY = 4096;
|
|
63
|
+
/** A new idea's request: a title and a text of up to 20,000 characters, in JSON (any of it may be escaped) */
|
|
64
|
+
const IDEA_BODY = 96 * 1024;
|
|
56
65
|
|
|
57
66
|
const STATUS = { unauthorized: 401, forbidden: 403, 'not-found': 404, conflict: 409, invalid: 400, 'rate-limited': 429, unavailable: 502, 'not-green': 409, 'too-large': 413 };
|
|
58
67
|
const SHA = /^[0-9a-f]{7,64}$/i;
|
|
59
68
|
const DIGEST = /^[0-9a-f]{32}$/;
|
|
60
69
|
/** What each action is called in the words of a refusal, and what it asks of the person */
|
|
61
|
-
const DOING = { merge: 'Landing', rerun: 'Re-running', spec: 'Starting a spec', build: 'Opening the build issue' };
|
|
62
|
-
const ONLY_MAINTAINERS = { merge: 'land pull requests or re-run checks', rerun: 'land pull requests or re-run checks', spec: 'start specs', build: 'open build issues' };
|
|
70
|
+
const DOING = { merge: 'Landing', rerun: 'Re-running', spec: 'Starting a spec', build: 'Opening the build issue', idea: 'Opening an idea' };
|
|
71
|
+
const ONLY_MAINTAINERS = { merge: 'land pull requests or re-run checks', rerun: 'land pull requests or re-run checks', spec: 'start specs', build: 'open build issues', idea: 'open ideas' };
|
|
63
72
|
|
|
64
73
|
/** Reads a small JSON object body, or refuses */
|
|
65
|
-
function readJson(req) {
|
|
74
|
+
function readJson(req, max = MAX_BODY) {
|
|
66
75
|
return new Promise((resolve, reject) => {
|
|
67
76
|
if (!/^application\/json(?:\s*;|$)/i.test(String(req.headers['content-type'] || ''))) {
|
|
68
77
|
req.resume();
|
|
@@ -75,7 +84,7 @@ function readJson(req) {
|
|
|
75
84
|
req.on('data', (c) => {
|
|
76
85
|
if (over) return;
|
|
77
86
|
size += c.length;
|
|
78
|
-
if (size <=
|
|
87
|
+
if (size <= max) { chunks.push(c); return; }
|
|
79
88
|
// Too large: answered now, and the rest is not read (the connection closes once the answer is sent)
|
|
80
89
|
over = true;
|
|
81
90
|
req.pause();
|
|
@@ -289,3 +298,95 @@ export async function serveAction(req, res, what, ctx, send) {
|
|
|
289
298
|
if (closeAfter) res.on('finish', () => req.destroy());
|
|
290
299
|
send(out.status, out.body, out.headers);
|
|
291
300
|
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* POST /api/forge/threads { title, body }: a new idea, an issue with the threads label, opened as the signed-in maintainer. The
|
|
304
|
+
* caller has found the path and the method to be this; every refusal is in words and written down, as in `serveAction`.
|
|
305
|
+
* @param {import('node:http').IncomingMessage} req
|
|
306
|
+
* @param {import('node:http').ServerResponse} res
|
|
307
|
+
* @param {{ config: any, session: { login: string, userId: string } | null, auth: { store: null | (() => Promise<import('node:sqlite').DatabaseSync>), forgeFor: Function, begin: Function, finish: Function }, now?: () => number, publicHost?: string | null }} ctx
|
|
308
|
+
* @param {(status: number, body: object, headers?: object) => void} send
|
|
309
|
+
*/
|
|
310
|
+
export async function serveIdea(req, res, ctx, send) {
|
|
311
|
+
const { config, session, auth, now = Date.now } = ctx;
|
|
312
|
+
if (!config.forge || !hasAdapter(config.forge.type)) {
|
|
313
|
+
req.resume();
|
|
314
|
+
return send(config.forge ? 501 : 404, { ok: false, error: config.forge ? `cornfield has no adapter for the forge type ${JSON.stringify(config.forge.type)} yet.` : 'No forge is configured: set `forge` in cornfield.config.js.', code: config.forge ? 'unavailable' : 'not-found' });
|
|
315
|
+
}
|
|
316
|
+
if (threadsOff(config)) {
|
|
317
|
+
req.resume();
|
|
318
|
+
return send(404, { ok: false, error: 'Threads are off in this config (threads: false).', code: 'not-found' });
|
|
319
|
+
}
|
|
320
|
+
if (!session || !auth.store) {
|
|
321
|
+
req.resume();
|
|
322
|
+
return send(403, { ok: false, error: `${DOING.idea} is done as the signed-in maintainer: set up sign-in first (docs/sign-in.md).`, code: 'forbidden' });
|
|
323
|
+
}
|
|
324
|
+
const flood = countAttempt(ctx, session.userId, now());
|
|
325
|
+
if (flood) {
|
|
326
|
+
req.resume();
|
|
327
|
+
return send(flood.status, { ok: false, error: flood.message, code: flood.code }, flood.headers);
|
|
328
|
+
}
|
|
329
|
+
const target = config.forge.repo;
|
|
330
|
+
/** @type {Record<string, unknown>} */
|
|
331
|
+
const request = { action: 'idea' };
|
|
332
|
+
let rowId = null;
|
|
333
|
+
let closeAfter = false;
|
|
334
|
+
let release = () => {};
|
|
335
|
+
/** @type {{ status: number, body: object, headers?: object, forgeStatus?: number | null }} */
|
|
336
|
+
let out;
|
|
337
|
+
try {
|
|
338
|
+
try {
|
|
339
|
+
rowId = await auth.begin({ login: session.login, action: 'idea', target, request });
|
|
340
|
+
} catch (err) {
|
|
341
|
+
console.error(`[cornfield] the audit row of idea ${target} could not be written: ${err instanceof Error ? err.message : err}`);
|
|
342
|
+
throw new Refused(503, 'cornfield cannot write its audit log, so it will not act on the forge.', 'unavailable');
|
|
343
|
+
}
|
|
344
|
+
if (!sameOrigin(req, ctx.publicHost ?? null)) {
|
|
345
|
+
req.resume();
|
|
346
|
+
throw new Refused(403, 'Do this from cornfield\'s own page.', 'forbidden');
|
|
347
|
+
}
|
|
348
|
+
let asked;
|
|
349
|
+
try {
|
|
350
|
+
asked = await readJson(req, IDEA_BODY);
|
|
351
|
+
} catch (err) {
|
|
352
|
+
closeAfter = Boolean(err && err.closeAfter);
|
|
353
|
+
throw err;
|
|
354
|
+
}
|
|
355
|
+
let idea;
|
|
356
|
+
try {
|
|
357
|
+
idea = ideaInput(asked);
|
|
358
|
+
} catch (err) {
|
|
359
|
+
throw new Refused(400, err instanceof Error ? err.message : 'That is not an idea.', 'invalid');
|
|
360
|
+
}
|
|
361
|
+
// The audit keeps what was asked for, not the words: the title, and how long the text was
|
|
362
|
+
request.title = idea.title;
|
|
363
|
+
request.bodyLength = idea.body.length;
|
|
364
|
+
release = throttle(ctx, session.userId, `idea|${idea.title.toLowerCase().slice(0, 80)}`, now());
|
|
365
|
+
const forge = auth.forgeFor(session);
|
|
366
|
+
await assertMaintainerNow(forge, session, config, 'idea');
|
|
367
|
+
const issue = await createIdea(forge, config, idea);
|
|
368
|
+
await forgetKept(auth, config);
|
|
369
|
+
// A label the forge did not have yet and that cornfield made for it (Forgejo) is part of the answer, and so of the audit row
|
|
370
|
+
out = { status: 201, body: { ok: true, action: 'idea', created: true, number: issue.number, issue: { number: issue.number, title: issue.title, url: issue.url }, ...(issue.labelsMade ? { labelsMade: issue.labelsMade } : {}) } };
|
|
371
|
+
} catch (err) {
|
|
372
|
+
if (err instanceof Refused) out = { status: err.status, body: { ok: false, error: err.message, code: err.code }, headers: err.headers };
|
|
373
|
+
else if (err instanceof ForgeError) {
|
|
374
|
+
const wait = err.rateLimit && err.rateLimit.retryAfter != null ? { 'Retry-After': String(Math.ceil(err.rateLimit.retryAfter)) } : {};
|
|
375
|
+
out = { status: STATUS[err.code] || 502, body: { ok: false, error: err.message, code: err.code }, headers: wait, forgeStatus: err.status ?? (err.code === 'unavailable' ? null : STATUS[err.code] || null) };
|
|
376
|
+
} else {
|
|
377
|
+
out = { status: 500, body: { ok: false, error: 'Something went wrong in cornfield; the log says what.', code: 'unavailable' } };
|
|
378
|
+
console.error(`[cornfield] idea ${target}: ${err instanceof Error ? err.stack || err.message : err}`);
|
|
379
|
+
}
|
|
380
|
+
} finally {
|
|
381
|
+
release();
|
|
382
|
+
}
|
|
383
|
+
if (rowId !== null) {
|
|
384
|
+
try {
|
|
385
|
+
await auth.finish(rowId, { target, request, status: 'forgeStatus' in out ? out.forgeStatus ?? null : out.status, answer: out.body });
|
|
386
|
+
} catch (err) {
|
|
387
|
+
console.error(`[cornfield] the audit row of idea ${target} could not be finished: ${err instanceof Error ? err.message : err}`);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
if (closeAfter) res.on('finish', () => req.destroy());
|
|
391
|
+
send(out.status, out.body, out.headers);
|
|
392
|
+
}
|
package/server/forge-forgejo.js
CHANGED
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
* - **Permission.** Forgejo has three levels, so a repo's people are `admin`, `write` or `read` and never
|
|
10
10
|
* `maintain` or `triage`; its admins are cornfield's. The signed-in user's own comes from the repo itself
|
|
11
11
|
* (`permissions`); anyone else's from the collaborator endpoint, which only a repo admin may ask (else 403).
|
|
12
|
-
* - **Issues.** `type=issues` leaves the pull requests out.
|
|
12
|
+
* - **Issues.** `type=issues` leaves the pull requests out. A new issue's labels are given as ids (Forgejo's API takes no names),
|
|
13
|
+
* so each is looked up and made when the repo lacks it.
|
|
13
14
|
* - **Checks.** Forgejo Actions reports each job as a commit status, so the commit's statuses are all the checks
|
|
14
15
|
* there are. Its API cannot re-run one: none is `rerunnable`.
|
|
15
16
|
* - **Runs.** `actions/runs` (Forgejo 11 and later, Gitea 1.24 and later), where Actions is on for the repo; an older
|
|
@@ -81,6 +82,9 @@ const FILE_STATUS = { added: 'added', copied: 'added', deleted: 'removed', remov
|
|
|
81
82
|
const toFile = (f) => ({ path: f.filename, status: FILE_STATUS[f.status] || 'modified', previousPath: f.previous_filename || null, additions: f.additions ?? 0, deletions: f.deletions ?? 0 });
|
|
82
83
|
|
|
83
84
|
/** @returns {import('./forge.js').Comment} */
|
|
85
|
+
/** @returns {import('./forge.js').PullCommit} */
|
|
86
|
+
const toCommit = (c) => ({ sha: String(c.sha ?? ''), message: typeof c.commit?.message === 'string' ? c.commit.message : '' });
|
|
87
|
+
|
|
84
88
|
const toComment = (c) => ({ id: String(c.id), author: author(c.user), body: c.body ?? '', url: c.html_url, createdAt: c.created_at, updatedAt: c.updated_at });
|
|
85
89
|
|
|
86
90
|
// A warning does not count as passing, so it stands in the way like a failure
|
|
@@ -142,6 +146,17 @@ export function createForgejoForge(forge, { token = null, fetch, apiUrl, timeout
|
|
|
142
146
|
return meOnce;
|
|
143
147
|
};
|
|
144
148
|
|
|
149
|
+
/**
|
|
150
|
+
* The id of the repo's label called `name`, making the label (plain grey) when it is not there: `{ id, made }`. Only
|
|
151
|
+
* `createIssue` calls this, so a label is only ever made on a write the maintainer asked for, never on a read.
|
|
152
|
+
*/
|
|
153
|
+
async function labelId(name) {
|
|
154
|
+
const have = (await client.list(`repos/${repo}/labels?limit=${PER_PAGE}`, { what: `the labels of ${forge.repo}` })).find((l) => typeof l.name === 'string' && l.name.toLowerCase() === name.toLowerCase());
|
|
155
|
+
if (have) return { id: have.id, made: false };
|
|
156
|
+
const { body } = await client.request('POST', `repos/${repo}/labels`, { body: { name, color: '#ededed' }, what: `the label ${name} in ${forge.repo}` });
|
|
157
|
+
return { id: body.id, made: true };
|
|
158
|
+
}
|
|
159
|
+
|
|
145
160
|
return {
|
|
146
161
|
me,
|
|
147
162
|
|
|
@@ -208,16 +223,29 @@ export function createForgejoForge(forge, { token = null, fetch, apiUrl, timeout
|
|
|
208
223
|
return toPull((await client.request('POST', `repos/${repo}/pulls`, { body: { title: draft ? `WIP: ${title}` : title, body, head, base }, what: `a pull request in ${forge.repo}` })).body);
|
|
209
224
|
},
|
|
210
225
|
|
|
211
|
-
async createIssue({ title, body }) {
|
|
212
|
-
|
|
226
|
+
async createIssue({ title, body, labels = [] }) {
|
|
227
|
+
// Forgejo takes label ids, not names: each is looked up, and made when the repo lacks it; the issue says which were made
|
|
228
|
+
const ids = [];
|
|
229
|
+
const made = [];
|
|
230
|
+
for (const name of labels) {
|
|
231
|
+
const label = await labelId(name);
|
|
232
|
+
ids.push(label.id);
|
|
233
|
+
if (label.made) made.push(name);
|
|
234
|
+
}
|
|
235
|
+
const issue = toIssue((await client.request('POST', `repos/${repo}/issues`, { body: { title, body, ...(ids.length ? { labels: ids } : {}) }, what: `an issue in ${forge.repo}` })).body);
|
|
236
|
+
return made.length ? { ...issue, labelsMade: made } : issue;
|
|
213
237
|
},
|
|
214
238
|
|
|
215
239
|
async pullFiles(n) {
|
|
216
240
|
return (await client.list(`repos/${repo}/pulls/${n}/files?limit=${PER_PAGE}`, { what: `the files of ${pullWhat(n)}` })).map(toFile);
|
|
217
241
|
},
|
|
218
242
|
|
|
219
|
-
async
|
|
220
|
-
return (await client.list(`repos/${repo}/
|
|
243
|
+
async pullCommits(n) {
|
|
244
|
+
return (await client.list(`repos/${repo}/pulls/${n}/commits?limit=${PER_PAGE}`, { what: `the commits of ${pullWhat(n)}` })).map(toCommit);
|
|
245
|
+
},
|
|
246
|
+
|
|
247
|
+
async comments(n, { since } = {}) {
|
|
248
|
+
return (await client.list(`repos/${repo}/issues/${n}/comments?limit=${PER_PAGE}${since ? `&since=${enc(since)}` : ''}`, { what: `the comments on #${n} in ${forge.repo}` })).map(toComment);
|
|
221
249
|
},
|
|
222
250
|
|
|
223
251
|
async comment(n, body) {
|
package/server/forge-github.js
CHANGED
|
@@ -66,6 +66,9 @@ const toPull = (p) => ({
|
|
|
66
66
|
url: p.html_url, createdAt: p.created_at, updatedAt: p.updated_at, mergedAt: p.merged_at ?? null,
|
|
67
67
|
});
|
|
68
68
|
|
|
69
|
+
/** @returns {import('./forge.js').PullCommit} */
|
|
70
|
+
const toCommit = (c) => ({ sha: String(c.sha ?? ''), message: typeof c.commit?.message === 'string' ? c.commit.message : '' });
|
|
71
|
+
|
|
69
72
|
const FILE_STATUS = { added: 'added', copied: 'added', removed: 'removed', renamed: 'renamed', modified: 'modified', changed: 'modified', unchanged: 'modified' };
|
|
70
73
|
/** @returns {import('./forge.js').PullFile} */
|
|
71
74
|
const toFile = (f) => ({ path: f.filename, status: FILE_STATUS[f.status] || 'modified', previousPath: f.previous_filename ?? null, additions: f.additions ?? 0, deletions: f.deletions ?? 0 });
|
|
@@ -221,16 +224,21 @@ export function createGitHubForge(forge, { token = null, fetch, apiUrl, timeoutM
|
|
|
221
224
|
return toPull((await client.request('POST', `repos/${repo}/pulls`, { body: { title, body, head, base, draft }, what: `a pull request in ${forge.repo}` })).body);
|
|
222
225
|
},
|
|
223
226
|
|
|
224
|
-
async createIssue({ title, body }) {
|
|
225
|
-
|
|
227
|
+
async createIssue({ title, body, labels = [] }) {
|
|
228
|
+
// GitHub makes a label that is not there yet when the label's name comes with a new issue (the user pushes to the repo)
|
|
229
|
+
return toIssue((await client.request('POST', `repos/${repo}/issues`, { body: { title, body, ...(labels.length ? { labels } : {}) }, what: `an issue in ${forge.repo}` })).body);
|
|
226
230
|
},
|
|
227
231
|
|
|
228
232
|
async pullFiles(n) {
|
|
229
233
|
return (await client.list(`repos/${repo}/pulls/${n}/files?per_page=${PER_PAGE}`, { what: `the files of ${pullWhat(n)}` })).map(toFile);
|
|
230
234
|
},
|
|
231
235
|
|
|
232
|
-
async
|
|
233
|
-
return (await client.list(`repos/${repo}/
|
|
236
|
+
async pullCommits(n) {
|
|
237
|
+
return (await client.list(`repos/${repo}/pulls/${n}/commits?per_page=${PER_PAGE}`, { what: `the commits of ${pullWhat(n)}` })).map(toCommit);
|
|
238
|
+
},
|
|
239
|
+
|
|
240
|
+
async comments(n, { since } = {}) {
|
|
241
|
+
return (await client.list(`repos/${repo}/issues/${n}/comments?per_page=${PER_PAGE}${since ? `&since=${enc(since)}` : ''}`, { what: `the comments on #${n} in ${forge.repo}` })).map(toComment);
|
|
234
242
|
},
|
|
235
243
|
|
|
236
244
|
async comment(n, body) {
|
package/server/forge-routes.js
CHANGED
|
@@ -19,6 +19,10 @@
|
|
|
19
19
|
* GET /api/forge/issues/<n>/spec the plan of step 1: what starting a spec on the issue would make (server/pipeline.js), never kept
|
|
20
20
|
* GET /api/forge/pulls/<n>/build?slug=<proposal> the plan of step 2: the build issue of an approved proposal, never kept
|
|
21
21
|
* POST /api/forge/issues/<n>/spec | /pulls/<n>/build do them, with the plan's digest (server/forge-actions.js)
|
|
22
|
+
* GET /api/forge/threads?state=open|closed|all&limit=100 the threads (docs/threads.md): ideas (issues with the threads label) and spec PRs' conversations,
|
|
23
|
+
* newest activity first, each with the `[[mentions]]` its text holds, as written (404 when `threads: false`)
|
|
24
|
+
* GET /api/forge/threads/<n> one thread's messages, as written, to read; replying is on the forge
|
|
25
|
+
* POST /api/forge/threads { title, body }: a new idea, an issue with the label, as the maintainer (server/forge-actions.js `serveIdea`)
|
|
22
26
|
* GET /api/forge/work what the agents view groups: pull requests and issues (any state), branches, open spec PRs, and the config's `agents` rules
|
|
23
27
|
* GET /api/forge/deploys?limit=100 the latest workflow runs that are deploys, each with what the
|
|
24
28
|
* config says it restarted (server/deploys.js, docs/deploys.md)
|
|
@@ -47,7 +51,9 @@ import fs from 'node:fs';
|
|
|
47
51
|
import path from 'node:path';
|
|
48
52
|
import { createHash } from 'node:crypto';
|
|
49
53
|
import { ForgeError, canRerun, hasAdapter, isMaintainer, isTagName, rateLimitWords } from './forge.js';
|
|
50
|
-
import { actionOf, countAttempt, serveAction } from './forge-actions.js';
|
|
54
|
+
import { actionOf, countAttempt, serveAction, serveIdea } from './forge-actions.js';
|
|
55
|
+
import { listThreads, readThread, snapshotOf, threadsOff } from './threads.js';
|
|
56
|
+
import { ideaLabel } from '../core/config.js';
|
|
51
57
|
import { landingOf, serveLanding } from './lander.js';
|
|
52
58
|
import { planSpec, planBuild, pullSpecs } from './pipeline.js';
|
|
53
59
|
import { listPullProposals } from './proposal-pulls.js';
|
|
@@ -133,6 +139,28 @@ function routeOf(reqPath, url, config, where, hooks = null) {
|
|
|
133
139
|
const mapping = createHash('sha256').update(JSON.stringify(config.deploys || null)).digest('hex').slice(0, 12);
|
|
134
140
|
return { key: `deploys?limit=${limit}&config=${mapping}`, load: async (forge) => ({ items: deploysOf(await forge.runs({ limit }), config.deploys) }) };
|
|
135
141
|
}
|
|
142
|
+
if (reqPath === '/api/forge/threads' || /^\/api\/forge\/threads\/[1-9]\d{0,8}$/.test(reqPath)) {
|
|
143
|
+
if (threadsOff(config)) throw new ForgeError('Threads are off in this config (threads: false).', { code: 'not-found' });
|
|
144
|
+
const label = ideaLabel(config);
|
|
145
|
+
if (reqPath === '/api/forge/threads') {
|
|
146
|
+
const q = listQuery(url, false);
|
|
147
|
+
return {
|
|
148
|
+
key: `threads?state=${q.state}&limit=${q.limit}&label=${label}`,
|
|
149
|
+
load: async (forge) => {
|
|
150
|
+
const found = await listThreads(forge, config, where || config, { state: q.state, limit: q.limit, hooks });
|
|
151
|
+
// Whatever a look reads is news to the hooks once (thread.opened, thread.message, thread.linked), as the spec PRs are
|
|
152
|
+
if (hooks) {
|
|
153
|
+
let nodes = [];
|
|
154
|
+
try { nodes = readTopology(config)?.nodes || []; } catch { nodes = []; }
|
|
155
|
+
hooks.threadsSeen(forge.repo, snapshotOf(found.gathered, nodes)).catch(() => {});
|
|
156
|
+
}
|
|
157
|
+
return { items: found.items, label, problem: found.problem };
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
const n = Number(reqPath.split('/').pop());
|
|
162
|
+
return { key: `threads/${n}&label=${label}`, load: async (forge) => ({ thread: await readThread(forge, config, where || config, n) }) };
|
|
163
|
+
}
|
|
136
164
|
if (reqPath === '/api/forge/work') {
|
|
137
165
|
return {
|
|
138
166
|
key: 'work',
|
|
@@ -236,6 +264,10 @@ export async function serveForge(req, res, reqPath, url, ctx) {
|
|
|
236
264
|
send(req, res, out.status, out.body, out.headers);
|
|
237
265
|
return true;
|
|
238
266
|
}
|
|
267
|
+
if (reqPath === '/api/forge/threads' && req.method === 'POST') {
|
|
268
|
+
await serveIdea(req, res, ctx, (status, body, headers) => send(req, res, status, body, headers));
|
|
269
|
+
return true;
|
|
270
|
+
}
|
|
239
271
|
const act = actionOf(reqPath);
|
|
240
272
|
if (act) {
|
|
241
273
|
if (!config.forge || !hasAdapter(config.forge.type)) {
|
package/server/forge.js
CHANGED
|
@@ -14,7 +14,9 @@
|
|
|
14
14
|
* issue(n) one issue (not a pull request: that is `not-found`)
|
|
15
15
|
* searchIssues(words) the issues whose text holds the words (one page; the forge's search, which can lag a moment)
|
|
16
16
|
* pullFiles(n) the files a pull request changes
|
|
17
|
-
*
|
|
17
|
+
* pullCommits(n) the commits of a pull request, oldest first: their shas and full messages (a run's trailer may be in one)
|
|
18
|
+
* comments(n, { since }) the comments on an issue or pull request, oldest first; `since` (an ISO time) only those
|
|
19
|
+
* created or edited after it, which is how a thread is followed without reading it all again
|
|
18
20
|
* comment(n, body) posts one, and answers it
|
|
19
21
|
* checks(ref) the checks on a commit (a branch, a tag or a sha)
|
|
20
22
|
* rerun(check) re-runs one that can be (`check.rerunnable`)
|
|
@@ -36,7 +38,9 @@
|
|
|
36
38
|
* createBranch(name, { from }) a branch at the head of the branch `from` (`conflict` when it exists)
|
|
37
39
|
* createFile({ branch, path, content, message }) one new file on a branch, as one commit (`invalid` when it exists)
|
|
38
40
|
* createPull({ title, body, head, base, draft }) a pull request; a draft one is `draft: true` (Forgejo: the "WIP: " title it uses)
|
|
39
|
-
* createIssue({ title, body })
|
|
41
|
+
* createIssue({ title, body, labels }) an issue; `labels` (names) are put on it, a label the repo lacks is made first (an idea is
|
|
42
|
+
* an issue with the threads label, docs/threads.md). Where cornfield makes the label itself
|
|
43
|
+
* (Forgejo), the issue carries `labelsMade: [names]`; GitHub makes it on its own and says nothing
|
|
40
44
|
*
|
|
41
45
|
* An adapter module (server/forge-github.js, server/forge-forgejo.js) implements these at its forge's level, with
|
|
42
46
|
* `mergeAt(n, { sha, method })` in place of `merge`; `wrap` checks the arguments and builds `merge` once for every
|
|
@@ -52,6 +56,7 @@
|
|
|
52
56
|
* @typedef {{ number: number, title: string, body: string, state: 'open' | 'closed' | 'merged', draft: boolean, author: Author | null, labels: string[], head: { ref: string, sha: string }, base: { ref: string }, url: string, createdAt: string, updatedAt: string, mergedAt: string | null }} Pull
|
|
53
57
|
* @typedef {{ path: string, status: 'added' | 'modified' | 'removed' | 'renamed', previousPath: string | null, additions: number, deletions: number }} PullFile
|
|
54
58
|
* @typedef {{ id: string, author: Author | null, body: string, url: string, createdAt: string, updatedAt: string }} Comment
|
|
59
|
+
* @typedef {{ sha: string, message: string }} PullCommit
|
|
55
60
|
* @typedef {'queued' | 'running' | 'success' | 'failure' | 'neutral' | 'skipped' | 'cancelled'} CheckState
|
|
56
61
|
* @typedef {{ id: string, name: string, state: CheckState, url: string | null, rerunnable: boolean }} Check
|
|
57
62
|
* @typedef {{ name: string, sha: string, protected: boolean }} Branch
|
|
@@ -78,9 +83,10 @@
|
|
|
78
83
|
* createBranch(name: string, opts: { from: string }): Promise<{ name: string, sha: string }>,
|
|
79
84
|
* createFile(file: { branch: string, path: string, content: string, message: string }): Promise<{ sha: string }>,
|
|
80
85
|
* createPull(pull: { title: string, body: string, head: string, base: string, draft: boolean }): Promise<Pull>,
|
|
81
|
-
* createIssue(issue: { title: string, body: string }): Promise<Issue>,
|
|
86
|
+
* createIssue(issue: { title: string, body: string, labels?: string[] }): Promise<Issue>,
|
|
82
87
|
* pullFiles(n: number): Promise<PullFile[]>,
|
|
83
|
-
*
|
|
88
|
+
* pullCommits(n: number): Promise<PullCommit[]>,
|
|
89
|
+
* comments(n: number, opts?: { since?: string }): Promise<Comment[]>,
|
|
84
90
|
* comment(n: number, body: string): Promise<Comment>,
|
|
85
91
|
* checks(ref: string): Promise<Check[]>,
|
|
86
92
|
* rerun(check: Check): Promise<void>,
|
|
@@ -111,7 +117,7 @@ import { ForgeError, FILE_MAX_BYTES } from './forge-http.js';
|
|
|
111
117
|
export { ForgeError, rateLimitWords } from './forge-http.js';
|
|
112
118
|
|
|
113
119
|
/** The calls of the interface (spec 0001 section 3, with `pull(n)`), and `rateLimit()`: what the last answer said is left */
|
|
114
|
-
export const FORGE_METHODS = Object.freeze(['me', 'permission', 'issues', 'searchIssues', 'pulls', 'pull', 'issue', 'pullFiles', 'comments', 'comment', 'checks', 'rerun', 'merge', 'runs', 'branches', 'file', 'fileBytes', 'defaultBranch', 'tags', 'tag', 'createReleaseDraft', 'createBranch', 'createFile', 'createPull', 'createIssue', 'rateLimit']);
|
|
120
|
+
export const FORGE_METHODS = Object.freeze(['me', 'permission', 'issues', 'searchIssues', 'pulls', 'pull', 'issue', 'pullFiles', 'pullCommits', 'comments', 'comment', 'checks', 'rerun', 'merge', 'runs', 'branches', 'file', 'fileBytes', 'defaultBranch', 'tags', 'tag', 'createReleaseDraft', 'createBranch', 'createFile', 'createPull', 'createIssue', 'rateLimit']);
|
|
115
121
|
export const PERMISSIONS = Object.freeze(['admin', 'maintain', 'write', 'triage', 'read', 'none']);
|
|
116
122
|
/** A check that has finished and does not stand in the way: success, or one the forge says does not count */
|
|
117
123
|
export const GREEN_STATES = Object.freeze(['success', 'neutral', 'skipped']);
|
|
@@ -120,6 +126,9 @@ export const DEFAULT_LIMIT = 1000;
|
|
|
120
126
|
/** The runs a listing returns unless asked for fewer: one forge page */
|
|
121
127
|
export const DEFAULT_RUN_LIMIT = 100;
|
|
122
128
|
|
|
129
|
+
/** The most labels one new issue is given */
|
|
130
|
+
const MAX_LABELS = 5;
|
|
131
|
+
const ISO_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:\d{2})$/;
|
|
123
132
|
const MERGE_METHODS = ['merge', 'squash', 'rebase'];
|
|
124
133
|
const STATES = ['open', 'closed', 'all'];
|
|
125
134
|
const REPO = /^[\w.-]+\/[\w.-]+$/;
|
|
@@ -270,7 +279,13 @@ export function wrap(a, forge) {
|
|
|
270
279
|
return a.searchIssues(words.trim());
|
|
271
280
|
},
|
|
272
281
|
pullFiles: async (n) => a.pullFiles(number(n)),
|
|
273
|
-
|
|
282
|
+
pullCommits: async (n) => a.pullCommits(number(n)),
|
|
283
|
+
comments: async (n, opts) => {
|
|
284
|
+
if (opts !== undefined && (opts === null || typeof opts !== 'object' || Array.isArray(opts))) throw invalid('The options are an object.');
|
|
285
|
+
const since = opts && opts.since;
|
|
286
|
+
if (since !== undefined && (typeof since !== 'string' || !ISO_TIME.test(since) || Number.isNaN(Date.parse(since)))) throw invalid('since is a time like 2026-11-05T09:00:00Z.');
|
|
287
|
+
return a.comments(number(n), since === undefined ? {} : { since: new Date(since).toISOString() });
|
|
288
|
+
},
|
|
274
289
|
comment: async (n, body) => a.comment(number(n), text(body, 'The comment')),
|
|
275
290
|
checks: async (ref) => a.checks(text(ref, 'The commit')),
|
|
276
291
|
rerun: async (check) => {
|
|
@@ -339,7 +354,9 @@ export function wrap(a, forge) {
|
|
|
339
354
|
},
|
|
340
355
|
createIssue: async (issue) => {
|
|
341
356
|
if (!issue || typeof issue !== 'object') throw invalid('Open which issue?');
|
|
342
|
-
|
|
357
|
+
const labels = issue.labels ?? [];
|
|
358
|
+
if (!Array.isArray(labels) || labels.length > MAX_LABELS || labels.some((l) => typeof l !== 'string' || l.trim() === '' || l.trim() !== l || l.length > 50 || l.includes(',') || l.includes('\0'))) throw invalid(`The labels are at most ${MAX_LABELS} names, without commas.`);
|
|
359
|
+
return a.createIssue({ title: bounded(issue.title, 'The title', MAX_TITLE), body: bounded(issue.body ?? ' ', 'The description', MAX_BODY), labels: [...new Set(labels)] });
|
|
343
360
|
},
|
|
344
361
|
defaultBranch: async () => a.defaultBranch(),
|
|
345
362
|
tags: async () => a.tags(),
|
package/server/hooks.js
CHANGED
|
@@ -13,8 +13,21 @@
|
|
|
13
13
|
* spec.approved a maintainer approved a proposal spec.sentBack a maintainer sent one back
|
|
14
14
|
* build.opened the build issue of an approved proposal was opened
|
|
15
15
|
* pr.landed a pull request was landed through cornfield (its Land it); a merge made on the forge sends nothing
|
|
16
|
+
* thread.opened a new idea (an issue with the threads label, docs/threads.md) was first seen
|
|
17
|
+
* thread.message a comment was added to a thread: an idea's, or a spec PR's conversation. Once per comment
|
|
18
|
+
* thread.linked a thread now points at the map: a message holds a `[[node]]` mention or a `cornfield-thread` block that
|
|
19
|
+
* resolves, or a spec PR names the thread with `Thread: #n`. Its body adds `nodes` and `proposal`
|
|
20
|
+
* Like `spec.ready`, these fire when cornfield next looks (the dashboard's threads list, or the poll,
|
|
21
|
+
* server/spec-poll.js); a comment's text is never in the payload: `{ event, at, thread, comment?, actor, nodes?,
|
|
22
|
+
* proposal?, mapUrl }`, so the receiver reads the words from the forge itself. The first look at a repo records
|
|
23
|
+
* what is there and sends nothing; each (repo, thread, comment) is sent once, across restarts (`thread_seen`)
|
|
16
24
|
* session.expiring a maintainer's sign-in ends within a day (`watchSessions`, below). Its body is its own:
|
|
17
25
|
* `{ event, at, actor, expiresAt, mapUrl }`
|
|
26
|
+
* agent.requested a maintainer asked for an agent run (server/agent-runs.js). Its body is its own: `{ event, at, request, actor, mapUrl }`,
|
|
27
|
+
* `request` being the whole request JSON of the contract (docs/agent-contract.md), which holds no credential by construction
|
|
28
|
+
* run.claimed run.state run.stopRequested run.finished a run was claimed by its runner, changed state, was asked to stop, ended. They are
|
|
29
|
+
* seen when cornfield looks (the spec poll, server/spec-poll.js), each once. Their body is their own:
|
|
30
|
+
* `{ event, at, runId, state, pull?, issue?, actor, mapUrl }`
|
|
18
31
|
*
|
|
19
32
|
* **A hook never gets in the way.** `emit` returns at once and never throws; the delivery runs in the background, from a
|
|
20
33
|
* queue in memory. A failed delivery (a network error, a timeout, anything but a 2xx) is tried again after 1s, 5s and 30s
|
|
@@ -38,6 +51,7 @@ import { createHmac, randomUUID } from 'node:crypto';
|
|
|
38
51
|
import { HOOK_EVENTS } from '../core/config.js';
|
|
39
52
|
import { pullProposals, readPullProposal } from './proposal-pulls.js';
|
|
40
53
|
import { proposalHash } from './reviews.js';
|
|
54
|
+
import { validateRequest } from '../core/agent-contract.js';
|
|
41
55
|
|
|
42
56
|
/** @typedef {ReturnType<typeof createHooks>} Hooks */
|
|
43
57
|
|
|
@@ -186,6 +200,29 @@ export function createHooks({ hooks, secret, fetch: doFetch = globalThis.fetch,
|
|
|
186
200
|
function bodyOf(event, p) {
|
|
187
201
|
// A session's end has no spec, pull request or proposal: its body is only who, until when, and where cornfield is
|
|
188
202
|
if (event === 'session.expiring') return JSON.stringify({ event, at: new Date(now()).toISOString(), actor: p.actor ?? null, expiresAt: p.expiresAt ?? null, mapUrl: publicUrl || null });
|
|
203
|
+
// A request goes out as it was posted, and is checked once more on the way: nothing like a credential can leave through a hook
|
|
204
|
+
if (event === 'agent.requested') {
|
|
205
|
+
const checked = validateRequest(p.request);
|
|
206
|
+
if (!checked.ok) throw new Error(checked.error);
|
|
207
|
+
return JSON.stringify({ event, at: new Date(now()).toISOString(), request: checked.value, actor: p.actor ?? null, mapUrl: publicUrl || null });
|
|
208
|
+
}
|
|
209
|
+
// The events of a run (claimed, state, stopRequested, finished) say which run, in what state, and about what
|
|
210
|
+
if (event.startsWith('run.')) {
|
|
211
|
+
return JSON.stringify({
|
|
212
|
+
event, at: new Date(now()).toISOString(), runId: p.runId ?? null, state: p.state ?? null,
|
|
213
|
+
...(p.pull != null ? { pull: p.pull } : {}), ...(p.issue != null ? { issue: p.issue } : {}), actor: p.actor ?? null, mapUrl: publicUrl || null,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
// A thread's event is only which thread, which comment, who and what it links to: never the words said
|
|
217
|
+
if (event.startsWith('thread.')) {
|
|
218
|
+
const out = { event, at: new Date(now()).toISOString(), thread: p.thread ?? null };
|
|
219
|
+
if (p.comment !== undefined) out.comment = p.comment;
|
|
220
|
+
out.actor = p.actor ?? null;
|
|
221
|
+
if (p.nodes !== undefined) out.nodes = p.nodes;
|
|
222
|
+
if (p.proposal !== undefined) out.proposal = p.proposal;
|
|
223
|
+
out.mapUrl = publicUrl || null;
|
|
224
|
+
return JSON.stringify(out);
|
|
225
|
+
}
|
|
189
226
|
const out = {
|
|
190
227
|
event, at: new Date(now()).toISOString(), slug: p.slug ?? null, pull: p.pull ?? null, headSha: p.headSha ?? null,
|
|
191
228
|
proposalSha256: p.proposalSha256 ?? null, actor: p.actor ?? null,
|
|
@@ -261,6 +298,68 @@ export function createHooks({ hooks, secret, fetch: doFetch = globalThis.fetch,
|
|
|
261
298
|
})().catch((err) => console.error(`[cornfield] the spec PRs could not be checked for spec.ready: ${err instanceof Error ? err.message : err}`));
|
|
262
299
|
}
|
|
263
300
|
|
|
301
|
+
/**
|
|
302
|
+
* Where each thread was last looked at: for the poll to ask the forge only for what is new. `looked` is whether the repo's
|
|
303
|
+
* threads have been looked at before (the first look sends nothing); `threads` maps a thread's number to the forge's
|
|
304
|
+
* last-update time when it was looked at (`stamp`) and the time of the newest comment seen (`since`, '' for none).
|
|
305
|
+
* @param {string} repo
|
|
306
|
+
* @returns {Promise<{ looked: boolean, threads: Map<number, { stamp: string, since: string }> }>}
|
|
307
|
+
*/
|
|
308
|
+
async function threadCursors(repo) {
|
|
309
|
+
const none = { looked: false, threads: new Map() };
|
|
310
|
+
if (!store) return none;
|
|
311
|
+
const db = await store();
|
|
312
|
+
const rows = db.prepare('SELECT issue, comment, stamp FROM thread_seen WHERE repo = ?').all(repo);
|
|
313
|
+
const threads = new Map();
|
|
314
|
+
for (const r of rows) {
|
|
315
|
+
if (r.issue === 0) continue;
|
|
316
|
+
const t = threads.get(r.issue) || { stamp: '', since: '' };
|
|
317
|
+
if (r.comment === '') t.stamp = r.stamp;
|
|
318
|
+
else if (!r.comment.includes(':') && r.stamp > t.since) t.since = r.stamp;
|
|
319
|
+
threads.set(r.issue, t);
|
|
320
|
+
}
|
|
321
|
+
return { looked: rows.some((r) => r.issue === 0 && r.comment === ''), threads };
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* `thread.opened`, `thread.message` and `thread.linked`: the threads a look just read (server/threads.js `snapshotOf`).
|
|
326
|
+
* Everything is recorded in `thread_seen` (repo, thread, comment) before it is announced, so a restart sends none twice,
|
|
327
|
+
* and the first look at a repo records what is there and announces nothing. Returns a promise that resolves once the
|
|
328
|
+
* events are queued; it never rejects.
|
|
329
|
+
* @param {string} repo 'owner/name'
|
|
330
|
+
* @param {{ threads: any[], pulls: { pull: number, thread: number, slug: string }[] }} snapshot
|
|
331
|
+
*/
|
|
332
|
+
async function threadsSeen(repo, snapshot) {
|
|
333
|
+
if (closed || !store || !snapshot || !Array.isArray(snapshot.threads)) return;
|
|
334
|
+
try {
|
|
335
|
+
const db = await store();
|
|
336
|
+
const at = new Date(now()).toISOString();
|
|
337
|
+
const looked = db.prepare("SELECT 1 FROM thread_seen WHERE repo = ? AND issue = 0 AND comment = ''").get(repo) !== undefined;
|
|
338
|
+
const insert = db.prepare('INSERT OR IGNORE INTO thread_seen (repo, issue, comment, stamp, at) VALUES (?, ?, ?, ?, ?)');
|
|
339
|
+
const move = db.prepare("INSERT INTO thread_seen (repo, issue, comment, stamp, at) VALUES (?, ?, '', ?, ?) ON CONFLICT(repo, issue, comment) DO UPDATE SET stamp = excluded.stamp");
|
|
340
|
+
/** Whether this (thread, key) is news: new to the table, and not the silent first look */
|
|
341
|
+
const fresh = (thread, key, stamp = '') => insert.run(repo, thread, key, stamp, at).changes === 1 && looked;
|
|
342
|
+
for (const t of snapshot.threads) {
|
|
343
|
+
if (t.opened && fresh(t.number, '', t.stamp)) emit('thread.opened', { thread: t.number, actor: t.opened.actor });
|
|
344
|
+
if (t.opened && t.opened.linked && fresh(t.number, 'link:body')) {
|
|
345
|
+
emit('thread.linked', { thread: t.number, actor: t.opened.actor, nodes: t.opened.linked.nodes, proposal: t.opened.linked.proposal });
|
|
346
|
+
}
|
|
347
|
+
for (const m of t.messages) {
|
|
348
|
+
if (fresh(t.number, String(m.id), m.stamp)) emit('thread.message', { thread: t.number, comment: m.id, actor: m.actor });
|
|
349
|
+
if (m.linked && fresh(t.number, `link:${m.id}`)) emit('thread.linked', { thread: t.number, comment: m.id, actor: m.actor, nodes: m.linked.nodes, proposal: m.linked.proposal });
|
|
350
|
+
}
|
|
351
|
+
// The last-update time moves with every look that read the thread, so the next look can skip it when nothing changed
|
|
352
|
+
move.run(repo, t.number, t.stamp, at);
|
|
353
|
+
}
|
|
354
|
+
for (const p of snapshot.pulls || []) {
|
|
355
|
+
if (fresh(p.thread, `pr:${p.pull}`)) emit('thread.linked', { thread: p.thread, actor: null, nodes: [], proposal: p.slug });
|
|
356
|
+
}
|
|
357
|
+
if (!looked) insert.run(repo, 0, '', '', at);
|
|
358
|
+
} catch (err) {
|
|
359
|
+
console.error(`[cornfield] the threads could not be checked for thread events: ${err instanceof Error ? err.message : err}`);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
264
363
|
/**
|
|
265
364
|
* `session.expiring`: once an hour (and once now), the live sessions of maintainers (the ones that keep a forge token)
|
|
266
365
|
* that end within 24 hours are announced, each once: the row's `expiring_sent_at` is set before the event is
|
|
@@ -296,7 +395,7 @@ export function createHooks({ hooks, secret, fetch: doFetch = globalThis.fetch,
|
|
|
296
395
|
timers.clear();
|
|
297
396
|
}
|
|
298
397
|
|
|
299
|
-
return { emit, specsSeen, watchSessions, close };
|
|
398
|
+
return { emit, specsSeen, threadCursors, threadsSeen, watchSessions, close };
|
|
300
399
|
}
|
|
301
400
|
|
|
302
401
|
/** The SHA-256 of a proposal file as `read` gives it, or null when it cannot be read (an event still goes out) */
|