cornfield 0.6.0 → 0.8.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.
@@ -211,6 +211,7 @@
211
211
  <div class="hx-forge-tabs" role="group" aria-label="Show">
212
212
  <button type="button" class="hx-forge-tab" data-forge-kind="pulls" aria-pressed="true">Pull requests</button>
213
213
  <button type="button" class="hx-forge-tab" data-forge-kind="issues" aria-pressed="false">Issues</button>
214
+ <button type="button" class="hx-forge-tab" data-forge-kind="threads" aria-pressed="false" title="Ideas and spec conversations, with the nodes they mention">Threads</button>
214
215
  <button type="button" class="hx-forge-tab" data-forge-kind="deploys" aria-pressed="false">Deploys</button>
215
216
  <button type="button" class="hx-forge-tab" data-forge-kind="work" aria-pressed="false" title="Work grouped by issue and proposal, agent-made work marked">Agents</button>
216
217
  <button type="button" class="hx-forge-tab" data-forge-kind="notes" aria-pressed="false">Release notes</button>
@@ -228,6 +229,23 @@
228
229
  </div>
229
230
  <p id="forge-notice" class="hx-forge-notice" hidden></p>
230
231
  <p id="forge-act-note" class="hx-forge-notice" hidden></p>
232
+ <!-- New idea (forge-panel.js): opens an issue with the threads label as you, on the forge; the thread itself lives there -->
233
+ <section id="forge-idea" class="hx-forge-notes" aria-label="New idea" hidden>
234
+ <div class="hx-forge-actions">
235
+ <button type="button" id="idea-open" class="hx-btn hx-btn--approve" data-action="idea-open" aria-expanded="false" aria-controls="idea-form">New idea …</button>
236
+ </div>
237
+ <p id="idea-message" class="hx-forge-msg" role="status" hidden></p>
238
+ <div id="idea-form" class="hx-forge-confirm" role="group" aria-labelledby="idea-form-title" hidden>
239
+ <h3 id="idea-form-title" class="hx-forge-confirm-title">New idea</h3>
240
+ <p id="idea-words"></p>
241
+ <label class="hx-forge-field">Title<input type="text" id="idea-title" class="hx-forge-input" maxlength="256" autocomplete="off" /></label>
242
+ <label class="hx-forge-field">What is the idea?<textarea id="idea-body" class="hx-forge-preview hx-thread-compose" rows="6" maxlength="20000" spellcheck="true"></textarea></label>
243
+ <div class="hx-forge-actions">
244
+ <button type="button" id="idea-send" class="hx-btn hx-btn--approve" data-action="idea-send">Open the idea</button>
245
+ <button type="button" class="hx-btn hx-btn--quiet" data-action="idea-cancel">Cancel</button>
246
+ </div>
247
+ </div>
248
+ </section>
231
249
  <!-- Release notes (forge-panel.js): the pull requests merged since a tag, grouped by the map; copy them, or have the forge keep them as a draft release -->
232
250
  <section id="forge-notes" class="hx-forge-notes" aria-label="Release notes" hidden>
233
251
  <label class="hx-forge-field">Since tag<select id="notes-since" class="hx-forge-select"></select></label>
@@ -125,6 +125,26 @@ function dataSummary(node) {
125
125
  return '';
126
126
  }
127
127
 
128
+ // ---------------------------------------------------------------------------
129
+ // Threads that mention the node
130
+ // ---------------------------------------------------------------------------
131
+
132
+ /** "Mentioned in": the threads whose text names the node (an idea, or a spec PR's conversation); each opens in the issues and PRs panel */
133
+ function threadsSection(node) {
134
+ const index = state.threadIndex;
135
+ const threads = index ? index.for(node.id) : null;
136
+ if (!threads || !threads.length) return '';
137
+ const rows = threads.map((t) => `<div class="hx-line"><span class="hx-glyph" style="--glyph:#fbbf24">◆</span><span><button type="button" class="hx-btn hx-thread-open" data-thread-open="${t.number}" aria-label="Open thread #${t.number}, ${escHtml(t.title)}, in the issues and pull requests panel">#${t.number} ${escHtml(t.title)}</button> <span class="hx-faint">${t.kind === 'proposal' ? 'proposal' : 'idea'}</span></span></div>`);
138
+ return section(`Mentioned in ${threads.length} ${threads.length === 1 ? 'thread' : 'threads'}:`, rows);
139
+ }
140
+
141
+ /** Draws "Mentioned in" again in the open panel (the list was just read, or the map changed); nothing else in the panel moves */
142
+ export function repaintThreads() {
143
+ const box = $('drawer-threads');
144
+ if (!box || !state.selectedNode) return;
145
+ box.innerHTML = threadsSection(state.selectedNode);
146
+ }
147
+
128
148
  // ---------------------------------------------------------------------------
129
149
  // The drawer
130
150
  // ---------------------------------------------------------------------------
@@ -179,6 +199,10 @@ export async function selectNode(node) {
179
199
  const mockups = nodeMockupsHtml(node.id);
180
200
  if (mockups) (proposed ? metaList.firstElementChild : metaList).insertAdjacentHTML(proposed ? 'afterend' : 'afterbegin', mockups);
181
201
 
202
+ // The threads that mention it (docs/threads.md): filled in place once the list is read
203
+ metaList.insertAdjacentHTML('beforeend', `<div id="drawer-threads">${threadsSection(node)}</div>`);
204
+ if (state.threadIndex) state.threadIndex.ensure();
205
+
182
206
  // What the live layer knows of it (spec 0029): a count, a linked machine's item, contents and sets; repainted in place by live.js
183
207
  metaList.insertAdjacentHTML('beforeend', `<div id="drawer-live">${liveSection(node)}</div>`);
184
208
  hydrateLivePhotos($('drawer-live'));
package/explorer/state.js CHANGED
@@ -119,6 +119,13 @@ export const state = {
119
119
  */
120
120
  forgeLit: null,
121
121
 
122
+ /**
123
+ * Which threads mention which nodes (forge-panel.js, docs/threads.md), for a node's panel: `{ for(nodeId) -> thread[] | null,
124
+ * ensure(), open(number) }`, or null when threads are off or there is no forge. `for` answers null until the list has been
125
+ * read; `ensure` reads it (once) and repaints the panel's "Mentioned in"; `open` shows a thread in the issues and PRs panel.
126
+ */
127
+ threadIndex: null,
128
+
122
129
  /**
123
130
  * Changes whenever anything the scene is built from changes (scene.js). render.js rebuilds the
124
131
  * scene only when this moved: never for a camera change or a packet frame.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cornfield",
3
- "version": "0.6.0",
3
+ "version": "0.8.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)",
@@ -31,6 +31,7 @@
31
31
  "./plugins/*": "./plugins/*",
32
32
  "./deriver-kit": "./core/deriver-kit.js",
33
33
  "./server": "./server/server.js",
34
+ "./agent-contract.schema.json": "./core/agent-contract.schema.json",
34
35
  "./package.json": "./package.json"
35
36
  },
36
37
  "files": [
package/server/db.js CHANGED
@@ -12,6 +12,9 @@
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
14
  * landings the spec PRs an approval is waiting to land (`proposals.approveLands`), so a restart picks them up (server/lander.js)
15
+ * thread_seen the (repo, issue, comment) triples whose thread.* hook event was sent, and each thread's last-seen time, so a
16
+ * restart sends none twice and a look asks the forge only for what is new (server/hooks.js, server/threads.js);
17
+ * a row with `issue = 0` says the repo's threads have been looked at once
15
18
  * sessions also keep a forge refresh token, the access token's expiry and when `session.expiring` was sent (migration 5)
16
19
  *
17
20
  * The schema is a list of numbered migrations (`MIGRATIONS`); the file's `PRAGMA user_version` is the number of
@@ -133,6 +136,18 @@ export const MIGRATIONS = Object.freeze([
133
136
  ALTER TABLE sessions ADD COLUMN token_expires_at TEXT; -- when the access token stops working; NULL when it does not expire
134
137
  ALTER TABLE sessions ADD COLUMN expiring_sent_at TEXT; -- when the \`session.expiring\` hook event was sent; NULL before
135
138
  `,
139
+ // 6: threads (server/threads.js, server/hooks.js): which comments of a thread have been announced. The thread itself is the
140
+ // forge's (an issue's comments, or a spec PR's conversation); this only remembers what was told, never what was said
141
+ `
142
+ CREATE TABLE thread_seen (
143
+ repo TEXT NOT NULL, -- 'owner/name'
144
+ issue INTEGER NOT NULL, -- the issue or pull request number of the thread; 0 with comment '' says the repo has been looked at once
145
+ comment TEXT NOT NULL, -- '' the thread itself (opened); a comment's id (message); 'link:<id>' or 'link:body' (linked); 'pr:<n>' a spec PR that names the thread
146
+ stamp TEXT NOT NULL DEFAULT '', -- the forge's own time: the comment's last edit, or (on the '' row) the thread's last update when it was looked at
147
+ at TEXT NOT NULL, -- when cornfield first saw it
148
+ PRIMARY KEY (repo, issue, comment)
149
+ ) STRICT;
150
+ `,
136
151
  ]);
137
152
 
138
153
  /** The version a file is at: its `user_version` */
package/server/defuse.js CHANGED
@@ -1,16 +1,7 @@
1
1
  /**
2
2
  * @module server/defuse
3
3
  *
4
- * Breaking the mentions and references in text cornfield posts on the forge for someone else: `@user`, `@org/team`,
5
- * `#1`, `owner/repo#1`, `GH-1`, and the links the forge would make of `https://…` or `www.…`. A zero-width space after
6
- * the character keeps the text as it reads and stops it notifying anyone or linking anything. The pipeline's issues
7
- * (server/pipeline.js) and a review that came through the review API (server/reviews.js) use it.
4
+ * Moved to core/defuse.js, so core/ (core/agent-contract.js) can use it without importing server code. Re-exported here so
5
+ * the server's imports keep working.
8
6
  */
9
-
10
- /** A zero-width space: it ends a mention or a reference without showing */
11
- export const ZW = '​';
12
-
13
- /** Text with every mention, reference and auto-link broken (see the module's head) */
14
- export function defuse(text) {
15
- return String(text).replace(/[@#]/g, (c) => `${c}${ZW}`).replace(/\b(GH)-(?=\d)/gi, `$1${ZW}-`).replace(/:\/\//g, `:${ZW}//`).replace(/\b(www)\./gi, `$1${ZW}.`);
16
- }
7
+ export { ZW, defuse } from '../core/defuse.js';
@@ -37,11 +37,17 @@
37
37
  * (server/db.js), begun before anything is asked of the forge and finished with the answer after (`auth.begin`,
38
38
  * `auth.finish`). Without an audit to write to nothing is done.
39
39
  *
40
+ * One more write is not about a pull request: **a new idea** (docs/threads.md), `POST /api/forge/threads { title, body }`, which opens an issue
41
+ * with the threads label as the signed-in maintainer (`serveIdea`, below). It goes through the same doors, in the same order:
42
+ * POST, signed in, the flood ceiling, an audit row begun, same origin, JSON (here up to 32 KB), not twice, the permission asked of
43
+ * the forge now. The row keeps the title and the text's length, never the text. It only adds: an issue, and on Forgejo the
44
+ * threads label first when the repo lacks it (the answer, and so the row, says `labelsMade`).
45
+ *
40
46
  * A pull request lands by the config's `forge.mergeMethod` (`merge`, `squash` or `rebase`; `merge` when unset), the way
41
47
  * the repo lands its pull requests; the forge refuses a method the repo does not allow, in its own words.
42
48
  */
43
49
 
44
- import { ForgeError, RERUN_STATES, canRerun, isMaintainer } from './forge.js';
50
+ import { ForgeError, RERUN_STATES, canRerun, isMaintainer, hasAdapter } from './forge.js';
45
51
  import { sameOrigin } from './proposals.js';
46
52
  import { planSpec, planBuild, startSpec, openBuildIssue } from './pipeline.js';
47
53
  import { PROPOSAL_FILE } from './files.js';
@@ -50,19 +56,22 @@ import { Refused, throttle, countAttempt } from './throttle.js';
50
56
  // The limits live in server/throttle.js (reviews share them); these are re-exported for the callers that always had them here
51
57
  export { COOLDOWN_MS, PER_MINUTE, ATTEMPTS_PER_MINUTE, countAttempt } from './throttle.js';
52
58
  import { proposalPayload, landedPayload } from './hooks.js';
59
+ import { ideaInput, createIdea, threadsOff } from './threads.js';
53
60
 
54
61
  /** A request body is a sha and a check's id */
55
62
  const MAX_BODY = 4096;
63
+ /** A new idea's request: a title and a text of up to 20,000 characters, in JSON (any of it may be escaped) */
64
+ const IDEA_BODY = 96 * 1024;
56
65
 
57
66
  const STATUS = { unauthorized: 401, forbidden: 403, 'not-found': 404, conflict: 409, invalid: 400, 'rate-limited': 429, unavailable: 502, 'not-green': 409, 'too-large': 413 };
58
67
  const SHA = /^[0-9a-f]{7,64}$/i;
59
68
  const DIGEST = /^[0-9a-f]{32}$/;
60
69
  /** What each action is called in the words of a refusal, and what it asks of the person */
61
- const DOING = { merge: 'Landing', rerun: 'Re-running', spec: 'Starting a spec', build: 'Opening the build issue' };
62
- const ONLY_MAINTAINERS = { merge: 'land pull requests or re-run checks', rerun: 'land pull requests or re-run checks', spec: 'start specs', build: 'open build issues' };
70
+ const DOING = { merge: 'Landing', rerun: 'Re-running', spec: 'Starting a spec', build: 'Opening the build issue', idea: 'Opening an idea' };
71
+ const ONLY_MAINTAINERS = { merge: 'land pull requests or re-run checks', rerun: 'land pull requests or re-run checks', spec: 'start specs', build: 'open build issues', idea: 'open ideas' };
63
72
 
64
73
  /** Reads a small JSON object body, or refuses */
65
- function readJson(req) {
74
+ function readJson(req, max = MAX_BODY) {
66
75
  return new Promise((resolve, reject) => {
67
76
  if (!/^application\/json(?:\s*;|$)/i.test(String(req.headers['content-type'] || ''))) {
68
77
  req.resume();
@@ -75,7 +84,7 @@ function readJson(req) {
75
84
  req.on('data', (c) => {
76
85
  if (over) return;
77
86
  size += c.length;
78
- if (size <= MAX_BODY) { chunks.push(c); return; }
87
+ if (size <= max) { chunks.push(c); return; }
79
88
  // Too large: answered now, and the rest is not read (the connection closes once the answer is sent)
80
89
  over = true;
81
90
  req.pause();
@@ -289,3 +298,95 @@ export async function serveAction(req, res, what, ctx, send) {
289
298
  if (closeAfter) res.on('finish', () => req.destroy());
290
299
  send(out.status, out.body, out.headers);
291
300
  }
301
+
302
+ /**
303
+ * POST /api/forge/threads { title, body }: a new idea, an issue with the threads label, opened as the signed-in maintainer. The
304
+ * caller has found the path and the method to be this; every refusal is in words and written down, as in `serveAction`.
305
+ * @param {import('node:http').IncomingMessage} req
306
+ * @param {import('node:http').ServerResponse} res
307
+ * @param {{ config: any, session: { login: string, userId: string } | null, auth: { store: null | (() => Promise<import('node:sqlite').DatabaseSync>), forgeFor: Function, begin: Function, finish: Function }, now?: () => number, publicHost?: string | null }} ctx
308
+ * @param {(status: number, body: object, headers?: object) => void} send
309
+ */
310
+ export async function serveIdea(req, res, ctx, send) {
311
+ const { config, session, auth, now = Date.now } = ctx;
312
+ if (!config.forge || !hasAdapter(config.forge.type)) {
313
+ req.resume();
314
+ return send(config.forge ? 501 : 404, { ok: false, error: config.forge ? `cornfield has no adapter for the forge type ${JSON.stringify(config.forge.type)} yet.` : 'No forge is configured: set `forge` in cornfield.config.js.', code: config.forge ? 'unavailable' : 'not-found' });
315
+ }
316
+ if (threadsOff(config)) {
317
+ req.resume();
318
+ return send(404, { ok: false, error: 'Threads are off in this config (threads: false).', code: 'not-found' });
319
+ }
320
+ if (!session || !auth.store) {
321
+ req.resume();
322
+ return send(403, { ok: false, error: `${DOING.idea} is done as the signed-in maintainer: set up sign-in first (docs/sign-in.md).`, code: 'forbidden' });
323
+ }
324
+ const flood = countAttempt(ctx, session.userId, now());
325
+ if (flood) {
326
+ req.resume();
327
+ return send(flood.status, { ok: false, error: flood.message, code: flood.code }, flood.headers);
328
+ }
329
+ const target = config.forge.repo;
330
+ /** @type {Record<string, unknown>} */
331
+ const request = { action: 'idea' };
332
+ let rowId = null;
333
+ let closeAfter = false;
334
+ let release = () => {};
335
+ /** @type {{ status: number, body: object, headers?: object, forgeStatus?: number | null }} */
336
+ let out;
337
+ try {
338
+ try {
339
+ rowId = await auth.begin({ login: session.login, action: 'idea', target, request });
340
+ } catch (err) {
341
+ console.error(`[cornfield] the audit row of idea ${target} could not be written: ${err instanceof Error ? err.message : err}`);
342
+ throw new Refused(503, 'cornfield cannot write its audit log, so it will not act on the forge.', 'unavailable');
343
+ }
344
+ if (!sameOrigin(req, ctx.publicHost ?? null)) {
345
+ req.resume();
346
+ throw new Refused(403, 'Do this from cornfield\'s own page.', 'forbidden');
347
+ }
348
+ let asked;
349
+ try {
350
+ asked = await readJson(req, IDEA_BODY);
351
+ } catch (err) {
352
+ closeAfter = Boolean(err && err.closeAfter);
353
+ throw err;
354
+ }
355
+ let idea;
356
+ try {
357
+ idea = ideaInput(asked);
358
+ } catch (err) {
359
+ throw new Refused(400, err instanceof Error ? err.message : 'That is not an idea.', 'invalid');
360
+ }
361
+ // The audit keeps what was asked for, not the words: the title, and how long the text was
362
+ request.title = idea.title;
363
+ request.bodyLength = idea.body.length;
364
+ release = throttle(ctx, session.userId, `idea|${idea.title.toLowerCase().slice(0, 80)}`, now());
365
+ const forge = auth.forgeFor(session);
366
+ await assertMaintainerNow(forge, session, config, 'idea');
367
+ const issue = await createIdea(forge, config, idea);
368
+ await forgetKept(auth, config);
369
+ // A label the forge did not have yet and that cornfield made for it (Forgejo) is part of the answer, and so of the audit row
370
+ out = { status: 201, body: { ok: true, action: 'idea', created: true, number: issue.number, issue: { number: issue.number, title: issue.title, url: issue.url }, ...(issue.labelsMade ? { labelsMade: issue.labelsMade } : {}) } };
371
+ } catch (err) {
372
+ if (err instanceof Refused) out = { status: err.status, body: { ok: false, error: err.message, code: err.code }, headers: err.headers };
373
+ else if (err instanceof ForgeError) {
374
+ const wait = err.rateLimit && err.rateLimit.retryAfter != null ? { 'Retry-After': String(Math.ceil(err.rateLimit.retryAfter)) } : {};
375
+ out = { status: STATUS[err.code] || 502, body: { ok: false, error: err.message, code: err.code }, headers: wait, forgeStatus: err.status ?? (err.code === 'unavailable' ? null : STATUS[err.code] || null) };
376
+ } else {
377
+ out = { status: 500, body: { ok: false, error: 'Something went wrong in cornfield; the log says what.', code: 'unavailable' } };
378
+ console.error(`[cornfield] idea ${target}: ${err instanceof Error ? err.stack || err.message : err}`);
379
+ }
380
+ } finally {
381
+ release();
382
+ }
383
+ if (rowId !== null) {
384
+ try {
385
+ await auth.finish(rowId, { target, request, status: 'forgeStatus' in out ? out.forgeStatus ?? null : out.status, answer: out.body });
386
+ } catch (err) {
387
+ console.error(`[cornfield] the audit row of idea ${target} could not be finished: ${err instanceof Error ? err.message : err}`);
388
+ }
389
+ }
390
+ if (closeAfter) res.on('finish', () => req.destroy());
391
+ send(out.status, out.body, out.headers);
392
+ }
@@ -9,7 +9,8 @@
9
9
  * - **Permission.** Forgejo has three levels, so a repo's people are `admin`, `write` or `read` and never
10
10
  * `maintain` or `triage`; its admins are cornfield's. The signed-in user's own comes from the repo itself
11
11
  * (`permissions`); anyone else's from the collaborator endpoint, which only a repo admin may ask (else 403).
12
- * - **Issues.** `type=issues` leaves the pull requests out.
12
+ * - **Issues.** `type=issues` leaves the pull requests out. A new issue's labels are given as ids (Forgejo's API takes no names),
13
+ * so each is looked up and made when the repo lacks it.
13
14
  * - **Checks.** Forgejo Actions reports each job as a commit status, so the commit's statuses are all the checks
14
15
  * there are. Its API cannot re-run one: none is `rerunnable`.
15
16
  * - **Runs.** `actions/runs` (Forgejo 11 and later, Gitea 1.24 and later), where Actions is on for the repo; an older
@@ -142,6 +143,17 @@ export function createForgejoForge(forge, { token = null, fetch, apiUrl, timeout
142
143
  return meOnce;
143
144
  };
144
145
 
146
+ /**
147
+ * The id of the repo's label called `name`, making the label (plain grey) when it is not there: `{ id, made }`. Only
148
+ * `createIssue` calls this, so a label is only ever made on a write the maintainer asked for, never on a read.
149
+ */
150
+ async function labelId(name) {
151
+ const have = (await client.list(`repos/${repo}/labels?limit=${PER_PAGE}`, { what: `the labels of ${forge.repo}` })).find((l) => typeof l.name === 'string' && l.name.toLowerCase() === name.toLowerCase());
152
+ if (have) return { id: have.id, made: false };
153
+ const { body } = await client.request('POST', `repos/${repo}/labels`, { body: { name, color: '#ededed' }, what: `the label ${name} in ${forge.repo}` });
154
+ return { id: body.id, made: true };
155
+ }
156
+
145
157
  return {
146
158
  me,
147
159
 
@@ -208,16 +220,25 @@ export function createForgejoForge(forge, { token = null, fetch, apiUrl, timeout
208
220
  return toPull((await client.request('POST', `repos/${repo}/pulls`, { body: { title: draft ? `WIP: ${title}` : title, body, head, base }, what: `a pull request in ${forge.repo}` })).body);
209
221
  },
210
222
 
211
- async createIssue({ title, body }) {
212
- return toIssue((await client.request('POST', `repos/${repo}/issues`, { body: { title, body }, what: `an issue in ${forge.repo}` })).body);
223
+ async createIssue({ title, body, labels = [] }) {
224
+ // Forgejo takes label ids, not names: each is looked up, and made when the repo lacks it; the issue says which were made
225
+ const ids = [];
226
+ const made = [];
227
+ for (const name of labels) {
228
+ const label = await labelId(name);
229
+ ids.push(label.id);
230
+ if (label.made) made.push(name);
231
+ }
232
+ const issue = toIssue((await client.request('POST', `repos/${repo}/issues`, { body: { title, body, ...(ids.length ? { labels: ids } : {}) }, what: `an issue in ${forge.repo}` })).body);
233
+ return made.length ? { ...issue, labelsMade: made } : issue;
213
234
  },
214
235
 
215
236
  async pullFiles(n) {
216
237
  return (await client.list(`repos/${repo}/pulls/${n}/files?limit=${PER_PAGE}`, { what: `the files of ${pullWhat(n)}` })).map(toFile);
217
238
  },
218
239
 
219
- async comments(n) {
220
- return (await client.list(`repos/${repo}/issues/${n}/comments?limit=${PER_PAGE}`, { what: `the comments on #${n} in ${forge.repo}` })).map(toComment);
240
+ async comments(n, { since } = {}) {
241
+ return (await client.list(`repos/${repo}/issues/${n}/comments?limit=${PER_PAGE}${since ? `&since=${enc(since)}` : ''}`, { what: `the comments on #${n} in ${forge.repo}` })).map(toComment);
221
242
  },
222
243
 
223
244
  async comment(n, body) {
@@ -221,16 +221,17 @@ export function createGitHubForge(forge, { token = null, fetch, apiUrl, timeoutM
221
221
  return toPull((await client.request('POST', `repos/${repo}/pulls`, { body: { title, body, head, base, draft }, what: `a pull request in ${forge.repo}` })).body);
222
222
  },
223
223
 
224
- async createIssue({ title, body }) {
225
- return toIssue((await client.request('POST', `repos/${repo}/issues`, { body: { title, body }, what: `an issue in ${forge.repo}` })).body);
224
+ async createIssue({ title, body, labels = [] }) {
225
+ // GitHub makes a label that is not there yet when the label's name comes with a new issue (the user pushes to the repo)
226
+ return toIssue((await client.request('POST', `repos/${repo}/issues`, { body: { title, body, ...(labels.length ? { labels } : {}) }, what: `an issue in ${forge.repo}` })).body);
226
227
  },
227
228
 
228
229
  async pullFiles(n) {
229
230
  return (await client.list(`repos/${repo}/pulls/${n}/files?per_page=${PER_PAGE}`, { what: `the files of ${pullWhat(n)}` })).map(toFile);
230
231
  },
231
232
 
232
- async comments(n) {
233
- return (await client.list(`repos/${repo}/issues/${n}/comments?per_page=${PER_PAGE}`, { what: `the comments on #${n} in ${forge.repo}` })).map(toComment);
233
+ async comments(n, { since } = {}) {
234
+ return (await client.list(`repos/${repo}/issues/${n}/comments?per_page=${PER_PAGE}${since ? `&since=${enc(since)}` : ''}`, { what: `the comments on #${n} in ${forge.repo}` })).map(toComment);
234
235
  },
235
236
 
236
237
  async comment(n, body) {
@@ -19,6 +19,10 @@
19
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
20
20
  * GET /api/forge/pulls/<n>/build?slug=<proposal> the plan of step 2: the build issue of an approved proposal, never kept
21
21
  * POST /api/forge/issues/<n>/spec | /pulls/<n>/build do them, with the plan's digest (server/forge-actions.js)
22
+ * GET /api/forge/threads?state=open|closed|all&limit=100 the threads (docs/threads.md): ideas (issues with the threads label) and spec PRs' conversations,
23
+ * newest activity first, each with the `[[mentions]]` its text holds, as written (404 when `threads: false`)
24
+ * GET /api/forge/threads/<n> one thread's messages, as written, to read; replying is on the forge
25
+ * POST /api/forge/threads { title, body }: a new idea, an issue with the label, as the maintainer (server/forge-actions.js `serveIdea`)
22
26
  * GET /api/forge/work what the agents view groups: pull requests and issues (any state), branches, open spec PRs, and the config's `agents` rules
23
27
  * GET /api/forge/deploys?limit=100 the latest workflow runs that are deploys, each with what the
24
28
  * config says it restarted (server/deploys.js, docs/deploys.md)
@@ -47,7 +51,9 @@ import fs from 'node:fs';
47
51
  import path from 'node:path';
48
52
  import { createHash } from 'node:crypto';
49
53
  import { ForgeError, canRerun, hasAdapter, isMaintainer, isTagName, rateLimitWords } from './forge.js';
50
- import { actionOf, countAttempt, serveAction } from './forge-actions.js';
54
+ import { actionOf, countAttempt, serveAction, serveIdea } from './forge-actions.js';
55
+ import { listThreads, readThread, snapshotOf, threadsOff } from './threads.js';
56
+ import { ideaLabel } from '../core/config.js';
51
57
  import { landingOf, serveLanding } from './lander.js';
52
58
  import { planSpec, planBuild, pullSpecs } from './pipeline.js';
53
59
  import { listPullProposals } from './proposal-pulls.js';
@@ -133,6 +139,28 @@ function routeOf(reqPath, url, config, where, hooks = null) {
133
139
  const mapping = createHash('sha256').update(JSON.stringify(config.deploys || null)).digest('hex').slice(0, 12);
134
140
  return { key: `deploys?limit=${limit}&config=${mapping}`, load: async (forge) => ({ items: deploysOf(await forge.runs({ limit }), config.deploys) }) };
135
141
  }
142
+ if (reqPath === '/api/forge/threads' || /^\/api\/forge\/threads\/[1-9]\d{0,8}$/.test(reqPath)) {
143
+ if (threadsOff(config)) throw new ForgeError('Threads are off in this config (threads: false).', { code: 'not-found' });
144
+ const label = ideaLabel(config);
145
+ if (reqPath === '/api/forge/threads') {
146
+ const q = listQuery(url, false);
147
+ return {
148
+ key: `threads?state=${q.state}&limit=${q.limit}&label=${label}`,
149
+ load: async (forge) => {
150
+ const found = await listThreads(forge, config, where || config, { state: q.state, limit: q.limit, hooks });
151
+ // Whatever a look reads is news to the hooks once (thread.opened, thread.message, thread.linked), as the spec PRs are
152
+ if (hooks) {
153
+ let nodes = [];
154
+ try { nodes = readTopology(config)?.nodes || []; } catch { nodes = []; }
155
+ hooks.threadsSeen(forge.repo, snapshotOf(found.gathered, nodes)).catch(() => {});
156
+ }
157
+ return { items: found.items, label, problem: found.problem };
158
+ },
159
+ };
160
+ }
161
+ const n = Number(reqPath.split('/').pop());
162
+ return { key: `threads/${n}&label=${label}`, load: async (forge) => ({ thread: await readThread(forge, config, where || config, n) }) };
163
+ }
136
164
  if (reqPath === '/api/forge/work') {
137
165
  return {
138
166
  key: 'work',
@@ -236,6 +264,10 @@ export async function serveForge(req, res, reqPath, url, ctx) {
236
264
  send(req, res, out.status, out.body, out.headers);
237
265
  return true;
238
266
  }
267
+ if (reqPath === '/api/forge/threads' && req.method === 'POST') {
268
+ await serveIdea(req, res, ctx, (status, body, headers) => send(req, res, status, body, headers));
269
+ return true;
270
+ }
239
271
  const act = actionOf(reqPath);
240
272
  if (act) {
241
273
  if (!config.forge || !hasAdapter(config.forge.type)) {
package/server/forge.js CHANGED
@@ -14,7 +14,8 @@
14
14
  * issue(n) one issue (not a pull request: that is `not-found`)
15
15
  * searchIssues(words) the issues whose text holds the words (one page; the forge's search, which can lag a moment)
16
16
  * pullFiles(n) the files a pull request changes
17
- * comments(n) the comments on an issue or pull request, oldest first
17
+ * comments(n, { since }) the comments on an issue or pull request, oldest first; `since` (an ISO time) only those
18
+ * created or edited after it, which is how a thread is followed without reading it all again
18
19
  * comment(n, body) posts one, and answers it
19
20
  * checks(ref) the checks on a commit (a branch, a tag or a sha)
20
21
  * rerun(check) re-runs one that can be (`check.rerunnable`)
@@ -36,7 +37,9 @@
36
37
  * createBranch(name, { from }) a branch at the head of the branch `from` (`conflict` when it exists)
37
38
  * createFile({ branch, path, content, message }) one new file on a branch, as one commit (`invalid` when it exists)
38
39
  * createPull({ title, body, head, base, draft }) a pull request; a draft one is `draft: true` (Forgejo: the "WIP: " title it uses)
39
- * createIssue({ title, body }) an issue
40
+ * createIssue({ title, body, labels }) an issue; `labels` (names) are put on it, a label the repo lacks is made first (an idea is
41
+ * an issue with the threads label, docs/threads.md). Where cornfield makes the label itself
42
+ * (Forgejo), the issue carries `labelsMade: [names]`; GitHub makes it on its own and says nothing
40
43
  *
41
44
  * An adapter module (server/forge-github.js, server/forge-forgejo.js) implements these at its forge's level, with
42
45
  * `mergeAt(n, { sha, method })` in place of `merge`; `wrap` checks the arguments and builds `merge` once for every
@@ -78,9 +81,9 @@
78
81
  * createBranch(name: string, opts: { from: string }): Promise<{ name: string, sha: string }>,
79
82
  * createFile(file: { branch: string, path: string, content: string, message: string }): Promise<{ sha: string }>,
80
83
  * createPull(pull: { title: string, body: string, head: string, base: string, draft: boolean }): Promise<Pull>,
81
- * createIssue(issue: { title: string, body: string }): Promise<Issue>,
84
+ * createIssue(issue: { title: string, body: string, labels?: string[] }): Promise<Issue>,
82
85
  * pullFiles(n: number): Promise<PullFile[]>,
83
- * comments(n: number): Promise<Comment[]>,
86
+ * comments(n: number, opts?: { since?: string }): Promise<Comment[]>,
84
87
  * comment(n: number, body: string): Promise<Comment>,
85
88
  * checks(ref: string): Promise<Check[]>,
86
89
  * rerun(check: Check): Promise<void>,
@@ -120,6 +123,9 @@ export const DEFAULT_LIMIT = 1000;
120
123
  /** The runs a listing returns unless asked for fewer: one forge page */
121
124
  export const DEFAULT_RUN_LIMIT = 100;
122
125
 
126
+ /** The most labels one new issue is given */
127
+ const MAX_LABELS = 5;
128
+ const ISO_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:\d{2})$/;
123
129
  const MERGE_METHODS = ['merge', 'squash', 'rebase'];
124
130
  const STATES = ['open', 'closed', 'all'];
125
131
  const REPO = /^[\w.-]+\/[\w.-]+$/;
@@ -270,7 +276,12 @@ export function wrap(a, forge) {
270
276
  return a.searchIssues(words.trim());
271
277
  },
272
278
  pullFiles: async (n) => a.pullFiles(number(n)),
273
- comments: async (n) => a.comments(number(n)),
279
+ comments: async (n, opts) => {
280
+ if (opts !== undefined && (opts === null || typeof opts !== 'object' || Array.isArray(opts))) throw invalid('The options are an object.');
281
+ const since = opts && opts.since;
282
+ if (since !== undefined && (typeof since !== 'string' || !ISO_TIME.test(since) || Number.isNaN(Date.parse(since)))) throw invalid('since is a time like 2026-11-05T09:00:00Z.');
283
+ return a.comments(number(n), since === undefined ? {} : { since: new Date(since).toISOString() });
284
+ },
274
285
  comment: async (n, body) => a.comment(number(n), text(body, 'The comment')),
275
286
  checks: async (ref) => a.checks(text(ref, 'The commit')),
276
287
  rerun: async (check) => {
@@ -339,7 +350,9 @@ export function wrap(a, forge) {
339
350
  },
340
351
  createIssue: async (issue) => {
341
352
  if (!issue || typeof issue !== 'object') throw invalid('Open which issue?');
342
- return a.createIssue({ title: bounded(issue.title, 'The title', MAX_TITLE), body: bounded(issue.body ?? ' ', 'The description', MAX_BODY) });
353
+ const labels = issue.labels ?? [];
354
+ if (!Array.isArray(labels) || labels.length > MAX_LABELS || labels.some((l) => typeof l !== 'string' || l.trim() === '' || l.trim() !== l || l.length > 50 || l.includes(',') || l.includes('\0'))) throw invalid(`The labels are at most ${MAX_LABELS} names, without commas.`);
355
+ return a.createIssue({ title: bounded(issue.title, 'The title', MAX_TITLE), body: bounded(issue.body ?? ' ', 'The description', MAX_BODY), labels: [...new Set(labels)] });
343
356
  },
344
357
  defaultBranch: async () => a.defaultBranch(),
345
358
  tags: async () => a.tags(),
package/server/hooks.js CHANGED
@@ -13,6 +13,14 @@
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
15
15
  * pr.landed a pull request was landed through cornfield (its Land it); a merge made on the forge sends nothing
16
+ * thread.opened a new idea (an issue with the threads label, docs/threads.md) was first seen
17
+ * thread.message a comment was added to a thread: an idea's, or a spec PR's conversation. Once per comment
18
+ * thread.linked a thread now points at the map: a message holds a `[[node]]` mention or a `cornfield-thread` block that
19
+ * resolves, or a spec PR names the thread with `Thread: #n`. Its body adds `nodes` and `proposal`
20
+ * Like `spec.ready`, these fire when cornfield next looks (the dashboard's threads list, or the poll,
21
+ * server/spec-poll.js); a comment's text is never in the payload: `{ event, at, thread, comment?, actor, nodes?,
22
+ * proposal?, mapUrl }`, so the receiver reads the words from the forge itself. The first look at a repo records
23
+ * what is there and sends nothing; each (repo, thread, comment) is sent once, across restarts (`thread_seen`)
16
24
  * session.expiring a maintainer's sign-in ends within a day (`watchSessions`, below). Its body is its own:
17
25
  * `{ event, at, actor, expiresAt, mapUrl }`
18
26
  *
@@ -186,6 +194,16 @@ export function createHooks({ hooks, secret, fetch: doFetch = globalThis.fetch,
186
194
  function bodyOf(event, p) {
187
195
  // A session's end has no spec, pull request or proposal: its body is only who, until when, and where cornfield is
188
196
  if (event === 'session.expiring') return JSON.stringify({ event, at: new Date(now()).toISOString(), actor: p.actor ?? null, expiresAt: p.expiresAt ?? null, mapUrl: publicUrl || null });
197
+ // A thread's event is only which thread, which comment, who and what it links to: never the words said
198
+ if (event.startsWith('thread.')) {
199
+ const out = { event, at: new Date(now()).toISOString(), thread: p.thread ?? null };
200
+ if (p.comment !== undefined) out.comment = p.comment;
201
+ out.actor = p.actor ?? null;
202
+ if (p.nodes !== undefined) out.nodes = p.nodes;
203
+ if (p.proposal !== undefined) out.proposal = p.proposal;
204
+ out.mapUrl = publicUrl || null;
205
+ return JSON.stringify(out);
206
+ }
189
207
  const out = {
190
208
  event, at: new Date(now()).toISOString(), slug: p.slug ?? null, pull: p.pull ?? null, headSha: p.headSha ?? null,
191
209
  proposalSha256: p.proposalSha256 ?? null, actor: p.actor ?? null,
@@ -261,6 +279,68 @@ export function createHooks({ hooks, secret, fetch: doFetch = globalThis.fetch,
261
279
  })().catch((err) => console.error(`[cornfield] the spec PRs could not be checked for spec.ready: ${err instanceof Error ? err.message : err}`));
262
280
  }
263
281
 
282
+ /**
283
+ * Where each thread was last looked at: for the poll to ask the forge only for what is new. `looked` is whether the repo's
284
+ * threads have been looked at before (the first look sends nothing); `threads` maps a thread's number to the forge's
285
+ * last-update time when it was looked at (`stamp`) and the time of the newest comment seen (`since`, '' for none).
286
+ * @param {string} repo
287
+ * @returns {Promise<{ looked: boolean, threads: Map<number, { stamp: string, since: string }> }>}
288
+ */
289
+ async function threadCursors(repo) {
290
+ const none = { looked: false, threads: new Map() };
291
+ if (!store) return none;
292
+ const db = await store();
293
+ const rows = db.prepare('SELECT issue, comment, stamp FROM thread_seen WHERE repo = ?').all(repo);
294
+ const threads = new Map();
295
+ for (const r of rows) {
296
+ if (r.issue === 0) continue;
297
+ const t = threads.get(r.issue) || { stamp: '', since: '' };
298
+ if (r.comment === '') t.stamp = r.stamp;
299
+ else if (!r.comment.includes(':') && r.stamp > t.since) t.since = r.stamp;
300
+ threads.set(r.issue, t);
301
+ }
302
+ return { looked: rows.some((r) => r.issue === 0 && r.comment === ''), threads };
303
+ }
304
+
305
+ /**
306
+ * `thread.opened`, `thread.message` and `thread.linked`: the threads a look just read (server/threads.js `snapshotOf`).
307
+ * Everything is recorded in `thread_seen` (repo, thread, comment) before it is announced, so a restart sends none twice,
308
+ * and the first look at a repo records what is there and announces nothing. Returns a promise that resolves once the
309
+ * events are queued; it never rejects.
310
+ * @param {string} repo 'owner/name'
311
+ * @param {{ threads: any[], pulls: { pull: number, thread: number, slug: string }[] }} snapshot
312
+ */
313
+ async function threadsSeen(repo, snapshot) {
314
+ if (closed || !store || !snapshot || !Array.isArray(snapshot.threads)) return;
315
+ try {
316
+ const db = await store();
317
+ const at = new Date(now()).toISOString();
318
+ const looked = db.prepare("SELECT 1 FROM thread_seen WHERE repo = ? AND issue = 0 AND comment = ''").get(repo) !== undefined;
319
+ const insert = db.prepare('INSERT OR IGNORE INTO thread_seen (repo, issue, comment, stamp, at) VALUES (?, ?, ?, ?, ?)');
320
+ const move = db.prepare("INSERT INTO thread_seen (repo, issue, comment, stamp, at) VALUES (?, ?, '', ?, ?) ON CONFLICT(repo, issue, comment) DO UPDATE SET stamp = excluded.stamp");
321
+ /** Whether this (thread, key) is news: new to the table, and not the silent first look */
322
+ const fresh = (thread, key, stamp = '') => insert.run(repo, thread, key, stamp, at).changes === 1 && looked;
323
+ for (const t of snapshot.threads) {
324
+ if (t.opened && fresh(t.number, '', t.stamp)) emit('thread.opened', { thread: t.number, actor: t.opened.actor });
325
+ if (t.opened && t.opened.linked && fresh(t.number, 'link:body')) {
326
+ emit('thread.linked', { thread: t.number, actor: t.opened.actor, nodes: t.opened.linked.nodes, proposal: t.opened.linked.proposal });
327
+ }
328
+ for (const m of t.messages) {
329
+ if (fresh(t.number, String(m.id), m.stamp)) emit('thread.message', { thread: t.number, comment: m.id, actor: m.actor });
330
+ if (m.linked && fresh(t.number, `link:${m.id}`)) emit('thread.linked', { thread: t.number, comment: m.id, actor: m.actor, nodes: m.linked.nodes, proposal: m.linked.proposal });
331
+ }
332
+ // The last-update time moves with every look that read the thread, so the next look can skip it when nothing changed
333
+ move.run(repo, t.number, t.stamp, at);
334
+ }
335
+ for (const p of snapshot.pulls || []) {
336
+ if (fresh(p.thread, `pr:${p.pull}`)) emit('thread.linked', { thread: p.thread, actor: null, nodes: [], proposal: p.slug });
337
+ }
338
+ if (!looked) insert.run(repo, 0, '', '', at);
339
+ } catch (err) {
340
+ console.error(`[cornfield] the threads could not be checked for thread events: ${err instanceof Error ? err.message : err}`);
341
+ }
342
+ }
343
+
264
344
  /**
265
345
  * `session.expiring`: once an hour (and once now), the live sessions of maintainers (the ones that keep a forge token)
266
346
  * that end within 24 hours are announced, each once: the row's `expiring_sent_at` is set before the event is
@@ -296,7 +376,7 @@ export function createHooks({ hooks, secret, fetch: doFetch = globalThis.fetch,
296
376
  timers.clear();
297
377
  }
298
378
 
299
- return { emit, specsSeen, watchSessions, close };
379
+ return { emit, specsSeen, threadCursors, threadsSeen, watchSessions, close };
300
380
  }
301
381
 
302
382
  /** The SHA-256 of a proposal file as `read` gives it, or null when it cannot be read (an event still goes out) */