cornfield 0.1.2 → 0.3.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.
@@ -45,13 +45,12 @@ import { ForgeError, RERUN_STATES, canRerun, isMaintainer } from './forge.js';
45
45
  import { sameOrigin } from './proposals.js';
46
46
  import { planSpec, planBuild, startSpec, openBuildIssue } from './pipeline.js';
47
47
  import { PROPOSAL_FILE } from './files.js';
48
+ import { Refused, throttle, countAttempt } from './throttle.js';
49
+
50
+ // The limits live in server/throttle.js (reviews share them); these are re-exported for the callers that always had them here
51
+ export { COOLDOWN_MS, PER_MINUTE, ATTEMPTS_PER_MINUTE, countAttempt } from './throttle.js';
52
+ import { proposalPayload, landedPayload } from './hooks.js';
48
53
 
49
- /** The same action on the same target is not asked again for this long (a double click, a retry before the forge has caught up) */
50
- export const COOLDOWN_MS = 5000;
51
- /** The most actions one person takes in a minute */
52
- export const PER_MINUTE = 20;
53
- /** The most attempts of any kind (refused ones too) one person makes in a minute, before any is written down */
54
- export const ATTEMPTS_PER_MINUTE = 60;
55
54
  /** A request body is a sha and a check's id */
56
55
  const MAX_BODY = 4096;
57
56
 
@@ -62,39 +61,6 @@ const DIGEST = /^[0-9a-f]{32}$/;
62
61
  const DOING = { merge: 'Landing', rerun: 'Re-running', spec: 'Starting a spec', build: 'Opening the build issue' };
63
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' };
64
63
 
65
- /** @type {WeakMap<object, { busy: Set<string>, last: Map<string, number>, perUser: Map<string, number[]>, attempts: Map<string, number[]> }>} */
66
- const throttles = new WeakMap();
67
-
68
- function throttlesOf(auth) {
69
- let t = throttles.get(auth);
70
- if (!t) throttles.set(auth, (t = { busy: new Set(), last: new Map(), perUser: new Map(), attempts: new Map() }));
71
- return t;
72
- }
73
-
74
- /** Counts an attempt of any kind; refuses one past the ceiling, without writing anything down (the release draft shares it) */
75
- export function countAttempt(ctx, userId, at) {
76
- const t = throttlesOf(ctx.auth);
77
- for (const [k, list] of t.attempts) if (!list.length || at - list[list.length - 1] >= 60_000) t.attempts.delete(k);
78
- const mine = (t.attempts.get(userId) || []).filter((when) => at - when < 60_000);
79
- if (mine.length >= ATTEMPTS_PER_MINUTE) {
80
- t.attempts.set(userId, mine);
81
- return new Refused(429, `That is ${ATTEMPTS_PER_MINUTE} attempts in a minute; wait a little.`, 'rate-limited', { 'Retry-After': String(Math.max(1, Math.ceil((60_000 - (at - mine[0])) / 1000))) });
82
- }
83
- mine.push(at);
84
- t.attempts.set(userId, mine);
85
- return null;
86
- }
87
-
88
- /** An action's own refusal: the status to answer and the words */
89
- class Refused extends Error {
90
- constructor(status, message, code, headers = {}) {
91
- super(message);
92
- this.status = status;
93
- this.code = code;
94
- this.headers = headers;
95
- }
96
- }
97
-
98
64
  /** Reads a small JSON object body, or refuses */
99
65
  function readJson(req) {
100
66
  return new Promise((resolve, reject) => {
@@ -129,23 +95,6 @@ function readJson(req) {
129
95
  });
130
96
  }
131
97
 
132
- /** Refuses a repeat and a flood; answers the key to release when the attempt is over */
133
- function throttle(ctx, userId, key, at) {
134
- const t = throttlesOf(ctx.auth);
135
- for (const [k, when] of t.last) if (at - when >= COOLDOWN_MS) t.last.delete(k);
136
- const mine = (t.perUser.get(userId) || []).filter((when) => at - when < 60_000);
137
- t.perUser.set(userId, mine);
138
- const wait = (ms, words) => new Refused(429, words, 'rate-limited', { 'Retry-After': String(Math.max(1, Math.ceil(ms / 1000))) });
139
- if (t.busy.has(key)) throw wait(COOLDOWN_MS, 'That is already being done; wait for it to finish.');
140
- const before = t.last.get(key);
141
- if (before !== undefined && at - before < COOLDOWN_MS) throw wait(COOLDOWN_MS - (at - before), 'That was just asked; wait a few seconds before asking again.');
142
- if (mine.length >= PER_MINUTE) throw wait(60_000 - (at - mine[0]), `That is ${PER_MINUTE} actions in a minute; wait a little.`);
143
- t.busy.add(key);
144
- t.last.set(key, at);
145
- mine.push(at);
146
- return () => { t.busy.delete(key); };
147
- }
148
-
149
98
  /**
150
99
  * The permission of the signed-in user on the repo, asked of the forge now with their own token. Refuses unless it is
151
100
  * the same forge user as the session's and a maintainer.
@@ -181,7 +130,7 @@ export function actionOf(reqPath) {
181
130
  * @param {import('node:http').IncomingMessage} req
182
131
  * @param {import('node:http').ServerResponse} res
183
132
  * @param {{ n: number, action: 'merge' | 'rerun' | 'spec' | 'build' }} what
184
- * @param {{ config: object, where?: { proposals: string }, session: { login: string, userId: string } | null, auth: { store: null | (() => Promise<import('node:sqlite').DatabaseSync>), forgeFor: Function }, now?: () => number, publicHost?: string | null }} ctx
133
+ * @param {{ config: object, where?: { proposals: string }, hooks?: import('./hooks.js').Hooks | null, session: { login: string, userId: string } | null, auth: { store: null | (() => Promise<import('node:sqlite').DatabaseSync>), forgeFor: Function }, now?: () => number, publicHost?: string | null }} ctx
185
134
  * @param {(status: number, body: object, headers?: object) => void} send
186
135
  */
187
136
  export async function serveAction(req, res, what, ctx, send) {
@@ -275,12 +224,15 @@ export async function serveAction(req, res, what, ctx, send) {
275
224
  if (!plan.existing && plan.sha.toLowerCase() !== sha) throw new ForgeError(`Pull request #${n} has changed since it was looked at (its proposal is now at ${plan.sha.slice(0, 7)}); look again.`, { code: 'conflict' });
276
225
  if (!plan.existing && plan.digest !== digest) throw new ForgeError(`What the build issue for ${slug} would say has changed since it was shown (a new review, or a push); look again.`, { code: 'conflict' });
277
226
  const done = await openBuildIssue(forge, plan);
227
+ // Only an issue this ask made is news: a second ask that found the first one's says nothing again
228
+ if (done.created) ctx.hooks?.emit('build.opened', () => proposalPayload(forge, ctx.where || { proposals: config.proposals }, plan, { actor: session.login }));
278
229
  await forgetKept(auth, config);
279
230
  out = { status: done.created ? 201 : 200, body: { ok: true, action, created: done.created, number: n, slug, issue: done.issue, path: plan.path } };
280
231
  } else if (action === 'merge') {
281
232
  const method = config.forge.mergeMethod || 'merge';
282
233
  request.method = method;
283
234
  const landed = await forge.merge(n, { onlyIfGreen: true, sha, method });
235
+ ctx.hooks?.emit('pr.landed', () => landedPayload(forge, ctx.where || { proposals: config.proposals }, n, /** @type {string} */ (sha), session.login));
284
236
  await forgetKept(auth, config);
285
237
  out = { status: 200, body: { ok: true, merged: true, number: n, sha: landed.sha, head: sha, method } };
286
238
  } else {
@@ -111,7 +111,7 @@ function notesInput(config, since) {
111
111
  * The reads: the request's route, the forge call that answers it, and the key it is kept under.
112
112
  * @returns {{ key: string, load: (forge: import('./forge.js').Forge) => Promise<object> } | null}
113
113
  */
114
- function routeOf(reqPath, url, config, where) {
114
+ function routeOf(reqPath, url, config, where, hooks = null) {
115
115
  if (reqPath === '/api/forge/release-notes') {
116
116
  const since = sinceOf(url.searchParams.get('since'));
117
117
  return { key: `release-notes?since=${since ?? ''}&label=${config.releaseNotes?.breakingLabel ?? ''}`, load: (forge) => releaseNotes(forge, notesInput(config, since)) };
@@ -136,7 +136,7 @@ function routeOf(reqPath, url, config, where) {
136
136
  key: 'work',
137
137
  load: async (forge) => {
138
138
  const [pulls, issues, branches, specs] = await Promise.all([
139
- forge.pulls({ state: 'all', limit: WORK_PULLS }), forge.issues({ state: 'all', labels: [], limit: WORK_ISSUES }), forge.branches(), listPullProposals(forge, config),
139
+ forge.pulls({ state: 'all', limit: WORK_PULLS }), forge.issues({ state: 'all', labels: [], limit: WORK_ISSUES }), forge.branches(), listPullProposals(forge, config, { hooks }),
140
140
  ]);
141
141
  return {
142
142
  pulls: pulls.map((p) => ({ ...p, body: p.body.slice(0, WORK_BODY_MAX) })),
@@ -220,7 +220,7 @@ async function servePlan(req, res, act, url, ctx) {
220
220
  * @param {import('node:http').ServerResponse} res
221
221
  * @param {string} reqPath the decoded path
222
222
  * @param {URL} url
223
- * @param {{ config: object, where?: { proposals: string }, session: { userId: string, login: string } | null, publicHost?: string | null, auth: { store: null | (() => Promise<import('node:sqlite').DatabaseSync>), forgeFor: Function }, now?: () => number }} ctx
223
+ * @param {{ config: object, where?: { proposals: string }, hooks?: import('./hooks.js').Hooks | null, session: { userId: string, login: string } | null, publicHost?: string | null, auth: { store: null | (() => Promise<import('node:sqlite').DatabaseSync>), forgeFor: Function }, now?: () => number }} ctx
224
224
  * `session` is the signed-in maintainer's (null without sign-in)
225
225
  * @returns {Promise<boolean>}
226
226
  */
@@ -270,7 +270,7 @@ export async function serveForge(req, res, reqPath, url, ctx) {
270
270
  return true;
271
271
  }
272
272
  try {
273
- const route = routeOf(reqPath, url, config, ctx.where);
273
+ const route = routeOf(reqPath, url, config, ctx.where, ctx.hooks);
274
274
  if (!route) {
275
275
  send(req, res, 404, { ok: false, error: `${reqPath} is not a forge route.`, code: 'not-found' });
276
276
  return true;
@@ -0,0 +1,303 @@
1
+ /**
2
+ * @module server/hooks
3
+ *
4
+ * Event hooks (PIPE-07, docs/config.md#hooks): cornfield tells outside code what happened. The config's
5
+ * `hooks: [{ url, events }]` lists where; when something happens, `emit(event, payload)` posts one JSON body to each
6
+ * hook that asked for that event:
7
+ *
8
+ * spec.ready a spec PR that is not a draft was first seen, or was seen at a new head: it is waiting for review.
9
+ * A draft (a "Start a spec" stub among them) is never announced; one marked ready is, at its head.
10
+ * cornfield has no webhook receiver: it notices when it next lists the PRs, so this fires when
11
+ * cornfield next looks, not at the push. The first look at a repo after hooks are added announces
12
+ * nothing: it records what is open, so only what changes after it is news
13
+ * spec.approved a maintainer approved a proposal spec.sentBack a maintainer sent one back
14
+ * build.opened the build issue of an approved proposal was opened
15
+ * pr.landed a pull request was landed through cornfield (its Land it); a merge made on the forge sends nothing
16
+ *
17
+ * **A hook never gets in the way.** `emit` returns at once and never throws; the delivery runs in the background, from a
18
+ * queue in memory. A failed delivery (a network error, a timeout, anything but a 2xx) is tried again after 1s, 5s and 30s
19
+ * (four attempts in all) and every attempt is a row in the `actions` audit table (`action: 'hook'`). A hook that is
20
+ * dead, slow or wrong changes nothing else in cornfield. Deliveries wait in memory only: a restart forgets the ones
21
+ * still being retried.
22
+ *
23
+ * **What is sent.** `{ event, at, slug, pull, headSha, proposalSha256, actor, note?, edits?, mapUrl }`, built here from
24
+ * a fixed list of fields, so nothing else can leak into it: never file contents, a token or an email. The hook's URL
25
+ * comes only from the maintainer's config and is still treated with care: no redirect is followed, a request is given
26
+ * 10 seconds, and at most `MAX_RESPONSE_BYTES` of the answer are read (it is never kept). The audit rows name the hook's
27
+ * origin only, never its path or query, which may hold a secret of the receiver's.
28
+ *
29
+ * **Signed.** Each request carries `X-Cornfield-Signature: sha256=<hex>`, the HMAC-SHA256 of the raw body under
30
+ * CORNFIELD_HOOK_SECRET (env only, at least 32 characters; hooks without it are a startup error, `hooksSettings`), and
31
+ * `X-Cornfield-Event`, `X-Cornfield-Delivery` (a UUID, the same on every retry) and `X-Cornfield-Timestamp` (the body's
32
+ * `at`), so a receiver can refuse a replay. The secret is never logged or put in a row.
33
+ */
34
+
35
+ import { createHmac, randomUUID } from 'node:crypto';
36
+ import { HOOK_EVENTS } from '../core/config.js';
37
+ import { pullProposals, readPullProposal } from './proposal-pulls.js';
38
+ import { proposalHash } from './reviews.js';
39
+
40
+ /** @typedef {ReturnType<typeof createHooks>} Hooks */
41
+
42
+ export { HOOK_EVENTS };
43
+ /** The env var the signing secret is read from: never the config file */
44
+ export const HOOK_SECRET_ENV = 'CORNFIELD_HOOK_SECRET';
45
+ export const MIN_HOOK_SECRET = 32;
46
+ /** The wait before each retry; the first attempt is made at once, so a failing hook is tried four times */
47
+ export const BACKOFF_MS = Object.freeze([1_000, 5_000, 30_000]);
48
+ /** How long one request may take */
49
+ export const TIMEOUT_MS = 10_000;
50
+ /** How much of an answer is read before it is dropped */
51
+ export const MAX_RESPONSE_BYTES = 4096;
52
+ /** The most deliveries waiting (or being retried) at once; one past it is dropped, and the audit table says so */
53
+ export const MAX_PENDING = 500;
54
+
55
+ /**
56
+ * The hook settings env gives, or null when the config lists no hooks. Throws, in words, when hooks are listed but cannot
57
+ * work: no secret, a short one, or no sign-in (every event comes from a signed-in maintainer's action or listing).
58
+ * @param {{ hooks?: unknown[] }} config
59
+ * @param {Record<string, string | undefined>} [env]
60
+ * @param {boolean} [signIn] whether sign-in is configured
61
+ * @returns {null | { secret: string }}
62
+ */
63
+ export function hooksSettings(config, env = process.env, signIn = true) {
64
+ if (!Array.isArray(config.hooks) || config.hooks.length === 0) return null;
65
+ const secret = env[HOOK_SECRET_ENV];
66
+ if (typeof secret !== 'string' || secret === '') throw new Error(`The config lists hooks, so ${HOOK_SECRET_ENV} is needed: a random value of at least ${MIN_HOOK_SECRET} characters that signs every event (openssl rand -base64 48). Never put it in the config.`);
67
+ if (secret.length < MIN_HOOK_SECRET) throw new Error(`${HOOK_SECRET_ENV} is too short: use at least ${MIN_HOOK_SECRET} random characters (openssl rand -base64 48).`);
68
+ if (!signIn) throw new Error('The config lists hooks, but sign-in is not set up. Every event comes from something a signed-in maintainer does in cornfield (a review, a build issue, a landing) or from the spec PRs cornfield lists for them, so without sign-in no hook would ever fire: set up sign-in (docs/sign-in.md), or remove `hooks`.');
69
+ return { secret };
70
+ }
71
+
72
+ /** The HMAC-SHA256 of a body under the secret, as a request header carries it */
73
+ export const sign = (secret, body) => `sha256=${createHmac('sha256', secret).update(body).digest('hex')}`;
74
+
75
+ /** A hook's address for the audit table: the origin only (a path or query may be a secret of the receiver's) */
76
+ const originOf = (url) => new URL(url).origin;
77
+
78
+ /** What went wrong with a request, without its message (a fetch error's text can hold the URL) */
79
+ const reasonOf = (err) => {
80
+ if (err && err.name === 'AbortError') return 'timeout';
81
+ const code = err && (err.cause?.code || err.code);
82
+ return typeof code === 'string' ? code : 'error';
83
+ };
84
+
85
+ /** Reads at most `cap` bytes of a response and drops the rest, so the connection is freed */
86
+ async function drain(res, cap) {
87
+ if (!res.body) return;
88
+ const reader = res.body.getReader();
89
+ let read = 0;
90
+ try {
91
+ while (read < cap) {
92
+ const { done, value } = await reader.read();
93
+ if (done) return;
94
+ read += value.length;
95
+ }
96
+ } finally {
97
+ await reader.cancel().catch(() => {});
98
+ }
99
+ }
100
+
101
+ /**
102
+ * The hooks of a server.
103
+ * @param {{
104
+ * hooks: { url: string, events: string[] | '*' }[],
105
+ * secret: string,
106
+ * fetch?: typeof fetch,
107
+ * record?: (entry: { login: string, action: string, target: string, request?: object, status?: number | null, answer?: object | null }) => Promise<void> | void,
108
+ * store?: (() => Promise<import('node:sqlite').DatabaseSync>) | null,
109
+ * now?: () => number,
110
+ * publicUrl?: string | null,
111
+ * timer?: { setTimeout: Function, clearTimeout: Function },
112
+ * backoff?: readonly number[],
113
+ * }} opts `fetch`, `timer` and `backoff` are for tests; `store` is the SQLite file `spec.ready` remembers what it sent in
114
+ */
115
+ export function createHooks({ hooks, secret, fetch: doFetch = globalThis.fetch, record, store = null, now = Date.now, publicUrl = null, timer = { setTimeout, clearTimeout }, backoff = BACKOFF_MS }) {
116
+ if (typeof secret !== 'string' || secret.length < MIN_HOOK_SECRET) throw new Error(`${HOOK_SECRET_ENV} must be at least ${MIN_HOOK_SECRET} characters.`);
117
+ const targets = hooks.map((h, i) => ({ index: i, url: h.url, origin: originOf(h.url), events: h.events === '*' ? HOOK_EVENTS : h.events }));
118
+ const timers = new Set();
119
+ let pending = 0;
120
+ let closed = false;
121
+
122
+ const later = (fn, ms) => {
123
+ const t = timer.setTimeout(() => { timers.delete(t); fn(); }, ms);
124
+ if (t && typeof t.unref === 'function') t.unref();
125
+ timers.add(t);
126
+ return t;
127
+ };
128
+
129
+ const audit = async (target, request, status, answer) => {
130
+ if (!record || closed) return;
131
+ try {
132
+ await record({ login: 'cornfield', action: 'hook', target, request, status, answer });
133
+ } catch (err) {
134
+ console.error(`[cornfield] a hook delivery could not be written to the audit table: ${err instanceof Error ? err.message : err}`);
135
+ }
136
+ };
137
+
138
+ /** One request, answered with its HTTP status (null when there was none) and why it failed (null when it did not) */
139
+ async function post(t, headers, body) {
140
+ const ctl = new AbortController();
141
+ const cut = timer.setTimeout(() => ctl.abort(), TIMEOUT_MS);
142
+ // A request still in flight at shutdown must not keep the process alive for its last seconds
143
+ if (cut && typeof cut.unref === 'function') cut.unref();
144
+ try {
145
+ // A redirect is never followed: the address is the maintainer's, and only it
146
+ const res = await doFetch(t.url, { method: 'POST', headers, body, redirect: 'manual', signal: ctl.signal });
147
+ await drain(res, MAX_RESPONSE_BYTES);
148
+ return res.status >= 200 && res.status < 300 ? { status: res.status, failed: null } : { status: res.status, failed: res.status >= 300 && res.status < 400 ? 'redirect' : 'status' };
149
+ } catch (err) {
150
+ return { status: null, failed: reasonOf(err) };
151
+ } finally {
152
+ timer.clearTimeout(cut);
153
+ }
154
+ }
155
+
156
+ function deliver(t, event, delivery, body) {
157
+ const headers = {
158
+ 'Content-Type': 'application/json',
159
+ 'User-Agent': 'cornfield-hooks',
160
+ 'X-Cornfield-Event': event,
161
+ 'X-Cornfield-Delivery': delivery,
162
+ 'X-Cornfield-Timestamp': JSON.parse(body).at,
163
+ 'X-Cornfield-Signature': sign(secret, body),
164
+ };
165
+ const target = `${event} to ${t.origin}`;
166
+ const attempt = async (n) => {
167
+ if (closed) { pending -= 1; return; }
168
+ const out = await post(t, headers, body);
169
+ // Shut down while the request was in flight: nothing more is written or scheduled
170
+ if (closed) { pending -= 1; return; }
171
+ const last = out.failed !== null && n > backoff.length;
172
+ const request = { event, delivery, hook: t.index, origin: t.origin, attempt: n };
173
+ await audit(target, request, out.status, out.failed === null ? { ok: true } : { ok: false, reason: out.failed, ...(last ? { gaveUp: true } : { retryInMs: backoff[n - 1] }) });
174
+ if (out.failed === null || last) { pending -= 1; return; }
175
+ later(() => { attempt(n + 1).catch(() => {}); }, backoff[n - 1]);
176
+ };
177
+ attempt(1).catch((err) => console.error(`[cornfield] a hook delivery failed unexpectedly: ${err instanceof Error ? err.message : err}`));
178
+ }
179
+
180
+ /** The body of an event: only the fields of the payload, in a fixed order */
181
+ function bodyOf(event, p) {
182
+ const out = {
183
+ event, at: new Date(now()).toISOString(), slug: p.slug ?? null, pull: p.pull ?? null, headSha: p.headSha ?? null,
184
+ proposalSha256: p.proposalSha256 ?? null, actor: p.actor ?? null,
185
+ };
186
+ if (p.note !== undefined) out.note = p.note;
187
+ if (p.edits !== undefined) out.edits = p.edits;
188
+ out.mapUrl = publicUrl || null;
189
+ return JSON.stringify(out);
190
+ }
191
+
192
+ /**
193
+ * Sends `event` to each hook that asked for it. Returns at once and never throws. The payload is an object, or a
194
+ * function returning one (or a promise of one): that is run in the background too, so gathering it never slows the
195
+ * action down, and a failure gathering it is written down and sends nothing.
196
+ * @param {string} event one of HOOK_EVENTS
197
+ * @param {object | (() => object | Promise<object>)} payload
198
+ */
199
+ function emit(event, payload) {
200
+ try {
201
+ if (closed || !HOOK_EVENTS.includes(event)) return;
202
+ const to = targets.filter((t) => t.events.includes(event));
203
+ if (!to.length) return;
204
+ Promise.resolve()
205
+ .then(() => (typeof payload === 'function' ? payload() : payload))
206
+ .then((p) => {
207
+ const body = bodyOf(event, p || {});
208
+ for (const t of to) {
209
+ if (pending >= MAX_PENDING) {
210
+ audit(`${event} to ${t.origin}`, { event, hook: t.index, origin: t.origin }, null, { ok: false, reason: 'queue-full', gaveUp: true });
211
+ continue;
212
+ }
213
+ pending += 1;
214
+ deliver(t, event, randomUUID(), body);
215
+ }
216
+ })
217
+ .catch((err) => {
218
+ console.error(`[cornfield] the ${event} hook event could not be built: ${err instanceof Error ? err.message : err}`);
219
+ audit(`${event}`, { event }, null, { ok: false, reason: 'payload', gaveUp: true });
220
+ });
221
+ } catch (err) {
222
+ console.error(`[cornfield] the ${event} hook event was not sent: ${err instanceof Error ? err.message : err}`);
223
+ }
224
+ }
225
+
226
+ /**
227
+ * `spec.ready`: the spec PRs a listing just showed. A (pull request, head, proposal) that is not a draft and was not
228
+ * seen before is announced once, and remembered in the SQLite file so a restart does not announce it again. A draft is
229
+ * not recorded, so marking it ready announces it at its head. The first listing of a repo (no `hook_seen` row for it,
230
+ * not even the `pr = 0` row that says it has been looked at) records what is open and announces nothing. Returns at
231
+ * once, never throws.
232
+ * @param {string} repo 'owner/name'
233
+ * @param {import('./proposal-pulls.js').PullEntry[]} entries
234
+ * @param {(entry: import('./proposal-pulls.js').PullEntry) => Promise<string>} read the proposal's text at its head
235
+ */
236
+ function specsSeen(repo, entries, read) {
237
+ // Even an empty listing counts: it is what makes the first spec PR opened afterwards news
238
+ if (closed || !store || !Array.isArray(entries)) return;
239
+ (async () => {
240
+ const db = await store();
241
+ const at = new Date(now()).toISOString();
242
+ const looked = db.prepare('SELECT 1 FROM hook_seen WHERE repo = ? AND pr = 0').get(repo) !== undefined;
243
+ const insert = db.prepare('INSERT OR IGNORE INTO hook_seen (repo, pr, head_sha, slug, at) VALUES (?, ?, ?, ?, ?)');
244
+ for (const e of entries) {
245
+ // Waiting for review is not a draft: a draft is left out, so it is news once it is marked ready
246
+ if (e.pull.draft) continue;
247
+ if (insert.run(repo, e.pull.number, e.sha, e.slug, at).changes !== 1 || !looked) continue;
248
+ emit('spec.ready', async () => ({
249
+ slug: e.slug, pull: e.pull.number, headSha: e.sha, proposalSha256: await hashOf(read, e), actor: e.pull.author,
250
+ }));
251
+ }
252
+ // The repo has been looked at: from now on, what is new is announced
253
+ if (!looked) insert.run(repo, 0, '', '', at);
254
+ })().catch((err) => console.error(`[cornfield] the spec PRs could not be checked for spec.ready: ${err instanceof Error ? err.message : err}`));
255
+ }
256
+
257
+ function close() {
258
+ closed = true;
259
+ for (const t of timers) timer.clearTimeout(t);
260
+ timers.clear();
261
+ }
262
+
263
+ return { emit, specsSeen, close };
264
+ }
265
+
266
+ /** The SHA-256 of a proposal file as `read` gives it, or null when it cannot be read (an event still goes out) */
267
+ async function hashOf(read, entry) {
268
+ try {
269
+ return proposalHash(await read(entry));
270
+ } catch {
271
+ return null;
272
+ }
273
+ }
274
+
275
+ /**
276
+ * The payload of an event about a proposal on a spec PR: the slug, the PR, its head and the file's hash. `extra` is who
277
+ * acted and, for a review, the note and edits.
278
+ * @param {import('./forge.js').Forge} forge
279
+ * @param {{ proposals: string }} where
280
+ * @param {{ slug: string, sha: string, path: string, pull: { number: number } }} entry
281
+ * @param {{ actor: string | null, note?: string, edits?: object[] }} extra
282
+ */
283
+ export async function proposalPayload(forge, where, entry, extra) {
284
+ return { slug: entry.slug, pull: entry.pull.number, headSha: entry.sha, proposalSha256: await hashOf((e) => readPullProposal(forge, where, e), entry), ...extra };
285
+ }
286
+
287
+ /**
288
+ * The payload of `pr.landed`: the PR, the head that landed, and, when it is a spec PR, its proposal's slug and hash (the
289
+ * first when it holds several). A forge that cannot say leaves those null; the event still goes out.
290
+ * @param {import('./forge.js').Forge} forge
291
+ * @param {{ proposals: string }} where
292
+ * @param {number} n the landed pull request
293
+ * @param {string} sha the head that landed
294
+ * @param {string} actor
295
+ */
296
+ export async function landedPayload(forge, where, n, sha, actor) {
297
+ let entry = null;
298
+ try {
299
+ entry = (await pullProposals(forge, where, await forge.pull(n)))[0] ?? null;
300
+ } catch { /* not known: a plain pull request */ }
301
+ if (!entry) return { slug: null, pull: n, headSha: sha, proposalSha256: null, actor };
302
+ return { ...(await proposalPayload(forge, where, entry, { actor })), pull: n, headSha: sha };
303
+ }
@@ -35,6 +35,7 @@ import { ForgeError, MAX_BODY, MAX_TITLE, isMaintainer } from './forge.js';
35
35
  import { PROPOSAL_FILE } from './files.js';
36
36
  import { findPullProposal, folderOf, readPullProposal } from './proposal-pulls.js';
37
37
  import { latestReview, proposalHash } from './reviews.js';
38
+ import { defuse } from './defuse.js';
38
39
  import { applyEdits, slug as slugOf, validateProposal } from '../core/proposal.js';
39
40
 
40
41
  /** The longest slug of a proposal's name made from an issue's title, before its number */
@@ -62,16 +63,8 @@ export function plain(value, max = 200) {
62
63
  return defuse(clip(one, max)).replace(/[&<>\\`*_[\]|~!]/g, (c) => `&#${c.charCodeAt(0)};`);
63
64
  }
64
65
 
65
- /** A zero-width space: it ends a mention or a reference without showing */
66
- const ZW = '\u200B';
67
-
68
- /**
69
- * Text with every mention and reference broken: `@user`, `@org/team`, `#1`, `owner/repo#1`, `GH-1`, and links the forge
70
- * would make of `https://…` or `www.…`. A zero-width space after the character keeps the text as it reads.
71
- */
72
- export function defuse(text) {
73
- return String(text).replace(/[@#]/g, (c) => `${c}${ZW}`).replace(/\b(GH)-(?=\d)/gi, `$1${ZW}-`).replace(/:\/\//g, `:${ZW}//`).replace(/\b(www)\./gi, `$1${ZW}.`);
74
- }
66
+ // Mentions and references are broken by server/defuse.js, which reviews share; re-exported for the callers that had it here
67
+ export { defuse } from './defuse.js';
75
68
 
76
69
  /** Untrusted text as a code span: no backtick or control character can end it */
77
70
  export function code(value, max = 100) {
@@ -79,11 +79,14 @@ function entryOf(pull, file) {
79
79
  /**
80
80
  * The proposals of every open PR that touches the proposals folder, newest PR first. A forge that cannot answer
81
81
  * gives `problem` in words, with what was found before it; the caller shows both.
82
+ * With `hooks` (server/hooks.js), the spec PRs found are handed over to announce the new ones (`spec.ready`); that runs
83
+ * in the background and changes nothing about the answer.
82
84
  * @param {import('./forge.js').Forge} forge
83
85
  * @param {{ proposals: string }} where
86
+ * @param {{ hooks?: { specsSeen: Function } | null }} [opts]
84
87
  * @returns {Promise<{ entries: PullEntry[], problem: string | null }>}
85
88
  */
86
- export async function listPullProposals(forge, where) {
89
+ export async function listPullProposals(forge, where, { hooks = null } = {}) {
87
90
  const folder = folderOf(where);
88
91
  let pulls;
89
92
  try {
@@ -105,7 +108,23 @@ export async function listPullProposals(forge, where) {
105
108
  }
106
109
  };
107
110
  await Promise.all(Array.from({ length: Math.min(PARALLEL, pulls.length) }, worker));
108
- return { entries: found.flat(), problem };
111
+ const entries = found.flat();
112
+ if (hooks) {
113
+ try { hooks.specsSeen(forge.repo, entries, (entry) => readPullProposal(forge, where, entry)); } catch { /* a hook never gets in the way */ }
114
+ }
115
+ return { entries, problem };
116
+ }
117
+
118
+ /**
119
+ * The proposals a pull request holds (open or not), read at its head.
120
+ * @param {import('./forge.js').Forge} forge
121
+ * @param {{ proposals: string }} where
122
+ * @param {{ number: number, title: string, url: string, draft: boolean, author: { login: string } | null, head: { sha: string } }} pull
123
+ * @returns {Promise<PullEntry[]>}
124
+ */
125
+ export async function pullProposals(forge, where, pull) {
126
+ const folder = folderOf(where);
127
+ return (await filesAt(forge, where, pull)).filter((f) => isProposal(f, folder)).map((f) => entryOf(pull, f));
109
128
  }
110
129
 
111
130
  /**
@@ -19,8 +19,9 @@
19
19
  * holds it when the maintainer approves (`hashPullMockups`); every approval also carries `proposal`, the SHA-256 of the
20
20
  * proposal file at the head (`proposalHash`). The page never sends either, and a PUT that does is refused. The agent drafting the spec
21
21
  * reads it (`cornfield review <slug>`) and folds the edits into the proposal. The PUT is same-origin only
22
- * (`sameOrigin`), at most `REVIEW_LIMIT` bytes, checked by `validateReview`, and only for a proposal the forge
23
- * says the PR holds; it is written to the `actions` audit table. A proposal in the checkout or a worktree has no
22
+ * (`sameOrigin`), at most `REVIEW_LIMIT` bytes, and goes through `submitReview` (server/submit-review.js), the one path the review
23
+ * API shares: the review is checked, the maintainer asked of the forge now, and only a proposal the forge says the PR
24
+ * holds is reviewed; every attempt is written to the `actions` audit table. A proposal in the checkout or a worktree has no
24
25
  * PR yet, so there is nothing to review it on: the PUT answers 409 and says to push it as a spec PR. Stage 1 kept
25
26
  * the review in the data folder; `migrateStage1Reviews` carries those over once, when the menu is opened.
26
27
  *
@@ -43,9 +44,10 @@ import path from 'node:path';
43
44
  import { findProposalFile, listProposalFiles } from './files.js';
44
45
  import { summarize, proposalProblems } from '../core/proposal.js';
45
46
  import { ForgeError } from './forge.js';
46
- import { REVIEW_LIMIT, validateReview, latestReview, postReview, migrateStage1Reviews, proposalHash } from './reviews.js';
47
+ import { REVIEW_LIMIT, latestReview, migrateStage1Reviews } from './reviews.js';
48
+ import { submitReview, refusal } from './submit-review.js';
47
49
  import { findPullProposal, forgeWords, listPullProposals, pullNumber, readPullProposal } from './proposal-pulls.js';
48
- import { hashPullMockups, serveMockups } from './mockups.js';
50
+ import { serveMockups } from './mockups.js';
49
51
 
50
52
  // The review's checks live in server/reviews.js; these are re-exported for the callers that always had them here
51
53
  export { REVIEW_LIMIT, MAX_REVIEW_EDITS, MAX_NOTE_CHARS, validateReview } from './reviews.js';
@@ -195,7 +197,7 @@ async function listWithPulls(where, forgeFor, signedIn) {
195
197
  } catch (err) {
196
198
  return { list: local, problem: forgeWords(err) };
197
199
  }
198
- const { entries, problem } = await listPullProposals(forge, where);
200
+ const { entries, problem } = await listPullProposals(forge, where, { hooks: signedIn?.hooks });
199
201
  if (signedIn && entries.length) await migrateStage1Reviews({ forge, dataDir: where.dataDir, entries, login: signedIn.login, record: signedIn.record });
200
202
  const pulls = await Promise.all(entries.map((entry) => describePull(entry, forge, where, topology)));
201
203
  const list = [...local, ...pulls].sort((a, b) => (a.slug < b.slug ? -1 : a.slug > b.slug ? 1 : 0));
@@ -260,26 +262,19 @@ async function putPullReview(req, res, where, forgeFor, signedIn, slug, n) {
260
262
  }
261
263
  // What an approval covers is the server's to record, from the files themselves, never the page's word
262
264
  if (parsed !== null && typeof parsed === 'object' && ('mockups' in parsed || 'proposal' in parsed)) return send(res, 400, { error: 'What an approval covers (the proposal and its mockups) is recorded by the server; a review does not send it.' });
263
- const checked = validateReview(parsed);
264
- if (!checked.ok) return send(res, 400, { error: checked.error });
265
265
  try {
266
- const forge = forgeFor();
267
- const entry = await findPullProposal(forge, where, slug, n);
268
- if (!entry) return send(res, 404, { error: 'No such proposal' });
269
- let review = checked.value;
270
- if (review.verdict === 'approved') {
271
- // The proposal as the head holds it now: a later push that changes it is not this approval's
272
- review = { ...review, proposal: proposalHash(await readPullProposal(forge, where, entry)) };
273
- const mockups = await hashPullMockups(forge, where, entry);
274
- if (mockups.length) review = { ...review, mockups };
275
- }
276
- return send(res, 200, await postReview(forge, n, slug, review, { login: signedIn.login, record: signedIn.record }));
266
+ // The checks and the post are submitReview's, shared with the review API (server/submit-review.js)
267
+ const found = await submitReview({
268
+ forge: forgeFor, where, repo: signedIn.repo, who: { login: signedIn.login, userId: signedIn.userId }, record: signedIn.record, limits: signedIn.limits, hooks: signedIn.hooks,
269
+ }, { pull: n, slug, review: parsed });
270
+ return send(res, 200, found);
277
271
  } catch (err) {
278
- return send(res, forgeStatus(err), { error: forgeWords(err) });
272
+ const out = refusal(err);
273
+ return send(res, out.status, { error: out.body.error }, out.headers);
279
274
  }
280
275
  }
281
276
 
282
- /** @typedef {{ login: string, record: (entry: import('./reviews.js').AuditEntry) => Promise<void> | void }} SignedIn */
277
+ /** @typedef {{ login: string, userId: string, repo: string, record: (entry: import('./reviews.js').AuditEntry) => Promise<void> | void, limits: { auth: object, now?: () => number }, hooks?: import('./hooks.js').Hooks | null }} SignedIn who is signed in, the repo, where their actions are recorded, and what the flood and repeat limits count by */
283
278
 
284
279
  /**
285
280
  * Answers a request under `/proposals`, or returns false when it is not one.