atris 3.75.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.
- package/commands/brief.js +219 -36
- package/commands/rating.js +18 -11
- package/commands/task.js +72 -5
- package/commands/worktree.js +27 -6
- package/lib/fleet.js +58 -4
- package/lib/gm-season.js +93 -1
- package/lib/held-pull-requests.js +589 -0
- package/lib/owner-brief.js +1060 -0
- package/lib/pull-request-link.js +25 -0
- package/lib/review-integrity.js +52 -5
- package/lib/task-db.js +519 -135
- package/package.json +1 -1
|
@@ -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
|
+
};
|