cornfield 0.5.0 → 0.7.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/core/agent-contract.js +728 -0
- package/core/agent-contract.schema.json +259 -0
- package/core/config.js +1 -1
- package/core/defuse.js +17 -0
- package/package.json +2 -1
- package/server/auth.js +224 -32
- package/server/db.js +8 -0
- package/server/defuse.js +3 -12
- package/server/hooks.js +37 -1
- package/server/server.js +1 -0
|
@@ -0,0 +1,728 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module core/agent-contract
|
|
3
|
+
*
|
|
4
|
+
* The contract between cornfield and a developer's runner (spec 0005, D1 to D5, D8, D10; docs/agent-contract.md). cornfield
|
|
5
|
+
* only asks: it writes a request, and whatever runner the developer has picks it up. This module is every byte of that
|
|
6
|
+
* contract, with no I/O of its own beyond the random bytes of a new run id:
|
|
7
|
+
*
|
|
8
|
+
* - **A run id,** `run-` and a 26 character ULID (Crockford base32): `newRunId`, `isRunId`.
|
|
9
|
+
* - **The request,** JSON v1, checked strictly (`validateRequest`, `createRequest`). It carries no credential, ever: a field
|
|
10
|
+
* named like a token, a secret or a password is refused, and so is a value shaped like one.
|
|
11
|
+
* - **The report** a runner may send (`validateReport`: claimed, working, done, failed, stopped) and **the stop request**
|
|
12
|
+
* cornfield sends (`validateStopRequest`: `stop-requested`).
|
|
13
|
+
* - **The fenced block,** one form for all three, modelled on the review block of ADR 0005 (server/reviews.js):
|
|
14
|
+
*
|
|
15
|
+
* Asked for a code run `run-01JC…` on #12 (maintainer-one, in cornfield).
|
|
16
|
+
*
|
|
17
|
+
* ```cornfield-run run-01JC…
|
|
18
|
+
* { "version": 1, … }
|
|
19
|
+
* ```
|
|
20
|
+
*
|
|
21
|
+
* `formatRunComment` writes it (the human line is defused, so it pings and links nobody), `readRunBlocks` and
|
|
22
|
+
* `parseRunBlocks` read it back: tolerant of the text around a block, strict about what is inside. A block that is not
|
|
23
|
+
* strictly a request, a report or a stop request is skipped, and `readRunBlocks` says which and why.
|
|
24
|
+
* - **The trailer,** `Cornfield-Run: run-…` on its own line of a pull request body or a commit message, with the run id in a
|
|
25
|
+
* branch name as the fallback (`parseTrailer`, `runIdFromBranch`, `findRunId`).
|
|
26
|
+
*
|
|
27
|
+
* What this module does not do, because it cannot: decide whether the author of a block is a maintainer. That is the
|
|
28
|
+
* caller's, and the runner's (D5). A comment is untrusted text: it is parsed, never rendered as HTML, never joined into a path.
|
|
29
|
+
*
|
|
30
|
+
* It lives in `core/` and imports only `core/defuse.js`, which imports nothing; its randomness is `crypto.getRandomValues`, so
|
|
31
|
+
* Node and the browser can both load it.
|
|
32
|
+
*
|
|
33
|
+
* **For the server (#80 on).** `createRequest` makes a request, `formatRunComment` the comment that carries it, and
|
|
34
|
+
* `runBlocksOf(comments, { trust })` reads a forge conversation back: every block with the comment and author it came from,
|
|
35
|
+
* the blocks whose author is not trusted (to write the audit row the spec asks for), and the refused ones. `runAuthors` names the
|
|
36
|
+
* logins worth asking the forge about first. `findRunId` ties a pull request to its run.
|
|
37
|
+
*
|
|
38
|
+
* @typedef {'code' | 'summary' | 'brief' | 'proposal'} RunKind
|
|
39
|
+
* @typedef {{ issue?: number, proposal?: string, pull?: number, thread?: number }} RunRefs
|
|
40
|
+
* @typedef {{ minutes?: number, turns?: number, cost?: number }} RunLimits
|
|
41
|
+
* @typedef {{ version: 1, runId: string, kind: RunKind, profile?: string, refs: RunRefs, branch?: string, brief: string,
|
|
42
|
+
* limits?: RunLimits, nodes?: string[], requestedBy: string, requestedAt: string }} RunRequest
|
|
43
|
+
* @typedef {'claimed' | 'working' | 'done' | 'failed' | 'stopped'} ReportState
|
|
44
|
+
* @typedef {{ state: ReportState, note?: string, url?: string, tokens?: number, cost?: number }} RunReport
|
|
45
|
+
* @typedef {{ state: 'stop-requested', note?: string }} StopRequest
|
|
46
|
+
* @typedef {{ ok: true, value: any } | { ok: false, error: string }} Checked
|
|
47
|
+
* @typedef {{ runId: string, type: 'request', value: RunRequest } | { runId: string, type: 'report', value: RunReport } | { runId: string, type: 'stop-request', value: StopRequest }} RunBlock
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
import { defuse } from './defuse.js';
|
|
51
|
+
|
|
52
|
+
/** Ten (or `n`) random bytes, from the Web Crypto API that Node and the browser both have */
|
|
53
|
+
const randomBytes = (n) => globalThis.crypto.getRandomValues(new Uint8Array(n));
|
|
54
|
+
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// The limits, in one place (the JSON Schema repeats them; a test keeps the two together)
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
export const CONTRACT_VERSION = 1;
|
|
60
|
+
export const RUN_KINDS = ['code', 'summary', 'brief', 'proposal'];
|
|
61
|
+
export const REPORT_STATES = ['claimed', 'working', 'done', 'failed', 'stopped'];
|
|
62
|
+
export const STOP_STATE = 'stop-requested';
|
|
63
|
+
/** The brief, in characters */
|
|
64
|
+
export const MAX_BRIEF_CHARS = 20000;
|
|
65
|
+
/** A report's or a stop request's note, in characters */
|
|
66
|
+
export const MAX_NOTE_CHARS = 4000;
|
|
67
|
+
export const MAX_URL_CHARS = 2000;
|
|
68
|
+
export const MAX_NODES = 100;
|
|
69
|
+
export const MAX_NODE_CHARS = 200;
|
|
70
|
+
export const MAX_BRANCH_CHARS = 200;
|
|
71
|
+
export const MAX_PROFILE_CHARS = 64;
|
|
72
|
+
export const MAX_SLUG_CHARS = 120;
|
|
73
|
+
/** The most an issue or a thread number may be (a 32 bit signed integer, as the forges keep them) */
|
|
74
|
+
export const MAX_NUMBER = 2147483647;
|
|
75
|
+
/** What the JSON in one block may hold, and what a whole comment may hold (GitHub refuses 65536; the human line comes with it) */
|
|
76
|
+
export const MAX_BLOCK_CHARS = 60000;
|
|
77
|
+
export const MAX_COMMENT_CHARS = 60000;
|
|
78
|
+
/** How much text is read for blocks, and how many blocks are read from it */
|
|
79
|
+
export const MAX_TEXT_CHARS = 1_000_000;
|
|
80
|
+
export const MAX_BLOCKS = 20;
|
|
81
|
+
/** The tag of the fenced block */
|
|
82
|
+
export const RUN_TAG = 'cornfield-run';
|
|
83
|
+
/** The key of the trailer */
|
|
84
|
+
export const TRAILER_KEY = 'Cornfield-Run';
|
|
85
|
+
|
|
86
|
+
const REQUEST_KEYS = ['version', 'runId', 'kind', 'profile', 'refs', 'branch', 'brief', 'limits', 'nodes', 'requestedBy', 'requestedAt'];
|
|
87
|
+
const REF_KEYS = ['issue', 'proposal', 'pull', 'thread'];
|
|
88
|
+
const LIMIT_KEYS = ['minutes', 'turns', 'cost'];
|
|
89
|
+
const REPORT_KEYS = ['state', 'note', 'url', 'tokens', 'cost'];
|
|
90
|
+
const STOP_KEYS = ['state', 'note'];
|
|
91
|
+
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
// Patterns. Each is plain ECMAScript with no flags, so the JSON Schema can carry the same source.
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
const CROCKFORD = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
|
|
97
|
+
/** The 26 characters of a ULID: a 48 bit time, then 80 random bits. The first is 0 to 7, so the time fits */
|
|
98
|
+
export const ULID_SOURCE = '[0-7][0-9A-HJKMNP-TV-Z]{25}';
|
|
99
|
+
export const RUN_ID_SOURCE = `^run-${ULID_SOURCE}$`;
|
|
100
|
+
export const LOGIN_SOURCE = '^[A-Za-z0-9][A-Za-z0-9._-]{0,39}$';
|
|
101
|
+
export const SLUG_SOURCE = `^[A-Za-z0-9][A-Za-z0-9._-]{0,${MAX_SLUG_CHARS - 1}}$`;
|
|
102
|
+
export const PROFILE_SOURCE = `^[A-Za-z0-9][A-Za-z0-9._-]{0,${MAX_PROFILE_CHARS - 1}}$`;
|
|
103
|
+
export const NODE_ID_SOURCE = `^[A-Za-z0-9][A-Za-z0-9._:/-]{0,${MAX_NODE_CHARS - 1}}$`;
|
|
104
|
+
export const ISO_SOURCE = '^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,3})?Z$';
|
|
105
|
+
/** An http(s) link with no user and password in it */
|
|
106
|
+
export const URL_SOURCE = '^https?://[^\\s/@?#]+([/?#]\\S*)?$';
|
|
107
|
+
|
|
108
|
+
/** `word` as a pattern that ignores case without a flag: `tok` is `[tT][oO][kK]` */
|
|
109
|
+
const anyCase = (word) => word.replace(/[a-z]/gi, (c) => `[${c.toLowerCase()}${c.toUpperCase()}]`);
|
|
110
|
+
|
|
111
|
+
/** A field name that says it holds a credential (matched anywhere in the name, any case) */
|
|
112
|
+
export const CREDENTIAL_KEY_SOURCE = `(?:${['token', 'secret', 'passw', 'credential', 'api[_-]?key', 'access[_-]?key', 'private[_-]?key', 'ssh[_-]?key', 'authorization', 'bearer', 'cookie'].map(anyCase).join('|')})`;
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Where a token may start: not inside a longer word, so prose like "desk-booking-service-for-teams" never reads as an `sk-` key.
|
|
116
|
+
* Every alternative below starts with it (or with a fixed text), which also keeps each one linear in the length of the text.
|
|
117
|
+
*/
|
|
118
|
+
const EDGE = '(?<![A-Za-z0-9])';
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* A value shaped like a credential: a forge or cloud token, a JWT, a private key, a bearer header, a user and password in a link.
|
|
122
|
+
* Prose that only talks about tokens and passwords passes; `sk-` keys and bearer values need a digit in their first 41
|
|
123
|
+
* characters, as real ones have, so a long hyphenated word after "Bearer" or "sk-" is not taken for one. Each alternative tries a
|
|
124
|
+
* bounded stretch from each place it may start, so a 20000 character brief is read in linear time whatever it holds.
|
|
125
|
+
*/
|
|
126
|
+
export const CREDENTIAL_VALUE_SOURCE = [
|
|
127
|
+
`${EDGE}gh[pousr]_[A-Za-z0-9]{30,}`,
|
|
128
|
+
`${EDGE}github_pat_[A-Za-z0-9_]{20,}`,
|
|
129
|
+
`${EDGE}glpat-[A-Za-z0-9_-]{15,}`,
|
|
130
|
+
`${EDGE}gto-[A-Za-z0-9]{20,}`,
|
|
131
|
+
`${EDGE}sk-(?=[A-Za-z0-9_-]{20})[A-Za-z_-]{0,40}[0-9]`,
|
|
132
|
+
`${EDGE}AKIA[0-9A-Z]{16}`,
|
|
133
|
+
`${EDGE}AIza[0-9A-Za-z_-]{35}`,
|
|
134
|
+
`${EDGE}xox[abprs]-[A-Za-z0-9-]{10,}`,
|
|
135
|
+
`${EDGE}eyJ[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{5,}`,
|
|
136
|
+
'-----BEGIN [A-Z ]{0,40}PRIVATE KEY-----',
|
|
137
|
+
`${EDGE}${anyCase('bearer')}[ \\t]+(?=[A-Za-z0-9._~+/=-]{20})[A-Za-z._~+/=-]{0,40}[0-9]`,
|
|
138
|
+
// a user and a password in a link: the scheme is a whole word, the user holds no colon, so no part of the text is tried twice
|
|
139
|
+
'(?<![A-Za-z0-9+.-])[A-Za-z][A-Za-z0-9+.-]{0,31}://[^\\s/@:]+:[^\\s/@]+@',
|
|
140
|
+
].join('|');
|
|
141
|
+
|
|
142
|
+
/** A link whose query or fragment carries a credential (`?token=…`) */
|
|
143
|
+
export const CREDENTIAL_QUERY_SOURCE = `[?&#;](?:[^\\s=&#;]*(?:${['token', 'secret', 'passw', 'key', 'signature'].map(anyCase).join('|')})[^\\s=&#;]*|${anyCase('sig')})=`;
|
|
144
|
+
|
|
145
|
+
const RUN_ID = new RegExp(RUN_ID_SOURCE);
|
|
146
|
+
const LOGIN = new RegExp(LOGIN_SOURCE);
|
|
147
|
+
const SLUG = new RegExp(SLUG_SOURCE);
|
|
148
|
+
const PROFILE = new RegExp(PROFILE_SOURCE);
|
|
149
|
+
const NODE_ID = new RegExp(NODE_ID_SOURCE);
|
|
150
|
+
const ISO = new RegExp(ISO_SOURCE);
|
|
151
|
+
const URL_RE = new RegExp(URL_SOURCE);
|
|
152
|
+
const CREDENTIAL_KEY = new RegExp(CREDENTIAL_KEY_SOURCE);
|
|
153
|
+
const CREDENTIAL_VALUE = new RegExp(CREDENTIAL_VALUE_SOURCE);
|
|
154
|
+
const CREDENTIAL_QUERY = new RegExp(CREDENTIAL_QUERY_SOURCE);
|
|
155
|
+
|
|
156
|
+
const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
157
|
+
const isInt = (v) => typeof v === 'number' && Number.isInteger(v);
|
|
158
|
+
/** A name as it is shown in a message: short, on one line, quoted */
|
|
159
|
+
const shown = (v) => JSON.stringify(String(v).replace(/\s+/g, ' ').slice(0, 60));
|
|
160
|
+
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
// Run ids
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* A new run id: `run-` and a ULID, which is 10 characters of the time in milliseconds and 16 of randomness, in Crockford base32,
|
|
167
|
+
* so ids sort by when they were minted. Nothing is kept between calls: two ids of the same millisecond differ only by their
|
|
168
|
+
* random part.
|
|
169
|
+
* @param {{ now?: () => number, random?: (n: number) => Uint8Array }} [opts] a clock and a source of bytes, for tests
|
|
170
|
+
* @returns {string}
|
|
171
|
+
*/
|
|
172
|
+
export function newRunId({ now = Date.now, random = randomBytes } = {}) {
|
|
173
|
+
let time = now();
|
|
174
|
+
if (!Number.isSafeInteger(time) || time < 0 || time > 2 ** 48 - 1) throw new RangeError('A run id needs a clock that reads a time in milliseconds between 1970 and the year 10889.');
|
|
175
|
+
let head = '';
|
|
176
|
+
for (let i = 0; i < 10; i++) {
|
|
177
|
+
head = CROCKFORD[time % 32] + head;
|
|
178
|
+
time = Math.floor(time / 32);
|
|
179
|
+
}
|
|
180
|
+
const bytes = random(10);
|
|
181
|
+
if (!bytes || bytes.length < 10) throw new RangeError('A run id needs ten random bytes.');
|
|
182
|
+
// 80 bits, five at a time, as a BigInt (exact, and the one place one is worth having)
|
|
183
|
+
let bits = 0n;
|
|
184
|
+
for (let i = 0; i < 10; i++) bits = (bits << 8n) | BigInt(bytes[i]);
|
|
185
|
+
let tail = '';
|
|
186
|
+
for (let i = 0; i < 16; i++) {
|
|
187
|
+
tail = CROCKFORD[Number(bits & 31n)] + tail;
|
|
188
|
+
bits >>= 5n;
|
|
189
|
+
}
|
|
190
|
+
return `run-${head}${tail}`;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Whether `value` is a run id exactly as `newRunId` writes one */
|
|
194
|
+
export function isRunId(value) {
|
|
195
|
+
return typeof value === 'string' && RUN_ID.test(value);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** The time a run id was minted at, or null when it is not a run id */
|
|
199
|
+
export function runIdTime(runId) {
|
|
200
|
+
if (!isRunId(runId)) return null;
|
|
201
|
+
let ms = 0;
|
|
202
|
+
for (const c of runId.slice(4, 14)) ms = ms * 32 + CROCKFORD.indexOf(c);
|
|
203
|
+
return new Date(ms);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
// Shared checks
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
/** The first problem with a set of keys: a credential by name, or a key the object may not have. Own keys only */
|
|
211
|
+
function keyProblem(body, allowed, at) {
|
|
212
|
+
for (const key of Object.keys(body)) {
|
|
213
|
+
if (allowed.includes(key)) continue;
|
|
214
|
+
if (CREDENTIAL_KEY.test(key)) return `${at} has "${key}", which looks like a credential. A request never carries one: the runner uses its own identity.`;
|
|
215
|
+
return `${at} has "${key}", which the contract does not have.`;
|
|
216
|
+
}
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** The first string anywhere in `value` shaped like a credential, as a message, or null */
|
|
221
|
+
function valueProblem(value, at) {
|
|
222
|
+
if (typeof value === 'string') {
|
|
223
|
+
return CREDENTIAL_VALUE.test(value) ? `${at} holds something shaped like a credential (a token, a key or a password in a link). Nothing like that goes in a request or a report.` : null;
|
|
224
|
+
}
|
|
225
|
+
if (Array.isArray(value)) {
|
|
226
|
+
for (const [i, v] of value.entries()) {
|
|
227
|
+
const p = valueProblem(v, `${at}[${i}]`);
|
|
228
|
+
if (p) return p;
|
|
229
|
+
}
|
|
230
|
+
} else if (isObject(value)) {
|
|
231
|
+
for (const [k, v] of Object.entries(value)) {
|
|
232
|
+
const p = valueProblem(v, `${at}.${k}`);
|
|
233
|
+
if (p) return p;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const text = (v, field, max) => {
|
|
240
|
+
if (typeof v !== 'string') return `"${field}" is text.`;
|
|
241
|
+
if (v.length > max) return `"${field}" is at most ${max} characters.`;
|
|
242
|
+
return null;
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* A safe git ref name for a branch hint: letters, digits and `. _ - /` only; no empty, hidden or `.lock` part, no `..`, no
|
|
247
|
+
* leading dash (it would read as an option), no leading or trailing slash. Returns the problem, or null.
|
|
248
|
+
* @param {unknown} name
|
|
249
|
+
*/
|
|
250
|
+
export function branchProblem(name) {
|
|
251
|
+
if (typeof name !== 'string' || name === '') return 'is the name of a branch.';
|
|
252
|
+
if (name.length > MAX_BRANCH_CHARS) return `is at most ${MAX_BRANCH_CHARS} characters.`;
|
|
253
|
+
if (!/^[A-Za-z0-9._/-]+$/.test(name)) return 'has only letters, digits and . _ - / in it.';
|
|
254
|
+
if (name.startsWith('-')) return 'does not start with a dash.';
|
|
255
|
+
if (name.startsWith('/') || name.endsWith('/') || name.includes('//')) return 'has no empty part between slashes.';
|
|
256
|
+
if (name.includes('..')) return 'does not hold "..".';
|
|
257
|
+
if (name.endsWith('.')) return 'does not end with a dot.';
|
|
258
|
+
if (name.split('/').some((p) => p.startsWith('.') || p.endsWith('.lock'))) return 'has no part that starts with a dot or ends with ".lock".';
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Whether `s` is an ISO 8601 UTC time (`2026-11-05T09:30:00Z`) that is a real date */
|
|
263
|
+
function isoProblem(s) {
|
|
264
|
+
if (typeof s !== 'string' || !ISO.test(s)) return 'is a UTC time like 2026-11-05T09:30:00Z.';
|
|
265
|
+
const t = Date.parse(s);
|
|
266
|
+
if (Number.isNaN(t) || new Date(t).toISOString().slice(0, 19) !== s.slice(0, 19)) return 'is a real date and time.';
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ---------------------------------------------------------------------------
|
|
271
|
+
// The request
|
|
272
|
+
// ---------------------------------------------------------------------------
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Checks a request: JSON v1, strictly. Unknown keys are refused, and so is any key named like a credential or a value shaped like
|
|
276
|
+
* one. Pure: the value it returns is a copy with its keys in the contract's order.
|
|
277
|
+
* @param {unknown} body the parsed JSON
|
|
278
|
+
* @param {{ profiles?: string[] }} [opts] `profiles`: the names the developer configured (`agents.profiles`); a profile outside the list is refused. With no list, any well formed name passes.
|
|
279
|
+
* @returns {Checked}
|
|
280
|
+
*/
|
|
281
|
+
export function validateRequest(body, { profiles } = {}) {
|
|
282
|
+
const no = (error) => ({ ok: false, error });
|
|
283
|
+
if (!isObject(body)) return no('A request is a JSON object.');
|
|
284
|
+
const keys = keyProblem(body, REQUEST_KEYS, 'A request');
|
|
285
|
+
if (keys) return no(keys);
|
|
286
|
+
const secret = valueProblem(body, 'The request');
|
|
287
|
+
if (secret) return no(secret);
|
|
288
|
+
|
|
289
|
+
if (body.version !== CONTRACT_VERSION) return no(`"version" is ${CONTRACT_VERSION}; this is the only contract there is.`);
|
|
290
|
+
if (!isRunId(body.runId)) return no('"runId" is "run-" and a 26 character ULID, like run-01JC8Z3Q5M7VXK2RT9N4B6HDEF.');
|
|
291
|
+
if (!RUN_KINDS.includes(body.kind)) return no(`"kind" is one of ${RUN_KINDS.join(', ')}.`);
|
|
292
|
+
|
|
293
|
+
if (body.profile !== undefined) {
|
|
294
|
+
if (typeof body.profile !== 'string' || !PROFILE.test(body.profile)) return no(`"profile" is a name of letters, digits and . _ -, at most ${MAX_PROFILE_CHARS} characters.`);
|
|
295
|
+
if (profiles !== undefined && !profiles.includes(body.profile)) return no(`"profile" ${shown(body.profile)} is not one of the configured profiles (${profiles.join(', ') || 'none'}).`);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (!isObject(body.refs)) return no('"refs" says what the run is about: { issue, proposal, thread }, at least one.');
|
|
299
|
+
const refKeys = keyProblem(body.refs, REF_KEYS, '"refs"');
|
|
300
|
+
if (refKeys) return no(refKeys);
|
|
301
|
+
if (!['issue', 'proposal', 'thread'].some((k) => k in body.refs)) return no('"refs" needs at least one of issue, proposal or thread.');
|
|
302
|
+
for (const field of ['issue', 'pull', 'thread']) {
|
|
303
|
+
const n = body.refs[field];
|
|
304
|
+
if (n !== undefined && (!isInt(n) || n < 1 || n > MAX_NUMBER)) return no(`"refs.${field}" is a number from 1, like 12.`);
|
|
305
|
+
}
|
|
306
|
+
if (body.refs.proposal !== undefined && (typeof body.refs.proposal !== 'string' || !SLUG.test(body.refs.proposal))) return no('"refs.proposal" is the proposal\'s name, like 0014-planner.');
|
|
307
|
+
if (body.refs.pull !== undefined && body.refs.proposal === undefined) return no('"refs.pull" is the spec pull request that holds "refs.proposal", so it comes with one.');
|
|
308
|
+
|
|
309
|
+
if (body.branch !== undefined) {
|
|
310
|
+
const problem = branchProblem(body.branch);
|
|
311
|
+
if (problem) return no(`"branch" ${problem}`);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
{
|
|
315
|
+
// Required, as the spec's table has it (it may be empty: a summary of a thread needs no more than the thread)
|
|
316
|
+
const problem = body.brief === undefined ? '"brief" is the text the runner hands to its agent (it may be empty).' : text(body.brief, 'brief', MAX_BRIEF_CHARS);
|
|
317
|
+
if (problem) return no(problem);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (body.limits !== undefined) {
|
|
321
|
+
if (!isObject(body.limits)) return no('"limits" is { minutes, turns, cost }.');
|
|
322
|
+
const limitKeys = keyProblem(body.limits, LIMIT_KEYS, '"limits"');
|
|
323
|
+
if (limitKeys) return no(limitKeys);
|
|
324
|
+
for (const field of LIMIT_KEYS) {
|
|
325
|
+
const n = body.limits[field];
|
|
326
|
+
if (n === undefined) continue;
|
|
327
|
+
if (typeof n !== 'number' || !Number.isFinite(n) || n <= 0) return no(`"limits.${field}" is a number above zero.`);
|
|
328
|
+
if (field === 'turns' && !Number.isInteger(n)) return no('"limits.turns" is a whole number.');
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (body.nodes !== undefined) {
|
|
333
|
+
if (!Array.isArray(body.nodes)) return no('"nodes" is a list of node ids.');
|
|
334
|
+
if (body.nodes.length > MAX_NODES) return no(`"nodes" has at most ${MAX_NODES} ids.`);
|
|
335
|
+
const bad = body.nodes.findIndex((n) => typeof n !== 'string' || !NODE_ID.test(n));
|
|
336
|
+
if (bad >= 0) return no(`Node ${bad + 1} of "nodes" is not a node id (letters, digits and . _ : / -, at most ${MAX_NODE_CHARS} characters).`);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (typeof body.requestedBy !== 'string' || !LOGIN.test(body.requestedBy)) return no('"requestedBy" is a forge login, like maintainer-one.');
|
|
340
|
+
const when = isoProblem(body.requestedAt);
|
|
341
|
+
if (when) return no(`"requestedAt" ${when}`);
|
|
342
|
+
|
|
343
|
+
const value = { version: CONTRACT_VERSION, runId: body.runId, kind: body.kind };
|
|
344
|
+
if (body.profile !== undefined) value.profile = body.profile;
|
|
345
|
+
value.refs = {};
|
|
346
|
+
for (const k of REF_KEYS) if (body.refs[k] !== undefined) value.refs[k] = body.refs[k];
|
|
347
|
+
if (body.branch !== undefined) value.branch = body.branch;
|
|
348
|
+
value.brief = body.brief;
|
|
349
|
+
if (body.limits !== undefined) {
|
|
350
|
+
value.limits = {};
|
|
351
|
+
for (const k of LIMIT_KEYS) if (body.limits[k] !== undefined) value.limits[k] = body.limits[k];
|
|
352
|
+
}
|
|
353
|
+
if (body.nodes !== undefined) value.nodes = [...body.nodes];
|
|
354
|
+
value.requestedBy = body.requestedBy;
|
|
355
|
+
value.requestedAt = body.requestedAt;
|
|
356
|
+
return { ok: true, value };
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Makes a request from what a maintainer asked for: mints the run id and the time, then checks the result like any other.
|
|
361
|
+
* @param {Omit<RunRequest, 'version' | 'runId' | 'requestedAt'>} fields
|
|
362
|
+
* @param {{ profiles?: string[], now?: () => number, random?: (n: number) => Uint8Array }} [opts]
|
|
363
|
+
* @returns {Checked}
|
|
364
|
+
*/
|
|
365
|
+
export function createRequest(fields, { profiles, now = Date.now, random } = {}) {
|
|
366
|
+
if (!isObject(fields)) return { ok: false, error: 'A request is a JSON object.' };
|
|
367
|
+
const at = now();
|
|
368
|
+
if (!Number.isFinite(at)) return { ok: false, error: 'The clock did not give a time.' };
|
|
369
|
+
const { version, runId, requestedAt, ...rest } = fields; // these three are cornfield's to write
|
|
370
|
+
if (version !== undefined || runId !== undefined || requestedAt !== undefined) return { ok: false, error: 'A new request does not set "version", "runId" or "requestedAt": they are written for it.' };
|
|
371
|
+
return validateRequest({ version: CONTRACT_VERSION, runId: newRunId({ now: () => at, ...(random ? { random } : {}) }), ...rest, requestedAt: new Date(at).toISOString().replace(/\.\d{3}Z$/, 'Z') }, { profiles });
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// ---------------------------------------------------------------------------
|
|
375
|
+
// The report, and the stop request
|
|
376
|
+
// ---------------------------------------------------------------------------
|
|
377
|
+
|
|
378
|
+
function noteAndUrl(body, no) {
|
|
379
|
+
if (body.note !== undefined) {
|
|
380
|
+
const problem = text(body.note, 'note', MAX_NOTE_CHARS);
|
|
381
|
+
if (problem) return no(problem);
|
|
382
|
+
}
|
|
383
|
+
return null;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Checks a report from a runner: `{ state, note?, url?, tokens?, cost? }` and nothing else. `tokens` is a whole number and `cost` a
|
|
388
|
+
* number, neither below zero; cornfield shows them as sent and never estimates them. `url` is where the runner keeps its logs
|
|
389
|
+
* (http or https, with no user and password in it).
|
|
390
|
+
* @param {unknown} body
|
|
391
|
+
* @returns {Checked}
|
|
392
|
+
*/
|
|
393
|
+
export function validateReport(body) {
|
|
394
|
+
const no = (error) => ({ ok: false, error });
|
|
395
|
+
if (!isObject(body)) return no('A report is a JSON object with a state.');
|
|
396
|
+
const keys = keyProblem(body, REPORT_KEYS, 'A report');
|
|
397
|
+
if (keys) return no(keys);
|
|
398
|
+
const secret = valueProblem(body, 'The report');
|
|
399
|
+
if (secret) return no(secret);
|
|
400
|
+
if (!REPORT_STATES.includes(body.state)) return no(`"state" is one of ${REPORT_STATES.join(', ')}.`);
|
|
401
|
+
const bad = noteAndUrl(body, no);
|
|
402
|
+
if (bad) return bad;
|
|
403
|
+
if (body.url !== undefined) {
|
|
404
|
+
if (typeof body.url !== 'string' || body.url.length > MAX_URL_CHARS || !URL_RE.test(body.url)) return no(`"url" is an http or https link of at most ${MAX_URL_CHARS} characters, with no user and password in it.`);
|
|
405
|
+
if (CREDENTIAL_QUERY.test(body.url)) return no('"url" carries what looks like a credential in its query. Link to the logs without one.');
|
|
406
|
+
}
|
|
407
|
+
if (body.tokens !== undefined && (!isInt(body.tokens) || body.tokens < 0 || !Number.isSafeInteger(body.tokens))) return no('"tokens" is a whole number, zero or more.');
|
|
408
|
+
if (body.cost !== undefined && (typeof body.cost !== 'number' || !Number.isFinite(body.cost) || body.cost < 0)) return no('"cost" is a number, zero or more.');
|
|
409
|
+
const value = { state: body.state };
|
|
410
|
+
for (const k of ['note', 'url', 'tokens', 'cost']) if (body[k] !== undefined) value[k] = body[k];
|
|
411
|
+
return { ok: true, value };
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Checks a stop request: `{ state: 'stop-requested', note? }`. cornfield sends it; the runner decides what stopping means.
|
|
416
|
+
* @param {unknown} body
|
|
417
|
+
* @returns {Checked}
|
|
418
|
+
*/
|
|
419
|
+
export function validateStopRequest(body) {
|
|
420
|
+
const no = (error) => ({ ok: false, error });
|
|
421
|
+
if (!isObject(body)) return no('A stop request is a JSON object with a state.');
|
|
422
|
+
const keys = keyProblem(body, STOP_KEYS, 'A stop request');
|
|
423
|
+
if (keys) return no(keys);
|
|
424
|
+
const secret = valueProblem(body, 'The stop request');
|
|
425
|
+
if (secret) return no(secret);
|
|
426
|
+
if (body.state !== STOP_STATE) return no(`"state" is "${STOP_STATE}".`);
|
|
427
|
+
const bad = noteAndUrl(body, no);
|
|
428
|
+
if (bad) return bad;
|
|
429
|
+
return { ok: true, value: { state: STOP_STATE, ...(body.note !== undefined ? { note: body.note } : {}) } };
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Checks whatever a `cornfield-run` block holds, by its shape: a `version` makes it a request, the state `stop-requested` a
|
|
434
|
+
* stop request, any other state a report.
|
|
435
|
+
* @param {unknown} body
|
|
436
|
+
* @param {{ profiles?: string[] }} [opts]
|
|
437
|
+
* @returns {{ ok: true, type: 'request' | 'report' | 'stop-request', value: any } | { ok: false, error: string }}
|
|
438
|
+
*/
|
|
439
|
+
export function validateRunBody(body, opts) {
|
|
440
|
+
if (Array.isArray(body)) return { ok: false, error: 'A cornfield-run block holds one JSON object, not a list.' };
|
|
441
|
+
if (!isObject(body)) return { ok: false, error: 'A cornfield-run block holds one JSON object.' };
|
|
442
|
+
const [type, checked] = 'version' in body || 'runId' in body ? ['request', validateRequest(body, opts)]
|
|
443
|
+
: body.state === STOP_STATE ? ['stop-request', validateStopRequest(body)]
|
|
444
|
+
: 'state' in body ? ['report', validateReport(body)]
|
|
445
|
+
: ['request', validateRequest(body, opts)]; // neither shape: the request check names the first key or field that is wrong
|
|
446
|
+
return checked.ok ? { ok: true, type, value: checked.value } : checked;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// ---------------------------------------------------------------------------
|
|
450
|
+
// The fenced block: written, and read back
|
|
451
|
+
// ---------------------------------------------------------------------------
|
|
452
|
+
|
|
453
|
+
const FENCE = '```';
|
|
454
|
+
|
|
455
|
+
/** The block for a checked request, report or stop request, as text: the fence named for the run, the JSON, the closing fence. Throws on anything that is not strictly one. */
|
|
456
|
+
export function formatRunBlock(runId, body, opts) {
|
|
457
|
+
if (!isRunId(runId)) throw new TypeError(`${shown(runId)} is not a run id.`);
|
|
458
|
+
const checked = validateRunBody(body, opts);
|
|
459
|
+
if (!checked.ok) throw new TypeError(checked.error);
|
|
460
|
+
if (checked.type === 'request' && checked.value.runId !== runId) throw new TypeError(`The block is for ${runId} but the request is for ${checked.value.runId}.`);
|
|
461
|
+
const json = JSON.stringify(checked.value, null, 2);
|
|
462
|
+
if (json.length > MAX_BLOCK_CHARS) throw new RangeError(`That is too long for a block (${json.length} characters, at most ${MAX_BLOCK_CHARS}); shorten the brief.`);
|
|
463
|
+
// JSON text never holds a raw newline inside a string, so nothing in it can close the fence
|
|
464
|
+
return `${FENCE}${RUN_TAG} ${runId}\n${json}\n${FENCE}`;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** One line, with nothing in it that could open a fence, and every mention and reference defused */
|
|
468
|
+
const oneLine = (s) => defuse(String(s).replace(/\s+/g, ' ').replace(/`{3,}/g, "'''").trim());
|
|
469
|
+
|
|
470
|
+
function defaultLine(runId, type, value) {
|
|
471
|
+
if (type === 'request') {
|
|
472
|
+
const about = [value.refs.issue && `issue #${value.refs.issue}`, value.refs.proposal && `proposal ${value.refs.proposal}${value.refs.pull ? ` (pull request #${value.refs.pull})` : ''}`, value.refs.thread && `thread #${value.refs.thread}`].filter(Boolean).join(', ');
|
|
473
|
+
return `Asked for a ${value.kind} run \`${runId}\` on ${about}${value.profile ? ` with the profile ${value.profile}` : ''} (${value.requestedBy}, in cornfield).`;
|
|
474
|
+
}
|
|
475
|
+
if (type === 'stop-request') return `Asked run \`${runId}\` to stop.`;
|
|
476
|
+
return `Run \`${runId}\`: ${value.state}.`;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* The text of a comment for a request, a report or a stop request: a human line (defused: it pings and links nobody), the note of a
|
|
481
|
+
* report or stop request quoted and defused, then the block. The block keeps every value as sent, for the runner.
|
|
482
|
+
* @param {string} runId
|
|
483
|
+
* @param {RunRequest | RunReport | StopRequest} body
|
|
484
|
+
* @param {{ line?: string, profiles?: string[] }} [opts] `line`: your own human line, in place of the default
|
|
485
|
+
* @returns {string}
|
|
486
|
+
*/
|
|
487
|
+
export function formatRunComment(runId, body, { line, profiles } = {}) {
|
|
488
|
+
const block = formatRunBlock(runId, body, { profiles });
|
|
489
|
+
const checked = validateRunBody(body, { profiles });
|
|
490
|
+
const head = oneLine(line ?? defaultLine(runId, checked.type, checked.value));
|
|
491
|
+
const note = checked.type === 'request' ? '' : (checked.value.note ?? '').trim();
|
|
492
|
+
const quoted = note ? `\n\n${defuse(note).split(/\r?\n/).map((l) => (l ? `> ${l}` : '>')).join('\n')}` : '';
|
|
493
|
+
const out = `${head}${quoted}\n\n${block}`;
|
|
494
|
+
if (out.length > MAX_COMMENT_CHARS) throw new RangeError(`That is too long for a comment (${out.length} characters, at most ${MAX_COMMENT_CHARS}); shorten the brief or the note.`);
|
|
495
|
+
return out;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/** A line that opens or closes a fence: three or more backticks or tildes at the start of the line, and the rest */
|
|
499
|
+
const FENCE_LINE = /^(`{3,}|~{3,})(.*)$/;
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* The fenced code blocks of a text, outermost only, in one pass over its lines: `{ fence, info, body, end }` for each, where
|
|
503
|
+
* `end` is false for a block nobody closed (it runs to the end of the text). A fence closes on a line of the same character, at
|
|
504
|
+
* least as long, with nothing after it but spaces, as Markdown reads it; so a block inside a longer fence (a ```` example, a
|
|
505
|
+
* ~~~ one) or inside a ```js block is part of that block, never a block of its own. Fences start a line: a quoted or indented
|
|
506
|
+
* one is text. Linear in the length of the text, whatever it holds.
|
|
507
|
+
* @param {string} text with `\n` line ends
|
|
508
|
+
*/
|
|
509
|
+
export function* fencedBlocks(text) {
|
|
510
|
+
const lines = text.split('\n');
|
|
511
|
+
for (let i = 0; i < lines.length; i++) {
|
|
512
|
+
const open = FENCE_LINE.exec(lines[i]);
|
|
513
|
+
if (!open) continue;
|
|
514
|
+
const [, fence, info] = open;
|
|
515
|
+
// An info string after backticks may not hold a backtick (Markdown); such a line is text
|
|
516
|
+
if (fence[0] === '`' && info.includes('`')) continue;
|
|
517
|
+
let j = i + 1;
|
|
518
|
+
for (; j < lines.length; j++) {
|
|
519
|
+
const close = FENCE_LINE.exec(lines[j]);
|
|
520
|
+
if (close && close[1][0] === fence[0] && close[1].length >= fence.length && close[2].trim() === '') break;
|
|
521
|
+
}
|
|
522
|
+
yield { fence, info, body: lines.slice(i + 1, j).join('\n'), end: j < lines.length };
|
|
523
|
+
i = j;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* Every block a comment's text holds, in order, and every block that was refused with the reason. A block that is not strictly a
|
|
529
|
+
* request, a report or a stop request is refused: not JSON, a list, two objects, a key it does not have, a credential, a fence
|
|
530
|
+
* that names no run id, a request for another run than its fence names. Text around a block, a quoted block (`> ```…`), an
|
|
531
|
+
* indented one, one nobody closed, and one inside another fence (a ```` example) are ignored (`fencedBlocks`). Nothing is thrown,
|
|
532
|
+
* and the time it takes is linear in the text.
|
|
533
|
+
* @param {unknown} body a comment's text
|
|
534
|
+
* @param {{ profiles?: string[] }} [opts]
|
|
535
|
+
* @returns {{ blocks: RunBlock[], refused: { runId: string | null, error: string }[] }}
|
|
536
|
+
*/
|
|
537
|
+
export function readRunBlocks(body, opts) {
|
|
538
|
+
const out = { blocks: [], refused: [] };
|
|
539
|
+
if (typeof body !== 'string' || !body.includes(RUN_TAG)) return out;
|
|
540
|
+
let seen = 0;
|
|
541
|
+
for (const b of fencedBlocks(body.slice(0, MAX_TEXT_CHARS).replace(/\r\n?/g, '\n'))) {
|
|
542
|
+
// A run block is a fence of exactly three backticks, as cornfield writes it, that was closed
|
|
543
|
+
if (b.fence !== FENCE || !b.end || !b.info.startsWith(RUN_TAG)) continue;
|
|
544
|
+
const m = [null, b.info.slice(RUN_TAG.length), b.body];
|
|
545
|
+
if (++seen > MAX_BLOCKS) {
|
|
546
|
+
out.refused.push({ runId: null, error: `A comment is read for at most ${MAX_BLOCKS} cornfield-run blocks.` });
|
|
547
|
+
break;
|
|
548
|
+
}
|
|
549
|
+
const name = m[1].trim();
|
|
550
|
+
const runId = isRunId(name) ? name : null;
|
|
551
|
+
const refuse = (error) => out.refused.push({ runId, error });
|
|
552
|
+
if (!runId) {
|
|
553
|
+
refuse(`The fence names ${shown(name)}, which is not a run id; it is \`${RUN_TAG} run-\` and a 26 character ULID.`);
|
|
554
|
+
continue;
|
|
555
|
+
}
|
|
556
|
+
if (m[2].length > MAX_BLOCK_CHARS) {
|
|
557
|
+
refuse(`The block is longer than ${MAX_BLOCK_CHARS} characters.`);
|
|
558
|
+
continue;
|
|
559
|
+
}
|
|
560
|
+
let json;
|
|
561
|
+
try {
|
|
562
|
+
json = JSON.parse(m[2]);
|
|
563
|
+
} catch {
|
|
564
|
+
refuse('The block is not JSON (it holds exactly one object).');
|
|
565
|
+
continue;
|
|
566
|
+
}
|
|
567
|
+
const checked = validateRunBody(json, opts);
|
|
568
|
+
if (!checked.ok) {
|
|
569
|
+
refuse(checked.error);
|
|
570
|
+
continue;
|
|
571
|
+
}
|
|
572
|
+
if (checked.type === 'request' && checked.value.runId !== runId) {
|
|
573
|
+
refuse(`The fence is for ${runId} but the request is for ${checked.value.runId}.`);
|
|
574
|
+
continue;
|
|
575
|
+
}
|
|
576
|
+
out.blocks.push({ runId, type: checked.type, value: checked.value });
|
|
577
|
+
}
|
|
578
|
+
return out;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/** How long after it was posted a comment may change and still count as not edited (the forge's own touch-ups), as for reviews */
|
|
582
|
+
export const EDIT_GRACE_MS = 5000;
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* @typedef {{ id: string, author: { login: string } | null, body: string, url?: string, createdAt: string, updatedAt?: string }} RunComment
|
|
586
|
+
* a forge comment, as server/forge.js gives it
|
|
587
|
+
* @typedef {RunBlock & { by: string, at: string, comment: { id: string, url: string | null } }} FoundBlock
|
|
588
|
+
*/
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* The logins whose comments hold something that may be a run block, once each, newest comment first: the ones worth asking the
|
|
592
|
+
* forge about (their permission, or whether they are in `agents.runners`) before `runBlocksOf`.
|
|
593
|
+
* @param {RunComment[]} comments
|
|
594
|
+
* @returns {string[]}
|
|
595
|
+
*/
|
|
596
|
+
export function runAuthors(comments) {
|
|
597
|
+
const out = new Set();
|
|
598
|
+
for (let i = (Array.isArray(comments) ? comments.length : 0) - 1; i >= 0; i--) {
|
|
599
|
+
const c = comments[i];
|
|
600
|
+
if (c?.author?.login && typeof c.body === 'string' && c.body.includes(RUN_TAG)) out.add(c.author.login);
|
|
601
|
+
}
|
|
602
|
+
return [...out];
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Every run block of a forge conversation (comments oldest first), with the comment it came from, sorted three ways:
|
|
607
|
+
* - `blocks`: from an author `trust(login)` accepts, in a comment not edited after it was posted;
|
|
608
|
+
* - `ignored`: well formed, but from someone else (or a deleted account), or in an edited comment: the spec wants one audit row
|
|
609
|
+
* for each, so they are kept with the reason;
|
|
610
|
+
* - `refused`: not strictly a request, report or stop request (`readRunBlocks`), with the author.
|
|
611
|
+
* Whom to trust is the caller's to decide, because it takes the forge: a maintainer now for a request or a stop, a login in
|
|
612
|
+
* `agents.runners` or a maintainer for a report (spec 0005). `trust` is asked once per block, with the block, so it can tell them
|
|
613
|
+
* apart.
|
|
614
|
+
* @param {RunComment[]} comments
|
|
615
|
+
* @param {{ trust: (login: string, block: RunBlock) => boolean, profiles?: string[] }} opts
|
|
616
|
+
* @returns {{ blocks: FoundBlock[], ignored: (FoundBlock & { why: 'author' | 'edited' })[], refused: { runId: string | null, error: string, by: string | null, comment: { id: string, url: string | null } }[] }}
|
|
617
|
+
*/
|
|
618
|
+
export function runBlocksOf(comments, { trust, profiles } = /** @type {any} */ ({})) {
|
|
619
|
+
if (typeof trust !== 'function') throw new TypeError('runBlocksOf needs a trust(login, block) function: whose blocks count is the caller\'s to say.');
|
|
620
|
+
const out = { blocks: [], ignored: [], refused: [] };
|
|
621
|
+
for (const c of Array.isArray(comments) ? comments : []) {
|
|
622
|
+
if (!c || typeof c.body !== 'string') continue;
|
|
623
|
+
const read = readRunBlocks(c.body, { profiles });
|
|
624
|
+
const by = c.author?.login ?? null;
|
|
625
|
+
const comment = { id: String(c.id), url: c.url ?? null };
|
|
626
|
+
for (const r of read.refused) out.refused.push({ ...r, by, comment });
|
|
627
|
+
const edited = c.updatedAt !== undefined && Date.parse(c.updatedAt) - Date.parse(c.createdAt) > EDIT_GRACE_MS;
|
|
628
|
+
for (const b of read.blocks) {
|
|
629
|
+
const found = { ...b, by, at: c.createdAt, comment };
|
|
630
|
+
if (by === null || !trust(by, b)) out.ignored.push({ ...found, why: 'author' });
|
|
631
|
+
else if (edited) out.ignored.push({ ...found, why: 'edited' });
|
|
632
|
+
else out.blocks.push(found);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
return out;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/** The blocks a comment's text holds, in order (see `readRunBlocks`; the refused ones are dropped) */
|
|
639
|
+
export function parseRunBlocks(body, opts) {
|
|
640
|
+
return readRunBlocks(body, opts).blocks;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* The latest block of `type` for `runId` in a list of blocks in the order they were written (the caller orders its comments and
|
|
645
|
+
* drops the ones whose author is not a maintainer, or whose comment was edited), or null. Later wins.
|
|
646
|
+
* @param {RunBlock[]} blocks
|
|
647
|
+
* @param {string} runId
|
|
648
|
+
* @param {'request' | 'report' | 'stop-request'} type
|
|
649
|
+
* @returns {RunBlock | null}
|
|
650
|
+
*/
|
|
651
|
+
export function latestBlock(blocks, runId, type) {
|
|
652
|
+
for (let i = blocks.length - 1; i >= 0; i--) if (blocks[i].runId === runId && blocks[i].type === type) return blocks[i];
|
|
653
|
+
return null;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// ---------------------------------------------------------------------------
|
|
657
|
+
// The trailer, and the branch
|
|
658
|
+
// ---------------------------------------------------------------------------
|
|
659
|
+
|
|
660
|
+
const TRAILER = new RegExp(`^ {0,3}${anyCase(TRAILER_KEY)}[ \\t]*:[ \\t]*(run-${ULID_SOURCE})[ \\t]*$`);
|
|
661
|
+
|
|
662
|
+
/** The trailer line for a run: `Cornfield-Run: run-…` */
|
|
663
|
+
export function formatTrailer(runId) {
|
|
664
|
+
if (!isRunId(runId)) throw new TypeError(`${shown(runId)} is not a run id.`);
|
|
665
|
+
return `${TRAILER_KEY}: ${runId}`;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* The run id a pull request body or a commit message names in a `Cornfield-Run:` trailer: the key in any case, the id on a line of
|
|
670
|
+
* its own, the last such line when there are several. A line that is quoted (`> `), indented as code (four spaces or a tab),
|
|
671
|
+
* inside a fenced code block (an example in a pull request body), or holds more than the trailer does not count.
|
|
672
|
+
* @param {unknown} message
|
|
673
|
+
* @returns {string | null}
|
|
674
|
+
*/
|
|
675
|
+
export function parseTrailer(message) {
|
|
676
|
+
if (typeof message !== 'string' || message.length > MAX_TEXT_CHARS) return null;
|
|
677
|
+
let found = null;
|
|
678
|
+
let fence = null;
|
|
679
|
+
for (const line of message.replace(/\r\n?/g, '\n').split('\n')) {
|
|
680
|
+
const f = FENCE_LINE.exec(line);
|
|
681
|
+
if (fence) {
|
|
682
|
+
if (f && f[1][0] === fence[0] && f[1].length >= fence.length && f[2].trim() === '') fence = null;
|
|
683
|
+
continue;
|
|
684
|
+
}
|
|
685
|
+
if (f && !(f[1][0] === '`' && f[2].includes('`'))) {
|
|
686
|
+
fence = f[1];
|
|
687
|
+
continue;
|
|
688
|
+
}
|
|
689
|
+
const m = TRAILER.exec(line);
|
|
690
|
+
if (m) found = m[1];
|
|
691
|
+
}
|
|
692
|
+
return found;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
const BRANCH_ID = new RegExp(`(?<![A-Za-z0-9])run-(${ULID_SOURCE})(?![A-Za-z0-9])`, 'gi');
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* The run id in a branch name, as a whole segment (`agent/run-01JC…/fix`, `fix-run-01JC…`, never part of a longer word). Branch
|
|
699
|
+
* names are often lower-cased on the way, so the case is forgiven and the id is returned as it was minted. The last one wins.
|
|
700
|
+
* @param {unknown} name
|
|
701
|
+
* @returns {string | null}
|
|
702
|
+
*/
|
|
703
|
+
export function runIdFromBranch(name) {
|
|
704
|
+
if (typeof name !== 'string' || name.length > 1000) return null;
|
|
705
|
+
let found = null;
|
|
706
|
+
for (const m of name.matchAll(BRANCH_ID)) {
|
|
707
|
+
const id = `run-${m[1].toUpperCase()}`;
|
|
708
|
+
if (isRunId(id)) found = id;
|
|
709
|
+
}
|
|
710
|
+
return found;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/**
|
|
714
|
+
* The run a pull request belongs to: the trailer in its body, else the trailer in the latest of its commit messages that has one,
|
|
715
|
+
* else the run id in its branch name (D3: no branch naming scheme is imposed, so the branch is the last resort).
|
|
716
|
+
* @param {{ body?: string, commits?: string[], branch?: string }} pull
|
|
717
|
+
* @returns {{ runId: string, via: 'body' | 'commit' | 'branch' } | null}
|
|
718
|
+
*/
|
|
719
|
+
export function findRunId({ body, commits, branch } = {}) {
|
|
720
|
+
const fromBody = parseTrailer(body);
|
|
721
|
+
if (fromBody) return { runId: fromBody, via: 'body' };
|
|
722
|
+
for (let i = (commits?.length ?? 0) - 1; i >= 0; i--) {
|
|
723
|
+
const fromCommit = parseTrailer(commits[i]);
|
|
724
|
+
if (fromCommit) return { runId: fromCommit, via: 'commit' };
|
|
725
|
+
}
|
|
726
|
+
const fromBranch = runIdFromBranch(branch);
|
|
727
|
+
return fromBranch ? { runId: fromBranch, via: 'branch' } : null;
|
|
728
|
+
}
|