cornfield 0.7.0 → 0.9.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/agent-contract.js +5 -2
- package/core/agent-contract.schema.json +9 -9
- package/core/config.js +94 -11
- package/core/mentions.js +239 -0
- package/explorer/explorer.css +12 -1
- package/explorer/forge-panel-model.js +88 -21
- package/explorer/forge-panel.js +512 -19
- package/explorer/index.html +46 -0
- package/explorer/panels.js +24 -0
- package/explorer/review-tokens-model.js +5 -1
- package/explorer/review-tokens.js +11 -7
- package/explorer/runs-model.js +107 -0
- package/explorer/state.js +7 -0
- package/package.json +1 -1
- package/server/agent-api.js +227 -0
- package/server/agent-runs.js +769 -0
- package/server/db.js +68 -0
- package/server/forge-actions.js +106 -5
- package/server/forge-forgejo.js +33 -5
- package/server/forge-github.js +12 -4
- package/server/forge-routes.js +33 -1
- package/server/forge.js +24 -7
- package/server/hooks.js +100 -1
- package/server/proposal-pulls.js +3 -3
- package/server/review-api.js +26 -12
- package/server/review-tokens.js +42 -25
- package/server/server.js +32 -8
- package/server/spec-poll.js +71 -14
- package/server/threads.js +275 -0
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module server/threads
|
|
3
|
+
*
|
|
4
|
+
* Threads (spec 0005, D6 and D7; docs/threads.md): a conversation that lives on the forge, which cornfield only shows.
|
|
5
|
+
*
|
|
6
|
+
* - **An idea** is an issue with the label `config.threads.label` (`idea`); its thread is that issue's comments.
|
|
7
|
+
* - **A proposal's thread** is the conversation of its spec pull request (an open PR that adds or changes a proposal).
|
|
8
|
+
* - A spec PR **names its idea** with `Thread: #n` or `Closes #n` in its text (`threadOfPull`), so the idea lists its proposals.
|
|
9
|
+
* - What a thread points at on the map is written in its text: `[[node-id]]`, `[[repo/path]]`, or a `cornfield-thread` block
|
|
10
|
+
* (core/mentions.js). The server hands the page the references as written (`refs`); the page resolves them against the map
|
|
11
|
+
* it is showing, so a proposal's what-if map and the live layer light the same way. A hook's payload resolves them here,
|
|
12
|
+
* against the checkout's map (`topology.json`).
|
|
13
|
+
*
|
|
14
|
+
* **Nothing here is stored.** The forge is read with the signed-in maintainer's own token, through the interface
|
|
15
|
+
* (server/forge.js); SQLite only caches answers (`forge_cache`, server/forge-routes.js) and remembers which events a hook sent
|
|
16
|
+
* (`thread_seen`, server/hooks.js). The one write is `createIdea`: an issue with the idea label, as the maintainer, which adds
|
|
17
|
+
* and changes nothing that exists (the label itself is made first when the repo lacks it: by the forge on GitHub, by the
|
|
18
|
+
* adapter on Forgejo, which says so in `labelsMade`). No read ever writes.
|
|
19
|
+
*
|
|
20
|
+
* **One look, two doors.** `gatherThreads` reads the ideas and the spec PRs' conversations. The dashboard's list calls it for
|
|
21
|
+
* everything (so a node's "Mentioned in" is complete); the poll (server/spec-poll.js) calls it with the cursors the database
|
|
22
|
+
* keeps, so a tick with nothing new costs the issue listing and one call per open spec PR. Both hand what they read to
|
|
23
|
+
* `hooks.threadsSeen`, which sends each event once.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { ForgeError } from './forge.js';
|
|
27
|
+
import { listPullProposals, pullProposals, forgeWords } from './proposal-pulls.js';
|
|
28
|
+
import { ideaLabel } from '../core/config.js';
|
|
29
|
+
import { linksOf, parseMentions, resolveMentions, threadOfPull } from '../core/mentions.js';
|
|
30
|
+
|
|
31
|
+
/** The most threads a list reads, and the most comments of one thread that are kept */
|
|
32
|
+
export const THREAD_LIMIT = 100;
|
|
33
|
+
export const POLL_LIMIT = 200;
|
|
34
|
+
export const MESSAGE_LIMIT = 200;
|
|
35
|
+
/** The longest text of one message that is sent to the page (a longer one is cut, and says so) */
|
|
36
|
+
export const BODY_LIMIT = 20_000;
|
|
37
|
+
/** How many threads have their comments asked at once */
|
|
38
|
+
const PARALLEL = 6;
|
|
39
|
+
|
|
40
|
+
const invalid = (message) => new ForgeError(message, { code: 'invalid' });
|
|
41
|
+
|
|
42
|
+
/** Whether threads are off in this config */
|
|
43
|
+
export const threadsOff = (config) => config.threads === false;
|
|
44
|
+
|
|
45
|
+
/** An issue is an idea when it carries the label (case does not matter, as on the forges) */
|
|
46
|
+
const isIdea = (issue, label) => issue.labels.some((l) => l.toLowerCase() === label.toLowerCase());
|
|
47
|
+
|
|
48
|
+
/** A comment, for the page: the author, the time, the text as written (cut when very long) */
|
|
49
|
+
const messageOf = (c) => ({
|
|
50
|
+
id: c.id, author: c.author, url: c.url, createdAt: c.createdAt, updatedAt: c.updatedAt,
|
|
51
|
+
body: c.body.length > BODY_LIMIT ? c.body.slice(0, BODY_LIMIT) : c.body, ...(c.body.length > BODY_LIMIT ? { cut: true } : {}), ...(c.opening ? { opening: true } : {}),
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
/** Runs `fn` over `items`, `PARALLEL` at a time, keeping the order */
|
|
55
|
+
async function each(items, fn) {
|
|
56
|
+
const out = new Array(items.length);
|
|
57
|
+
let next = 0;
|
|
58
|
+
await Promise.all(Array.from({ length: Math.min(PARALLEL, items.length) }, async () => {
|
|
59
|
+
while (next < items.length) {
|
|
60
|
+
const at = next++;
|
|
61
|
+
out[at] = await fn(items[at]);
|
|
62
|
+
}
|
|
63
|
+
}));
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Reads the threads off the forge: the ideas (issues with the label) and the spec PRs' conversations.
|
|
69
|
+
* @param {import('./forge.js').Forge} forge
|
|
70
|
+
* @param {any} config
|
|
71
|
+
* @param {{ proposals: string }} where
|
|
72
|
+
* @param {{
|
|
73
|
+
* state?: 'open' | 'closed' | 'all', limit?: number, hooks?: any,
|
|
74
|
+
* cursors?: Map<number, { stamp: string, since: string }> | null,
|
|
75
|
+
* }} [opts] `cursors` (the poll): a thread whose `updatedAt` is the one last looked at is not read again, and the others only
|
|
76
|
+
* from the time of the newest comment seen
|
|
77
|
+
* @returns {Promise<{ threads: any[], pulls: any[], problem: string | null }>} each thread
|
|
78
|
+
* `{ kind, number, item, comments, failed }` (`item` the issue or pull request); `pulls` the spec PRs that name an idea:
|
|
79
|
+
* `{ pull, thread, slug }`
|
|
80
|
+
*/
|
|
81
|
+
export async function gatherThreads(forge, config, where, { state = 'open', limit = THREAD_LIMIT, hooks = null, cursors = null } = {}) {
|
|
82
|
+
const label = ideaLabel(config);
|
|
83
|
+
if (label === null) return { threads: [], pulls: [], problem: null };
|
|
84
|
+
let problem = null;
|
|
85
|
+
const [issues, specs] = await Promise.all([
|
|
86
|
+
forge.issues({ state, labels: [label], limit }),
|
|
87
|
+
listPullProposals(forge, where, { hooks }),
|
|
88
|
+
]);
|
|
89
|
+
problem = specs.problem;
|
|
90
|
+
const slugs = new Map();
|
|
91
|
+
for (const e of specs.entries) slugs.set(e.pull.number, [...(slugs.get(e.pull.number) || []), e.slug]);
|
|
92
|
+
const proposals = specs.pulls.filter((p) => slugs.has(p.number));
|
|
93
|
+
const all = [
|
|
94
|
+
...issues.filter((i) => isIdea(i, label)).map((item) => ({ kind: 'idea', number: item.number, item, slugs: [] })),
|
|
95
|
+
...proposals.map((item) => ({ kind: 'proposal', number: item.number, item, slugs: slugs.get(item.number) })),
|
|
96
|
+
];
|
|
97
|
+
const read = await each(all, async (t) => {
|
|
98
|
+
const cursor = cursors && cursors.get(t.number);
|
|
99
|
+
if (cursor && cursor.stamp === t.item.updatedAt) return { ...t, comments: [], unchanged: true, failed: false };
|
|
100
|
+
// Nothing to ask for an idea that has no comment (its text is the issue's)
|
|
101
|
+
if (t.kind === 'idea' && !cursor && t.item.comments === 0) return { ...t, comments: [], failed: false };
|
|
102
|
+
try {
|
|
103
|
+
const comments = await forge.comments(t.number, cursor && cursor.since ? { since: cursor.since } : undefined);
|
|
104
|
+
return { ...t, comments, failed: false };
|
|
105
|
+
} catch (err) {
|
|
106
|
+
problem ??= forgeWords(err);
|
|
107
|
+
return { ...t, comments: [], failed: true };
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
const ideas = new Set(read.filter((t) => t.kind === 'idea').map((t) => t.number));
|
|
111
|
+
const pulls = [];
|
|
112
|
+
for (const p of proposals) {
|
|
113
|
+
const thread = threadOfPull(p.body);
|
|
114
|
+
if (thread !== null && ideas.has(thread)) pulls.push({ pull: p.number, thread, slug: slugs.get(p.number)[0] });
|
|
115
|
+
}
|
|
116
|
+
return { threads: read, pulls, problem };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** One thread as the list shows it: no message text, but the references its text holds, as written */
|
|
120
|
+
function summaryOf(t, pulls) {
|
|
121
|
+
const { item } = t;
|
|
122
|
+
const texts = [...(t.kind === 'idea' ? [item.body] : []), ...t.comments.map((c) => c.body)];
|
|
123
|
+
const links = linksOf(texts);
|
|
124
|
+
const named = pulls.filter((p) => p.thread === t.number).map((p) => ({ pull: p.pull, slug: p.slug }));
|
|
125
|
+
const own = pulls.find((p) => p.pull === t.number);
|
|
126
|
+
return {
|
|
127
|
+
kind: t.kind,
|
|
128
|
+
number: t.number,
|
|
129
|
+
title: item.title,
|
|
130
|
+
state: item.state,
|
|
131
|
+
author: item.author,
|
|
132
|
+
labels: item.labels,
|
|
133
|
+
url: item.url,
|
|
134
|
+
createdAt: item.createdAt,
|
|
135
|
+
updatedAt: lastActivity(item, t.comments),
|
|
136
|
+
comments: t.kind === 'idea' ? Math.max(item.comments ?? 0, t.comments.length) : t.comments.length,
|
|
137
|
+
refs: links.refs,
|
|
138
|
+
...(links.proposal ? { proposal: links.proposal } : {}),
|
|
139
|
+
...(t.kind === 'idea' && named.length ? { proposals: named } : {}),
|
|
140
|
+
...(t.kind === 'proposal' ? { slugs: t.slugs, ...(own ? { idea: own.thread } : {}) } : {}),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The newest time among a thread and its comments: what "newest activity first" sorts by */
|
|
145
|
+
const lastActivity = (item, comments) => comments.reduce((newest, c) => (c.updatedAt > newest ? c.updatedAt : newest), item.updatedAt);
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The threads list for the page: ideas and proposals' conversations, newest activity first.
|
|
149
|
+
* @returns {Promise<{ items: object[], problem: string | null, label: string }>}
|
|
150
|
+
*/
|
|
151
|
+
export async function listThreads(forge, config, where, opts = {}) {
|
|
152
|
+
const got = await gatherThreads(forge, config, where, opts);
|
|
153
|
+
const items = got.threads.filter((t) => !t.failed).map((t) => summaryOf(t, got.pulls));
|
|
154
|
+
items.sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : a.updatedAt > b.updatedAt ? -1 : b.number - a.number));
|
|
155
|
+
return { items, problem: got.problem, label: ideaLabel(config), gathered: got };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* One thread's messages for the page, read-only: an idea's text and its comments, or a proposal's conversation. A number that
|
|
160
|
+
* is no idea (no label) and no spec PR is not a thread.
|
|
161
|
+
* @param {import('./forge.js').Forge} forge
|
|
162
|
+
* @param {any} config
|
|
163
|
+
* @param {{ proposals: string }} where
|
|
164
|
+
* @param {number} n
|
|
165
|
+
*/
|
|
166
|
+
export async function readThread(forge, config, where, n) {
|
|
167
|
+
const label = ideaLabel(config);
|
|
168
|
+
if (label === null) throw new ForgeError('Threads are off in this config (threads: false).', { code: 'not-found' });
|
|
169
|
+
const notAThread = () => new ForgeError(`#${n} is not a thread: an idea is an issue labelled "${label}", and a proposal's thread is its spec pull request.`, { code: 'not-found' });
|
|
170
|
+
let issue = null;
|
|
171
|
+
try {
|
|
172
|
+
issue = await forge.issue(n);
|
|
173
|
+
} catch (err) {
|
|
174
|
+
if (!(err instanceof ForgeError) || err.code !== 'not-found') throw err;
|
|
175
|
+
}
|
|
176
|
+
if (issue) {
|
|
177
|
+
if (!isIdea(issue, label)) throw notAThread();
|
|
178
|
+
const comments = await forge.comments(n);
|
|
179
|
+
const shown = comments.slice(-MESSAGE_LIMIT);
|
|
180
|
+
const opening = { id: 'opening', author: issue.author, url: issue.url, createdAt: issue.createdAt, updatedAt: issue.updatedAt, body: issue.body, opening: true };
|
|
181
|
+
return {
|
|
182
|
+
kind: 'idea', number: n, title: issue.title, state: issue.state, author: issue.author, url: issue.url, labels: issue.labels,
|
|
183
|
+
createdAt: issue.createdAt, updatedAt: lastActivity(issue, comments),
|
|
184
|
+
messages: [messageOf(opening), ...shown.map(messageOf)], hidden: comments.length - shown.length,
|
|
185
|
+
...(await namedBy(forge, where, n)),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
let pull;
|
|
189
|
+
try {
|
|
190
|
+
pull = await forge.pull(n);
|
|
191
|
+
} catch (err) {
|
|
192
|
+
if (err instanceof ForgeError && err.code === 'not-found') throw notAThread();
|
|
193
|
+
throw err;
|
|
194
|
+
}
|
|
195
|
+
const entries = await pullProposals(forge, where, pull);
|
|
196
|
+
if (!entries.length) throw notAThread();
|
|
197
|
+
const comments = await forge.comments(n);
|
|
198
|
+
const shown = comments.slice(-MESSAGE_LIMIT);
|
|
199
|
+
const idea = threadOfPull(pull.body);
|
|
200
|
+
return {
|
|
201
|
+
kind: 'proposal', number: n, title: pull.title, state: pull.state, author: pull.author, url: pull.url, labels: pull.labels, slugs: entries.map((e) => e.slug),
|
|
202
|
+
createdAt: pull.createdAt, updatedAt: lastActivity(pull, comments), messages: shown.map(messageOf), hidden: comments.length - shown.length,
|
|
203
|
+
...(idea !== null ? { idea } : {}),
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** The spec PRs that name idea `n`: `proposals: [{ pull, slug }]` */
|
|
208
|
+
async function namedBy(forge, where, n) {
|
|
209
|
+
try {
|
|
210
|
+
const specs = await listPullProposals(forge, where);
|
|
211
|
+
const slugs = new Map();
|
|
212
|
+
for (const e of specs.entries) slugs.set(e.pull.number, e.slug);
|
|
213
|
+
const proposals = specs.pulls.filter((p) => slugs.has(p.number) && threadOfPull(p.body) === n).map((p) => ({ pull: p.number, slug: slugs.get(p.number) }));
|
|
214
|
+
return proposals.length ? { proposals } : {};
|
|
215
|
+
} catch {
|
|
216
|
+
return {};
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** The title and text of a new idea, checked: a title, and a text that may be left empty */
|
|
221
|
+
export function ideaInput(asked) {
|
|
222
|
+
const plain = asked !== null && typeof asked === 'object' && !Array.isArray(asked);
|
|
223
|
+
const extra = plain ? Object.keys(asked).find((k) => !['title', 'body'].includes(k)) : null;
|
|
224
|
+
if (!plain || extra) throw invalid(extra ? `An idea has a title and a text, not "${extra}".` : 'An idea is { title, body }.');
|
|
225
|
+
if (typeof asked.title !== 'string' || asked.title.trim() === '' || asked.title.includes('\0')) throw invalid('Give the idea a title.');
|
|
226
|
+
if (asked.title.length > 256) throw invalid('The title is at most 256 characters.');
|
|
227
|
+
if (asked.body !== undefined && (typeof asked.body !== 'string' || asked.body.includes('\0'))) throw invalid('The text of an idea is text.');
|
|
228
|
+
const body = typeof asked.body === 'string' ? asked.body : '';
|
|
229
|
+
if (body.length > BODY_LIMIT) throw invalid(`The text is at most ${BODY_LIMIT} characters.`);
|
|
230
|
+
return { title: asked.title.trim(), body: body.trim() === '' ? '_No description yet._' : body };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Opens an idea: an issue with the label, as whoever's token the forge is bound to. It only adds.
|
|
235
|
+
* @param {import('./forge.js').Forge} forge
|
|
236
|
+
* @param {any} config
|
|
237
|
+
* @param {{ title: string, body: string }} idea as `ideaInput` gives it
|
|
238
|
+
*/
|
|
239
|
+
export function createIdea(forge, config, idea) {
|
|
240
|
+
const label = ideaLabel(config);
|
|
241
|
+
if (label === null) throw new ForgeError('Threads are off in this config (threads: false).', { code: 'not-found' });
|
|
242
|
+
return forge.createIssue({ title: idea.title, body: idea.body, labels: [label] });
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ---------------------------------------------------------------------------
|
|
246
|
+
// What a hook is told
|
|
247
|
+
// ---------------------------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
const linkOf = (text, nodes) => {
|
|
250
|
+
const got = parseMentions(text);
|
|
251
|
+
const refs = [...got.refs, ...(got.block ? got.block.nodes : [])];
|
|
252
|
+
const ids = resolveMentions(refs, nodes).ids;
|
|
253
|
+
const proposal = got.block ? got.block.proposal : null;
|
|
254
|
+
return ids.length || proposal ? { nodes: ids, proposal } : null;
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* What `hooks.threadsSeen` is handed: for each thread, who opened it, its comments (their id, author, own time and what they
|
|
259
|
+
* link to, resolved against `nodes`), and the spec PRs that name it. Never a text: a hook says that something was said, not what.
|
|
260
|
+
* @param {{ threads: any[], pulls: any[] }} got `gatherThreads`' answer
|
|
261
|
+
* @param {{ id: string, file?: string }[]} nodes the checkout's map
|
|
262
|
+
*/
|
|
263
|
+
export function snapshotOf(got, nodes) {
|
|
264
|
+
return {
|
|
265
|
+
threads: got.threads.filter((t) => !t.failed).map((t) => ({
|
|
266
|
+
number: t.number,
|
|
267
|
+
kind: t.kind,
|
|
268
|
+
stamp: t.item.updatedAt,
|
|
269
|
+
unchanged: Boolean(t.unchanged),
|
|
270
|
+
opened: t.kind === 'idea' ? { actor: t.item.author ? t.item.author.login : null, linked: linkOf(t.item.body, nodes) } : null,
|
|
271
|
+
messages: t.comments.map((c) => ({ id: c.id, actor: c.author ? c.author.login : null, stamp: c.updatedAt, linked: linkOf(c.body, nodes) })),
|
|
272
|
+
})),
|
|
273
|
+
pulls: got.pulls,
|
|
274
|
+
};
|
|
275
|
+
}
|