@popcorn245/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.
- package/core/config.js +44 -0
- package/explorer/explorer.css +10 -0
- package/explorer/index.html +32 -0
- package/explorer/main.js +3 -0
- package/explorer/review-tokens-model.js +86 -0
- package/explorer/review-tokens.js +216 -0
- package/package.json +1 -1
- package/server/auth.js +15 -0
- package/server/db.js +29 -0
- package/server/defuse.js +16 -0
- package/server/forge-actions.js +9 -57
- package/server/forge-routes.js +4 -4
- package/server/hooks.js +303 -0
- package/server/pipeline.js +3 -10
- package/server/proposal-pulls.js +21 -2
- package/server/proposals.js +15 -20
- package/server/review-api.js +288 -0
- package/server/review-tokens.js +161 -0
- package/server/reviews.js +15 -8
- package/server/server.js +20 -5
- package/server/submit-review.js +167 -0
- package/server/throttle.js +93 -0
package/server/forge-actions.js
CHANGED
|
@@ -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 {
|
package/server/forge-routes.js
CHANGED
|
@@ -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;
|
package/server/hooks.js
ADDED
|
@@ -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
|
+
}
|
package/server/pipeline.js
CHANGED
|
@@ -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
|
-
|
|
66
|
-
|
|
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) {
|
package/server/proposal-pulls.js
CHANGED
|
@@ -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
|
-
|
|
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
|
/**
|
package/server/proposals.js
CHANGED
|
@@ -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,
|
|
23
|
-
*
|
|
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,
|
|
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 {
|
|
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
|
-
|
|
267
|
-
const
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
-
|
|
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.
|