cornfield 0.7.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.
package/README.md CHANGED
@@ -42,6 +42,7 @@ ssh -L 3350:127.0.0.1:3350 you@the-machine
42
42
  - **Derived, not drawn by hand.** `cornfield sync` runs the derivers named in your config and merges what they find into `topology.json`; hand-written parts (descriptions, flows, anything of a type no deriver owns) are kept. `cornfield validate` fails on drift, so CI keeps the map honest.
43
43
  - **Proposals.** `docs/specs/proposals/NNNN-slug.json` says what a change would add, change and remove, with scenarios. cornfield lists them from your checkout, from every git worktree in the configured worktrees folder and, with sign-in, from your open spec PRs, and shows Before / Diff / After, the scenario player, what-if edits, and Approve / Send back. Writing, reviewing and landing one in your repo: [docs/proposals.md](docs/proposals.md). The format and its validator: [core/proposal.js](core/proposal.js).
44
44
  - **Issues and pull requests.** With a forge in the config and sign-in, a panel of its pull requests and issues (GitHub; Forgejo behind the same interface), with filters. Select a pull request and the nodes its files are light up on the map, and a failing check lights the tests it names. A Deploys list reads the forge's workflow runs and lights what a selected deploy restarted, from a mapping in the config ([docs/deploys.md](docs/deploys.md)). A maintainer can land a green pull request, re-run a failed check, start a spec on an issue and open the build issue of an approved spec, each one button with a confirm step. A bottom sheet on a phone. A **Release notes** tab drafts the notes from the pull requests merged since the last tag, grouped by what they changed on the map, to copy or to post as a draft release (never published); `cornfield notes` prints the same Markdown. [docs/issues-and-prs.md](docs/issues-and-prs.md).
45
+ - **Threads.** The panel's Threads tab shows ideas (issues labelled `idea`) and spec PRs' conversations from the forge, read-only, and lights the nodes they mention with `[[node-id]]` or `[[repo/path]]`; a node's panel lists the threads that mention it. "New idea" opens an issue as you. Hooks tell outside code when a thread opens, gets a comment or is linked to the map. Nothing is kept in cornfield. [docs/threads.md](docs/threads.md).
45
46
  - **Agents, tracked.** The panel's Agents tab groups branches, spec PRs and PRs by the issue and the proposal they belong to, and marks the work agents made (by author, bot account, label or branch prefix, set in the config). Nothing is launched. [docs/agents.md](docs/agents.md).
46
47
  - **Personas, not people.** `topology.personas.json` holds archetypes (Admin, Member, Guest); scenarios act as them, never as a real person.
47
48
  - **The cloud you do not control.** `topology.cloud.json` describes the outside services; the cloud deriver's detectors (hosts in code, Dockerfiles, container images, CI workflows, a Caddyfile) keep it complete in both directions. "Cloudflare is down" plays as an outage: what breaks now, at the next deploy, at the next certificate renewal. [docs/cloud.md](docs/cloud.md).
package/core/config.js CHANGED
@@ -23,7 +23,8 @@
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
+ * hookPoll: { specs: '5m' }, // how often cornfield looks for ready specs and thread activity on its own, for the spec.ready and thread.* hooks; false turns it off (docs/config.md#hook-poll)
27
+ * threads: { label: 'idea' }, // an idea is an issue with this label, its thread the issue's comments; false turns threads off (docs/threads.md)
27
28
  * links: [], // [{ label, href }] shown in the explorer's header
28
29
  * proxy: {}, // { '/api/': 'http://127.0.0.1:3245' }: same-origin paths for a live source
29
30
  * importMap: {}, // bare imports a repo-local browser module needs: { name: '/repo/path.js' }
@@ -66,7 +67,12 @@ export const FORGE_TYPES = ['github', 'forgejo'];
66
67
  /** How a pull request cornfield lands is merged (`forge.mergeMethod`): the way the repo lands its pull requests */
67
68
  export const MERGE_METHODS = ['merge', 'squash', 'rebase'];
68
69
  /** The events a hook may ask for (`hooks[].events`), and what each one means: docs/config.md#hooks */
69
- export const HOOK_EVENTS = Object.freeze(['spec.ready', 'spec.approved', 'spec.sentBack', 'build.opened', 'pr.landed', 'session.expiring']);
70
+ export const HOOK_EVENTS = Object.freeze(['spec.ready', 'spec.approved', 'spec.sentBack', 'build.opened', 'pr.landed', 'session.expiring', 'thread.opened', 'thread.message', 'thread.linked']);
71
+ /** The events that come from looking at threads (server/threads.js): a poll that wants any of them looks at threads too */
72
+ export const THREAD_EVENTS = Object.freeze(['thread.opened', 'thread.message', 'thread.linked']);
73
+ /** The keys of `threads` when it is an object, and the label's longest length */
74
+ export const THREADS_KEYS = Object.freeze(['label']);
75
+ export const MAX_LABEL = 50;
70
76
  /** The most hooks a config lists */
71
77
  export const MAX_HOOKS = 20;
72
78
  /** The bounds of `hookPoll.specs` in milliseconds: not under a minute (the forge is not hammered), not over an hour */
@@ -85,14 +91,26 @@ export function parseDuration(v) {
85
91
  return m ? Number(m[1]) * { s: 1000, m: 60_000, h: 3_600_000 }[m[2]] : null;
86
92
  }
87
93
 
94
+ /** Whether some hook subscribes to any of `events` (by name, or with '*') */
95
+ export function hookWants(config, events) {
96
+ return Array.isArray(config.hooks) && config.hooks.some((h) => h.events === '*' || (Array.isArray(h.events) && h.events.some((e) => events.includes(e))));
97
+ }
98
+
99
+ /** Whether threads are on: `threads: false` turns them off, anything else (the default included) leaves them on */
100
+ export const threadsOn = (config) => config.threads !== false;
101
+
102
+ /** The label that makes an issue an idea, or null when threads are off */
103
+ export const ideaLabel = (config) => (threadsOn(config) ? (config.threads && config.threads.label) || DEFAULTS.threads.label : null);
104
+
88
105
  /**
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
106
+ * How often cornfield looks by itself, in milliseconds, or null when it does not: when `hookPoll.specs` is false, or no
107
+ * hook subscribes to something the look finds (`spec.ready`, or a `thread.*` event while threads are on), by name or with '*'.
108
+ * One look covers both: the open spec PRs and the threads (server/spec-poll.js).
109
+ * @param {{ hooks?: { events: string[] | '*' }[], hookPoll?: { specs?: string | false } | false, threads?: false | { label?: string } }} config a loaded config
92
110
  * @returns {number | null}
93
111
  */
94
112
  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')));
113
+ const wanted = hookWants(config, ['spec.ready']) || (threadsOn(config) && hookWants(config, THREAD_EVENTS));
96
114
  const every = config.hookPoll === false ? false : config.hookPoll ? config.hookPoll.specs : DEFAULTS.hookPoll.specs;
97
115
  return wanted && every !== false ? parseDuration(every ?? DEFAULTS.hookPoll.specs) : null;
98
116
  }
@@ -115,6 +133,7 @@ export const DEFAULTS = Object.freeze({
115
133
  agents: { authors: [], labels: [], branchPrefixes: [], bots: true },
116
134
  hooks: [],
117
135
  hookPoll: { specs: '5m' },
136
+ threads: { label: 'idea' },
118
137
  links: [],
119
138
  proxy: {},
120
139
  importMap: {},
@@ -241,6 +260,14 @@ export function configProblems(c) {
241
260
  }
242
261
  }
243
262
  }
263
+ if (c.threads !== undefined && c.threads !== false) {
264
+ if (!isObject(c.threads)) out.push('"threads" is { label: "idea" }, or false to turn threads off.');
265
+ else {
266
+ for (const k of Object.keys(c.threads)) if (!THREADS_KEYS.includes(k)) out.push(`threads.${k}: the key is ${THREADS_KEYS.join(', ')}.`);
267
+ const l = c.threads.label;
268
+ if (l !== undefined && (!isText(l) || l.includes(',') || l.trim() !== l || l.length > MAX_LABEL)) out.push(`threads.label is the name of the label that makes an issue an idea, like "idea": text of at most ${MAX_LABEL} characters, no comma, no space at either end.`);
269
+ }
270
+ }
244
271
  if (c.releaseNotes !== undefined) {
245
272
  if (!isObject(c.releaseNotes)) out.push('"releaseNotes" is { breakingLabel }.');
246
273
  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".');
@@ -323,7 +350,7 @@ export async function loadConfig(root, { env = process.env, file } = {}) {
323
350
  if (problems.length) throw new Error(`${path.relative(absRoot, configFile) || CONFIG_FILE}:\n ${problems.join('\n ')}`);
324
351
  }
325
352
  const config = {
326
- ...structuredClone({ ...DEFAULTS, sources: { ...DEFAULTS.sources }, personas: { ...DEFAULTS.personas }, cloud: { ...DEFAULTS.cloud }, agents: { ...DEFAULTS.agents }, releaseNotes: { ...DEFAULTS.releaseNotes } }),
353
+ ...structuredClone({ ...DEFAULTS, sources: { ...DEFAULTS.sources }, personas: { ...DEFAULTS.personas }, cloud: { ...DEFAULTS.cloud }, agents: { ...DEFAULTS.agents }, releaseNotes: { ...DEFAULTS.releaseNotes }, threads: { ...DEFAULTS.threads } }),
327
354
  ...raw,
328
355
  sources: { ...DEFAULTS.sources, ...(raw.sources || {}) },
329
356
  personas: { ...DEFAULTS.personas, ...(raw.personas || {}) },
@@ -332,6 +359,8 @@ export async function loadConfig(root, { env = process.env, file } = {}) {
332
359
  deploys: { ...DEFAULTS.deploys, ...(raw.deploys || {}) },
333
360
  agents: { ...DEFAULTS.agents, ...(raw.agents || {}) },
334
361
  };
362
+ // `threads: false` stays false; an object is read over the default
363
+ config.threads = raw.threads === false ? false : { ...DEFAULTS.threads, ...(raw.threads || {}) };
335
364
  // `hookPoll: false` is `{ specs: false }`; an object is read over the default
336
365
  config.hookPoll = raw.hookPoll === false ? { specs: false } : { ...DEFAULTS.hookPoll, ...(raw.hookPoll || {}) };
337
366
  // `proposals` is the folder to everything downstream, whichever way the file wrote it; the options are read from `approveLands`
@@ -0,0 +1,239 @@
1
+ /**
2
+ * @module core/mentions
3
+ *
4
+ * How a thread points at the map (spec 0005, D7). Pure: no I/O, no DOM, no Node built-ins, so the server (a hook's
5
+ * payload) and the explorer (the lights, a node's "Mentioned in") load the same file.
6
+ *
7
+ * - **Mentions.** People write `[[core-server-auth]]` (a node's id) or `[[server/auth.js]]` (a repo path) in prose.
8
+ * `parseMentions` reads them outside code: a fenced block and an inline code span are text about code, not links.
9
+ * - **A block.** Tools and agents may write a fenced block for precise links:
10
+ *
11
+ * ```cornfield-thread
12
+ * { "nodes": ["core-server-auth", "server/hooks.js"], "proposal": "0013-meals" }
13
+ * ```
14
+ *
15
+ * `nodes` are read like mentions (an id or a path); `proposal` is a proposal's slug. A block that is not that shape
16
+ * is ignored, and prose is read as before.
17
+ * - **Resolving.** `resolveMention` answers a reference in this order: a node's id; a node whose `file` is exactly
18
+ * that path; the node(s) that own the path (the deepest `file` that is the path or a folder above it, as a file
19
+ * lights its node for a pull request, `nodesForFile`). A reference that is none of these resolves to nothing and
20
+ * stays plain text, which reads sensibly on the forge without cornfield.
21
+ * - **Caps.** At most `MAX_MENTIONS` distinct references are read from one text, and a reference is at most
22
+ * `MAX_REF` characters, so a comment cannot make the map light up everything or cost a lot to read.
23
+ * - **Links.** `threadOfPull` reads the thread a spec PR names: `Thread: #n` or `Closes #n` (also Fixes and Resolves).
24
+ */
25
+
26
+ /** The most distinct references read from one text, and the longest one */
27
+ export const MAX_MENTIONS = 50;
28
+ export const MAX_REF = 200;
29
+ /** The most a `cornfield-thread` block may name */
30
+ export const MAX_BLOCK_NODES = 50;
31
+ /** The fence's info string that marks a block of links */
32
+ export const THREAD_BLOCK = 'cornfield-thread';
33
+
34
+ const SLUG = /^[a-z0-9][a-z0-9._-]{0,99}$/i;
35
+
36
+ const clean = (p) => String(p).replace(/^\.\//, '').replace(/\/+$/, '');
37
+
38
+ /**
39
+ * The node(s) whose `file` holds a path: the node's `file` is the path itself or a folder above it, and the deepest
40
+ * (longest) `file` wins. A file that holds several nodes (a page and what renders it) lights all of them. (The explorer's
41
+ * forge panel lights a pull request's files with this same rule.)
42
+ * @param {string} path a repo-relative path
43
+ * @param {{ id: string, file?: string }[]} nodes
44
+ * @returns {object[]} the nodes, [] when none holds it
45
+ */
46
+ export function nodesForFile(path, nodes) {
47
+ const p = clean(path);
48
+ let best = -1;
49
+ let out = [];
50
+ for (const node of nodes) {
51
+ if (typeof node.file !== 'string' || node.file === '') continue;
52
+ const file = clean(node.file);
53
+ if (p !== file && !p.startsWith(`${file}/`)) continue;
54
+ if (file.length > best) { best = file.length; out = [node]; } else if (file.length === best) out.push(node);
55
+ }
56
+ return out;
57
+ }
58
+
59
+ /**
60
+ * Splits text into prose and code: `prose` is the text with every fenced block and inline code span blanked (same length,
61
+ * so offsets hold), `fences` are the fenced blocks as `{ info, body }`.
62
+ * @param {string} text
63
+ * @returns {{ prose: string, fences: { info: string, body: string }[] }}
64
+ */
65
+ export function scan(text) {
66
+ const src = String(text ?? '');
67
+ const lines = src.split('\n');
68
+ const fences = [];
69
+ const out = [];
70
+ let open = null; // { char, len, info, body: [] }
71
+ for (const line of lines) {
72
+ if (open) {
73
+ const close = new RegExp(`^ {0,3}${open.char}{${open.len},}[ \\t]*\\r?$`).test(line);
74
+ if (close) { fences.push({ info: open.info, body: open.body.join('\n') }); open = null; } else open.body.push(line.replace(/\r$/, ''));
75
+ out.push(' '.repeat(line.length));
76
+ continue;
77
+ }
78
+ const m = /^ {0,3}(`{3,}|~{3,})[ \t]*([^\r]*)$/.exec(line);
79
+ // An info string of a backtick fence cannot hold a backtick (then it is an inline span, not a fence)
80
+ if (m && !(m[1][0] === '`' && m[2].includes('`'))) {
81
+ open = { char: m[1][0], len: m[1].length, info: m[2].trim(), body: [] };
82
+ out.push(' '.repeat(line.length));
83
+ continue;
84
+ }
85
+ out.push(line);
86
+ }
87
+ // A fence left open runs to the end of the text, as the forge's Markdown reads it
88
+ if (open) fences.push({ info: open.info, body: open.body.join('\n') });
89
+ return { prose: blankSpans(out.join('\n')), fences };
90
+ }
91
+
92
+ /** Inline code spans blanked: a run of backticks closes at the next run of the same length */
93
+ function blankSpans(text) {
94
+ let out = '';
95
+ let i = 0;
96
+ while (i < text.length) {
97
+ if (text[i] !== '`') { out += text[i++]; continue; }
98
+ let n = 0;
99
+ while (text[i + n] === '`') n++;
100
+ let j = i + n;
101
+ let end = -1;
102
+ while (j < text.length) {
103
+ if (text[j] !== '`') { j++; continue; }
104
+ let m = 0;
105
+ while (text[j + m] === '`') m++;
106
+ if (m === n) { end = j + m; break; }
107
+ j += m;
108
+ }
109
+ if (end === -1) { out += text.slice(i, i + n); i += n; continue; }
110
+ out += text.slice(i, end).replace(/[^\n]/g, ' ');
111
+ i = end;
112
+ }
113
+ return out;
114
+ }
115
+
116
+ /** A reference as written, made comparable: no `./`, no trailing slash, no `#L10` or `:10` line suffix; or null when it is not one */
117
+ export function normalizeRef(raw) {
118
+ let ref = String(raw ?? '').trim();
119
+ if (ref === '' || ref.length > MAX_REF || /[\0\n\r]/.test(ref)) return null;
120
+ ref = ref.replace(/#.*$/, '').replace(/:\d+(?:-\d+)?$/, '');
121
+ ref = clean(ref).trim();
122
+ if (ref === '' || ref.startsWith('/') || ref.split('/').some((p) => p === '..' || p === '.')) return null;
123
+ return ref;
124
+ }
125
+
126
+ /**
127
+ * The `cornfield-thread` block of a text, or null: the first well-formed one wins.
128
+ * @param {{ info: string, body: string }[]} fences
129
+ * @returns {{ nodes: string[], proposal: string | null } | null}
130
+ */
131
+ function blockOf(fences) {
132
+ for (const f of fences) {
133
+ if (f.info.split(/\s+/)[0] !== THREAD_BLOCK) continue;
134
+ let json;
135
+ try { json = JSON.parse(f.body); } catch { continue; }
136
+ if (json === null || typeof json !== 'object' || Array.isArray(json)) continue;
137
+ const nodes = Array.isArray(json.nodes) ? json.nodes.map((n) => (typeof n === 'string' ? normalizeRef(n) : null)).filter((r) => r !== null).slice(0, MAX_BLOCK_NODES) : [];
138
+ const proposal = typeof json.proposal === 'string' && SLUG.test(json.proposal) ? json.proposal : null;
139
+ if (!nodes.length && !proposal) continue;
140
+ return { nodes: [...new Set(nodes)], proposal };
141
+ }
142
+ return null;
143
+ }
144
+
145
+ /**
146
+ * What a text points at, before the map is asked: the `[[…]]` references outside code (each once, in order, at most
147
+ * `MAX_MENTIONS`), and the `cornfield-thread` block when there is one.
148
+ * @param {string} text
149
+ * @returns {{ refs: string[], block: { nodes: string[], proposal: string | null } | null, truncated: boolean }}
150
+ */
151
+ export function parseMentions(text) {
152
+ const { prose, fences } = scan(text);
153
+ const refs = [];
154
+ let truncated = false;
155
+ for (const m of prose.matchAll(/(?<!\\)\[\[([^[\]\n]{1,400})\]\]/g)) {
156
+ const ref = normalizeRef(m[1]);
157
+ if (ref === null || refs.includes(ref)) continue;
158
+ if (refs.length >= MAX_MENTIONS) { truncated = true; break; }
159
+ refs.push(ref);
160
+ }
161
+ return { refs, block: blockOf(fences), truncated };
162
+ }
163
+
164
+ /**
165
+ * The nodes one reference stands for, in order of how sure it is: the id; else nodes whose `file` is exactly the path;
166
+ * else the nodes that own the path.
167
+ * @param {string} ref
168
+ * @param {{ id: string, file?: string }[]} nodes
169
+ * @returns {object[]}
170
+ */
171
+ export function resolveMention(ref, nodes) {
172
+ const r = normalizeRef(ref);
173
+ if (r === null) return [];
174
+ const byId = nodes.filter((n) => n.id === r);
175
+ if (byId.length) return byId;
176
+ const exact = nodes.filter((n) => typeof n.file === 'string' && n.file !== '' && clean(n.file) === r);
177
+ if (exact.length) return exact;
178
+ return nodesForFile(r, nodes);
179
+ }
180
+
181
+ /**
182
+ * References resolved against a map.
183
+ * @param {string[]} refs references as `parseMentions` gives them (or a block's `nodes`)
184
+ * @param {{ id: string, file?: string }[]} nodes
185
+ * @returns {{ resolved: { ref: string, ids: string[] }[], ids: string[], unresolved: string[] }} `ids`: every node once, in
186
+ * order; `unresolved`: the references that are on no node (they stay text)
187
+ */
188
+ export function resolveMentions(refs, nodes) {
189
+ const resolved = [];
190
+ const unresolved = [];
191
+ const seen = new Set();
192
+ const list = Array.isArray(refs) ? refs.slice(0, MAX_MENTIONS + MAX_BLOCK_NODES) : [];
193
+ for (const ref of list) {
194
+ const found = resolveMention(ref, nodes);
195
+ if (!found.length) { unresolved.push(ref); continue; }
196
+ const ids = found.map((n) => n.id);
197
+ resolved.push({ ref, ids });
198
+ for (const id of ids) seen.add(id);
199
+ }
200
+ return { resolved, ids: [...seen], unresolved };
201
+ }
202
+
203
+ /**
204
+ * Everything a set of texts (a thread's messages) points at, before the map is asked: the references of every text and of
205
+ * its blocks, each once, and the proposal a block names (the first).
206
+ * @param {string[]} texts
207
+ * @returns {{ refs: string[], proposal: string | null, truncated: boolean }}
208
+ */
209
+ export function linksOf(texts) {
210
+ const refs = [];
211
+ let proposal = null;
212
+ let truncated = false;
213
+ const cap = MAX_MENTIONS * 2;
214
+ for (const text of texts) {
215
+ const got = parseMentions(text);
216
+ truncated ||= got.truncated;
217
+ for (const ref of [...got.refs, ...(got.block ? got.block.nodes : [])]) {
218
+ if (refs.includes(ref)) continue;
219
+ if (refs.length >= cap) { truncated = true; break; }
220
+ refs.push(ref);
221
+ }
222
+ if (proposal === null && got.block && got.block.proposal) proposal = got.block.proposal;
223
+ }
224
+ return { refs, proposal, truncated };
225
+ }
226
+
227
+ /**
228
+ * The thread a spec PR's text names: `Thread: #n`, or `Closes #n` (also Fixes and Resolves, and their other tenses), outside
229
+ * code. The first one named. An issue in another repo (`owner/name#n`) is not this repo's thread.
230
+ * @param {string} body
231
+ * @returns {number | null}
232
+ */
233
+ export function threadOfPull(body) {
234
+ const { prose } = scan(body);
235
+ // `(?:\s*:)?\s+` and not `\s*:?\s+`: two whitespace runs side by side backtrack against each other, which made a long
236
+ // run of spaces after "Closes" (or a code block, blanked to spaces) quadratic; this way each run is read once
237
+ const m = /(?:^|[\s(])(?:thread\s*:|(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)(?:\s*:)?)\s+#([1-9]\d{0,8})\b/i.exec(prose);
238
+ return m ? Number(m[1]) : null;
239
+ }
@@ -913,3 +913,12 @@ a.hx-forge-row:hover, button.hx-forge-row:hover { background: var(--hx-control-h
913
913
  :root[data-theme="light"] .hx-forge-tab[aria-pressed="true"] { box-shadow: 0 1px 3px rgba(15, 23, 42, 0.25); }
914
914
  :root[data-theme="light"] .hx-forge { box-shadow: 10px 0 30px -12px rgba(15, 23, 42, 0.18); }
915
915
  :root[data-theme="light"] .hx-forge:not(.forge-open) { box-shadow: none; }
916
+
917
+ /* Threads (forge-panel.js, docs/threads.md): messages as written, read-only; a node's "Mentioned in" opens one */
918
+ .hx-thread-msgs { display: flex; flex-direction: column; gap: 8px; margin: 0; padding: 0; list-style: none; }
919
+ .hx-thread-msg { display: flex; flex-direction: column; gap: 4px; padding: 8px 10px; border: 1px solid var(--hx-line); border-radius: 10px; background: var(--hx-control); }
920
+ .hx-thread-body { margin: 0; color: var(--hx-ink); line-height: 1.5; white-space: pre-wrap; overflow-wrap: anywhere; user-select: text; }
921
+ .hx-thread-nodes { padding-left: 0; }
922
+ .hx-thread-compose { font-family: inherit; min-height: 8em; }
923
+ .hx-thread-open { display: block; max-width: 100%; text-align: left; overflow-wrap: anywhere; }
924
+ #forge-idea[hidden], #idea-form[hidden], #idea-message[hidden] { display: none; }
@@ -24,17 +24,21 @@
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
25
  * landingUrl, cancelLandingUrl, landingWords
26
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
27
+ * threadsUrl, threadUrl, IDEAS_URL, threadKindWords, threadMeta, litForThread, mentionsIndex, mentionsOf, messageNodes, ideaRequest, ideaWords
28
+ * the Threads tab (docs/threads.md): what to ask, a thread's words, which nodes its text lights and which threads mention a node
27
29
  * notesUrl, sinceChoices, notesSummary, validTag, confirmWords, postedWords, draftRequest
28
30
  * the Release notes view: what to ask, the tags to start from, and the words around a draft
29
31
  */
30
32
 
31
33
  import { relativeTime } from './review-model.js';
34
+ import { nodesForFile, linksOf, parseMentions, resolveMentions } from '../core/mentions.js';
32
35
 
33
- export const KINDS = Object.freeze(['pulls', 'issues', 'deploys', 'notes']);
36
+ export const KINDS = Object.freeze(['pulls', 'issues', 'threads', 'deploys', 'notes']);
34
37
  /** The states a list offers: [value, label]; a pull request that landed is "merged", one closed without landing "closed" */
35
38
  export const STATES = Object.freeze({
36
39
  pulls: [['open', 'Open'], ['merged', 'Merged'], ['closed', 'Closed'], ['all', 'All']],
37
40
  issues: [['open', 'Open'], ['closed', 'Closed'], ['all', 'All']],
41
+ threads: [['open', 'Open'], ['closed', 'Closed'], ['all', 'All']],
38
42
  deploys: [['all', 'All'], ['success', 'Succeeded'], ['failure', 'Failed'], ['running', 'Running or waiting'], ['other', 'Cancelled or skipped']],
39
43
  });
40
44
  /** How many a list asks for (the server's own default; its most is 300) */
@@ -50,9 +54,13 @@ export const defaultFilters = (kind = 'pulls') => ({ state: kind === 'deploys' ?
50
54
  export const serverState = (state) => (state === 'merged' ? 'closed' : state);
51
55
 
52
56
  /** The address of a list */
53
- export const listUrl = (kind, state) => (kind === 'deploys' ? `/api/forge/deploys?limit=${LIST_LIMIT}` : `/api/forge/${kind}?state=${serverState(state)}&limit=${LIST_LIMIT}`);
57
+ export const listUrl = (kind, state) => (kind === 'deploys' ? `/api/forge/deploys?limit=${LIST_LIMIT}` : kind === 'threads' ? threadsUrl(state) : `/api/forge/${kind}?state=${serverState(state)}&limit=${LIST_LIMIT}`);
54
58
  /** What a list is kept under: a deploys list is filtered by state here, so every state is one list */
55
59
  export const listKeyOf = (kind, state) => (kind === 'deploys' ? 'deploys' : `${kind}|${serverState(state)}`);
60
+ /** The address of the threads list for a state, of one thread, and where a new idea is posted */
61
+ export const threadsUrl = (state) => `/api/forge/threads?state=${state}&limit=${LIST_LIMIT}`;
62
+ export const threadUrl = (n) => `/api/forge/threads/${n}`;
63
+ export const IDEAS_URL = '/api/forge/threads';
56
64
  /** The address of one pull request with its files and checks */
57
65
  export const pullUrl = (n) => `/api/forge/pulls/${n}`;
58
66
 
@@ -152,25 +160,8 @@ export function checksWords(checks) {
152
160
 
153
161
  const clean = (p) => String(p).replace(/^\.\//, '').replace(/\/+$/, '');
154
162
 
155
- /**
156
- * The node(s) whose `file` holds a path: the node's `file` is the path itself or a folder above it, and the deepest
157
- * (longest) `file` wins. A file that holds several nodes (a page and what renders it) lights all of them.
158
- * @param {string} path a repo-relative path
159
- * @param {{ id: string, file?: string }[]} nodes
160
- * @returns {object[]} the nodes, [] when none holds it
161
- */
162
- export function nodesForFile(path, nodes) {
163
- const p = clean(path);
164
- let best = -1;
165
- let out = [];
166
- for (const node of nodes) {
167
- if (typeof node.file !== 'string' || node.file === '') continue;
168
- const file = clean(node.file);
169
- if (p !== file && !p.startsWith(`${file}/`)) continue;
170
- if (file.length > best) { best = file.length; out = [node]; } else if (file.length === best) out.push(node);
171
- }
172
- return out;
173
- }
163
+ // The map's rule for a file's node(s) is core/mentions.js's too (a thread's [[server/auth.js]] resolves the same way)
164
+ export { nodesForFile };
174
165
 
175
166
  const TEST_PATH = /(?:^|\/)(?:tests?|__tests__|spec|specs)\/|\.(?:test|spec)\.[a-z]+$|_test\.[a-z]+$|(?:^|\/)test_[^/]+\.py$/i;
176
167
  /** Whether a path looks like a test file */
@@ -279,6 +270,82 @@ export function litForDeploy(deploy, nodes) {
279
270
  : { nodes: hit, missing, changed: ids, failing: new Set() };
280
271
  }
281
272
 
273
+ // ---------------------------------------------------------------------------
274
+ // Threads: a conversation on the forge that points at the map (core/mentions.js)
275
+ // ---------------------------------------------------------------------------
276
+
277
+ /** "Idea", or "Proposal 0040-meals" for a spec PR's conversation */
278
+ export const threadKindWords = (t) => (t.kind === 'proposal' ? `Proposal${t.slugs && t.slugs.length ? ` ${t.slugs.join(', ')}` : ''}` : 'Idea');
279
+
280
+ /** "#41 · maintainer-one · 3 comments · 2 days ago" (the time of the last message) */
281
+ export function threadMeta(t, nowMs) {
282
+ const count = t.kind === 'proposal' && !t.comments ? 'no comments' : `${t.comments} ${t.comments === 1 ? 'comment' : 'comments'}`;
283
+ return [`#${t.number}`, t.author ? t.author.login : 'someone', count, relativeTime(t.updatedAt, nowMs)].filter(Boolean).join(' · ');
284
+ }
285
+
286
+ /**
287
+ * The nodes the references in some texts stand for, against the map now on screen. `refs` are as the server gives them (or
288
+ * as `linksOf` finds them); what is on no node stays text.
289
+ * @param {string[]} refs
290
+ * @param {{ id: string, file?: string }[]} nodes
291
+ * @returns {{ ids: string[], nodes: object[], unresolved: string[], resolved: { ref: string, ids: string[] }[] }}
292
+ */
293
+ export function mentionsOf(refs, nodes) {
294
+ const got = resolveMentions(refs, nodes);
295
+ const byId = new Map(nodes.map((n) => [n.id, n]));
296
+ return { ...got, nodes: got.ids.map((id) => byId.get(id)).filter(Boolean) };
297
+ }
298
+
299
+ /**
300
+ * What an open thread lights: the nodes its messages mention, as a pull request's files light theirs (amber, the rest
301
+ * faded). `messages` carry `body`; each message also says which nodes are its own (for the chips under it).
302
+ * @param {{ messages: { id: string, body: string }[] }} thread
303
+ * @param {{ id: string, file?: string }[]} nodes
304
+ * @returns {{ nodes: object[], unresolved: string[], changed: Set<string>, failing: Set<string>, perMessage: Map<string, { nodes: object[], unresolved: string[] }> }}
305
+ */
306
+ export function litForThread(thread, nodes) {
307
+ const all = linksOf(thread.messages.map((m) => m.body));
308
+ const got = mentionsOf(all.refs, nodes);
309
+ const perMessage = new Map();
310
+ for (const m of thread.messages) perMessage.set(m.id, messageNodes(m.body, nodes));
311
+ return { nodes: got.nodes, unresolved: got.unresolved, changed: new Set(got.ids), failing: new Set(), perMessage };
312
+ }
313
+
314
+ /** The nodes one message mentions (its `[[…]]` and its `cornfield-thread` block), and the references that are on none */
315
+ export function messageNodes(body, nodes) {
316
+ const got = parseMentions(body);
317
+ const found = mentionsOf([...got.refs, ...(got.block ? got.block.nodes : [])], nodes);
318
+ return { nodes: found.nodes, unresolved: found.unresolved };
319
+ }
320
+
321
+ /**
322
+ * Which threads mention which nodes, from the list the server gave (each thread's references as written), against the map
323
+ * on screen.
324
+ * @param {{ number: number, refs: string[] }[]} items
325
+ * @param {{ id: string, file?: string }[]} nodes
326
+ * @returns {Map<string, object[]>} node id -> the threads, in the order of `items`
327
+ */
328
+ export function mentionsIndex(items, nodes) {
329
+ const index = new Map();
330
+ for (const t of items) {
331
+ for (const id of resolveMentions(t.refs || [], nodes).ids) index.set(id, [...(index.get(id) || []), t]);
332
+ }
333
+ return index;
334
+ }
335
+
336
+ /** A new idea's request body: the title and the text, trimmed, or null when there is no title */
337
+ export function ideaRequest(title, body) {
338
+ const t = String(title ?? '').trim();
339
+ return t ? { title: t, body: String(body ?? '').trim() } : null;
340
+ }
341
+
342
+ /** What a new idea came to, in words */
343
+ export function ideaWords(forgeName, res) {
344
+ if (res.ok) return `Opened idea #${res.body.number} on ${forgeName}. It shows in the list within a minute.`;
345
+ if (res.status === 401) return 'Your sign-in has ended. Sign in again to open an idea.';
346
+ return errorWords(res.status, res.body);
347
+ }
348
+
282
349
  // ---------------------------------------------------------------------------
283
350
  // The picture
284
351
  // ---------------------------------------------------------------------------