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.
- package/README.md +1 -0
- package/core/agent-contract.js +728 -0
- package/core/agent-contract.schema.json +259 -0
- package/core/config.js +36 -7
- package/core/defuse.js +17 -0
- 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 +2 -1
- package/server/db.js +15 -0
- package/server/defuse.js +3 -12
- 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/server/proposal-pulls.js
CHANGED
|
@@ -84,7 +84,7 @@ function entryOf(pull, file) {
|
|
|
84
84
|
* @param {import('./forge.js').Forge} forge
|
|
85
85
|
* @param {{ proposals: string }} where
|
|
86
86
|
* @param {{ hooks?: { specsSeen: Function } | null }} [opts]
|
|
87
|
-
* @returns {Promise<{ entries: PullEntry[], problem: string | null }>}
|
|
87
|
+
* @returns {Promise<{ entries: PullEntry[], problem: string | null, pulls: import('./forge.js').Pull[] }>} `pulls`: the open pull requests listed (a thread look reads their text, server/threads.js)
|
|
88
88
|
*/
|
|
89
89
|
export async function listPullProposals(forge, where, { hooks = null } = {}) {
|
|
90
90
|
const folder = folderOf(where);
|
|
@@ -92,7 +92,7 @@ export async function listPullProposals(forge, where, { hooks = null } = {}) {
|
|
|
92
92
|
try {
|
|
93
93
|
pulls = await forge.pulls({ state: 'open' });
|
|
94
94
|
} catch (err) {
|
|
95
|
-
return { entries: [], problem: forgeWords(err) };
|
|
95
|
+
return { entries: [], problem: forgeWords(err), pulls: [] };
|
|
96
96
|
}
|
|
97
97
|
let problem = null;
|
|
98
98
|
const found = pulls.map(() => []);
|
|
@@ -112,7 +112,7 @@ export async function listPullProposals(forge, where, { hooks = null } = {}) {
|
|
|
112
112
|
if (hooks) {
|
|
113
113
|
try { hooks.specsSeen(forge.repo, entries, (entry) => readPullProposal(forge, where, entry)); } catch { /* a hook never gets in the way */ }
|
|
114
114
|
}
|
|
115
|
-
return { entries, problem };
|
|
115
|
+
return { entries, problem, pulls };
|
|
116
116
|
}
|
|
117
117
|
|
|
118
118
|
/**
|
package/server/server.js
CHANGED
|
@@ -42,7 +42,7 @@ import http from 'node:http';
|
|
|
42
42
|
import path from 'node:path';
|
|
43
43
|
import { createHash } from 'node:crypto';
|
|
44
44
|
import { fileURLToPath } from 'node:url';
|
|
45
|
-
import { browserSources, BUILTIN_SOURCES, specPollMs } from '../core/config.js';
|
|
45
|
+
import { browserSources, BUILTIN_SOURCES, specPollMs, ideaLabel } from '../core/config.js';
|
|
46
46
|
import { CLOUD_FILE } from '../core/cloud.js';
|
|
47
47
|
import { decodePath, inside, notFound, sendFile } from './files.js';
|
|
48
48
|
import { serveProposals } from './proposals.js';
|
|
@@ -59,7 +59,7 @@ import { createSpecPoll } from './spec-poll.js';
|
|
|
59
59
|
export const CORNFIELD_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
60
60
|
const EXPLORER_DIR = path.join(CORNFIELD_DIR, 'explorer');
|
|
61
61
|
/** The pure core modules the explorer imports (no Node built-ins, no files): the only ones of core/ served */
|
|
62
|
-
export const BROWSER_CORE = ['schema.js', 'proposal.js', 'personas.js', 'cloud.js', 'text.js'];
|
|
62
|
+
export const BROWSER_CORE = ['schema.js', 'proposal.js', 'personas.js', 'cloud.js', 'text.js', 'mentions.js'];
|
|
63
63
|
/** cornfield's own browser sources, by request path */
|
|
64
64
|
const BROWSER_PLUGINS = new Map(Object.values(BUILTIN_SOURCES).map((p) => [p, path.join(CORNFIELD_DIR, p)]));
|
|
65
65
|
const PKG = JSON.parse(fs.readFileSync(path.join(CORNFIELD_DIR, 'package.json'), 'utf8'));
|
|
@@ -109,6 +109,8 @@ export function clientConfig(config, signIn = false) {
|
|
|
109
109
|
title: config.title,
|
|
110
110
|
links: config.links || [],
|
|
111
111
|
forge: config.forge ? { type: config.forge.type, url: config.forge.url || (config.forge.type === 'github' ? 'https://github.com' : null), repo: config.forge.repo, mergeMethod: config.forge.mergeMethod || 'merge', ...(config.approveLands ? { approveLands: true } : {}) } : null,
|
|
112
|
+
// Threads (docs/threads.md): the idea label, or false when `threads: false` turns the Threads tab off
|
|
113
|
+
threads: config.threads === false ? false : { label: ideaLabel(config) },
|
|
112
114
|
sources: browserSources(config),
|
|
113
115
|
signIn,
|
|
114
116
|
};
|
package/server/spec-poll.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @module server/spec-poll
|
|
3
3
|
*
|
|
4
|
-
* `spec.ready` without anyone at the dashboard (PIPE-10, docs/config.md#hook-poll).
|
|
4
|
+
* `spec.ready` and the `thread.*` events without anyone at the dashboard (PIPE-10, docs/config.md#hook-poll, docs/threads.md).
|
|
5
|
+
* cornfield notices a spec PR when it
|
|
5
6
|
* lists the open ones, and that listing used to run only when a maintainer opened the proposals menu or the issues and PRs
|
|
6
7
|
* panel. A team that reviews from chat never does, so this lists them on a timer.
|
|
7
8
|
*
|
|
@@ -11,6 +12,12 @@
|
|
|
11
12
|
* nothing here decides what is news. The forge answers come through the same per-head cache, so a tick with nothing new
|
|
12
13
|
* costs the open-PR listing (a call a page) plus the two calls that check the token's owner; see the docs for the count.
|
|
13
14
|
*
|
|
15
|
+
* **Threads ride the same tick.** When a hook wants `thread.opened`, `thread.message` or `thread.linked` (and threads are on),
|
|
16
|
+
* the look also reads the ideas and the spec PRs' conversations (server/threads.js) with the same sign-in, the same timer and
|
|
17
|
+
* the same backoff: one look, one set of calls to the forge for the open PRs, then what the database says is new
|
|
18
|
+
* (`hooks.threadCursors`: an idea whose update time is the one last seen is not read again, and the rest are read from their
|
|
19
|
+
* newest seen comment on). A failure in either half is the tick's failure.
|
|
20
|
+
*
|
|
14
21
|
* **Whose token.** Never one from the config. The newest live sign-in of a user the forge confirms is a maintainer now
|
|
15
22
|
* (`auth.forgeForUser`, then `me()` and `permission()` with that token; the same check the review API and the lander make).
|
|
16
23
|
* A user who is not, or whose token the forge refuses, is passed over for the next newest, at most `MAX_CANDIDATES`; their
|
|
@@ -24,6 +31,9 @@
|
|
|
24
31
|
|
|
25
32
|
import { isMaintainer } from './forge.js';
|
|
26
33
|
import { listPullProposals } from './proposal-pulls.js';
|
|
34
|
+
import { gatherThreads, snapshotOf, POLL_LIMIT } from './threads.js';
|
|
35
|
+
import { hookWants, threadsOn, THREAD_EVENTS } from '../core/config.js';
|
|
36
|
+
import { readTopology } from '../core/validate.js';
|
|
27
37
|
|
|
28
38
|
/** How long after start the first look is made: soon, so the repo's silent first look is not left to the first interval */
|
|
29
39
|
export const FIRST_MS = 15_000;
|
|
@@ -71,6 +81,24 @@ export function createSpecPoll({ config, where, auth, hooks, intervalMs, now = D
|
|
|
71
81
|
return null;
|
|
72
82
|
}
|
|
73
83
|
|
|
84
|
+
/** The checkout's nodes, for a hook's `nodes` to be the ones a mention resolves to; none when there is no map */
|
|
85
|
+
function mapNodes() {
|
|
86
|
+
try {
|
|
87
|
+
const topology = readTopology(config);
|
|
88
|
+
return topology && Array.isArray(topology.nodes) ? topology.nodes : [];
|
|
89
|
+
} catch {
|
|
90
|
+
return [];
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The ideas and the spec PRs' conversations, read from where the database left off and handed to the hooks */
|
|
95
|
+
async function lookAtThreads(forge, withSpecs) {
|
|
96
|
+
const cursors = await hooks.threadCursors(repo);
|
|
97
|
+
const got = await gatherThreads(forge, config, where, { state: 'all', limit: POLL_LIMIT, hooks: withSpecs ? hooks : null, cursors: cursors.looked ? cursors.threads : null });
|
|
98
|
+
await hooks.threadsSeen(repo, snapshotOf(got, mapNodes()));
|
|
99
|
+
return got.problem;
|
|
100
|
+
}
|
|
101
|
+
|
|
74
102
|
/** One look. Resolves to what happened; never rejects. */
|
|
75
103
|
async function look() {
|
|
76
104
|
let found;
|
|
@@ -90,10 +118,13 @@ export function createSpecPoll({ config, where, auth, hooks, intervalMs, now = D
|
|
|
90
118
|
}
|
|
91
119
|
if (noSession) log('a signed-in maintainer is back; checking the open spec PRs again');
|
|
92
120
|
noSession = false;
|
|
93
|
-
if (found.login !== lookingAs) log(`looking at the open spec PRs with the sign-in of @${found.login}, a maintainer`);
|
|
121
|
+
if (found.login !== lookingAs) log(`looking at the open spec PRs${threadsOn(config) && hookWants(config, THREAD_EVENTS) ? ' and threads' : ''} with the sign-in of @${found.login}, a maintainer`);
|
|
94
122
|
lookingAs = found.login;
|
|
95
123
|
try {
|
|
96
|
-
const
|
|
124
|
+
const specs = hookWants(config, ['spec.ready']);
|
|
125
|
+
const threads = threadsOn(config) && hookWants(config, THREAD_EVENTS);
|
|
126
|
+
// One listing of the open PRs serves both: the threads' look makes it itself when it runs
|
|
127
|
+
const problem = threads ? await lookAtThreads(found.forge, specs) : (await listPullProposals(found.forge, where, { hooks })).problem;
|
|
97
128
|
if (problem) {
|
|
98
129
|
log(`the forge could not be asked: ${problem}`);
|
|
99
130
|
return 'error';
|
|
@@ -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
|
+
}
|