atris 3.76.0 → 3.77.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.
@@ -0,0 +1,1060 @@
1
+ 'use strict';
2
+
3
+ // The owner's one screen: what is waiting on a person, what landed this week,
4
+ // and how each teammate is doing.
5
+ //
6
+ // Everything here is read, nothing is written. The task store is opened
7
+ // read-only, the links come from what jobs saved (a merge commit also uses the
8
+ // workspace's own git origin), and no network is touched. Landings, ratings, and
9
+ // miss reasons come from lib/gm-season.js, the code atris rating and atris gm run,
10
+ // so this screen cannot disagree with them. Every link, title, note, and reason is
11
+ // cleaned of secrets before it is returned, so nothing printed from here (the
12
+ // screen, the page, the text sent, the json) carries one.
13
+
14
+ const fs = require('fs');
15
+ const { spawnSync } = require('child_process');
16
+ const { scrub } = require('../utils/agent-client');
17
+ const { SECRET_RULES } = require('./security-scan');
18
+
19
+ const DAY_MS = 24 * 60 * 60 * 1000;
20
+ const WEEK_MS = 7 * DAY_MS;
21
+ // Needs you prints this many, then "and N more".
22
+ const SHOWN = 10;
23
+ // A job a person sent back this many times is not getting better on its own.
24
+ const SENT_BACK_LIMIT = 2;
25
+ const REASON_MAX = 100;
26
+
27
+ // ---- small helpers -----------------------------------------------------------
28
+
29
+ function parseJson(text) {
30
+ if (!text) return {};
31
+ try {
32
+ const value = JSON.parse(text);
33
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
34
+ } catch {
35
+ return {};
36
+ }
37
+ }
38
+
39
+ function plain(value) {
40
+ return String(value == null ? '' : value)
41
+ .replace(/[\u2013\u2014]/g, '-')
42
+ .replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ')
43
+ .replace(/\s+/g, ' ')
44
+ .trim();
45
+ }
46
+
47
+ // At most `max` characters, cut at a word when there is one near the end.
48
+ function clip(value, max) {
49
+ const text = plain(value);
50
+ if (text.length <= max) return text;
51
+ const cut = text.slice(0, Math.max(0, max - 3));
52
+ const space = cut.lastIndexOf(' ');
53
+ return `${(space > max * 0.6 ? cut.slice(0, space) : cut).trim()}...`;
54
+ }
55
+
56
+ // Someone else's words, quoted on a line of their own: no quote marks inside, no
57
+ // full stop at the end.
58
+ function quoted(value, max) {
59
+ return `"${clip(plain(value).replace(/"/g, "'").replace(/[.!?]+$/, ''), max)}"`;
60
+ }
61
+
62
+ // A length of time in words: "40 minutes", "5 hours", "2 days".
63
+ function spanWords(ms) {
64
+ const minutes = Math.max(0, Math.round((Number(ms) || 0) / 60000));
65
+ if (minutes < 60) return minutes <= 1 ? 'a minute' : `${minutes} minutes`;
66
+ const hours = Math.round(minutes / 60);
67
+ if (hours < 24) return hours === 1 ? 'an hour' : `${hours} hours`;
68
+ const days = Math.round(hours / 24);
69
+ return days === 1 ? 'a day' : `${days} days`;
70
+ }
71
+
72
+ function withMeta(row) {
73
+ return { ...row, metadata: parseJson(row.metadata) };
74
+ }
75
+
76
+ // ---- secrets -----------------------------------------------------------------
77
+
78
+ // Notes, reasons, titles, names, tags, and links come from jobs and from other people,
79
+ // and a token that went through a task command can sit in any of them. The repo has no
80
+ // cleaner for free text, so this joins the two pieces it does have: the provider
81
+ // patterns the security scan finds secrets with (lib/security-scan.js) and scrub, which
82
+ // keeps client secrets and JWTs out of error text (utils/agent-client.js). On top of
83
+ // those it covers what people paste in practice: a login inside a link, the query
84
+ // string and fragment of a link, a short Bearer token, a private key block, and the
85
+ // value of a pair like token=abc, whatever its length (withoutAssignedSecrets).
86
+ //
87
+ // Two changes to the scan's rules. The scan matches a provider key between word
88
+ // boundaries, and a letter, a digit, and an underscore are word characters, so
89
+ // worker_ghp_... or xAKIA... gets past it. Here a known provider key is looked for with
90
+ // no boundary at either end and the same body length, so it is hidden after any
91
+ // character while ordinary words (task-force, ghp_short) still do not match. And the
92
+ // scan's assigned-secret rule is left out: withoutAssignedSecrets reads the same pairs
93
+ // for more names and in more forms, and reads a quoted value to its real end, where the
94
+ // rule stops at an escaped quote and leaves the rest of the value showing.
95
+ //
96
+ // A note can be as long as someone makes it, and a pattern that starts again at every
97
+ // character can take longer than linear time on a long run of the same few characters.
98
+ // So the cleaner reads the first CLEAN_MAX characters of a field and no more, cut back to
99
+ // the last space (cutAtSpace), and the patterns below are written so that each one looks at
100
+ // a character once: a start that cannot begin a match is refused at once, by what is before
101
+ // it, rather than tried and backed out of. What it gives back is at most 300 characters, the
102
+ // most scrub lets through, and they end at a whole word (scrubbed, below). A field that was
103
+ // cut can end in a piece of a key, which no key rule knows: the start of a key at the end of a
104
+ // field is hidden (withoutCutKey, below), and a reason the store itself cut at 300 characters is
105
+ // read without its last word (reasonOf, below).
106
+ const REDACTED = '[redacted]';
107
+ const withoutBoundaries = (rule) => (rule.known ? rule.re.source.replace(/^\\b/, '').replace(/\\b$/, '') : rule.re.source);
108
+ const SECRET_PATTERNS = [
109
+ [/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)/g, REDACTED],
110
+ // a login in a link, found from the :// outward: whatever stands before a login is the scheme
111
+ [/(?<=:\/\/)[^\s/?#@]+(?=@)/g, REDACTED],
112
+ // a link without its query string and fragment. The query is optional, so a link with none
113
+ // still matches and is used up whole, where a link that fails to match would be tried
114
+ // again from every https:// inside it.
115
+ [/\b(https?:\/\/[^\s?#"'<>]+)(?:[?#][^\s"'<>]*)?/gi, '$1'],
116
+ ...SECRET_RULES
117
+ .filter((rule) => rule.id !== 'assigned-secret')
118
+ .map((rule) => [new RegExp(withoutBoundaries(rule), rule.re.flags.includes('g') ? rule.re.flags : `${rule.re.flags}g`), REDACTED]),
119
+ [/\bBearer\s+[A-Za-z0-9._~+/=-]{8,}/g, REDACTED],
120
+ ];
121
+
122
+ // Names a value is a credential under. A name is cut into words at dashes, underscores,
123
+ // digits, and capital letters, so access_token, access-token, accessToken, and
124
+ // ACCESS_TOKEN read the same, and whole words only count: monkey, keyboard, author, and
125
+ // design are not keys. There are two kinds of name.
126
+ // strong: always a credential. token, secret, password, passwd, pwd, authorization, a
127
+ // run-together spelling that ends in one of them (accesstoken, clientsecret, apikey),
128
+ // and key after a word that makes it a credential (api_key, private_key, x-api-key).
129
+ // weak: auth, key, session, sig, signature. These are also plain words in a sentence
130
+ // ("Fix auth: redirect loop"), so what follows one is judged (looksLikeToken).
131
+ const STRONG_NAME_WORD = /^(?:token|secret|password|passwd|pwd|authorization)$|(?:token|secret|password|passwd|pwd|(?:access|auth|refresh|api|client|private|secret)key)$/;
132
+ const WEAK_NAME_WORD = /^(?:key|auth|session|sig|signature)$/;
133
+ const KEY_QUALIFIER = /^(?:access|auth|refresh|api|client|private|secret)$/;
134
+
135
+ function secretNameKind(name) {
136
+ const words = String(name)
137
+ .replace(/([a-z])([A-Z])/g, '$1 $2')
138
+ .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
139
+ .replace(/([A-Za-z])([0-9])/g, '$1 $2')
140
+ .replace(/([0-9])([A-Za-z])/g, '$1 $2')
141
+ .toLowerCase()
142
+ .split(/[^a-z0-9]+/)
143
+ .filter(Boolean);
144
+ if (words.some((word, i) => STRONG_NAME_WORD.test(word) || (word === 'key' && KEY_QUALIFIER.test(words[i - 1] || '')))) return 'strong';
145
+ return words.some((word) => WEAK_NAME_WORD.test(word)) ? 'weak' : null;
146
+ }
147
+
148
+ // What follows a weak name in a sentence reads as a token when it has a digit, or is 16
149
+ // characters or more with no space in it, or has a key already cut out of it with letters
150
+ // or digits still stuck to the end of the marker (a key, then ,def). A token with a known
151
+ // shape (a provider key, a JWT, a Bearer value, an atcs_ secret) has already been cut out
152
+ // by the patterns, or is cut out by scrub right after, so the key itself never depends on
153
+ // this check; what is stuck to it does. A marker does not count toward the length, and a
154
+ // marker with nothing but punctuation after it is not a token, so cleaning clean text
155
+ // changes nothing.
156
+ function looksLikeToken(value) {
157
+ const parts = String(value).replace(/^["']|["']$/g, '').split(REDACTED);
158
+ if (parts.slice(1).some((part) => /[A-Za-z0-9]/.test(part.split(/\s/, 1)[0]))) return true;
159
+ const bare = parts.join('');
160
+ return /[0-9]/.test(bare) || (bare.length >= 16 && !/\s/.test(bare));
161
+ }
162
+
163
+ // The value of a pair like token=abc, password: abc, or "api_key": "abc", when the name
164
+ // says it is a credential. Short values are the ones the patterns above cannot tell from
165
+ // ordinary words, and the name is all that gives them away. Two forms are told apart.
166
+ // Written as a setting (name=value, name := value, name => value, or a json pair with
167
+ // the name in quotes), every credential name hides its value whatever its length, since
168
+ // links and config text use these forms. Written as a sentence (a bare name: value), a
169
+ // weak name hides the value only when it looks like a token, so "Fix auth: redirect
170
+ // loop" and "session: rename the button" read as written. A quoted value runs to its
171
+ // closing quote, with backslash escapes read, so password="abc\"def" is one value; a quote
172
+ // that never closes runs to the end of the text, so a cut or a typo cannot leave the rest
173
+ // of a quoted secret showing. Any other value runs to the next space or the end of the
174
+ // text, so password=abc,def hides all of abc,def. That can hide a little more than the
175
+ // secret, which is the safe side. The run is the same whatever ran before this rule: a key
176
+ // an earlier pattern already cut out is part of it like any other characters, so
177
+ // password=[redacted],def is hidden to the end of the run, marker included, and nothing
178
+ // stuck to a key is left showing. The cost is that a json pair cleaned a second time loses
179
+ // the comma after it ("password": [redacted], becomes "password": [redacted]), which hides
180
+ // nothing more than was hidden. A pair is looked for anywhere, so one inside a link's
181
+ // path (runs/token=abc) is found too, and a name that is not a credential, or a weak one
182
+ // left alone, is skipped without using up what follows it (https: comes before the path it
183
+ // sits in front of).
184
+ function withoutAssignedSecrets(text) {
185
+ const pairs = /([A-Za-z][A-Za-z0-9_-]{0,63})(["']?)\s*(:=|=>|[:=])\s*/g;
186
+ const value = /(?:(?:bearer|basic|digest|token)\s+)?(?:(?<closed>"(?:[^"\\]|\\[\s\S])*"|'(?:[^'\\]|\\[\s\S])*')|(?<open>["'][\s\S]*)|(?<run>\S+))/iy;
187
+ // Inside a value that is already hidden, a pair matters only if its own value can run past
188
+ // the end of that one: a quoted value, or one after a word like Bearer, can reach beyond
189
+ // the space that ends the run. Any other value ends where the run it sits in ends.
190
+ const reachesPast = /["']|(?:bearer|basic|digest|token)\s/iy;
191
+ let out = '';
192
+ let from = 0;
193
+ let found;
194
+ while ((found = pairs.exec(text))) {
195
+ const kind = secretNameKind(found[1]);
196
+ if (!kind) continue;
197
+ const start = found.index + found[0].length;
198
+ if (start < from) {
199
+ reachesPast.lastIndex = start;
200
+ if (!reachesPast.test(text)) continue;
201
+ }
202
+ value.lastIndex = start;
203
+ const hit = value.exec(text);
204
+ if (!hit) continue;
205
+ const asSetting = found[2] !== '' || found[3] !== ':';
206
+ // in a sentence, an open quote is judged by its first word: the rest of the text is not the value's
207
+ const { closed, open, run } = hit.groups;
208
+ if (kind === 'weak' && !asSetting && !looksLikeToken(closed || run || open.split(/\s/, 1)[0])) continue;
209
+ const end = start + hit[0].length;
210
+ if (found.index >= from) {
211
+ out += `${text.slice(from, start)}${REDACTED}`;
212
+ from = end;
213
+ } else if (end > from) {
214
+ // the pair began inside a value already hidden and runs past it: one hidden stretch
215
+ from = end;
216
+ }
217
+ }
218
+ return out + text.slice(from);
219
+ }
220
+
221
+ function withoutSecrets(text) {
222
+ let out = String(text == null ? '' : text);
223
+ for (const [pattern, replacement] of SECRET_PATTERNS) out = out.replace(pattern, replacement);
224
+ return withoutAssignedSecrets(out);
225
+ }
226
+
227
+ // The most of one field the cleaner reads. What it gives back is at most 300 characters (scrubbed,
228
+ // below), so this is far more than is ever kept. It only caps the work a very long note can cost.
229
+ const CLEAN_MAX = 5000;
230
+
231
+ // At most `max` characters, cut back to the last space so no word is split. A piece of a
232
+ // token is not a token (ghp_ and twelve more characters is under the rule's 36 and would
233
+ // print as it is), so the word the cut falls in is dropped whole, and so is a text with no
234
+ // space in what is read. A space at the cut means the cut falls between two words.
235
+ function cutAtSpace(text, max = CLEAN_MAX) {
236
+ const value = String(text == null ? '' : text);
237
+ if (value.length <= max || /\s/.test(value[max])) return value.slice(0, max);
238
+ let end = max;
239
+ while (end > 0 && !/\s/.test(value[end - 1])) end -= 1;
240
+ return value.slice(0, end);
241
+ }
242
+
243
+ // The start of every kind of key the patterns above find whole. A field that was cut can end in
244
+ // the first part of a key: a flight cuts a reason at 300 characters and the store squeezes runs
245
+ // of spaces after that, so a cut reason can be far shorter than 300 (a reviewer saved one of 41).
246
+ // A piece of a key is under what the key rule needs (ghp_ and ten more characters is under its
247
+ // 36), so it would print as it is. But a cut can only leave the start of a key at the end of a
248
+ // field, so the last word of a field is hidden from the first key start in it to its end,
249
+ // whatever the length of the field, of the word, or of what follows the start (withoutCutKey).
250
+ // A key start with a short body anywhere else is left to the key rules, which want the whole
251
+ // body. Most starts are not part of any ordinary word, so they count after any character, as a
252
+ // whole key does (Workerghp_...). The others also stand inside ordinary words (task-12, MSG.,
253
+ // EURASIA, pnpm_store, highs_total, task_live_report, SurveyJob), so they count only at the
254
+ // start of a word or after anything that is not a letter or a digit. sk- is the start of
255
+ // sk-proj- and sk-ant- too. An npm token is npm_ and letters and digits, so npm_ followed by
256
+ // letters or digits and another underscore (npm_config_cache) is a variable name. The atcs_
257
+ // secret is here for the same reason; scrub finds it with any body.
258
+ //
259
+ // A repository, a branch, and a folder are named by people, and a name can begin the way a key
260
+ // does (acme/sk-tools). So a start right after a slash does not count. A link is whole as it
261
+ // stands: only what comes in front of its path is read, where a login or a Slack webhook address
262
+ // would be, and only for the starts in the first list, which no host or name begins with.
263
+ const KEY_START = /gh[pour]_|github_pat_|xox[baprs]-|AIza|atcs_|https:\/\/hooks\.slack\.com\/services\//y;
264
+ const WORD_KEY_START = /sk-|[rs]k_live_|ghs_|npm_(?![A-Za-z0-9]*_)|SG\.|AKIA|ASIA|eyJ/y;
265
+
266
+ // The text with its last word cut off from the first key start in it. The last word is the last
267
+ // run of characters with no space, and it is looked at once, from its first character. A link is
268
+ // whole as it stands, so only the part in front of its first path slash is looked at.
269
+ function withoutCutKey(text) {
270
+ const value = String(text == null ? '' : text);
271
+ let end = value.length;
272
+ while (end > 0 && /\s/.test(value[end - 1])) end -= 1;
273
+ let start = end;
274
+ while (start > 0 && !/\s/.test(value[start - 1])) start -= 1;
275
+ const scheme = value.indexOf('://', start);
276
+ const link = scheme >= 0 && scheme < end;
277
+ const slash = link ? value.indexOf('/', scheme + 3) : -1;
278
+ const stop = slash >= 0 && slash < end ? slash : end;
279
+ for (let at = start; at < stop; at += 1) {
280
+ const before = at > start ? value[at - 1] : '';
281
+ if (before === '/' && !link) continue;
282
+ KEY_START.lastIndex = at;
283
+ let found = KEY_START.test(value);
284
+ if (!found && !link && !/[A-Za-z0-9]/.test(before)) {
285
+ WORD_KEY_START.lastIndex = at;
286
+ found = WORD_KEY_START.test(value);
287
+ }
288
+ if (found) return `${value.slice(0, at)}${REDACTED}`;
289
+ }
290
+ return value;
291
+ }
292
+
293
+ // scrub (utils/agent-client.js) keeps the first 300 characters of any text, wherever the 300th
294
+ // falls, so a link or a path that stands across it is left as a piece (https://example.org/rep).
295
+ // Where a person reads the title, the task codes in front of it are taken out, and then the
296
+ // piece is all that shows of it. So the cut is made here, at a word. Each word goes through scrub
297
+ // on its own (what scrub hides never spans white space), and words are kept for as long as they
298
+ // all fit in 300 characters once hidden. A word that does not fit, or that scrub itself would cut,
299
+ // is dropped whole with everything after it, so no word is ever shown in pieces. A text that fits
300
+ // once its white space is squeezed comes out exactly as scrub makes it. What withoutSecrets hides
301
+ // is hidden before any of this, so a secret that stands across the 300th is its marker, never a
302
+ // piece of itself.
303
+ const SCRUB_KEEPS = 300;
304
+
305
+ function scrubbed(text) {
306
+ let out = '';
307
+ for (const [word] of String(text == null ? '' : text).matchAll(/\S+/g)) {
308
+ const shown = scrub(word, []);
309
+ // at 300 scrub may have cut the word; it is whole only if it came back as it went in (a word that
310
+ // hiding changed and left at exactly 300 is dropped too, the safe side)
311
+ if (shown.length >= SCRUB_KEEPS && shown !== word) break;
312
+ const next = out ? `${out} ${shown}` : shown;
313
+ if (next.length > SCRUB_KEEPS) break;
314
+ out = next;
315
+ }
316
+ return out;
317
+ }
318
+
319
+ // Text that is not a field's own words, ready to print: a link, which is whole as it stands, so a
320
+ // folder named sk-tools in its path is not the start of a key at the end of anything. The patterns
321
+ // run on the first CLEAN_MAX characters at once, so a token is never cut in half by scrub's length
322
+ // limit, and by none of ours either: what is cut off is cut at a space (cutAtSpace, scrubbed).
323
+ function cleaned(text) {
324
+ return scrubbed(withoutSecrets(cutAtSpace(text)));
325
+ }
326
+
327
+ // A title, a note, a reason, a name, or a tag, ready to print: cleaned as above, and with the start
328
+ // of a key at the end of it hidden.
329
+ function redacted(text) {
330
+ return scrubbed(withoutCutKey(withoutSecrets(cutAtSpace(text))));
331
+ }
332
+
333
+ // A release reason is kept to 300 characters. The store cuts what a person types and ends it
334
+ // with "..." (RELEASE_REASON_MAX in lib/task-db.js), and a flight cuts its own reason at 300
335
+ // with nothing added (releaseReason in lib/fleet.js). Whatever stood across that cut is a
336
+ // piece of a word now, and a piece of a key is not a key (ghp_ and twelve more characters is
337
+ // under the key rule's 36), so it would print as it is. Two rules keep it off the screen. The
338
+ // start of a key at the end of any field is hidden at any length (withoutCutKey, above), since
339
+ // the store squeezes runs of spaces after a flight has cut and a cut reason can come out far
340
+ // shorter than 300. And a reason or a note that is as long as the cut has its last word
341
+ // treated as possibly cut, whatever it is: the run is dropped before the text is cleaned, the
342
+ // way cutAtSpace drops the word its own cut falls in, which also covers a piece of a secret
343
+ // that has no start to know it by (a cut password value, or the middle of a key). The room
344
+ // below 300 is for the squeeze. A text over 300 was not cut by the store, and one well under
345
+ // it was not cut so close to the end, so both keep their last word unless it starts a key.
346
+ const STORE_CUT = 300;
347
+ const STORE_CUT_ROOM = 20;
348
+
349
+ function withoutCutWord(text) {
350
+ const value = String(text == null ? '' : text);
351
+ if (value.length < STORE_CUT - STORE_CUT_ROOM || value.length > STORE_CUT) return value;
352
+ let end = value.length;
353
+ while (end > 0 && /\s/.test(value[end - 1])) end -= 1;
354
+ while (end > 0 && !/\s/.test(value[end - 1])) end -= 1;
355
+ return value.slice(0, end);
356
+ }
357
+
358
+ // A reason or a note read back from the store, ready to print.
359
+ function reasonOf(text) {
360
+ return redacted(withoutCutWord(text));
361
+ }
362
+
363
+ // A builder name or a tag, ready to print. Whoever claims a job (task claim --as) or adds
364
+ // one (task add --tag) types these, so each can carry a token like any note. Null when
365
+ // nothing is left. Names are cleaned where they leave this file; the raw name is still
366
+ // what jobs are grouped and compared by.
367
+ function nameOf(value) {
368
+ return redacted(plain(value)) || null;
369
+ }
370
+
371
+ // ---- links -------------------------------------------------------------------
372
+
373
+ // https://github.com/<owner>/<repo> from a git remote in any of its usual forms
374
+ // (scp style, https, ssh, git), or null for another host, a local path, or
375
+ // anything that does not name exactly one owner and one repo. Credentials in the
376
+ // remote are dropped here and never printed.
377
+ function githubBaseUrl(remote) {
378
+ const text = plain(remote);
379
+ if (!text) return null;
380
+ const match = text.match(/^(?:[\w.+-]+@)?github\.com:(?!\/)(.+)$/i)
381
+ || text.match(/^(?:https?|ssh|git|git\+ssh|ssh\+git):\/\/(?:[^@/\s]*@)?github\.com(?::\d+)?\/(.+)$/i);
382
+ if (!match) return null;
383
+ const parts = match[1].replace(/\/+$/, '').replace(/\.git$/i, '').split('/');
384
+ const sound = parts.length === 2 && parts.every((part) => /^[\w.-]+$/.test(part) && part !== '.' && part !== '..');
385
+ return sound ? `https://github.com/${parts[0]}/${parts[1]}` : null;
386
+ }
387
+
388
+ // The workspace's own origin, read from its git config. This runs git on the
389
+ // local repo and nothing else: no gh, no network.
390
+ function originBase(workspace) {
391
+ try {
392
+ const res = spawnSync('git', ['remote', 'get-url', 'origin'], {
393
+ cwd: workspace, encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'],
394
+ });
395
+ return res.status === 0 ? githubBaseUrl(res.stdout) : null;
396
+ } catch {
397
+ return null;
398
+ }
399
+ }
400
+
401
+ // A link, ready to print: kept as it was saved but with nothing in it that could carry
402
+ // a secret. The login, query string, and fragment are dropped. What is left goes
403
+ // through the same redactor every note and title goes through, and a link it would
404
+ // still change (a secret in its path, or a link too long to keep whole) is not printed
405
+ // at all. Null for anything that is not a plain http or https link. Every link this
406
+ // screen prints is made here, including the ones it builds itself.
407
+ function printableLink(value) {
408
+ const text = plain(value);
409
+ if (!/^https?:\/\/\S+$/i.test(text)) return null;
410
+ let bare = text.split(/[?#]/)[0];
411
+ try {
412
+ const url = new URL(bare);
413
+ if (url.username || url.password) {
414
+ url.username = '';
415
+ url.password = '';
416
+ bare = url.href;
417
+ }
418
+ } catch {
419
+ return null;
420
+ }
421
+ return cleaned(bare) === bare ? bare : null;
422
+ }
423
+
424
+ function positiveInt(value) {
425
+ const number = Number(value);
426
+ return Number.isInteger(number) && number > 0 ? number : null;
427
+ }
428
+
429
+ function commitHash(value) {
430
+ const text = plain(value);
431
+ return /^[0-9a-f]{7,64}$/i.test(text) ? text.toLowerCase() : null;
432
+ }
433
+
434
+ function isObject(value) {
435
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
436
+ }
437
+
438
+ // The GitHub home page of the repository a pull request record names, or null. The
439
+ // records fleet writes today name none; this is for records that do, as
440
+ // "owner/name", a GitHub link or remote, or a repository record.
441
+ function recordedRepository(pull) {
442
+ for (const field of [pull.repository, pull.repo]) {
443
+ const text = plain(isObject(field) ? field.full_name || field.nameWithOwner || field.html_url : field);
444
+ const base = /^[\w.-]+\/[\w.-]+$/.test(text) ? githubBaseUrl(`https://github.com/${text}`) : githubBaseUrl(text);
445
+ if (base) return base;
446
+ }
447
+ return null;
448
+ }
449
+
450
+ // The link a pull request record gives: the one it saved, else its number on the
451
+ // repository the record names. A number alone is never turned into a link. The
452
+ // workspace's own origin is not necessarily where the pull request lives, and a
453
+ // guessed link can open somebody else's pull request.
454
+ function pullLink(pull) {
455
+ if (!isObject(pull)) return null;
456
+ const saved = printableLink(pull.url);
457
+ if (saved) return saved;
458
+ const number = positiveInt(pull.number);
459
+ const base = number ? recordedRepository(pull) : null;
460
+ return base ? printableLink(`${base}/pull/${number}`) : null;
461
+ }
462
+
463
+ // A pull request record that says it was merged, in the same words the rating
464
+ // reads (see hasStructuredMerge in lib/gm-season.js).
465
+ function isMergedPull(pull) {
466
+ if (!isObject(pull)) return false;
467
+ return String(pull.state || pull.status || '').toLowerCase() === 'merged'
468
+ || pull.merged === true || Boolean(pull.merged_at) || Boolean(commitHash(pull.merge_commit));
469
+ }
470
+
471
+ // Older records name the merged pull request on `merged_pr`: a record, a link, or
472
+ // just its number.
473
+ function mergedPullOf(record) {
474
+ if (!isObject(record) || record.merged_pr == null) return null;
475
+ if (isObject(record.merged_pr)) return record.merged_pr;
476
+ const text = plain(record.merged_pr);
477
+ if (printableLink(text)) return { url: text };
478
+ // A link that could not be printed is not a number either: the digits in it can be
479
+ // part of the secret that kept it off the screen.
480
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(text)) return null;
481
+ const number = positiveInt((text.match(/^\D*(\d+)\D*$/) || [])[1]);
482
+ return number ? { number } : null;
483
+ }
484
+
485
+ // Where to open a landed job: its merged pull request, else its merge commit on the
486
+ // workspace's GitHub (`base`). When the pull request has no link to open, its number
487
+ // comes back instead, so the screen can say so.
488
+ //
489
+ // `payload` is the landing's own history entry, and nothing else is read. The job's
490
+ // own record is not a source: it names only the latest merge, and reopening a job
491
+ // removes its pull request but keeps its merge commit, so for a job that landed twice
492
+ // the record points at the first landing. A landing whose entry records nothing (a
493
+ // person accepting it, say) gets no link.
494
+ function landingLink(payload, base) {
495
+ const pulls = [payload.pull_request, payload.pr].filter(isMergedPull)
496
+ .concat([payload, payload.landing].map(mergedPullOf).filter(Boolean));
497
+ for (const pull of pulls) {
498
+ const link = pullLink(pull);
499
+ if (link) return { link, pull_request: null };
500
+ }
501
+ const records = [payload.landing, payload.pull_request, payload.pr, payload, ...pulls];
502
+ const commit = records
503
+ .map((record) => (isObject(record) ? commitHash(record.merge_commit || record.merge_commit_sha || record.merge_sha) : null))
504
+ .find(Boolean);
505
+ return {
506
+ link: base && commit ? printableLink(`${base}/commit/${commit}`) : null,
507
+ pull_request: pulls.map((pull) => positiveInt(pull.number)).find(Boolean) || null,
508
+ };
509
+ }
510
+
511
+ // ---- the store ---------------------------------------------------------------
512
+
513
+ function protectedLane(task) {
514
+ // Fleet decides what is protected: staffing, shipping, and this screen ask it.
515
+ return require('./fleet').protectedLaneOfTask(task);
516
+ }
517
+
518
+ // Display refs (CLI-12) the season already worked out for open, claimed, and
519
+ // review jobs, so this does not rank the whole task list a second time.
520
+ function refsOf(season) {
521
+ const refs = new Map();
522
+ for (const list of [season.open_tasks, season.reviews, season.claims]) {
523
+ for (const row of list || []) {
524
+ const id = row.task_id || row.id;
525
+ if (id && row.ref) refs.set(id, row.ref);
526
+ }
527
+ }
528
+ return refs;
529
+ }
530
+
531
+ function needsItem(fields, ctx) {
532
+ const since = Number(fields.since) || 0;
533
+ return {
534
+ kind: fields.kind,
535
+ id: fields.accept_id || ctx.refs.get(fields.task_id) || fields.task_id || '',
536
+ task_id: fields.task_id || null,
537
+ title: ctx.titleOf(fields.title) || 'untitled',
538
+ why: fields.why,
539
+ link: fields.link || null,
540
+ // The pull request's number, only when it has no link to open.
541
+ pull_request: fields.pull_request || null,
542
+ since,
543
+ age: since ? spanWords(ctx.now - since) : '',
544
+ built_by: nameOf(fields.built_by),
545
+ accept_id: fields.accept_id || null,
546
+ };
547
+ }
548
+
549
+ // Jobs a person sent back from review SENT_BACK_LIMIT times or more that are still
550
+ // not finished. Only a send-back from review counts. A reopen says where the job
551
+ // was (previous_status): one from review counts, one from finished, failed, or
552
+ // archived work does not. A revision says so only when it was made on work already
553
+ // accepted as done, and that was not review either.
554
+ function sentBackJobs(db, workspace) {
555
+ const rows = db.prepare(`
556
+ SELECT e.task_id AS task_id, e.event_type AS event_type, e.payload AS payload, e.created_at AS created_at
557
+ FROM task_events e JOIN tasks t ON t.id = e.task_id
558
+ WHERE t.workspace_root = ? AND t.status IN ('open', 'claimed', 'review')
559
+ AND e.event_type IN ('revision_requested', 'reopened')
560
+ ORDER BY e.task_id, e.version`).all(workspace);
561
+ const jobs = new Map();
562
+ for (const row of rows) {
563
+ const payload = parseJson(row.payload);
564
+ const from = plain(payload.previous_status).toLowerCase();
565
+ if (row.event_type === 'reopened' ? from !== 'review' : from && from !== 'review') continue;
566
+ const job = jobs.get(row.task_id) || { times: 0, last_at: 0, note: '' };
567
+ job.times += 1;
568
+ job.last_at = Number(row.created_at);
569
+ job.note = plain(payload.note || payload.reason);
570
+ jobs.set(row.task_id, job);
571
+ }
572
+ for (const [taskId, job] of jobs) {
573
+ if (job.times < SENT_BACK_LIMIT) jobs.delete(taskId);
574
+ else job.note = reasonOf(job.note);
575
+ }
576
+ return jobs;
577
+ }
578
+
579
+ function sentBackWhy(job) {
580
+ return `Sent back ${job.times} times and still not done.${job.note ? ` Last note: ${quoted(job.note, REASON_MAX)}` : ''}`;
581
+ }
582
+
583
+ // What a finished job with an open pull request says to the owner. With no usable
584
+ // link it still needs a person, but it is not called ready to merge: there is
585
+ // nothing to open, so the line says the link is missing and where to look.
586
+ function heldWhy({ builder, link, number, proofLost = false, sentBackTimes = 0 }) {
587
+ const pull = !link && number ? `pull request ${number}` : 'a pull request';
588
+ const opening = builder ? `${builder} finished this and opened ${pull}` : `This is finished and has ${pull} open`;
589
+ const parts = [`${opening}${link ? '' : ', but its link was not saved'}.`];
590
+ if (proofLost) parts.push('Its proof could not be saved, so the job shows as open.');
591
+ parts.push(link ? 'It is waiting for you to merge it.' : 'Find it in the repository.');
592
+ if (sentBackTimes) parts.push(`It was sent back ${sentBackTimes} times before.`);
593
+ return parts.join(' ');
594
+ }
595
+
596
+ // Finished work whose pull request is open: a person has to merge it.
597
+ function heldItems(db, workspace, sentBacks, ctx) {
598
+ const rows = db.prepare(`
599
+ SELECT id, title, status, tag, claimed_by, created_at, updated_at, metadata,
600
+ (SELECT MAX(e.created_at) FROM task_events e WHERE e.task_id = tasks.id AND e.event_type = 'proof_ready') AS proof_at
601
+ FROM tasks WHERE workspace_root = ? AND status = 'review'`).all(workspace).map(withMeta);
602
+ const items = [];
603
+ for (const row of rows) {
604
+ const pull = row.metadata.pull_request;
605
+ if (!isObject(pull) || String(pull.state || '').toLowerCase() !== 'open') continue;
606
+ const builder = nameOf(row.claimed_by);
607
+ const earlier = sentBacks.get(row.id);
608
+ const link = pullLink(pull);
609
+ const number = positiveInt(pull.number);
610
+ items.push(needsItem({
611
+ kind: 'held',
612
+ task_id: row.id,
613
+ title: row.title,
614
+ why: heldWhy({ builder, link, number, sentBackTimes: earlier ? earlier.times : 0 }),
615
+ link,
616
+ pull_request: link ? null : number,
617
+ since: row.proof_at || row.updated_at,
618
+ built_by: builder,
619
+ }, ctx));
620
+ }
621
+ return items;
622
+ }
623
+
624
+ // Work a builder finished whose pull request is open but whose proof could not be
625
+ // saved. The flight let go of the claim and put the pull request on the release
626
+ // entry, so the job is back to open with no pull request on its own record and
627
+ // heldItems cannot see it. The release is the only place that knows the link.
628
+ //
629
+ // `releases` are the plays openReleasePlays found: open jobs whose latest release is
630
+ // of this kind and that nobody has claimed, landed, archived, or reopened since, however
631
+ // long ago that was. They come from the store itself and not from the rating's 30 days of
632
+ // history, so the job stays here until somebody deals with it. Releases of the other kind
633
+ // are not listed here (see needsYou).
634
+ function unsavedProofItems(db, releases, ctx) {
635
+ const release = db.prepare('SELECT payload FROM task_events WHERE event_id = ?');
636
+ const items = [];
637
+ for (const play of releases) {
638
+ if (play.kind !== 'held') continue;
639
+ const pull = parseJson((release.get(play.event_id) || {}).payload).pull_request;
640
+ const link = pullLink(pull);
641
+ const number = isObject(pull) ? positiveInt(pull.number) : null;
642
+ const builder = nameOf(play.member);
643
+ items.push(needsItem({
644
+ kind: 'held',
645
+ task_id: play.task_id,
646
+ title: play.title,
647
+ why: heldWhy({ builder, link, number, proofLost: true }),
648
+ link,
649
+ pull_request: link ? null : number,
650
+ since: play.at,
651
+ built_by: builder,
652
+ }, ctx));
653
+ }
654
+ return items;
655
+ }
656
+
657
+ // The last thing that happened to each job in the window, not counting a builder
658
+ // taking it.
659
+ function lastPlays(plays) {
660
+ const last = new Map();
661
+ for (const play of plays) {
662
+ if (play.kind !== 'took') last.set(play.task_id, play);
663
+ }
664
+ return last;
665
+ }
666
+
667
+ // Protected jobs a builder could not take through: it gave up on one (failed), or
668
+ // it let go of one (a release whose reason says where it stopped) and nobody has
669
+ // picked it up since. A job a builder still holds, or one waiting on a pull
670
+ // request, is not here.
671
+ function refusedItems(db, workspace, last, ctx) {
672
+ const items = [];
673
+ const failed = db.prepare(`
674
+ SELECT id, title, status, tag, claimed_by, created_at, updated_at, metadata
675
+ FROM tasks WHERE workspace_root = ? AND status = 'failed' AND updated_at >= ?`)
676
+ .all(workspace, ctx.now - ctx.seasonDays * DAY_MS).map(withMeta);
677
+ for (const row of failed) {
678
+ const lane = protectedLane(row);
679
+ if (!lane) continue;
680
+ const builder = nameOf(row.claimed_by);
681
+ items.push(needsItem({
682
+ kind: 'refused',
683
+ task_id: row.id,
684
+ title: row.title,
685
+ why: `${builder || 'A builder'} gave up on this protected ${lane} job, so it needs your call.`,
686
+ since: row.updated_at,
687
+ built_by: builder,
688
+ }, ctx));
689
+ }
690
+ const openTask = db.prepare(`
691
+ SELECT id, title, status, tag, claimed_by, created_at, updated_at, metadata
692
+ FROM tasks WHERE id = ? AND workspace_root = ? AND status = 'open'`);
693
+ // A release older than the job's latest archive or reopen is not counted, as in openReleasePlays:
694
+ // the job was closed and then opened again for new work.
695
+ const renewed = db.prepare(`
696
+ SELECT 1 FROM task_events r
697
+ JOIN task_events later ON later.task_id = r.task_id AND later.version > r.version
698
+ WHERE r.event_id = ? AND later.event_type IN ('archived', 'reopened') LIMIT 1`);
699
+ for (const [taskId, play] of last) {
700
+ if (play.kind !== 'dropped') continue;
701
+ const found = openTask.get(taskId, workspace);
702
+ if (!found || (play.event_id && renewed.get(play.event_id))) continue;
703
+ const row = withMeta(found);
704
+ const lane = protectedLane(row);
705
+ if (!lane) continue;
706
+ const reason = reasonOf(play.reason);
707
+ const builder = nameOf(play.member);
708
+ items.push(needsItem({
709
+ kind: 'refused',
710
+ task_id: row.id,
711
+ title: row.title,
712
+ why: `${builder || 'A builder'} let go of this protected ${lane} job and nobody has taken it since.${reason ? ` Reason given: ${quoted(reason, REASON_MAX)}` : ''}`,
713
+ since: play.at,
714
+ built_by: builder,
715
+ }, ctx));
716
+ }
717
+ return items;
718
+ }
719
+
720
+ function sentBackItems(db, sentBacks, ctx) {
721
+ const row = db.prepare('SELECT id, title, claimed_by FROM tasks WHERE id = ?');
722
+ const items = [];
723
+ for (const [taskId, job] of sentBacks) {
724
+ const found = row.get(taskId);
725
+ if (!found) continue;
726
+ items.push(needsItem({
727
+ kind: 'sent_back',
728
+ task_id: taskId,
729
+ title: found.title,
730
+ why: sentBackWhy(job),
731
+ since: job.last_at,
732
+ built_by: nameOf(found.claimed_by),
733
+ }, ctx));
734
+ }
735
+ return items;
736
+ }
737
+
738
+ // Everything waiting on a person, newest first: finished work to merge (with its
739
+ // proof saved or not), protected jobs a builder could not take through, jobs sent
740
+ // back again and again, then the decision items brief already showed. A job
741
+ // counted twice shows once, as the first of those it fits.
742
+ function needsYou(db, workspace, plays, decisions, ctx) {
743
+ const sentBacks = sentBackJobs(db, workspace);
744
+ const last = lastPlays(plays);
745
+ const releases = require('./gm-season').openReleasePlays(db, workspace);
746
+ // A job the build machine could not start (a release of the could_not_start kind) is
747
+ // left out on purpose, whichever way it would get here. Nothing was built, nothing
748
+ // is waiting on a person, and the next run tries the job again. That includes the
749
+ // older waiting list: it lists a job tagged protected or for a person whatever its
750
+ // state, so such a job is dropped from the decisions too.
751
+ const unstarted = new Set(releases.filter((play) => play.kind === 'could_not_start').map((play) => play.task_id));
752
+ const seen = new Set();
753
+ const items = [];
754
+ const add = (item) => {
755
+ if (item.task_id && seen.has(item.task_id)) return;
756
+ if (item.task_id) seen.add(item.task_id);
757
+ items.push(item);
758
+ };
759
+ heldItems(db, workspace, sentBacks, ctx).forEach(add);
760
+ unsavedProofItems(db, releases, ctx).forEach(add);
761
+ refusedItems(db, workspace, last, ctx).forEach(add);
762
+ sentBackItems(db, sentBacks, ctx).forEach(add);
763
+ decisions.filter((decision) => !unstarted.has(decision.task_id))
764
+ .forEach((decision) => add(needsItem({ kind: 'decision', ...decision }, ctx)));
765
+ return items.sort((a, b) => b.since - a.since || a.title.localeCompare(b.title));
766
+ }
767
+
768
+ // Every landing in the last 7 days, newest first, by the rating's own idea of
769
+ // landed: one line per landing, with the member who held the job for that landing.
770
+ // A job that landed, was reopened, and landed again is two lines. The link is the
771
+ // merged pull request, else the merge commit, taken from that landing's own history
772
+ // entry and from nowhere else (see landingLink).
773
+ function doneThisWeek(db, plays, ctx) {
774
+ const eventPayload = db.prepare('SELECT payload FROM task_events WHERE event_id = ?');
775
+ const landed = plays.filter((play) => play.kind === 'landed' && play.at >= ctx.now - WEEK_MS).sort((a, b) => b.at - a.at);
776
+ return landed.map((play) => {
777
+ const payload = parseJson((eventPayload.get(play.event_id) || {}).payload);
778
+ const { link, pull_request: pullRequest } = landingLink(payload, ctx.base);
779
+ return {
780
+ task_id: play.task_id,
781
+ title: ctx.titleOf(play.title) || 'untitled',
782
+ built_by: nameOf(play.member),
783
+ landed_at: play.at,
784
+ age: spanWords(ctx.now - play.at),
785
+ link,
786
+ pull_request: pullRequest,
787
+ };
788
+ });
789
+ }
790
+
791
+ // One row per teammate who finished a job in the last 30 days: the rating the
792
+ // board shows, the same rating a week ago, how many landed, the lane they land
793
+ // most in, and their most common miss.
794
+ function teamRows(db, plays, earlier, ctx) {
795
+ const { rosterRows, memberRatings, missReasons } = require('./gm-season');
796
+ const rows = rosterRows({ outcomes: plays, limit: Number.MAX_SAFE_INTEGER })
797
+ .filter((row) => row.finished > 0 || row.not_needed > 0 || row.held > 0 || row.could_not_start > 0);
798
+ const then = memberRatings(earlier);
799
+ const tagOf = db.prepare('SELECT tag FROM tasks WHERE id = ?');
800
+ const tagCache = new Map();
801
+ const laneOf = (taskId) => {
802
+ if (!tagCache.has(taskId)) tagCache.set(taskId, plain((tagOf.get(taskId) || {}).tag));
803
+ return tagCache.get(taskId);
804
+ };
805
+ return rows.map((row) => {
806
+ const before = then.get(row.name);
807
+ const ago = before ? before.ovr : null;
808
+ const trend = row.ovr != null && ago != null ? (row.ovr > ago ? 'up' : row.ovr < ago ? 'down' : 'same') : null;
809
+ const lanes = new Map();
810
+ for (const play of plays) {
811
+ if (play.member !== row.name || play.kind !== 'landed') continue;
812
+ const lane = laneOf(play.task_id);
813
+ if (lane) lanes.set(lane, (lanes.get(lane) || 0) + 1);
814
+ }
815
+ const best = [...lanes].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))[0];
816
+ const miss = missReasons(plays, row.name, 1)[0];
817
+ return {
818
+ member: nameOf(row.name),
819
+ rating: row.ovr,
820
+ rating_a_week_ago: ago,
821
+ trend,
822
+ change: trend === 'up' || trend === 'down' ? Math.abs(row.ovr - ago) : null,
823
+ landed: row.landed,
824
+ finished: row.finished,
825
+ best_lane: best ? nameOf(best[0]) : null,
826
+ top_miss: miss ? { reason: reasonOf(miss.reason), count: miss.count } : null,
827
+ };
828
+ });
829
+ }
830
+
831
+ function readStore({ target, workspace, now, decisions, titleOf }) {
832
+ const gm = require('./gm-season');
833
+ const season = gm.loadSeason(workspace, { now, days: gm.SEASON_DAYS, dbPath: target, lastSeen: null });
834
+ if (season.error) throw new Error(season.error);
835
+ const plays = season.outcomes;
836
+ const ctx = { now, titleOf, seasonDays: gm.SEASON_DAYS, refs: refsOf(season), base: originBase(workspace) };
837
+ const { DatabaseSync } = require('node:sqlite');
838
+ const db = new DatabaseSync(target, { readOnly: true });
839
+ try {
840
+ // The same ratings a week ago, made from what had happened by then, so a later
841
+ // landing or a later "not needed" note cannot change what last week looked like.
842
+ const earlier = gm.outcomesAsOf(db, workspace, now - WEEK_MS);
843
+ return {
844
+ needs_you: needsYou(db, workspace, plays, decisions, ctx),
845
+ done_this_week: doneThisWeek(db, plays, ctx),
846
+ team: teamRows(db, plays, earlier, ctx),
847
+ };
848
+ } finally {
849
+ try { db.close(); } catch { /* nothing to close */ }
850
+ }
851
+ }
852
+
853
+ // What the owner needs, read from the task store and nothing else. `decisions`
854
+ // are the items brief has always shown from the task projection file, already in
855
+ // plain words ({ task_id, accept_id, title, why, since }). When there is no task
856
+ // store yet, only those items and empty lists come back. When it cannot be read,
857
+ // the same comes back with `error` saying why. This never throws and never writes.
858
+ function buildOwnerBrief(root, { now = Date.now(), decisions = [] } = {}) {
859
+ // A title is somebody's words too, and can carry a link or a token. It is cleaned here and kept
860
+ // to 300 characters at a whole word (scrubbed); where a person reads it, the screen makes it
861
+ // shorter still (see commands/brief.js).
862
+ const titleOf = (title) => plain(redacted(title));
863
+ const only = () => {
864
+ const ctx = { now, titleOf, refs: new Map() };
865
+ return {
866
+ needs_you: decisions.map((decision) => needsItem({ kind: 'decision', ...decision }, ctx)).sort((a, b) => b.since - a.since),
867
+ done_this_week: [],
868
+ team: [],
869
+ };
870
+ };
871
+ try {
872
+ const taskDb = require('./task-db');
873
+ const workspace = taskDb.workspaceRoot(root);
874
+ const target = taskDb.getDbPath();
875
+ if (!fs.existsSync(target)) return only();
876
+ return readStore({ target, workspace, now, decisions, titleOf });
877
+ } catch (err) {
878
+ return { ...only(), error: plain(err && err.message ? err.message : err) || 'unknown error' };
879
+ }
880
+ }
881
+
882
+ // ---- the text ----------------------------------------------------------------
883
+
884
+ // Titles, notes, and names come from jobs and from other people. Nothing in them
885
+ // should be able to move the cursor, clear the screen, or print a long dash.
886
+ function safeForTerminal(text) {
887
+ return String(text)
888
+ .replace(/[\u2013\u2014]/g, '-')
889
+ .replace(/[\u0000-\u0009\u000b-\u001f\u007f-\u009f]/g, '');
890
+ }
891
+
892
+ function ratingCell(row) {
893
+ if (row.rating == null) return 'new';
894
+ if (row.trend === 'up') return `${row.rating} ↑${row.change}`;
895
+ if (row.trend === 'down') return `${row.rating} ↓${row.change}`;
896
+ if (row.trend === 'same') return `${row.rating} =`;
897
+ return String(row.rating);
898
+ }
899
+
900
+ function missCell(row) {
901
+ return row.top_miss ? `${clip(row.top_miss.reason, 40)} (${row.top_miss.count})` : '-';
902
+ }
903
+
904
+ function teamLines(team) {
905
+ if (!team.length) return [' No one has finished a job here in the last 30 days.'];
906
+ const cells = team.map((row) => [
907
+ clip(row.member, 24),
908
+ ratingCell(row),
909
+ String(row.landed),
910
+ row.best_lane ? clip(row.best_lane, 20) : '-',
911
+ missCell(row),
912
+ ]);
913
+ const head = ['member', 'rating', 'landed', 'best lane', 'top miss'];
914
+ const widths = head.map((title, col) => Math.max(title.length, ...cells.map((row) => [...row[col]].length)));
915
+ const line = (row) => ` ${row.map((cell, col) => (col === row.length - 1 ? cell : cell.padEnd(widths[col] + 2))).join('')}`.trimEnd();
916
+ return [line(head), ...cells.map(line), '', ' Arrows compare with the same rating a week ago.'];
917
+ }
918
+
919
+ // One idea per line: a reason breaks after each sentence, but never inside
920
+ // someone's quoted words.
921
+ function sentences(text) {
922
+ const out = [];
923
+ let current = '';
924
+ let inQuote = false;
925
+ const chars = [...plain(text)];
926
+ chars.forEach((ch, index) => {
927
+ if (ch === '"') inQuote = !inQuote;
928
+ current += ch;
929
+ if (!inQuote && /[.!?]/.test(ch) && chars[index + 1] === ' ') {
930
+ out.push(current.trim());
931
+ current = '';
932
+ }
933
+ });
934
+ if (current.trim()) out.push(current.trim());
935
+ return out;
936
+ }
937
+
938
+ function lists(data) {
939
+ return {
940
+ needs: Array.isArray(data.needs_you) ? data.needs_you : [],
941
+ done: Array.isArray(data.done_this_week) ? data.done_this_week : [],
942
+ team: Array.isArray(data.team) ? data.team : [],
943
+ broken: typeof data.owner_error === 'string' && data.owner_error ? data.owner_error : '',
944
+ };
945
+ }
946
+
947
+ function brokenLine(message) {
948
+ return `Could not read the task list here: ${plain(message).replace(/[.!?]+$/, '')}.`;
949
+ }
950
+
951
+ // The whole screen, as plain lines. Needs you is numbered because "a 2" at the
952
+ // prompt means the second item here.
953
+ function renderOwnerText(data) {
954
+ const { needs, done, team, broken } = lists(data);
955
+ const lines = [];
956
+
957
+ lines.push(`Needs you${needs.length ? ` (${needs.length})` : ''}`, '');
958
+ if (broken) {
959
+ lines.push(` ${brokenLine(broken)}`);
960
+ lines.push(needs.length ? ' Showing only the items from the review list.' : ' Nothing else could be checked.', '');
961
+ } else if (!needs.length) {
962
+ lines.push(' Nothing is waiting on you.', '');
963
+ }
964
+ needs.slice(0, SHOWN).forEach((item, index) => {
965
+ // The lines under an item start where its title does, for one digit or two.
966
+ const head = `${index + 1}. `;
967
+ const under = ' '.repeat(head.length);
968
+ lines.push(`${head}${item.title}`);
969
+ for (const sentence of sentences(item.why)) lines.push(`${under}${sentence}`);
970
+ if (item.age) lines.push(`${under}Waiting ${item.age}.`);
971
+ if (item.link) lines.push(`${under}${item.link}`);
972
+ lines.push('');
973
+ });
974
+ if (needs.length > SHOWN) lines.push(`and ${needs.length - SHOWN} more (atris brief --json lists them all)`, '');
975
+
976
+ lines.push(`Done this week${done.length ? ` (${done.length})` : ''}`, '');
977
+ if (broken) lines.push(' Not available.');
978
+ else if (!done.length) lines.push(' Nothing landed in the last 7 days.');
979
+ for (const item of done) {
980
+ lines.push(`- ${item.title}, by ${item.built_by || 'unknown'}, ${item.age} ago`);
981
+ if (item.pull_request) lines.push(` Pull request ${item.pull_request} has no saved link.`);
982
+ if (item.link) lines.push(` ${item.link}`);
983
+ }
984
+ lines.push('');
985
+
986
+ lines.push('Team (last 30 days)', '');
987
+ if (broken) lines.push(' Not available.');
988
+ else lines.push(...teamLines(team));
989
+ return safeForTerminal(lines.join('\n'));
990
+ }
991
+
992
+ // The same three parts as page blocks, for atris brief --html. A link is shown as
993
+ // text under its item, because the page has no clickable rows.
994
+ function ownerBlocks(data) {
995
+ const { needs, done, team, broken } = lists(data);
996
+ const needRows = needs.slice(0, SHOWN).map((item, index) => ({
997
+ title: `${index + 1}. ${item.title}`,
998
+ sub: [item.why, item.link].filter(Boolean).join(' '),
999
+ value: item.age || '-',
1000
+ valueSub: 'waiting',
1001
+ sev: index,
1002
+ }));
1003
+ if (needs.length > SHOWN) {
1004
+ needRows.push({ title: `and ${needs.length - SHOWN} more`, sub: 'atris brief --json lists them all', value: String(needs.length - SHOWN), valueSub: 'more', sev: 2 });
1005
+ }
1006
+ if (broken) needRows.unshift({ title: 'Could not read the task list here', sub: plain(broken), value: '!', valueSub: 'error', sev: 0 });
1007
+ else if (!needRows.length) needRows.push({ title: 'Nothing is waiting on you', sub: '', value: '0', valueSub: 'items', sev: 2 });
1008
+
1009
+ const doneRows = done.map((item, index) => ({
1010
+ title: item.title,
1011
+ sub: [`by ${item.built_by || 'unknown'}${item.pull_request ? `, pull request ${item.pull_request} has no saved link.` : ''}`, item.link].filter(Boolean).join(' '),
1012
+ value: item.age,
1013
+ valueSub: 'ago',
1014
+ sev: index,
1015
+ }));
1016
+ if (!doneRows.length) doneRows.push({ title: broken ? 'Not available' : 'Nothing landed in the last 7 days', sub: '', value: '0', valueSub: 'jobs', sev: 2 });
1017
+
1018
+ const teamRows = team.map((row, index) => ({
1019
+ title: row.member,
1020
+ sub: `landed ${row.landed}, best lane ${row.best_lane || '-'}, top miss ${missCell(row)}`,
1021
+ value: ratingCell(row),
1022
+ valueSub: 'rating',
1023
+ sev: index,
1024
+ }));
1025
+ if (!teamRows.length) teamRows.push({ title: broken ? 'Not available' : 'No one has finished a job in the last 30 days', sub: '', value: '0', valueSub: 'members', sev: 2 });
1026
+
1027
+ return [
1028
+ {
1029
+ type: 'panel',
1030
+ heading: 'Needs you',
1031
+ sub: needs.length ? 'Merge, approve, or send back.' : 'Nothing is waiting.',
1032
+ panel: { header: { title: 'Needs you', meta: String(needs.length) }, rows: needRows },
1033
+ },
1034
+ {
1035
+ type: 'panel',
1036
+ heading: 'Done this week',
1037
+ sub: done.length ? 'Landed in the last 7 days.' : 'Nothing landed.',
1038
+ panel: { header: { title: 'Done this week', meta: String(done.length) }, rows: doneRows },
1039
+ },
1040
+ {
1041
+ type: 'panel',
1042
+ heading: 'Team',
1043
+ sub: 'Last 30 days. Arrows compare with a week ago.',
1044
+ panel: { header: { title: 'Team', meta: String(team.length) }, rows: teamRows },
1045
+ },
1046
+ ];
1047
+ }
1048
+
1049
+ module.exports = {
1050
+ CLEAN_MAX,
1051
+ SHOWN,
1052
+ buildOwnerBrief,
1053
+ cutAtSpace,
1054
+ githubBaseUrl,
1055
+ ownerBlocks,
1056
+ printableLink,
1057
+ reasonOf,
1058
+ redacted,
1059
+ renderOwnerText,
1060
+ };