@popcorn245/cornfield 0.4.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 +44 -0
- package/package.json +1 -1
- package/server/forge-routes.js +1 -1
- package/server/hooks.js +2 -2
- package/server/server.js +9 -4
- package/server/spec-poll.js +146 -0
package/core/config.js
CHANGED
|
@@ -23,6 +23,7 @@
|
|
|
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: {},
|
|
@@ -199,6 +228,19 @@ export function configProblems(c) {
|
|
|
199
228
|
});
|
|
200
229
|
}
|
|
201
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
|
+
}
|
|
202
244
|
if (c.releaseNotes !== undefined) {
|
|
203
245
|
if (!isObject(c.releaseNotes)) out.push('"releaseNotes" is { breakingLabel }.');
|
|
204
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".');
|
|
@@ -290,6 +332,8 @@ export async function loadConfig(root, { env = process.env, file } = {}) {
|
|
|
290
332
|
deploys: { ...DEFAULTS.deploys, ...(raw.deploys || {}) },
|
|
291
333
|
agents: { ...DEFAULTS.agents, ...(raw.agents || {}) },
|
|
292
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 || {}) };
|
|
293
337
|
// `proposals` is the folder to everything downstream, whichever way the file wrote it; the options are read from `approveLands`
|
|
294
338
|
config.approveLands = isObject(raw.proposals) && raw.proposals.approveLands === true;
|
|
295
339
|
config.proposals = (isObject(raw.proposals) ? raw.proposals.dir : raw.proposals) ?? DEFAULTS.proposals;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@popcorn245/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/forge-routes.js
CHANGED
|
@@ -138,7 +138,7 @@ function routeOf(reqPath, url, config, where, hooks = null) {
|
|
|
138
138
|
key: 'work',
|
|
139
139
|
load: async (forge) => {
|
|
140
140
|
const [pulls, issues, branches, specs] = await Promise.all([
|
|
141
|
-
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 }),
|
|
142
142
|
]);
|
|
143
143
|
return {
|
|
144
144
|
pulls: pulls.map((p) => ({ ...p, body: p.body.slice(0, WORK_BODY_MAX) })),
|
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/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';
|
|
@@ -54,6 +54,7 @@ import { assertBind, hostAllowed } from './bind.js';
|
|
|
54
54
|
import { createAuth, signInSettings } from './auth.js';
|
|
55
55
|
import { createHooks, hooksSettings } from './hooks.js';
|
|
56
56
|
import { createLander } from './lander.js';
|
|
57
|
+
import { createSpecPoll } from './spec-poll.js';
|
|
57
58
|
|
|
58
59
|
export const CORNFIELD_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
59
60
|
const EXPLORER_DIR = path.join(CORNFIELD_DIR, 'explorer');
|
|
@@ -126,10 +127,10 @@ export const HA_CACHE_MS = 30_000;
|
|
|
126
127
|
/**
|
|
127
128
|
* The server for one repo. It does not listen: `listen(config)` does, where server/bind.js allows.
|
|
128
129
|
* @param {object} config a loaded config (core/config.js `loadConfig`)
|
|
129
|
-
* @param {Parameters<typeof createAuth>[1] & { homeAssistant?: object, hookFetch?: typeof fetch, hookTimer?: { setTimeout: Function, clearTimeout: Function }, landTimer?: { 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;
|
|
130
131
|
* see server/auth.js); the Home Assistant URL and token are read from the same `env`, and `homeAssistant` is what
|
|
131
132
|
* `homeAssistantHouse` is handed besides them (the tests' fake WebSocket); `hookFetch` and `hookTimer` are the
|
|
132
|
-
* tests' fake network and clock for the event hooks (server/hooks.js); `landTimer` is the clock of landing on approval (server/lander.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)
|
|
133
134
|
* @returns {import('node:http').Server}
|
|
134
135
|
*/
|
|
135
136
|
export function createCornfieldServer(config, opts = {}) {
|
|
@@ -154,6 +155,9 @@ export function createCornfieldServer(config, opts = {}) {
|
|
|
154
155
|
// What a restart left waiting: looked at again, in the background
|
|
155
156
|
lander.resume().catch((err) => console.error(`[cornfield] pending landings could not be picked up: ${err instanceof Error ? err.message : err}`));
|
|
156
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;
|
|
157
161
|
const proxies = Object.entries(config.proxy || {});
|
|
158
162
|
const houseSource = config.sources.house && (config.sources.house === 'house' || config.sources.house.use === 'house') ? config.sources.house : null;
|
|
159
163
|
const houseFile = houseSource && houseSource.options && houseSource.options.file ? inside(root, houseSource.options.file) : null;
|
|
@@ -191,7 +195,8 @@ export function createCornfieldServer(config, opts = {}) {
|
|
|
191
195
|
else res.end();
|
|
192
196
|
}
|
|
193
197
|
});
|
|
194
|
-
server.on('
|
|
198
|
+
server.on('listening', () => specPoll?.start());
|
|
199
|
+
server.on('close', () => { specPoll?.close(); lander?.close(); hooks?.close(); auth.close(); });
|
|
195
200
|
return server;
|
|
196
201
|
|
|
197
202
|
async function answer(req, res) {
|
|
@@ -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
|
+
}
|