@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 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.4.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)",
@@ -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 next lists the PRs, so this fires when
11
- * cornfield next looks, not at the push. The first look at a repo after hooks are added announces
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('close', () => { lander?.close(); hooks?.close(); auth.close(); });
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
+ }