cornfield 0.2.0 → 0.4.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/server/db.js CHANGED
@@ -8,8 +8,10 @@
8
8
  * reviews a cache of the latest review per spec PR and proposal (the PR comment is the truth)
9
9
  * actions the audit of every action cornfield takes on the forge: who, what, when, the forge's answer
10
10
  * forge_cache forge answers kept for a while (an ETag and a body), so a page load is not a dozen API calls
11
+ * review_tokens the review API's tokens: a SHA-256 hash, owner, label, expiry, revoked, last used (never the token)
11
12
  * hook_seen the (pull request, head, proposal) triples whose `spec.ready` event was sent, so a restart sends none twice;
12
13
  * a row with `pr = 0` says the repo has been looked at once (server/hooks.js)
14
+ * landings the spec PRs an approval is waiting to land (`proposals.approveLands`), so a restart picks them up (server/lander.js)
13
15
  *
14
16
  * The schema is a list of numbered migrations (`MIGRATIONS`); the file's `PRAGMA user_version` is the number of
15
17
  * the last one it has. `openStore` runs the ones a file lacks, each in its own transaction, so a fresh file and one
@@ -94,6 +96,35 @@ export const MIGRATIONS = Object.freeze([
94
96
  PRIMARY KEY (repo, pr, head_sha, slug)
95
97
  ) STRICT;
96
98
  `,
99
+ // 3: review tokens, for the review API (server/review-tokens.js)
100
+ `
101
+ CREATE TABLE review_tokens (
102
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
103
+ token_hash TEXT NOT NULL UNIQUE, -- the SHA-256 of the token, hex; the token itself is never kept
104
+ user_id TEXT NOT NULL, -- the owner, by the forge's id (a rename is followed)
105
+ login TEXT NOT NULL, -- the owner's login when it was made
106
+ label TEXT NOT NULL, -- what the owner calls it; the review comment names it
107
+ created_at TEXT NOT NULL,
108
+ expires_at TEXT NOT NULL,
109
+ revoked_at TEXT,
110
+ last_used TEXT
111
+ ) STRICT;
112
+ CREATE INDEX review_tokens_user_id ON review_tokens (user_id);
113
+ `,
114
+ // 4: landing on approval (server/lander.js): a spec PR waiting for its checks, to be landed as the approver
115
+ `
116
+ CREATE TABLE landings (
117
+ repo TEXT NOT NULL, -- 'owner/name'
118
+ pull INTEGER NOT NULL,
119
+ slug TEXT NOT NULL, -- the proposal that was approved
120
+ sha TEXT NOT NULL, -- the head that was approved: the only one that may land
121
+ user_id TEXT NOT NULL, -- the approver, by the forge's id: it lands as them, with their own sign-in
122
+ login TEXT NOT NULL, -- their login when they approved (for the audit)
123
+ started TEXT NOT NULL,
124
+ state TEXT NOT NULL CHECK (state IN ('pending', 'landed', 'cancelled', 'left-open', 'done')),
125
+ PRIMARY KEY (repo, pull)
126
+ ) STRICT;
127
+ `,
97
128
  ]);
98
129
 
99
130
  /** The version a file is at: its `user_version` */
@@ -0,0 +1,16 @@
1
+ /**
2
+ * @module server/defuse
3
+ *
4
+ * Breaking the mentions and references in text cornfield posts on the forge for someone else: `@user`, `@org/team`,
5
+ * `#1`, `owner/repo#1`, `GH-1`, and the links the forge would make of `https://…` or `www.…`. A zero-width space after
6
+ * the character keeps the text as it reads and stops it notifying anyone or linking anything. The pipeline's issues
7
+ * (server/pipeline.js) and a review that came through the review API (server/reviews.js) use it.
8
+ */
9
+
10
+ /** A zero-width space: it ends a mention or a reference without showing */
11
+ export const ZW = '​';
12
+
13
+ /** Text with every mention, reference and auto-link broken (see the module's head) */
14
+ export function defuse(text) {
15
+ return String(text).replace(/[@#]/g, (c) => `${c}${ZW}`).replace(/\b(GH)-(?=\d)/gi, `$1${ZW}-`).replace(/:\/\//g, `:${ZW}//`).replace(/\b(www)\./gi, `$1${ZW}.`);
16
+ }
@@ -45,14 +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';
48
52
  import { proposalPayload, landedPayload } from './hooks.js';
49
53
 
50
- /** 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) */
51
- export const COOLDOWN_MS = 5000;
52
- /** The most actions one person takes in a minute */
53
- export const PER_MINUTE = 20;
54
- /** The most attempts of any kind (refused ones too) one person makes in a minute, before any is written down */
55
- export const ATTEMPTS_PER_MINUTE = 60;
56
54
  /** A request body is a sha and a check's id */
57
55
  const MAX_BODY = 4096;
58
56
 
@@ -63,39 +61,6 @@ const DIGEST = /^[0-9a-f]{32}$/;
63
61
  const DOING = { merge: 'Landing', rerun: 'Re-running', spec: 'Starting a spec', build: 'Opening the build issue' };
64
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' };
65
63
 
66
- /** @type {WeakMap<object, { busy: Set<string>, last: Map<string, number>, perUser: Map<string, number[]>, attempts: Map<string, number[]> }>} */
67
- const throttles = new WeakMap();
68
-
69
- function throttlesOf(auth) {
70
- let t = throttles.get(auth);
71
- if (!t) throttles.set(auth, (t = { busy: new Set(), last: new Map(), perUser: new Map(), attempts: new Map() }));
72
- return t;
73
- }
74
-
75
- /** Counts an attempt of any kind; refuses one past the ceiling, without writing anything down (the release draft shares it) */
76
- export function countAttempt(ctx, userId, at) {
77
- const t = throttlesOf(ctx.auth);
78
- for (const [k, list] of t.attempts) if (!list.length || at - list[list.length - 1] >= 60_000) t.attempts.delete(k);
79
- const mine = (t.attempts.get(userId) || []).filter((when) => at - when < 60_000);
80
- if (mine.length >= ATTEMPTS_PER_MINUTE) {
81
- t.attempts.set(userId, mine);
82
- 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))) });
83
- }
84
- mine.push(at);
85
- t.attempts.set(userId, mine);
86
- return null;
87
- }
88
-
89
- /** An action's own refusal: the status to answer and the words */
90
- class Refused extends Error {
91
- constructor(status, message, code, headers = {}) {
92
- super(message);
93
- this.status = status;
94
- this.code = code;
95
- this.headers = headers;
96
- }
97
- }
98
-
99
64
  /** Reads a small JSON object body, or refuses */
100
65
  function readJson(req) {
101
66
  return new Promise((resolve, reject) => {
@@ -130,23 +95,6 @@ function readJson(req) {
130
95
  });
131
96
  }
132
97
 
133
- /** Refuses a repeat and a flood; answers the key to release when the attempt is over */
134
- function throttle(ctx, userId, key, at) {
135
- const t = throttlesOf(ctx.auth);
136
- for (const [k, when] of t.last) if (at - when >= COOLDOWN_MS) t.last.delete(k);
137
- const mine = (t.perUser.get(userId) || []).filter((when) => at - when < 60_000);
138
- t.perUser.set(userId, mine);
139
- const wait = (ms, words) => new Refused(429, words, 'rate-limited', { 'Retry-After': String(Math.max(1, Math.ceil(ms / 1000))) });
140
- if (t.busy.has(key)) throw wait(COOLDOWN_MS, 'That is already being done; wait for it to finish.');
141
- const before = t.last.get(key);
142
- if (before !== undefined && at - before < COOLDOWN_MS) throw wait(COOLDOWN_MS - (at - before), 'That was just asked; wait a few seconds before asking again.');
143
- if (mine.length >= PER_MINUTE) throw wait(60_000 - (at - mine[0]), `That is ${PER_MINUTE} actions in a minute; wait a little.`);
144
- t.busy.add(key);
145
- t.last.set(key, at);
146
- mine.push(at);
147
- return () => { t.busy.delete(key); };
148
- }
149
-
150
98
  /**
151
99
  * The permission of the signed-in user on the repo, asked of the forge now with their own token. Refuses unless it is
152
100
  * the same forge user as the session's and a maintainer.
@@ -165,6 +113,27 @@ async function forgetKept(auth, config) {
165
113
  } catch { /* a cache that cannot be cleared expires in a minute */ }
166
114
  }
167
115
 
116
+ /** How a pull request cornfield lands is merged: the config's `forge.mergeMethod`, `merge` when unset */
117
+ export const mergeMethodOf = (config) => config.forge.mergeMethod || 'merge';
118
+
119
+ /**
120
+ * Lands pull request `n` at head `sha`, the one way cornfield lands anything: `forge.merge` with `onlyIfGreen` and the
121
+ * sha (the forge adapter checks open, not a draft, still at `sha`, every check green and its own readiness), then the
122
+ * `pr.landed` hook, then the kept forge answers are let go. Land it (above) and landing on approval (server/lander.js)
123
+ * both call this, so the two cannot differ. Throws what `forge.merge` throws.
124
+ * @param {{ forge: import('./forge.js').Forge, config: any, where?: { proposals: string }, hooks?: import('./hooks.js').Hooks | null, auth: { store: null | (() => Promise<import('node:sqlite').DatabaseSync>) } }} ctx
125
+ * @param {number} n
126
+ * @param {string} sha
127
+ * @param {string} login who landed it, for the hook
128
+ * @returns {Promise<{ sha: string }>}
129
+ */
130
+ export async function landPull({ forge, config, where, hooks, auth }, n, sha, login) {
131
+ const landed = await forge.merge(n, { onlyIfGreen: true, sha, method: mergeMethodOf(config) });
132
+ hooks?.emit('pr.landed', () => landedPayload(forge, where || { proposals: config.proposals }, n, sha, login));
133
+ await forgetKept(auth, config);
134
+ return landed;
135
+ }
136
+
168
137
  /**
169
138
  * The action the request is, or null for any other path.
170
139
  * @param {string} reqPath
@@ -281,11 +250,9 @@ export async function serveAction(req, res, what, ctx, send) {
281
250
  await forgetKept(auth, config);
282
251
  out = { status: done.created ? 201 : 200, body: { ok: true, action, created: done.created, number: n, slug, issue: done.issue, path: plan.path } };
283
252
  } else if (action === 'merge') {
284
- const method = config.forge.mergeMethod || 'merge';
253
+ const method = mergeMethodOf(config);
285
254
  request.method = method;
286
- const landed = await forge.merge(n, { onlyIfGreen: true, sha, method });
287
- ctx.hooks?.emit('pr.landed', () => landedPayload(forge, ctx.where || { proposals: config.proposals }, n, /** @type {string} */ (sha), session.login));
288
- await forgetKept(auth, config);
255
+ const landed = await landPull({ forge, config, where: ctx.where, hooks: ctx.hooks, auth }, n, /** @type {string} */ (sha), session.login);
289
256
  out = { status: 200, body: { ok: true, merged: true, number: n, sha: landed.sha, head: sha, method } };
290
257
  } else {
291
258
  const pull = await forge.pull(n);
@@ -15,6 +15,7 @@
15
15
  * (each check says whether cornfield can re-run it: `canRerun`)
16
16
  * (and `specs`: the proposals it changes and whether each is approved, for the pipeline's step 2)
17
17
  * POST /api/forge/pulls/<n>/merge | /rerun land it, re-run a failed check (server/forge-actions.js)
18
+ * GET /api/forge/pulls/<n>/landing, POST …/landing/cancel a landing waiting on an approval (`proposals.approveLands`), and stopping it (server/lander.js)
18
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
19
20
  * GET /api/forge/pulls/<n>/build?slug=<proposal> the plan of step 2: the build issue of an approved proposal, never kept
20
21
  * POST /api/forge/issues/<n>/spec | /pulls/<n>/build do them, with the plan's digest (server/forge-actions.js)
@@ -47,6 +48,7 @@ import path from 'node:path';
47
48
  import { createHash } from 'node:crypto';
48
49
  import { ForgeError, canRerun, hasAdapter, isMaintainer, isTagName, rateLimitWords } from './forge.js';
49
50
  import { actionOf, countAttempt, serveAction } from './forge-actions.js';
51
+ import { landingOf, serveLanding } from './lander.js';
50
52
  import { planSpec, planBuild, pullSpecs } from './pipeline.js';
51
53
  import { listPullProposals } from './proposal-pulls.js';
52
54
  import { deploysOf } from './deploys.js';
@@ -220,13 +222,20 @@ async function servePlan(req, res, act, url, ctx) {
220
222
  * @param {import('node:http').ServerResponse} res
221
223
  * @param {string} reqPath the decoded path
222
224
  * @param {URL} url
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
225
+ * @param {{ config: object, where?: { proposals: string }, hooks?: import('./hooks.js').Hooks | null, lander?: import('./lander.js').Lander | null, session: { userId: string, login: string } | null, publicHost?: string | null, auth: { store: null | (() => Promise<import('node:sqlite').DatabaseSync>), forgeFor: Function }, now?: () => number }} ctx
224
226
  * `session` is the signed-in maintainer's (null without sign-in)
225
227
  * @returns {Promise<boolean>}
226
228
  */
227
229
  export async function serveForge(req, res, reqPath, url, ctx) {
228
230
  if (reqPath !== '/api/forge' && !reqPath.startsWith('/api/forge/')) return false;
229
231
  const { config, session, auth, now = Date.now } = ctx;
232
+ const landing = landingOf(reqPath);
233
+ if (landing) {
234
+ // What is waiting to land on its approval, and stopping it: nothing is asked of the forge to read, and it is never kept
235
+ const out = await serveLanding(req, landing, ctx);
236
+ send(req, res, out.status, out.body, out.headers);
237
+ return true;
238
+ }
230
239
  const act = actionOf(reqPath);
231
240
  if (act) {
232
241
  if (!config.forge || !hasAdapter(config.forge.type)) {
@@ -0,0 +1,394 @@
1
+ /**
2
+ * @module server/lander
3
+ *
4
+ * Landing on approval (PIPE-09, docs/proposals.md#land-on-approval): for a team that has set `proposals: { approveLands: true }`,
5
+ * approving a spec also lands its pull request, once it is mergeable and every check is green, with no second click. It is
6
+ * off unless the maintainer turned it on (ADR 0006), and with it off nothing here is built.
7
+ *
8
+ * `submitReview` (server/submit-review.js) calls `start` once, after an approval is posted, and never for a send-back. The
9
+ * review is already on the PR by then and its response does not wait for this. A landing is a row in `landings`
10
+ * (server/db.js) and a timer; on a restart `resume` picks up the rows still pending.
11
+ *
12
+ * **One polled path, for both forges.** Forgejo has a server-side "merge when checks succeed" and GitHub does not; using it
13
+ * would mean two code paths, and the pinned sha and the green rule would be the forge's to enforce rather than ours. So
14
+ * both are polled (every `POLL_MS` for the first `FAST_FOR_MS`, then every `SLOW_POLL_MS`, for up to `TIMEOUT_MS`; each
15
+ * look reads the PR, its head and its checks fresh from the forge, never a kept answer, since they decide the merge), and the
16
+ * landing is `landPull` (server/forge-actions.js): the same
17
+ * `forge.merge(n, { onlyIfGreen: true, sha })` Land it uses, with the same `pr.landed` hook.
18
+ *
19
+ * Each look stops at the first terminal state, in this order:
20
+ *
21
+ * no sign-in left to land with left open: sign in to land (nothing else is done; there is no token to comment with)
22
+ * the PR is closed or merged done (landed elsewhere, or closed: nothing to say)
23
+ * its head is not the approved sha cancelled, "a push after approval" (the approval covered another version)
24
+ * a check is red left open: <check> failed
25
+ * another proposal not approved waits, saying nothing (a PR with several proposals lands only once every one of them
26
+ * is approved by a maintainer at its current SHA-256, `allApproved`)
27
+ * green, not a draft, mergeable lands, pinned to the approved sha
28
+ * the time is up left open: timed out
29
+ *
30
+ * and otherwise (checks still running, the forge still working out mergeability, a forge that did not answer) it waits.
31
+ *
32
+ * **As the approver.** It lands with the approver's own forge token, the newest live sign-in of that forge user
33
+ * (`auth.forgeForUser`, as the review API posts a review), asked who they are and whether they still maintain the repo just
34
+ * before it merges. Their session ending before the checks finish is "left open: sign in to land".
35
+ *
36
+ * **Says so.** A landing that is cancelled or left open (and one a maintainer cancels) posts one comment on the PR as the
37
+ * approver, through the review's comment call (`postNote`, server/reviews.js) with its mentions and references defused (server/defuse.js:
38
+ * a check's name is the forge's, not ours). Every state change is a row in `actions` (`action: 'landing'`; a landing itself is the
39
+ * `merge` row Land it writes, with `via: 'approval'`). A landing posts no comment: the merge is its own news.
40
+ */
41
+
42
+ import { ForgeError, isGreen, isMaintainer } from './forge.js';
43
+ import { landPull, mergeMethodOf } from './forge-actions.js';
44
+ import { latestReview, postNote, proposalHash } from './reviews.js';
45
+ import { pullProposals, readPullProposal } from './proposal-pulls.js';
46
+ import { sameOrigin } from './proposals.js';
47
+ import { Refused, countAttempt } from './throttle.js';
48
+
49
+ /** How often a pending landing looks at its PR, for its first `FAST_FOR_MS` */
50
+ export const POLL_MS = 20_000;
51
+ /** How long a landing is looked at every `POLL_MS`; after that, every `SLOW_POLL_MS` (most checks finish in minutes) */
52
+ export const FAST_FOR_MS = 5 * 60_000;
53
+ export const SLOW_POLL_MS = 60_000;
54
+ /** How long a landing waits for its checks before it is left open */
55
+ export const TIMEOUT_MS = 60 * 60_000;
56
+ /** The most of a check's name that goes into a comment */
57
+ const NAME_MAX = 80;
58
+
59
+ /** @typedef {'pending' | 'landed' | 'cancelled' | 'left-open' | 'done'} LandingState */
60
+ /** @typedef {{ repo: string, pull: number, slug: string, sha: string, user_id: string, login: string, started: string, state: LandingState }} LandingRow */
61
+
62
+ const iso = (ms) => new Date(ms).toISOString();
63
+ const oneLine = (v, max) => String(v).replace(/\s+/g, ' ').trim().slice(0, max);
64
+
65
+ /**
66
+ * @param {{
67
+ * config: any,
68
+ * where?: { proposals: string },
69
+ * auth: { store: () => Promise<import('node:sqlite').DatabaseSync>, forgeForUser: (userId: string) => Promise<import('./forge.js').Forge>, record: Function },
70
+ * hooks?: import('./hooks.js').Hooks | null,
71
+ * now?: () => number,
72
+ * timer?: { setTimeout: Function, clearTimeout: Function },
73
+ * pollMs?: number,
74
+ * timeoutMs?: number,
75
+ * }} opts `timer`, `pollMs` and `timeoutMs` are the tests' clock
76
+ */
77
+ export function createLander({ config, where, auth, hooks = null, now = Date.now, timer = { setTimeout, clearTimeout }, pollMs = POLL_MS, slowPollMs = SLOW_POLL_MS, fastForMs = FAST_FOR_MS, timeoutMs = TIMEOUT_MS }) {
78
+ const repo = config.forge.repo;
79
+ const folder = where || { proposals: config.proposals };
80
+ const targetOf = (pull) => `${repo}#${pull}`;
81
+ /** The landings being waited for in this process, by pull request */
82
+ const jobs = new Map();
83
+ /** The looks in flight, so a test (and a shutdown) can wait for them */
84
+ const looking = new Set();
85
+ let closed = false;
86
+
87
+ const log = (what, err) => console.error(`[cornfield] landing on approval: ${what}: ${err instanceof Error ? err.message : err}`);
88
+
89
+ const audit = async (job, request, status, answer) => {
90
+ try {
91
+ await auth.record({ login: job.login, action: 'landing', target: targetOf(job.pull), request: { slug: job.slug, sha: job.sha, ...request }, status, answer });
92
+ } catch (err) {
93
+ log(`the audit row of #${job.pull} could not be written`, err);
94
+ }
95
+ };
96
+
97
+ const setState = async (job, state) => {
98
+ try {
99
+ (await auth.store()).prepare("UPDATE landings SET state = ? WHERE repo = ? AND pull = ? AND sha = ? AND state = 'pending'").run(state, repo, job.pull, job.sha);
100
+ } catch (err) {
101
+ log(`#${job.pull} could not be marked ${state}`, err);
102
+ }
103
+ };
104
+
105
+ /** Ends a landing: marks it, writes the audit row, and posts the one comment when there is one and a forge to post it with */
106
+ async function end(job, state, why, { forge = null, comment = null, request = {} } = {}) {
107
+ if (job.over) return;
108
+ job.over = true;
109
+ if (job.timer !== null) timer.clearTimeout(job.timer);
110
+ if (jobs.get(job.pull) === job) jobs.delete(job.pull);
111
+ await setState(job, state);
112
+ await audit(job, { state, ...request }, null, { state, why });
113
+ if (forge && comment) {
114
+ try {
115
+ await postNote(forge, job.pull, comment);
116
+ } catch (err) {
117
+ log(`the comment on #${job.pull} could not be posted`, err);
118
+ }
119
+ }
120
+ }
121
+
122
+ const leftOpen = (job, why, forge) => end(job, 'left-open', why, {
123
+ forge, comment: `Left open: ${why}. cornfield will not land \`${job.slug}\` on its own now; land it with Land it once it is green.`,
124
+ });
125
+
126
+ /**
127
+ * Whether every proposal the PR holds at its head has, as its latest review by a maintainer, an approval of the file as it
128
+ * is now (its SHA-256). Approving one proposal of a PR that holds several does not land the others.
129
+ * @param {import('./forge.js').Forge} forge
130
+ * @param {import('./forge.js').Pull} pull read fresh from the forge
131
+ */
132
+ async function allApproved(forge, pull) {
133
+ const entries = await pullProposals(forge, folder, pull);
134
+ if (!entries.length) return false;
135
+ const comments = await forge.comments(pull.number);
136
+ for (const e of entries) {
137
+ const review = await latestReview(forge, pull.number, e.slug, comments);
138
+ if (!review || review.verdict !== 'approved' || typeof review.proposal !== 'string') return false;
139
+ if (review.proposal !== proposalHash(await readPullProposal(forge, folder, e))) return false;
140
+ }
141
+ return true;
142
+ }
143
+
144
+ /** One look at the PR. Resolves when it has ended the landing or decided to wait. */
145
+ async function look(job) {
146
+ if (job.over) return;
147
+ if (now() - Date.parse(job.started) >= timeoutMs) {
148
+ let forge = null;
149
+ try { forge = await auth.forgeForUser(job.userId); } catch { /* no sign-in left: no comment, the audit row says it */ }
150
+ return leftOpen(job, `its checks did not pass within ${Math.round(timeoutMs / 60_000)} minutes`, forge);
151
+ }
152
+ let forge;
153
+ try {
154
+ forge = await auth.forgeForUser(job.userId);
155
+ } catch (err) {
156
+ if (err instanceof ForgeError && err.code === 'unauthorized') return end(job, 'left-open', 'sign in to land');
157
+ log(`#${job.pull} waits`, err);
158
+ return;
159
+ }
160
+ try {
161
+ const pull = await forge.pull(job.pull);
162
+ if (pull.state !== 'open') return end(job, 'done', `the pull request is ${pull.state}`);
163
+ if (pull.head.sha.toLowerCase() !== job.sha) {
164
+ return end(job, 'cancelled', 'a push after approval', {
165
+ forge, request: { head: pull.head.sha },
166
+ comment: `Landing on approval was cancelled: a push after approval (the head is now ${pull.head.sha.slice(0, 7)}, not ${job.sha.slice(0, 7)}). \`${job.slug}\` was approved as it was; approve it again to land it.`,
167
+ });
168
+ }
169
+ const checks = await forge.checks(pull.head.sha);
170
+ const red = checks.filter((c) => c.state === 'failure' || c.state === 'cancelled');
171
+ if (red.length) return leftOpen(job, red.map((c) => `${oneLine(c.name, NAME_MAX)} ${c.state === 'failure' ? 'failed' : 'was cancelled'}`).join('; '), forge);
172
+ if (pull.draft || !isGreen(checks)) return;
173
+ // The approver, now: their sign-in is theirs, and a maintainer is what lands
174
+ const me = await forge.me();
175
+ if (String(me.id) !== job.userId) return end(job, 'left-open', 'sign in to land');
176
+ if (!isMaintainer(await forge.permission(repo, me.login))) return leftOpen(job, `${oneLine(me.login, NAME_MAX)} is not a maintainer of the repo now`, forge);
177
+ // Every proposal of the PR approved as it is now, not only the one that started this: else it waits, saying nothing
178
+ if (!(await allApproved(forge, pull))) return;
179
+ const method = mergeMethodOf(config);
180
+ // Stopped (cancelled, approved again, or the server shutting down) while the forge was being asked: nothing more is done
181
+ if (job.over || closed) return;
182
+ let landed;
183
+ try {
184
+ landed = await landPull({ forge, config, where, hooks, auth }, job.pull, job.sha, me.login);
185
+ } catch (err) {
186
+ return await merging(job, forge, err);
187
+ }
188
+ job.over = true;
189
+ if (job.timer !== null) timer.clearTimeout(job.timer);
190
+ jobs.delete(job.pull);
191
+ await setState(job, 'landed');
192
+ try {
193
+ await auth.record({ login: me.login, action: 'merge', target: targetOf(job.pull), request: { action: 'merge', number: job.pull, sha: job.sha, method, via: 'approval', slug: job.slug }, status: 200, answer: { ok: true, merged: true, number: job.pull, sha: landed.sha, head: job.sha, method } });
194
+ } catch (err) {
195
+ log(`the audit row of the landing of #${job.pull} could not be written`, err);
196
+ }
197
+ } catch (err) {
198
+ if (!(err instanceof ForgeError)) return log(`#${job.pull} waits`, err);
199
+ if (err.code === 'unauthorized') return end(job, 'left-open', 'sign in to land');
200
+ // Not found, rate-limited, unavailable: the forge's trouble, tried again at the next look (until the time is up)
201
+ }
202
+ }
203
+
204
+ /** What a refused merge means: not ready yet (wait), the head moved (cancelled), or a refusal for good (left open) */
205
+ async function merging(job, forge, err) {
206
+ if (!(err instanceof ForgeError)) throw err;
207
+ if (err.code === 'unauthorized') return end(job, 'left-open', 'sign in to land');
208
+ if (err.code === 'not-green' || err.code === 'rate-limited' || err.code === 'unavailable') return;
209
+ if (err.code === 'conflict') {
210
+ const pull = await forge.pull(job.pull);
211
+ if (pull.state !== 'open') return end(job, 'done', `the pull request is ${pull.state}`);
212
+ if (pull.head.sha.toLowerCase() !== job.sha) {
213
+ return end(job, 'cancelled', 'a push after approval', {
214
+ forge, request: { head: pull.head.sha },
215
+ comment: `Landing on approval was cancelled: a push after approval (the head is now ${pull.head.sha.slice(0, 7)}, not ${job.sha.slice(0, 7)}). \`${job.slug}\` was approved as it was; approve it again to land it.`,
216
+ });
217
+ }
218
+ if (pull.draft) return;
219
+ }
220
+ return leftOpen(job, oneLine(err.message, 300).replace(/\.$/, ''), forge);
221
+ }
222
+
223
+ /** A look, now or after a wait; schedules the next unless the landing ended */
224
+ function run(job) {
225
+ if (closed || job.over || job.looking) return Promise.resolve();
226
+ job.looking = true;
227
+ const p = look(job)
228
+ .catch((err) => log(`#${job.pull}`, err))
229
+ .finally(() => {
230
+ job.looking = false;
231
+ looking.delete(p);
232
+ if (!job.over && !closed) schedule(job);
233
+ });
234
+ looking.add(p);
235
+ return p;
236
+ }
237
+
238
+ function schedule(job) {
239
+ // Often at first, while checks usually finish; then less, so an hour's wait is not a request every 20 seconds
240
+ const waited = now() - Date.parse(job.started);
241
+ const t = timer.setTimeout(() => { job.timer = null; run(job); }, waited < fastForMs ? pollMs : slowPollMs);
242
+ if (t && typeof t.unref === 'function') t.unref();
243
+ job.timer = t;
244
+ }
245
+
246
+ /** Starts waiting for a landing; the first look is made at once */
247
+ function begin(row) {
248
+ const job = { ...row, pull: row.pull, userId: row.user_id, over: false, looking: false, timer: null };
249
+ const had = jobs.get(job.pull);
250
+ if (had) {
251
+ // Approved again: the newer approval replaces the older (its sha, its approver)
252
+ had.over = true;
253
+ if (had.timer !== null) timer.clearTimeout(had.timer);
254
+ }
255
+ jobs.set(job.pull, job);
256
+ return job;
257
+ }
258
+
259
+ /**
260
+ * Starts a landing for a spec PR that was just approved at `sha`, as `who`. Resolves once the row is written and the
261
+ * first look is made. A second approval of the same PR replaces the first.
262
+ * @param {{ pull: number, slug: string, sha: string, who: { userId: string, login: string } }} input
263
+ */
264
+ async function start({ pull, slug, sha, who }) {
265
+ if (closed) return;
266
+ // A PR that holds other proposals not approved as they are now: this approval does not start a landing (the last
267
+ // approval does). Asked fresh of the forge; a forge that cannot say now is asked again at every look before the merge.
268
+ try {
269
+ const forge = await auth.forgeForUser(String(who.userId));
270
+ const now_ = await forge.pull(pull);
271
+ if (now_.state === 'open' && now_.head.sha.toLowerCase() === sha.toLowerCase() && !(await allApproved(forge, now_))) return;
272
+ } catch { /* the looks decide */ }
273
+ const row = { repo, pull, slug, sha: sha.toLowerCase(), user_id: String(who.userId), login: who.login, started: iso(now()), state: /** @type {LandingState} */ ('pending') };
274
+ (await auth.store()).prepare("INSERT INTO landings (repo, pull, slug, sha, user_id, login, started, state) VALUES (?, ?, ?, ?, ?, ?, ?, 'pending') ON CONFLICT(repo, pull) DO UPDATE SET slug = excluded.slug, sha = excluded.sha, user_id = excluded.user_id, login = excluded.login, started = excluded.started, state = 'pending'")
275
+ .run(repo, pull, row.slug, row.sha, row.user_id, row.login, row.started);
276
+ const job = begin(row);
277
+ await audit(job, { state: 'pending' }, null, { state: 'pending', why: 'waiting for the checks' });
278
+ await run(job);
279
+ }
280
+
281
+ /**
282
+ * Picks up what a restart left: a pending row is waited for again, from when it started; one that is not pending, or whose
283
+ * time ran out while cornfield was away, is dropped (the timed-out one with an audit row).
284
+ */
285
+ async function resume() {
286
+ if (closed) return;
287
+ const db = await auth.store();
288
+ db.prepare("DELETE FROM landings WHERE repo = ? AND state <> 'pending'").run(repo);
289
+ const rows = /** @type {LandingRow[]} */ (db.prepare("SELECT * FROM landings WHERE repo = ? AND state = 'pending'").all(repo));
290
+ const looks = [];
291
+ for (const row of rows) {
292
+ const job = begin(row);
293
+ if (now() - Date.parse(row.started) >= timeoutMs) {
294
+ job.over = true;
295
+ jobs.delete(row.pull);
296
+ db.prepare('DELETE FROM landings WHERE repo = ? AND pull = ?').run(repo, row.pull);
297
+ await audit(job, { state: 'left-open' }, null, { state: 'left-open', why: 'its time ran out while cornfield was not running' });
298
+ continue;
299
+ }
300
+ looks.push(run(job));
301
+ }
302
+ await Promise.all(looks);
303
+ }
304
+
305
+ /**
306
+ * What is being waited for on a PR, or null.
307
+ * @returns {{ pull: number, slug: string, sha: string, by: string, since: string } | null}
308
+ */
309
+ function pending(pull) {
310
+ const job = jobs.get(pull);
311
+ return job && !job.over ? { pull, slug: job.slug, sha: job.sha, by: job.login, since: job.started } : null;
312
+ }
313
+
314
+ /**
315
+ * A maintainer stops a landing. Posts the one comment, as them. False when nothing was waiting.
316
+ * @param {number} pull
317
+ * @param {{ forge: import('./forge.js').Forge, login: string }} by
318
+ */
319
+ async function cancel(pull, by) {
320
+ const job = jobs.get(pull);
321
+ if (!job || job.over) return false;
322
+ await end(job, 'cancelled', `cancelled by ${by.login}`, {
323
+ forge: by.forge, request: { by: by.login },
324
+ comment: `Landing on approval was cancelled by ${oneLine(by.login, NAME_MAX)}. \`${job.slug}\` stays approved and the pull request stays open; land it with Land it when you are ready.`,
325
+ });
326
+ return true;
327
+ }
328
+
329
+ /** Waits for the looks in flight (the tests', and a clean shutdown's) */
330
+ async function idle() {
331
+ while (looking.size) await Promise.allSettled([...looking]);
332
+ }
333
+
334
+ function close() {
335
+ closed = true;
336
+ for (const job of jobs.values()) if (job.timer !== null) timer.clearTimeout(job.timer);
337
+ jobs.clear();
338
+ }
339
+
340
+ return { start, resume, pending, cancel, idle, close };
341
+ }
342
+
343
+ /** @typedef {ReturnType<typeof createLander>} Lander */
344
+
345
+ // ---------------------------------------------------------------------------
346
+ // The dashboard's two routes
347
+ // ---------------------------------------------------------------------------
348
+
349
+ /**
350
+ * The landing route of the path, or null: `GET /api/forge/pulls/<n>/landing` (what is waiting) and
351
+ * `POST /api/forge/pulls/<n>/landing/cancel` (stop it).
352
+ * @param {string} reqPath
353
+ * @returns {{ n: number, cancel: boolean } | null}
354
+ */
355
+ export function landingOf(reqPath) {
356
+ const m = /^\/api\/forge\/pulls\/([1-9]\d{0,8})\/landing(\/cancel)?$/.exec(reqPath);
357
+ return m ? { n: Number(m[1]), cancel: m[2] !== undefined } : null;
358
+ }
359
+
360
+ /**
361
+ * Answers a landing route. The read needs only a signed-in maintainer and asks the forge nothing; the cancel is a POST from
362
+ * cornfield's own page by a maintainer the forge confirms now, counted like any action.
363
+ * @param {import('node:http').IncomingMessage} req
364
+ * @param {{ n: number, cancel: boolean }} what
365
+ * @param {{ config: any, session: { login: string, userId: string } | null, auth: any, lander?: Lander | null, now?: () => number, publicHost?: string | null }} ctx
366
+ * @returns {Promise<{ status: number, body: object, headers?: Record<string, string> }>}
367
+ */
368
+ export async function serveLanding(req, what, ctx) {
369
+ const { config, session, auth, lander = null, now = Date.now } = ctx;
370
+ req.resume();
371
+ if (!session) return { status: 403, body: { ok: false, error: 'Landing on approval is for the signed-in maintainer: set up sign-in first (docs/sign-in.md).', code: 'forbidden' } };
372
+ if (!what.cancel) {
373
+ if (req.method !== 'GET' && req.method !== 'HEAD') return { status: 405, body: { ok: false, error: 'Ask what is waiting with GET.', code: 'invalid' }, headers: { Allow: 'GET, HEAD' } };
374
+ return { status: 200, body: { ok: true, enabled: lander !== null, landing: lander ? lander.pending(what.n) : null } };
375
+ }
376
+ if (req.method !== 'POST') return { status: 405, body: { ok: false, error: 'Cancelling is done with POST.', code: 'invalid' }, headers: { Allow: 'POST' } };
377
+ try {
378
+ const flood = countAttempt(ctx, session.userId, now());
379
+ if (flood) throw flood;
380
+ if (!sameOrigin(req, ctx.publicHost ?? null)) throw new Refused(403, 'Do this from cornfield\'s own page.', 'forbidden');
381
+ if (!lander) throw new Refused(404, 'Landing on approval is not turned on (proposals.approveLands).', 'not-found');
382
+ const forge = auth.forgeFor(session);
383
+ const me = await forge.me();
384
+ if (String(me.id) !== String(session.userId)) throw new Refused(403, 'The forge says this sign-in is someone else now; sign in again.', 'forbidden');
385
+ if (!isMaintainer(await forge.permission(config.forge.repo, me.login))) throw new Refused(403, `Only the repo's maintainers stop a landing; ${me.login} is not one now.`, 'forbidden');
386
+ const stopped = await lander.cancel(what.n, { forge, login: me.login });
387
+ if (!stopped) throw new Refused(404, `Nothing is waiting to land pull request #${what.n}.`, 'not-found');
388
+ return { status: 200, body: { ok: true, cancelled: true, number: what.n } };
389
+ } catch (err) {
390
+ if (err instanceof Refused) return { status: err.status, body: { ok: false, error: err.message, code: err.code }, headers: err.headers };
391
+ if (err instanceof ForgeError) return { status: err.code === 'unauthorized' ? 401 : 502, body: { ok: false, error: err.message, code: err.code } };
392
+ throw err;
393
+ }
394
+ }
@@ -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) {