cornfield 0.3.0 → 0.5.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 +59 -2
- package/explorer/forge-panel-model.js +17 -0
- package/explorer/forge-panel.js +41 -2
- package/package.json +1 -1
- package/server/db.js +15 -0
- package/server/forge-actions.js +23 -4
- package/server/forge-routes.js +11 -2
- package/server/hooks.js +2 -2
- package/server/lander.js +394 -0
- package/server/proposals.js +2 -2
- package/server/review-api.js +2 -2
- package/server/reviews.js +16 -0
- package/server/server.js +21 -8
- package/server/spec-poll.js +146 -0
- package/server/submit-review.js +11 -1
package/core/config.js
CHANGED
|
@@ -18,11 +18,12 @@
|
|
|
18
18
|
* host: '127.0.0.1', // loopback, unless sign-in is configured with an https publicUrl (server/bind.js)
|
|
19
19
|
* publicUrl: null, // the origin people open it at; sign-in needs it (server/auth.js)
|
|
20
20
|
* worktrees: '.claude/worktrees', // a folder of git worktrees whose unpushed proposals are listed too
|
|
21
|
-
* proposals: 'docs/specs/proposals', // where proposal files live, in the checkout and in each worktree
|
|
21
|
+
* proposals: 'docs/specs/proposals', // where proposal files live, in the checkout and in each worktree; or { dir, approveLands } (docs/config.md#proposals)
|
|
22
22
|
* topology: 'topology.json',
|
|
23
23
|
* personas: { roles: null }, // the roles a persona may have (null: any)
|
|
24
24
|
* releaseNotes: { breakingLabel: 'breaking' }, // the pull request label that lists a change under "Breaking" in the release notes
|
|
25
25
|
* hooks: [], // [{ url, events }]: signed JSON events posted to outside code (docs/config.md#hooks); the secret is env only
|
|
26
|
+
* hookPoll: { specs: '5m' }, // how often cornfield looks for ready specs on its own, for the spec.ready hook; false turns it off (docs/config.md#hook-poll)
|
|
26
27
|
* links: [], // [{ label, href }] shown in the explorer's header
|
|
27
28
|
* proxy: {}, // { '/api/': 'http://127.0.0.1:3245' }: same-origin paths for a live source
|
|
28
29
|
* importMap: {}, // bare imports a repo-local browser module needs: { name: '/repo/path.js' }
|
|
@@ -68,6 +69,33 @@ export const MERGE_METHODS = ['merge', 'squash', 'rebase'];
|
|
|
68
69
|
export const HOOK_EVENTS = Object.freeze(['spec.ready', 'spec.approved', 'spec.sentBack', 'build.opened', 'pr.landed']);
|
|
69
70
|
/** The most hooks a config lists */
|
|
70
71
|
export const MAX_HOOKS = 20;
|
|
72
|
+
/** The bounds of `hookPoll.specs` in milliseconds: not under a minute (the forge is not hammered), not over an hour */
|
|
73
|
+
export const HOOK_POLL_MIN_MS = 60_000;
|
|
74
|
+
export const HOOK_POLL_MAX_MS = 3_600_000;
|
|
75
|
+
/** The keys of `hookPoll` when it is an object */
|
|
76
|
+
export const HOOK_POLL_KEYS = Object.freeze(['specs']);
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* A duration like '90s', '5m' or '1h' in milliseconds, or null when it is not one (whole numbers only).
|
|
80
|
+
* @param {unknown} v
|
|
81
|
+
* @returns {number | null}
|
|
82
|
+
*/
|
|
83
|
+
export function parseDuration(v) {
|
|
84
|
+
const m = typeof v === 'string' ? /^([1-9]\d{0,5})(s|m|h)$/.exec(v) : null;
|
|
85
|
+
return m ? Number(m[1]) * { s: 1000, m: 60_000, h: 3_600_000 }[m[2]] : null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* How often cornfield looks at the open spec PRs by itself, in milliseconds, or null when it does not: when `hookPoll.specs`
|
|
90
|
+
* is false, or no hook subscribes to `spec.ready` (by name or with '*').
|
|
91
|
+
* @param {{ hooks?: { events: string[] | '*' }[], hookPoll?: { specs?: string | false } | false }} config a loaded config
|
|
92
|
+
* @returns {number | null}
|
|
93
|
+
*/
|
|
94
|
+
export function specPollMs(config) {
|
|
95
|
+
const wanted = Array.isArray(config.hooks) && config.hooks.some((h) => h.events === '*' || (Array.isArray(h.events) && h.events.includes('spec.ready')));
|
|
96
|
+
const every = config.hookPoll === false ? false : config.hookPoll ? config.hookPoll.specs : DEFAULTS.hookPoll.specs;
|
|
97
|
+
return wanted && every !== false ? parseDuration(every ?? DEFAULTS.hookPoll.specs) : null;
|
|
98
|
+
}
|
|
71
99
|
|
|
72
100
|
export const DEFAULTS = Object.freeze({
|
|
73
101
|
title: null,
|
|
@@ -86,6 +114,7 @@ export const DEFAULTS = Object.freeze({
|
|
|
86
114
|
releaseNotes: { breakingLabel: 'breaking' },
|
|
87
115
|
agents: { authors: [], labels: [], branchPrefixes: [], bots: true },
|
|
88
116
|
hooks: [],
|
|
117
|
+
hookPoll: { specs: '5m' },
|
|
89
118
|
links: [],
|
|
90
119
|
proxy: {},
|
|
91
120
|
importMap: {},
|
|
@@ -93,6 +122,8 @@ export const DEFAULTS = Object.freeze({
|
|
|
93
122
|
});
|
|
94
123
|
|
|
95
124
|
const KEYS = Object.keys(DEFAULTS);
|
|
125
|
+
/** The keys of `proposals` when it is an object: the folder, and whether an approval also lands the spec PR (opt-in, off unless true) */
|
|
126
|
+
export const PROPOSALS_KEYS = Object.freeze(['dir', 'approveLands']);
|
|
96
127
|
const AGENT_KEYS = ['authors', 'labels', 'branchPrefixes', 'bots'];
|
|
97
128
|
const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
98
129
|
const isText = (v) => typeof v === 'string' && v.trim() !== '';
|
|
@@ -197,13 +228,34 @@ export function configProblems(c) {
|
|
|
197
228
|
});
|
|
198
229
|
}
|
|
199
230
|
}
|
|
231
|
+
if (c.hookPoll !== undefined && c.hookPoll !== false) {
|
|
232
|
+
if (!isObject(c.hookPoll)) out.push('"hookPoll" is { specs: "5m" }, or false for none.');
|
|
233
|
+
else {
|
|
234
|
+
for (const k of Object.keys(c.hookPoll)) if (!HOOK_POLL_KEYS.includes(k)) out.push(`hookPoll.${k}: the key is ${HOOK_POLL_KEYS.join(', ')}.`);
|
|
235
|
+
const v = c.hookPoll.specs;
|
|
236
|
+
if (v !== undefined && v !== false) {
|
|
237
|
+
const ms = parseDuration(v);
|
|
238
|
+
if (ms === null) out.push('hookPoll.specs is a duration like "90s", "5m" or "1h" (from 60s to 1h), or false.');
|
|
239
|
+
else if (ms < HOOK_POLL_MIN_MS) out.push(`hookPoll.specs ${JSON.stringify(v)} is under the floor of 60s, so the forge is not hammered; use "60s" or more.`);
|
|
240
|
+
else if (ms > HOOK_POLL_MAX_MS) out.push(`hookPoll.specs ${JSON.stringify(v)} is over the ceiling of 1h; use "1h" or less, or false to turn the poll off.`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
200
244
|
if (c.releaseNotes !== undefined) {
|
|
201
245
|
if (!isObject(c.releaseNotes)) out.push('"releaseNotes" is { breakingLabel }.');
|
|
202
246
|
else if (c.releaseNotes.breakingLabel !== undefined && (!isText(c.releaseNotes.breakingLabel) || c.releaseNotes.breakingLabel.includes(','))) out.push('releaseNotes.breakingLabel is a label\'s name, like "breaking".');
|
|
203
247
|
}
|
|
204
|
-
for (const key of ['worktrees', '
|
|
248
|
+
for (const key of ['worktrees', 'topology']) {
|
|
205
249
|
if (c[key] !== undefined && c[key] !== null && !inRepo(c[key])) out.push(`"${key}" is a folder or file inside the repo, written relative to it.`);
|
|
206
250
|
}
|
|
251
|
+
// The folder as a string (always so), or { dir?, approveLands? }: the object form is how the folder's options are set
|
|
252
|
+
if (c.proposals !== undefined && c.proposals !== null) {
|
|
253
|
+
if (isObject(c.proposals)) {
|
|
254
|
+
for (const k of Object.keys(c.proposals)) if (!PROPOSALS_KEYS.includes(k)) out.push(`proposals.${k}: the keys are ${PROPOSALS_KEYS.join(', ')}.`);
|
|
255
|
+
if (c.proposals.dir !== undefined && !inRepo(c.proposals.dir)) out.push('proposals.dir is the proposals folder, inside the repo and written relative to it.');
|
|
256
|
+
if (c.proposals.approveLands !== undefined && typeof c.proposals.approveLands !== 'boolean') out.push('proposals.approveLands is true or false: whether approving a spec also lands its pull request once every check is green.');
|
|
257
|
+
} else if (!inRepo(c.proposals)) out.push('"proposals" is the proposals folder inside the repo, written relative to it (or { dir, approveLands }).');
|
|
258
|
+
}
|
|
207
259
|
if (c.links !== undefined) {
|
|
208
260
|
if (!Array.isArray(c.links)) out.push('"links" is a list of { label, href }.');
|
|
209
261
|
else c.links.forEach((l, i) => {
|
|
@@ -280,6 +332,11 @@ export async function loadConfig(root, { env = process.env, file } = {}) {
|
|
|
280
332
|
deploys: { ...DEFAULTS.deploys, ...(raw.deploys || {}) },
|
|
281
333
|
agents: { ...DEFAULTS.agents, ...(raw.agents || {}) },
|
|
282
334
|
};
|
|
335
|
+
// `hookPoll: false` is `{ specs: false }`; an object is read over the default
|
|
336
|
+
config.hookPoll = raw.hookPoll === false ? { specs: false } : { ...DEFAULTS.hookPoll, ...(raw.hookPoll || {}) };
|
|
337
|
+
// `proposals` is the folder to everything downstream, whichever way the file wrote it; the options are read from `approveLands`
|
|
338
|
+
config.approveLands = isObject(raw.proposals) && raw.proposals.approveLands === true;
|
|
339
|
+
config.proposals = (isObject(raw.proposals) ? raw.proposals.dir : raw.proposals) ?? DEFAULTS.proposals;
|
|
283
340
|
if (env.CORNFIELD_PORT) config.port = Number(env.CORNFIELD_PORT);
|
|
284
341
|
if (env.PORT && !env.CORNFIELD_PORT) config.port = Number(env.PORT);
|
|
285
342
|
if (env.CORNFIELD_HOST) config.host = env.CORNFIELD_HOST;
|
|
@@ -22,6 +22,8 @@
|
|
|
22
22
|
* specBlock, buildOffers the pipeline's steps (PIPE-05): why a spec cannot be started on an issue; which proposals of a spec pull request can have a build issue
|
|
23
23
|
* planUrl, confirmSpec, confirmBuild, specBody, buildBody, existingWords
|
|
24
24
|
* where a step's plan is read, what the confirm step shows of it, what is sent to confirm it, and what is said of one that is made already
|
|
25
|
+
* landingUrl, cancelLandingUrl, landingWords
|
|
26
|
+
* a spec pull request waiting to land on its approval (`proposals.approveLands`, server/lander.js): where it is read, where it is cancelled, and the words
|
|
25
27
|
* notesUrl, sinceChoices, notesSummary, validTag, confirmWords, postedWords, draftRequest
|
|
26
28
|
* the Release notes view: what to ask, the tags to start from, and the words around a draft
|
|
27
29
|
*/
|
|
@@ -54,6 +56,21 @@ export const listKeyOf = (kind, state) => (kind === 'deploys' ? 'deploys' : `${k
|
|
|
54
56
|
/** The address of one pull request with its files and checks */
|
|
55
57
|
export const pullUrl = (n) => `/api/forge/pulls/${n}`;
|
|
56
58
|
|
|
59
|
+
/** Where a pull request's landing on approval is read (GET) and cancelled (POST) */
|
|
60
|
+
export const landingUrl = (n) => `/api/forge/pulls/${n}/landing`;
|
|
61
|
+
export const cancelLandingUrl = (n) => `/api/forge/pulls/${n}/landing/cancel`;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The line for a pull request waiting to land on its approval: "Will land when checks pass".
|
|
65
|
+
* @param {{ slug: string, by: string, since: string } | null} landing what the server says is waiting
|
|
66
|
+
* @param {number} nowMs
|
|
67
|
+
* @returns {string} empty when nothing is waiting
|
|
68
|
+
*/
|
|
69
|
+
export function landingWords(landing, nowMs) {
|
|
70
|
+
if (!landing) return '';
|
|
71
|
+
return `Will land when checks pass. ${landing.slug} was approved by ${landing.by} ${relativeTime(landing.since, nowMs)}; it lands at that head only, and a push cancels it.`;
|
|
72
|
+
}
|
|
73
|
+
|
|
57
74
|
/** Whether a list's item passes the filters (the state of a pull request is decided here: open, merged, closed without landing) */
|
|
58
75
|
export function matches(item, kind, filters) {
|
|
59
76
|
const f = { ...defaultFilters(), ...filters };
|
package/explorer/forge-panel.js
CHANGED
|
@@ -29,6 +29,9 @@
|
|
|
29
29
|
* it goes, how, each check); a scrim covers the page, the dialog is `aria-modal`, Tab stays inside, Escape and Cancel close it and give the
|
|
30
30
|
* focus back, and it is announced. What is sent is the head that was shown: a push since is refused by the server,
|
|
31
31
|
* which checks everything again (server/forge-actions.js). While one is in flight the buttons are off.
|
|
32
|
+
* - With `proposals.approveLands` on, a spec pull request that was approved and is waiting for its checks says **Will land when
|
|
33
|
+
* checks pass** (read from `GET /api/forge/pulls/<n>/landing`, the server's own state) with a **Cancel** that POSTs to
|
|
34
|
+
* `…/landing/cancel` (server/lander.js). With the key off the panel asks nothing and shows nothing more.
|
|
32
35
|
* - The pipeline's own steps (PIPE-05, server/pipeline.js), each one button with a confirm step that shows exactly what
|
|
33
36
|
* will be made, read from the server first (the plan, with a digest the server must find again):
|
|
34
37
|
* 1. an open issue in the Issues list has **Start a spec …**: a branch, a stub proposal and a draft spec pull request;
|
|
@@ -56,7 +59,7 @@ import { LAYER_DEFS, toggleLayer } from './layers.js';
|
|
|
56
59
|
import { WORK_URL, groupWork, filterGroups, groupWords, workStatus, markWords } from './agents-model.js';
|
|
57
60
|
import {
|
|
58
61
|
STATES, defaultFilters, listUrl, pullUrl, serverState, filterItems, facetsOf, stateWords, metaWords, checkWords, checksWords, litFor, errorWords,
|
|
59
|
-
mergeBlock, rerunWhy, confirmMerge, mergeBody, actionUrl, actionWords,
|
|
62
|
+
landingUrl, cancelLandingUrl, landingWords, mergeBlock, rerunWhy, confirmMerge, mergeBody, actionUrl, actionWords,
|
|
60
63
|
specBlock, buildOffers, planUrl, confirmSpec, confirmBuild, specBody, buildBody, existingWords,
|
|
61
64
|
listKeyOf, litForDeploy, nodesForRestarts, deployWords, deployMeta,
|
|
62
65
|
DRAFTS_URL, notesUrl, sinceChoices, notesSummary, validTag, confirmWords, postedWords, draftRequest,
|
|
@@ -79,6 +82,7 @@ const ui = {
|
|
|
79
82
|
error: '',
|
|
80
83
|
selected: null, // the pull request's number
|
|
81
84
|
detail: null, // { pull, files, checks, at, rate, stale } of the selected one
|
|
85
|
+
landing: null, // what waits to land on its approval for the selected pull request ({ slug, by, since }), or null
|
|
82
86
|
detailError: '',
|
|
83
87
|
lit: null, // litFor(detail, nodes)
|
|
84
88
|
deploy: null, // the selected deploy's id; the deploy itself is `deployItem`
|
|
@@ -181,6 +185,7 @@ async function selectPull(n) {
|
|
|
181
185
|
ui.litDeploy = null;
|
|
182
186
|
ui.selected = n;
|
|
183
187
|
ui.detail = null;
|
|
188
|
+
ui.landing = null;
|
|
184
189
|
ui.detailError = '';
|
|
185
190
|
ui.lit = null;
|
|
186
191
|
ui.note = null;
|
|
@@ -198,10 +203,35 @@ async function selectPull(n) {
|
|
|
198
203
|
}
|
|
199
204
|
ui.detail = { pull: res.body.pull, files: res.body.files || [], checks: res.body.checks || [], specs: res.body.specs || [], at: res.body.at, rate: res.body.rate || '', stale: res.body.stale || '' };
|
|
200
205
|
relight();
|
|
206
|
+
await loadLanding(n, token);
|
|
201
207
|
$('forge-panel').querySelector('.hx-forge-body').scrollTop = 0; // the pull request's files are what was asked for, not the row that was tapped
|
|
202
208
|
announce(selectionWords());
|
|
203
209
|
}
|
|
204
210
|
|
|
211
|
+
/** Asks the server whether the selected pull request waits to land on its approval (a read of the server's own state, not of the forge); nothing is shown when it does not */
|
|
212
|
+
async function loadLanding(n, token) {
|
|
213
|
+
if (!(ui.config && ui.config.forge && ui.config.forge.approveLands)) return; // the key is off: nothing to ask
|
|
214
|
+
const res = await request(landingUrl(n), true);
|
|
215
|
+
if (token !== pullToken || ui.selected !== n) return;
|
|
216
|
+
ui.landing = res.ok && res.body && res.body.landing ? res.body.landing : null;
|
|
217
|
+
paint();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Stops the landing that waits on the selected pull request: a POST from this page, then the pull request is read again */
|
|
221
|
+
async function cancelLanding(n) {
|
|
222
|
+
if (ui.busy) return;
|
|
223
|
+
ui.busy = 'landing';
|
|
224
|
+
ui.note = null;
|
|
225
|
+
paint();
|
|
226
|
+
const res = await post(cancelLandingUrl(n), {});
|
|
227
|
+
ui.busy = '';
|
|
228
|
+
ui.note = { text: res.ok ? `Pull request #${n} will not land on its own. It stays open.` : `Not cancelled: ${(res.body && res.body.error) || 'cornfield did not answer.'}`, ok: res.ok };
|
|
229
|
+
announce(ui.note.text);
|
|
230
|
+
ui.landing = null;
|
|
231
|
+
paint();
|
|
232
|
+
if (ui.selected === n) await refreshForge({ quiet: true });
|
|
233
|
+
}
|
|
234
|
+
|
|
205
235
|
/** Lets go of the selected pull request: nothing is lit any more */
|
|
206
236
|
export function clearPull() {
|
|
207
237
|
pullToken++;
|
|
@@ -210,6 +240,7 @@ export function clearPull() {
|
|
|
210
240
|
ui.selected = null;
|
|
211
241
|
ui.note = null;
|
|
212
242
|
ui.detail = null;
|
|
243
|
+
ui.landing = null;
|
|
213
244
|
ui.detailError = '';
|
|
214
245
|
ui.lit = null;
|
|
215
246
|
state.forgeLit = null;
|
|
@@ -570,6 +601,10 @@ function detailHtml() {
|
|
|
570
601
|
const act = block
|
|
571
602
|
? `<p class="hx-forge-why hx-faint">${pull.state === 'merged' ? '' : 'Cannot be landed from here. '}${escHtml(block)}</p>`
|
|
572
603
|
: `<button type="button" class="hx-btn hx-btn--approve hx-forge-act" data-forge-merge="${pull.number}"${ui.busy ? ' disabled' : ''}>Land it …</button><p class="hx-forge-why hx-faint">Opens a confirm step that shows what will land. Never forced, never deployed.</p>`;
|
|
604
|
+
// A spec approved with `proposals.approveLands` waits for its checks: said here, with a way to stop it
|
|
605
|
+
const waiting = ui.landing && pull.state === 'open'
|
|
606
|
+
? `<div class="hx-forge-step"><p class="hx-forge-notice hx-forge-notice--ok" role="status">${escHtml(landingWords(ui.landing, now()))}</p><button type="button" class="hx-btn hx-forge-act" data-forge-landing-cancel="${pull.number}"${ui.busy ? ' disabled' : ''}>Cancel</button></div>`
|
|
607
|
+
: '';
|
|
573
608
|
const note = ui.note ? `<p class="hx-forge-notice${ui.note.ok ? ' hx-forge-notice--ok' : ''}">${escHtml(ui.note.text)}</p>` : '';
|
|
574
609
|
// Step 2 of the pipeline: a spec pull request with an approved proposal opens its build issue
|
|
575
610
|
const offers = buildOffers(ui.detail);
|
|
@@ -589,6 +624,7 @@ function detailHtml() {
|
|
|
589
624
|
${build}
|
|
590
625
|
<h4 class="hx-forge-subact">Land it</h4>
|
|
591
626
|
${note}
|
|
627
|
+
${waiting}
|
|
592
628
|
${act}
|
|
593
629
|
<h4 class="hx-forge-sub">${escHtml(plural(lit.files.length, 'file'))} changed, ${mapped} on the map</h4>
|
|
594
630
|
${key}
|
|
@@ -951,6 +987,7 @@ export async function refreshForge({ quiet = false } = {}) {
|
|
|
951
987
|
if (token !== pullToken || !res.ok) return;
|
|
952
988
|
ui.detail = { pull: res.body.pull, files: res.body.files || [], checks: res.body.checks || [], specs: res.body.specs || [], at: res.body.at, rate: res.body.rate || '', stale: res.body.stale || '' };
|
|
953
989
|
relight();
|
|
990
|
+
await loadLanding(n, token);
|
|
954
991
|
}
|
|
955
992
|
}
|
|
956
993
|
|
|
@@ -1005,6 +1042,8 @@ function onClick(e) {
|
|
|
1005
1042
|
announce(`${node.label}, ${node.type}`);
|
|
1006
1043
|
return;
|
|
1007
1044
|
}
|
|
1045
|
+
const unland = t.closest('[data-forge-landing-cancel]');
|
|
1046
|
+
if (unland) { cancelLanding(Number(unland.dataset.forgeLandingCancel)); return; }
|
|
1008
1047
|
const merge = t.closest('[data-forge-merge]');
|
|
1009
1048
|
if (merge) { openConfirm('[data-forge-merge]'); return; }
|
|
1010
1049
|
const spec = t.closest('[data-forge-spec]');
|
|
@@ -1119,5 +1158,5 @@ export function resetForgePanelForTests(clock = () => Date.now()) {
|
|
|
1119
1158
|
pullToken++;
|
|
1120
1159
|
notesToken++;
|
|
1121
1160
|
state.forgeLit = null;
|
|
1122
|
-
Object.assign(ui, { config: null, open: false, kind: 'pulls', filters: defaultFilters(), lists: {}, work: null, workFilters: { agentsOnly: false, finished: false }, loading: false, error: '', selected: null, detail: null, detailError: '', lit: null, deploy: null, deployItem: null, litDeploy: null, nodesSeen: null, busy: '', note: null, confirm: null, actNote: null, notes: freshNotes() });
|
|
1161
|
+
Object.assign(ui, { config: null, open: false, kind: 'pulls', filters: defaultFilters(), lists: {}, work: null, workFilters: { agentsOnly: false, finished: false }, loading: false, error: '', selected: null, detail: null, landing: null, detailError: '', lit: null, deploy: null, deployItem: null, litDeploy: null, nodesSeen: null, busy: '', note: null, confirm: null, actNote: null, notes: freshNotes() });
|
|
1123
1162
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cornfield",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "A self-hostable dev dashboard with the topology map at its centre: proposals on the map, scenarios, outages and what-if edits, for any repo.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "popcorn245 (https://github.com/popcorn245)",
|
package/server/db.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* review_tokens the review API's tokens: a SHA-256 hash, owner, label, expiry, revoked, last used (never the token)
|
|
12
12
|
* hook_seen the (pull request, head, proposal) triples whose `spec.ready` event was sent, so a restart sends none twice;
|
|
13
13
|
* a row with `pr = 0` says the repo has been looked at once (server/hooks.js)
|
|
14
|
+
* landings the spec PRs an approval is waiting to land (`proposals.approveLands`), so a restart picks them up (server/lander.js)
|
|
14
15
|
*
|
|
15
16
|
* The schema is a list of numbered migrations (`MIGRATIONS`); the file's `PRAGMA user_version` is the number of
|
|
16
17
|
* the last one it has. `openStore` runs the ones a file lacks, each in its own transaction, so a fresh file and one
|
|
@@ -110,6 +111,20 @@ export const MIGRATIONS = Object.freeze([
|
|
|
110
111
|
) STRICT;
|
|
111
112
|
CREATE INDEX review_tokens_user_id ON review_tokens (user_id);
|
|
112
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
|
+
`,
|
|
113
128
|
]);
|
|
114
129
|
|
|
115
130
|
/** The version a file is at: its `user_version` */
|
package/server/forge-actions.js
CHANGED
|
@@ -113,6 +113,27 @@ async function forgetKept(auth, config) {
|
|
|
113
113
|
} catch { /* a cache that cannot be cleared expires in a minute */ }
|
|
114
114
|
}
|
|
115
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
|
+
|
|
116
137
|
/**
|
|
117
138
|
* The action the request is, or null for any other path.
|
|
118
139
|
* @param {string} reqPath
|
|
@@ -229,11 +250,9 @@ export async function serveAction(req, res, what, ctx, send) {
|
|
|
229
250
|
await forgetKept(auth, config);
|
|
230
251
|
out = { status: done.created ? 201 : 200, body: { ok: true, action, created: done.created, number: n, slug, issue: done.issue, path: plan.path } };
|
|
231
252
|
} else if (action === 'merge') {
|
|
232
|
-
const method = config
|
|
253
|
+
const method = mergeMethodOf(config);
|
|
233
254
|
request.method = method;
|
|
234
|
-
const landed = await forge
|
|
235
|
-
ctx.hooks?.emit('pr.landed', () => landedPayload(forge, ctx.where || { proposals: config.proposals }, n, /** @type {string} */ (sha), session.login));
|
|
236
|
-
await forgetKept(auth, config);
|
|
255
|
+
const landed = await landPull({ forge, config, where: ctx.where, hooks: ctx.hooks, auth }, n, /** @type {string} */ (sha), session.login);
|
|
237
256
|
out = { status: 200, body: { ok: true, merged: true, number: n, sha: landed.sha, head: sha, method } };
|
|
238
257
|
} else {
|
|
239
258
|
const pull = await forge.pull(n);
|
package/server/forge-routes.js
CHANGED
|
@@ -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';
|
|
@@ -136,7 +138,7 @@ function routeOf(reqPath, url, config, where, hooks = null) {
|
|
|
136
138
|
key: 'work',
|
|
137
139
|
load: async (forge) => {
|
|
138
140
|
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, { hooks }),
|
|
141
|
+
forge.pulls({ state: 'all', limit: WORK_PULLS }), forge.issues({ state: 'all', labels: [], limit: WORK_ISSUES }), forge.branches(), listPullProposals(forge, where || config, { hooks }),
|
|
140
142
|
]);
|
|
141
143
|
return {
|
|
142
144
|
pulls: pulls.map((p) => ({ ...p, body: p.body.slice(0, WORK_BODY_MAX) })),
|
|
@@ -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)) {
|
package/server/hooks.js
CHANGED
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
*
|
|
8
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
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
|
|
11
|
-
*
|
|
10
|
+
* cornfield has no webhook receiver: it notices when it lists the PRs (a maintainer's dashboard, or its own
|
|
11
|
+
* timer, server/spec-poll.js), so this fires within the poll interval of the push, not at it. The first look at a repo after hooks are added announces
|
|
12
12
|
* nothing: it records what is open, so only what changes after it is news
|
|
13
13
|
* spec.approved a maintainer approved a proposal spec.sentBack a maintainer sent one back
|
|
14
14
|
* build.opened the build issue of an approved proposal was opened
|
package/server/lander.js
ADDED
|
@@ -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
|
+
}
|
package/server/proposals.js
CHANGED
|
@@ -265,7 +265,7 @@ async function putPullReview(req, res, where, forgeFor, signedIn, slug, n) {
|
|
|
265
265
|
try {
|
|
266
266
|
// The checks and the post are submitReview's, shared with the review API (server/submit-review.js)
|
|
267
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,
|
|
268
|
+
forge: forgeFor, where, repo: signedIn.repo, who: { login: signedIn.login, userId: signedIn.userId }, record: signedIn.record, limits: signedIn.limits, hooks: signedIn.hooks, lander: signedIn.lander,
|
|
269
269
|
}, { pull: n, slug, review: parsed });
|
|
270
270
|
return send(res, 200, found);
|
|
271
271
|
} catch (err) {
|
|
@@ -274,7 +274,7 @@ async function putPullReview(req, res, where, forgeFor, signedIn, slug, n) {
|
|
|
274
274
|
}
|
|
275
275
|
}
|
|
276
276
|
|
|
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 */
|
|
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, lander?: import('./lander.js').Lander | null }} SignedIn who is signed in, the repo, where their actions are recorded, and what the flood and repeat limits count by */
|
|
278
278
|
|
|
279
279
|
/**
|
|
280
280
|
* Answers a request under `/proposals`, or returns false when it is not one.
|
package/server/review-api.js
CHANGED
|
@@ -57,7 +57,7 @@ function send(res, status, body, headers = {}) {
|
|
|
57
57
|
|
|
58
58
|
const plainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
59
59
|
|
|
60
|
-
/** @typedef {{ config: any, where: any, auth: any, hooks?: import('./hooks.js').Hooks | null, now?: () => number, publicHost?: string | null }} ApiContext */
|
|
60
|
+
/** @typedef {{ config: any, where: any, auth: any, hooks?: import('./hooks.js').Hooks | null, lander?: import('./lander.js').Lander | null, now?: () => number, publicHost?: string | null }} ApiContext */
|
|
61
61
|
|
|
62
62
|
/**
|
|
63
63
|
* `POST /api/reviews`. The caller has found the path to be this one; this runs before the sign-in guard.
|
|
@@ -123,7 +123,7 @@ export async function serveReviewCall(req, res, ctx) {
|
|
|
123
123
|
try {
|
|
124
124
|
const found = await submitReview({
|
|
125
125
|
forge: () => auth.forgeForUser(row.user_id), where, repo, who: { login: row.login, userId: row.user_id },
|
|
126
|
-
record: (entry) => auth.record(entry), limits: { auth, now: ctx.now }, via: { token }, requireSha256: true, hooks: ctx.hooks,
|
|
126
|
+
record: (entry) => auth.record(entry), limits: { auth, now: ctx.now }, via: { token }, requireSha256: true, hooks: ctx.hooks, lander: ctx.lander,
|
|
127
127
|
stillAllowed: async () => {
|
|
128
128
|
const again = findToken(await auth.store(), /** @type {string} */ (presented), now());
|
|
129
129
|
if (again && again.state === 'active') return null;
|
package/server/reviews.js
CHANGED
|
@@ -240,6 +240,22 @@ export async function postReview(forge, n, slug, review, { login, record, carrie
|
|
|
240
240
|
}
|
|
241
241
|
}
|
|
242
242
|
|
|
243
|
+
/**
|
|
244
|
+
* Posts a plain note on PR `n`, as the token's user: the other thing cornfield says on a spec PR besides a review (server/lander.js:
|
|
245
|
+
* a landing on approval that was cancelled or left open). It goes by the same comment call as a review, with every mention and
|
|
246
|
+
* reference in it defused, since part of it (a check's name) is the forge's text and not cornfield's. The caller records it.
|
|
247
|
+
* Throws what the forge throws.
|
|
248
|
+
* @param {import('./forge.js').Forge} forge
|
|
249
|
+
* @param {number} n
|
|
250
|
+
* @param {string} text
|
|
251
|
+
* @returns {Promise<import('./forge.js').Comment>}
|
|
252
|
+
*/
|
|
253
|
+
export async function postNote(forge, n, text) {
|
|
254
|
+
const body = defuse(text);
|
|
255
|
+
if (body.length > MAX_COMMENT_CHARS) throw new ForgeError(`That note is too long for a comment (${body.length} characters, at most ${MAX_COMMENT_CHARS}).`, { code: 'invalid' });
|
|
256
|
+
return forge.comment(n, body);
|
|
257
|
+
}
|
|
258
|
+
|
|
243
259
|
// ---------------------------------------------------------------------------
|
|
244
260
|
// Stage 1 reviews, once
|
|
245
261
|
// ---------------------------------------------------------------------------
|
package/server/server.js
CHANGED
|
@@ -42,7 +42,7 @@ import http from 'node:http';
|
|
|
42
42
|
import path from 'node:path';
|
|
43
43
|
import { createHash } from 'node:crypto';
|
|
44
44
|
import { fileURLToPath } from 'node:url';
|
|
45
|
-
import { browserSources, BUILTIN_SOURCES } from '../core/config.js';
|
|
45
|
+
import { browserSources, BUILTIN_SOURCES, specPollMs } from '../core/config.js';
|
|
46
46
|
import { CLOUD_FILE } from '../core/cloud.js';
|
|
47
47
|
import { decodePath, inside, notFound, sendFile } from './files.js';
|
|
48
48
|
import { serveProposals } from './proposals.js';
|
|
@@ -53,6 +53,8 @@ import { HA_ENV, homeAssistantHouse } from '../plugins/house/home-assistant.js';
|
|
|
53
53
|
import { assertBind, hostAllowed } from './bind.js';
|
|
54
54
|
import { createAuth, signInSettings } from './auth.js';
|
|
55
55
|
import { createHooks, hooksSettings } from './hooks.js';
|
|
56
|
+
import { createLander } from './lander.js';
|
|
57
|
+
import { createSpecPoll } from './spec-poll.js';
|
|
56
58
|
|
|
57
59
|
export const CORNFIELD_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
58
60
|
const EXPLORER_DIR = path.join(CORNFIELD_DIR, 'explorer');
|
|
@@ -106,7 +108,7 @@ export function clientConfig(config, signIn = false) {
|
|
|
106
108
|
version: PKG.version,
|
|
107
109
|
title: config.title,
|
|
108
110
|
links: config.links || [],
|
|
109
|
-
forge: config.forge ? { type: config.forge.type, url: config.forge.url || (config.forge.type === 'github' ? 'https://github.com' : null), repo: config.forge.repo, mergeMethod: config.forge.mergeMethod || 'merge' } : null,
|
|
111
|
+
forge: config.forge ? { type: config.forge.type, url: config.forge.url || (config.forge.type === 'github' ? 'https://github.com' : null), repo: config.forge.repo, mergeMethod: config.forge.mergeMethod || 'merge', ...(config.approveLands ? { approveLands: true } : {}) } : null,
|
|
110
112
|
sources: browserSources(config),
|
|
111
113
|
signIn,
|
|
112
114
|
};
|
|
@@ -125,10 +127,10 @@ export const HA_CACHE_MS = 30_000;
|
|
|
125
127
|
/**
|
|
126
128
|
* The server for one repo. It does not listen: `listen(config)` does, where server/bind.js allows.
|
|
127
129
|
* @param {object} config a loaded config (core/config.js `loadConfig`)
|
|
128
|
-
* @param {Parameters<typeof createAuth>[1] & { homeAssistant?: object, hookFetch?: typeof fetch, hookTimer?: { setTimeout: Function, clearTimeout: Function } }} [opts] what sign-in reads (`env`, and tests' fakes;
|
|
130
|
+
* @param {Parameters<typeof createAuth>[1] & { homeAssistant?: object, hookFetch?: typeof fetch, hookTimer?: { setTimeout: Function, clearTimeout: Function }, landTimer?: { setTimeout: Function, clearTimeout: Function }, specPollTimer?: { setTimeout: Function, clearTimeout: Function } }} [opts] what sign-in reads (`env`, and tests' fakes;
|
|
129
131
|
* see server/auth.js); the Home Assistant URL and token are read from the same `env`, and `homeAssistant` is what
|
|
130
132
|
* `homeAssistantHouse` is handed besides them (the tests' fake WebSocket); `hookFetch` and `hookTimer` are the
|
|
131
|
-
* tests' fake network and clock for the event hooks (server/hooks.js)
|
|
133
|
+
* tests' fake network and clock for the event hooks (server/hooks.js); `landTimer` is the clock of landing on approval (server/lander.js); `specPollTimer` is the clock of the `spec.ready` poll (server/spec-poll.js)
|
|
132
134
|
* @returns {import('node:http').Server}
|
|
133
135
|
*/
|
|
134
136
|
export function createCornfieldServer(config, opts = {}) {
|
|
@@ -139,6 +141,8 @@ export function createCornfieldServer(config, opts = {}) {
|
|
|
139
141
|
const hooks = hookSettings
|
|
140
142
|
? createHooks({ hooks: config.hooks, secret: hookSettings.secret, fetch: opts.hookFetch, record: (entry) => auth.record(entry), store: auth.store, now: opts.now, publicUrl: config.publicUrl, timer: opts.hookTimer })
|
|
141
143
|
: null;
|
|
144
|
+
// Landing on approval needs somebody signed in to land as, like the hooks' events: a half-set key is a startup error
|
|
145
|
+
if (config.approveLands && !auth.enabled) throw new Error('The config says proposals.approveLands, but sign-in is not set up. An approval is a signed-in maintainer\'s, and it lands as them, so without sign-in nothing would ever land: set up sign-in (docs/sign-in.md), or remove approveLands.');
|
|
142
146
|
const root = config.root;
|
|
143
147
|
const importMap = importMapOf(config);
|
|
144
148
|
const csp = cspOf(importMap);
|
|
@@ -146,6 +150,14 @@ export function createCornfieldServer(config, opts = {}) {
|
|
|
146
150
|
const publicHost = config.publicUrl ? new URL(config.publicUrl).hostname : null;
|
|
147
151
|
const where = { root, proposals: config.proposals, worktrees: config.worktrees, topology: config.topology, dataDir: config.dataDir, publicHost };
|
|
148
152
|
const fixtureCache = new Map();
|
|
153
|
+
const lander = config.approveLands ? createLander({ config, where, auth, hooks, now: opts.now, timer: opts.landTimer }) : null;
|
|
154
|
+
if (lander) {
|
|
155
|
+
// What a restart left waiting: looked at again, in the background
|
|
156
|
+
lander.resume().catch((err) => console.error(`[cornfield] pending landings could not be picked up: ${err instanceof Error ? err.message : err}`));
|
|
157
|
+
}
|
|
158
|
+
// Looks at the open spec PRs on a timer when a hook wants spec.ready (config `hookPoll`); started when the server listens
|
|
159
|
+
const pollMs = hooks ? specPollMs(config) : null;
|
|
160
|
+
const specPoll = pollMs ? createSpecPoll({ config, where, auth, hooks, intervalMs: pollMs, now: opts.now, timer: opts.specPollTimer }) : null;
|
|
149
161
|
const proxies = Object.entries(config.proxy || {});
|
|
150
162
|
const houseSource = config.sources.house && (config.sources.house === 'house' || config.sources.house.use === 'house') ? config.sources.house : null;
|
|
151
163
|
const houseFile = houseSource && houseSource.options && houseSource.options.file ? inside(root, houseSource.options.file) : null;
|
|
@@ -183,7 +195,8 @@ export function createCornfieldServer(config, opts = {}) {
|
|
|
183
195
|
else res.end();
|
|
184
196
|
}
|
|
185
197
|
});
|
|
186
|
-
server.on('
|
|
198
|
+
server.on('listening', () => specPoll?.start());
|
|
199
|
+
server.on('close', () => { specPoll?.close(); lander?.close(); hooks?.close(); auth.close(); });
|
|
187
200
|
return server;
|
|
188
201
|
|
|
189
202
|
async function answer(req, res) {
|
|
@@ -219,18 +232,18 @@ export function createCornfieldServer(config, opts = {}) {
|
|
|
219
232
|
return sendJson(res, 200, { ok: true });
|
|
220
233
|
}
|
|
221
234
|
// The review API takes a token, not a session: it is the one route before the guard (and it reads no cookie)
|
|
222
|
-
if (reqPath === REVIEWS_PATH) return serveReviewCall(req, res, { config, where, auth, hooks, now: opts.now });
|
|
235
|
+
if (reqPath === REVIEWS_PATH) return serveReviewCall(req, res, { config, where, auth, hooks, lander, now: opts.now });
|
|
223
236
|
const gate = await auth.gate(req, res, reqPath, url);
|
|
224
237
|
if (gate.done) return;
|
|
225
238
|
const signedIn = gate.session ? { login: gate.session.login } : false;
|
|
226
239
|
|
|
227
240
|
// Before the proxies: a repo's `/api/` proxy must not swallow the panel's own routes
|
|
228
241
|
if (await serveTokenRoutes(req, res, reqPath, { config, where, auth, session: gate.session, now: opts.now, publicHost })) return;
|
|
229
|
-
if (await serveForge(req, res, reqPath, url, { config, where, session: gate.session, auth, hooks, now: opts.now, publicHost })) return;
|
|
242
|
+
if (await serveForge(req, res, reqPath, url, { config, where, session: gate.session, auth, hooks, lander, now: opts.now, publicHost })) return;
|
|
230
243
|
for (const [prefix, target] of proxies) if (reqPath.startsWith(prefix)) return proxyTo(req, res, target);
|
|
231
244
|
const session = gate.session;
|
|
232
245
|
const forgeFor = session ? () => auth.forgeFor(session) : null;
|
|
233
|
-
const reviewer = session ? { login: session.login, userId: session.userId, repo: config.forge.repo, record: (entry) => auth.record(entry), limits: { auth, now: opts.now }, hooks } : null;
|
|
246
|
+
const reviewer = session ? { login: session.login, userId: session.userId, repo: config.forge.repo, record: (entry) => auth.record(entry), limits: { auth, now: opts.now }, hooks, lander } : null;
|
|
234
247
|
if (serveProposals(req, res, reqPath, url, where, forgeFor, reviewer)) return;
|
|
235
248
|
|
|
236
249
|
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module server/spec-poll
|
|
3
|
+
*
|
|
4
|
+
* `spec.ready` without anyone at the dashboard (PIPE-10, docs/config.md#hook-poll). cornfield notices a spec PR when it
|
|
5
|
+
* lists the open ones, and that listing used to run only when a maintainer opened the proposals menu or the issues and PRs
|
|
6
|
+
* panel. A team that reviews from chat never does, so this lists them on a timer.
|
|
7
|
+
*
|
|
8
|
+
* **It is the same look, not a second one.** Each tick calls `listPullProposals` (server/proposal-pulls.js) with the hooks,
|
|
9
|
+
* exactly as the dashboard does, so the draft rule, the silent first look, the once-per-(PR, head, proposal) rule and the
|
|
10
|
+
* `hook_seen` table are the ones that already exist: a poll and a dashboard look never announce the same head twice, and
|
|
11
|
+
* nothing here decides what is news. The forge answers come through the same per-head cache, so a tick with nothing new
|
|
12
|
+
* costs the open-PR listing (a call a page) plus the two calls that check the token's owner; see the docs for the count.
|
|
13
|
+
*
|
|
14
|
+
* **Whose token.** Never one from the config. The newest live sign-in of a user the forge confirms is a maintainer now
|
|
15
|
+
* (`auth.forgeForUser`, then `me()` and `permission()` with that token; the same check the review API and the lander make).
|
|
16
|
+
* A user who is not, or whose token the forge refuses, is passed over for the next newest, at most `MAX_CANDIDATES`; their
|
|
17
|
+
* token is only ever asked those two questions, never used to list. With no such session the tick does nothing and says so
|
|
18
|
+
* once for the stretch (and once more when a session is back). Whose sign-in it looks with is logged each time that changes.
|
|
19
|
+
*
|
|
20
|
+
* **Never in the way.** One tick runs at a time (the next is scheduled when one ends, so a slow forge cannot pile them up).
|
|
21
|
+
* A forge error is logged and never thrown; the next tick waits twice the interval until one succeeds. The timer is
|
|
22
|
+
* unref'd and `close()` clears it.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { isMaintainer } from './forge.js';
|
|
26
|
+
import { listPullProposals } from './proposal-pulls.js';
|
|
27
|
+
|
|
28
|
+
/** How long after start the first look is made: soon, so the repo's silent first look is not left to the first interval */
|
|
29
|
+
export const FIRST_MS = 15_000;
|
|
30
|
+
/** The most signed-in users tried per tick before giving up for this tick */
|
|
31
|
+
export const MAX_CANDIDATES = 5;
|
|
32
|
+
|
|
33
|
+
const iso = (ms) => new Date(ms).toISOString();
|
|
34
|
+
const words = (err) => (err instanceof Error ? err.message : String(err));
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @param {{
|
|
38
|
+
* config: any,
|
|
39
|
+
* where: { proposals: string },
|
|
40
|
+
* auth: { store: () => Promise<import('node:sqlite').DatabaseSync>, forgeForUser: (userId: string) => Promise<import('./forge.js').Forge> },
|
|
41
|
+
* hooks: import('./hooks.js').Hooks,
|
|
42
|
+
* intervalMs: number,
|
|
43
|
+
* now?: () => number,
|
|
44
|
+
* timer?: { setTimeout: Function, clearTimeout: Function },
|
|
45
|
+
* firstMs?: number,
|
|
46
|
+
* }} opts `now`, `timer` and `firstMs` are the tests' clock
|
|
47
|
+
*/
|
|
48
|
+
export function createSpecPoll({ config, where, auth, hooks, intervalMs, now = Date.now, timer = { setTimeout, clearTimeout }, firstMs = FIRST_MS }) {
|
|
49
|
+
const repo = config.forge.repo;
|
|
50
|
+
const log = (what) => console.error(`[cornfield] spec.ready poll: ${what}`);
|
|
51
|
+
let handle = null;
|
|
52
|
+
let closed = false;
|
|
53
|
+
let running = null;
|
|
54
|
+
let failing = false;
|
|
55
|
+
/** Whether the last tick found no maintainer to look as; what makes the log once per stretch */
|
|
56
|
+
let noSession = false;
|
|
57
|
+
/** Whose token the last look used: the log names them each time that changes, so whose sign-in is read with is on record */
|
|
58
|
+
let lookingAs = null;
|
|
59
|
+
|
|
60
|
+
/** The forge as the newest live sign-in of a user who is a maintainer now, and their login, or null when there is none */
|
|
61
|
+
async function maintainerForge() {
|
|
62
|
+
const rows = /** @type {{ user_id: string }[]} */ ((await auth.store()).prepare("SELECT user_id FROM sessions WHERE token <> '' AND expires_at > ? ORDER BY created_at DESC, rowid DESC").all(iso(now())));
|
|
63
|
+
const users = [...new Set(rows.map((r) => r.user_id))].slice(0, MAX_CANDIDATES);
|
|
64
|
+
for (const userId of users) {
|
|
65
|
+
try {
|
|
66
|
+
const forge = await auth.forgeForUser(userId);
|
|
67
|
+
const me = await forge.me();
|
|
68
|
+
if (String(me.id) === userId && isMaintainer(await forge.permission(repo, me.login))) return { forge, login: me.login };
|
|
69
|
+
} catch { /* this user's token is gone or refused: the next one */ }
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** One look. Resolves to what happened; never rejects. */
|
|
75
|
+
async function look() {
|
|
76
|
+
let found;
|
|
77
|
+
try {
|
|
78
|
+
found = await maintainerForge();
|
|
79
|
+
} catch (err) {
|
|
80
|
+
log(`the sign-ins could not be read: ${words(err)}`);
|
|
81
|
+
return 'error';
|
|
82
|
+
}
|
|
83
|
+
// Closed while the sign-ins were checked: the open PRs are not listed with a token after close()
|
|
84
|
+
if (closed) return 'closed';
|
|
85
|
+
if (!found) {
|
|
86
|
+
lookingAs = null;
|
|
87
|
+
if (!noSession) log('no signed-in maintainer to look as, so the open spec PRs are not checked; it resumes when one signs in');
|
|
88
|
+
noSession = true;
|
|
89
|
+
return 'no-session';
|
|
90
|
+
}
|
|
91
|
+
if (noSession) log('a signed-in maintainer is back; checking the open spec PRs again');
|
|
92
|
+
noSession = false;
|
|
93
|
+
if (found.login !== lookingAs) log(`looking at the open spec PRs with the sign-in of @${found.login}, a maintainer`);
|
|
94
|
+
lookingAs = found.login;
|
|
95
|
+
try {
|
|
96
|
+
const { problem } = await listPullProposals(found.forge, where, { hooks });
|
|
97
|
+
if (problem) {
|
|
98
|
+
log(`the forge could not be asked: ${problem}`);
|
|
99
|
+
return 'error';
|
|
100
|
+
}
|
|
101
|
+
return 'ok';
|
|
102
|
+
} catch (err) {
|
|
103
|
+
log(`failed: ${words(err)}`);
|
|
104
|
+
return 'error';
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function schedule(ms) {
|
|
109
|
+
if (closed) return;
|
|
110
|
+
handle = timer.setTimeout(() => { handle = null; tick(); }, ms);
|
|
111
|
+
if (handle && typeof handle.unref === 'function') handle.unref();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* One tick: a look, unless one is still running, then the next is scheduled (twice the interval after a failure, until
|
|
116
|
+
* one succeeds).
|
|
117
|
+
* @returns {Promise<'ok' | 'error' | 'no-session' | 'busy' | 'closed'>}
|
|
118
|
+
*/
|
|
119
|
+
function tick() {
|
|
120
|
+
if (closed) return Promise.resolve('closed');
|
|
121
|
+
if (running) return Promise.resolve('busy');
|
|
122
|
+
running = look().then((out) => {
|
|
123
|
+
if (out === 'closed') { running = null; return out; }
|
|
124
|
+
if (out !== 'no-session') failing = out === 'error';
|
|
125
|
+
running = null;
|
|
126
|
+
schedule(failing ? intervalMs * 2 : intervalMs);
|
|
127
|
+
return out;
|
|
128
|
+
});
|
|
129
|
+
return running;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
intervalMs,
|
|
134
|
+
tick,
|
|
135
|
+
/** Starts the timer: the first look after `firstMs`, then every interval */
|
|
136
|
+
start() {
|
|
137
|
+
if (closed || handle || running) return;
|
|
138
|
+
schedule(firstMs);
|
|
139
|
+
},
|
|
140
|
+
close() {
|
|
141
|
+
closed = true;
|
|
142
|
+
if (handle !== null) timer.clearTimeout(handle);
|
|
143
|
+
handle = null;
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
}
|
package/server/submit-review.js
CHANGED
|
@@ -28,7 +28,10 @@
|
|
|
28
28
|
* only its id and label.
|
|
29
29
|
*
|
|
30
30
|
* `submitReview` is the one place a review has been posted, so the event hooks' `spec.approved` and `spec.sentBack`
|
|
31
|
-
* (server/hooks.js) are emitted here, after `postReview`, not in either caller.
|
|
31
|
+
* (server/hooks.js) are emitted here, after `postReview`, not in either caller. So is landing on approval
|
|
32
|
+
* (`proposals.approveLands`, server/lander.js): after an approval is posted, and never for a send-back, `lander.start` is
|
|
33
|
+
* begun in the background with the head that was approved. It is not awaited and cannot fail the review: the review is
|
|
34
|
+
* already on the PR, and what goes wrong in landing is the lander's to say on the PR.
|
|
32
35
|
*/
|
|
33
36
|
|
|
34
37
|
import { ForgeError, isMaintainer } from './forge.js';
|
|
@@ -52,6 +55,7 @@ const MAX_PULL = 999_999_999;
|
|
|
52
55
|
* @property {{ auth: object, now?: () => number }} limits what `server/throttle.js` counts by (one `auth` per server)
|
|
53
56
|
* @property {import('./reviews.js').ApiVia} [via] set when the review came through the API: the token it came with
|
|
54
57
|
* @property {import('./hooks.js').Hooks | null} [hooks] the event hooks (server/hooks.js): `spec.approved` or `spec.sentBack` after a post
|
|
58
|
+
* @property {import('./lander.js').Lander | null} [lander] landing on approval (server/lander.js), set only when the config says `proposals.approveLands`
|
|
55
59
|
* @property {boolean} [requireSha256] the caller must name the version it read (the API does; the button reads the head itself)
|
|
56
60
|
* @property {() => string | null | Promise<string | null>} [stillAllowed] asked once more just before the post, after every
|
|
57
61
|
* forge call: null to go on, or the words for why not (the API's token was revoked or expired meanwhile), refused 403
|
|
@@ -129,6 +133,12 @@ export async function submitReview(ctx, { pull, slug, review, sha256 = null, pro
|
|
|
129
133
|
// The one place a review is posted, so the button and the API fire the same event; the actor is who the forge says
|
|
130
134
|
// the poster is (with the API, the token's owner), never a name the caller sent
|
|
131
135
|
ctx.hooks?.emit(posted.verdict === 'approved' ? 'spec.approved' : 'spec.sentBack', () => proposalPayload(forge, ctx.where, entry, { actor: me.login, note: checkedReview.note, edits: checkedReview.edits }));
|
|
136
|
+
if (posted.verdict === 'approved' && ctx.lander) {
|
|
137
|
+
const { lander } = ctx;
|
|
138
|
+
Promise.resolve()
|
|
139
|
+
.then(() => lander.start({ pull, slug, sha: entry.sha, who: { userId: who.userId, login: me.login } }))
|
|
140
|
+
.catch((err) => console.error(`[cornfield] landing ${target} on approval could not begin: ${err instanceof Error ? err.message : err}`));
|
|
141
|
+
}
|
|
132
142
|
return posted;
|
|
133
143
|
} catch (err) {
|
|
134
144
|
if (!(err instanceof Refused) && !(err instanceof ForgeError)) console.error(`[cornfield] the review of ${target}: ${err instanceof Error ? err.stack || err.message : err}`);
|