@popcorn245/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 +1 -0
- package/core/config.js +36 -7
- package/core/mentions.js +239 -0
- package/explorer/explorer.css +9 -0
- package/explorer/forge-panel-model.js +88 -21
- package/explorer/forge-panel.js +310 -15
- package/explorer/index.html +18 -0
- package/explorer/panels.js +24 -0
- package/explorer/state.js +7 -0
- package/package.json +1 -1
- package/server/db.js +15 -0
- package/server/forge-actions.js +106 -5
- package/server/forge-forgejo.js +26 -5
- package/server/forge-github.js +5 -4
- package/server/forge-routes.js +33 -1
- package/server/forge.js +19 -6
- package/server/hooks.js +81 -1
- package/server/proposal-pulls.js +3 -3
- package/server/server.js +4 -2
- package/server/spec-poll.js +34 -3
- package/server/threads.js +275 -0
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
|
|
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
|
|
90
|
-
*
|
|
91
|
-
*
|
|
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 =
|
|
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`
|
package/core/mentions.js
ADDED
|
@@ -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
|
+
}
|
package/explorer/explorer.css
CHANGED
|
@@ -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
|
-
|
|
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
|
// ---------------------------------------------------------------------------
|