atris 3.74.29 → 3.76.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/atris/skills/calendar/SKILL.md +9 -0
- package/atris/skills/drive/SKILL.md +7 -0
- package/atris/skills/email-agent/SKILL.md +7 -0
- package/atris/skills/magic-inbox/SKILL.md +7 -0
- package/atris/skills/slides/SKILL.md +7 -0
- package/bin/atris.js +14 -2
- package/commands/engine.js +2 -2
- package/commands/integrations.js +539 -54
- package/commands/mission.js +2 -2
- package/commands/rating.js +29 -5
- package/commands/task.js +72 -5
- package/commands/worktree.js +66 -18
- package/lib/fleet.js +602 -55
- package/lib/gm-season.js +47 -10
- package/lib/held-pull-requests.js +589 -0
- package/lib/known-commands.js +1 -1
- package/lib/pull-request-link.js +25 -0
- package/lib/release-kind.js +31 -7
- package/lib/review-integrity.js +52 -5
- package/lib/task-db.js +703 -160
- package/lib/worktree-space.js +4 -45
- package/package.json +1 -1
|
@@ -0,0 +1,589 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Held jobs. A held job is work that is done and whose pull request waits for a person to land
|
|
4
|
+
// it: the job sits in Review under the builder's name with the pull request open on the row.
|
|
5
|
+
// When a person merges that pull request on GitHub, by any method, nothing in the task store
|
|
6
|
+
// hears about it, so the rating shows the job held forever and the builder is never credited.
|
|
7
|
+
// `atris task sweep --held` asks GitHub about each held job and writes down what happened.
|
|
8
|
+
//
|
|
9
|
+
// merged: the merge is recorded the way any landed merge is recorded, and the job closes as
|
|
10
|
+
// done with the action "merged", which the rating counts as landed for the builder
|
|
11
|
+
// who held the claim. A person's merge is the acceptance, so it is not a review
|
|
12
|
+
// pass and it certifies nothing. The landing is dated at the merge, not at the
|
|
13
|
+
// sweep, so a merge swept in late does not count as new.
|
|
14
|
+
// closed: the pull request is recorded as closed and the claim is released with the reason
|
|
15
|
+
// "pull request closed without landing: <url>". The rating counts that as a miss,
|
|
16
|
+
// and a person can still mark the job not needed.
|
|
17
|
+
// open: nothing changes.
|
|
18
|
+
//
|
|
19
|
+
// The link on a row is whatever a builder wrote there, so before it credits a merge or lets a
|
|
20
|
+
// claim go the sweep proves the pull request is this job's own build, from what GitHub itself
|
|
21
|
+
// says. It is in the repository the job's workspace came from (the owner and name in GitHub's
|
|
22
|
+
// own link equal those of the workspace's origin remote), and so is its head branch (a fork's
|
|
23
|
+
// branch is not this workspace's build). The head branch carries the job's row id as a whole
|
|
24
|
+
// word, and no other row id: that is how the flights name the branch they build on
|
|
25
|
+
// (flightTaskName in lib/fleet.js). No other job has the pull request: none already landed with
|
|
26
|
+
// it, and none still on the board also points at it, because two jobs cannot both be credited for
|
|
27
|
+
// one merge. And, for a merge, it was merged after the claim now on the job began, and into the
|
|
28
|
+
// repository's default branch (a merge into a feature branch has not landed). If any of that
|
|
29
|
+
// fails the job is refused: nothing is written, one plain line says why, and the sweep goes on to
|
|
30
|
+
// the next job. A merge with no usable merge time is not credited either. The sweep never looks
|
|
31
|
+
// at what is inside the pull request: a person merged it, and that person is the judge of that.
|
|
32
|
+
//
|
|
33
|
+
// GitHub is the only source, so anyone may run this, agents included: the run grants nothing
|
|
34
|
+
// of its own, it writes down what GitHub says. It works by row id, only on jobs in the
|
|
35
|
+
// workspace being swept (or every workspace when asked), and only while the row is still
|
|
36
|
+
// what it read: GitHub answers slowly, and a job someone settled in the meantime is left alone.
|
|
37
|
+
//
|
|
38
|
+
// One outage rule. When GitHub does not answer in time, or gh is not installed (it is not found,
|
|
39
|
+
// or a shim says "command not found" and exits 127), the run stops asking and changes nothing
|
|
40
|
+
// more. Any other failure leaves that one job unknown, and the rest are still checked.
|
|
41
|
+
|
|
42
|
+
const { spawnSync } = require('child_process');
|
|
43
|
+
const { pullRequestOfLink } = require('./pull-request-link');
|
|
44
|
+
|
|
45
|
+
// Per call. A hung gh must not hang the whole sweep.
|
|
46
|
+
const ASK_TIMEOUT_MS = 30000;
|
|
47
|
+
const FIELDS = 'state,mergeCommit,mergedAt,mergedBy,number,url,headRefName,headRepository,headRepositoryOwner,baseRefName';
|
|
48
|
+
const COMMIT = /^[0-9a-f]{7,64}$/i;
|
|
49
|
+
// How GitHub names a person or a bot: one word of letters, digits and a few marks.
|
|
50
|
+
const LOGIN = /^[A-Za-z0-9][A-Za-z0-9_.\-[\]/]{0,63}$/;
|
|
51
|
+
const STATES = { OPEN: 'open', MERGED: 'merged', CLOSED: 'closed' };
|
|
52
|
+
// One word of an owner or a repository name as GitHub writes them.
|
|
53
|
+
const REPO_WORD = /^[A-Za-z0-9_.-]+$/;
|
|
54
|
+
// The shape of a job's row id: 26 characters of the alphabet row ids are written in (digits and
|
|
55
|
+
// lower case letters, never i, l, o or u).
|
|
56
|
+
const ROW_ID_WORD = /^[0-9a-hjkmnp-tv-z]{26}$/;
|
|
57
|
+
|
|
58
|
+
const TITLE_MAX = 80;
|
|
59
|
+
const DETAIL_MAX = 200;
|
|
60
|
+
|
|
61
|
+
function oneLine(value, max) {
|
|
62
|
+
const text = String(value || '').replace(/\s+/g, ' ').trim();
|
|
63
|
+
return text.length > max ? `${text.slice(0, Math.max(0, max - 3)).trim()}...` : text;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function firstLine(value) {
|
|
67
|
+
return String(value || '').split('\n').map((line) => line.trim()).find(Boolean) || '';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function secondsLabel(ms) {
|
|
71
|
+
const seconds = ms / 1000;
|
|
72
|
+
return `${seconds} ${seconds === 1 ? 'second' : 'seconds'}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// The owner and name, in lower case, of the repository a pull request link is in
|
|
76
|
+
// (https://<host>/<owner>/<name>/pull/<number>, however it is decorated), or null when it is not
|
|
77
|
+
// such a link.
|
|
78
|
+
function repoOfPullRequestLink(link) {
|
|
79
|
+
const named = pullRequestOfLink(link);
|
|
80
|
+
return named ? named.repo : null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// The owner and name, in lower case, of a repository given as an owner and a name (the head
|
|
84
|
+
// repository GitHub reports for a branch), or null when either is missing or not a name.
|
|
85
|
+
function repoOfParts(owner, name) {
|
|
86
|
+
const ownerText = typeof owner === 'string' ? owner.trim() : '';
|
|
87
|
+
const nameText = typeof name === 'string' ? name.trim() : '';
|
|
88
|
+
return REPO_WORD.test(ownerText) && REPO_WORD.test(nameText) ? `${ownerText}/${nameText}`.toLowerCase() : null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// The owner and name, in lower case, of the repository a git remote points at, or null when it
|
|
92
|
+
// does not read as one: a web or ssh address (https://host/owner/name.git, ssh://git@host/owner/name,
|
|
93
|
+
// git@host:owner/name.git) with exactly an owner and a name after the host. The host is not
|
|
94
|
+
// compared, because an ssh alias in front of github.com is an ordinary way to name it.
|
|
95
|
+
function repoOfRemote(remote) {
|
|
96
|
+
const text = String(remote || '').trim();
|
|
97
|
+
const match = /^(?:https?|ssh|git):\/\/(?:[^@/\s]+@)?[^/:\s]+(?::\d+)?\/([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/i.exec(text)
|
|
98
|
+
|| /^(?:[^@/\s]+@)?[^/:\s]+:([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/.exec(text);
|
|
99
|
+
return match ? `${match[1]}/${match[2]}`.toLowerCase() : null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// The row ids in a branch name: the words of it (split on anything that is not a letter or a
|
|
103
|
+
// digit, any case) that have the shape of a row id, in order.
|
|
104
|
+
function jobIdsIn(headRef) {
|
|
105
|
+
return String(headRef || '').toLowerCase().split(/[^a-z0-9]+/).filter((word) => ROW_ID_WORD.test(word));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Whether a pull request's head branch is this job's build: it carries exactly one row id as a whole
|
|
109
|
+
// word, and it is this job's. The case is ignored, so a hand-named branch with the id as the store
|
|
110
|
+
// spells it counts. Another job's id, or two ids (even this job's twice), say the branch is not one
|
|
111
|
+
// job's build, so it binds nothing. Only words the shape of a row id are ever counted, so a job whose
|
|
112
|
+
// own id is not that shape binds nothing either.
|
|
113
|
+
function branchNamesJob(headRef, rowId) {
|
|
114
|
+
const id = String(rowId || '').trim().toLowerCase();
|
|
115
|
+
const named = jobIdsIn(headRef);
|
|
116
|
+
return named.length === 1 && named[0] === id;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// One call to gh, with its failures told apart the one way every question the sweep asks tells
|
|
120
|
+
// them apart. Exactly one of:
|
|
121
|
+
// { stdout } gh ran and exited 0
|
|
122
|
+
// { outage } GitHub did not answer in time, or gh is missing (not found, or a shim or wrapper
|
|
123
|
+
// that cannot find it exits 127): the run stops asking
|
|
124
|
+
// { unknown } one plain sentence on why this one question could not be answered
|
|
125
|
+
function runGh(args, { run = spawnSync, timeoutMs = ASK_TIMEOUT_MS } = {}) {
|
|
126
|
+
// gh is found on the PATH: a fixed path adds no boundary, since a process that can change the PATH can write the task store directly.
|
|
127
|
+
const result = run('gh', args, {
|
|
128
|
+
encoding: 'utf8',
|
|
129
|
+
timeout: timeoutMs,
|
|
130
|
+
killSignal: 'SIGKILL',
|
|
131
|
+
maxBuffer: 1024 * 1024,
|
|
132
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
133
|
+
}) || {};
|
|
134
|
+
if (result.error) {
|
|
135
|
+
if (result.error.code === 'ETIMEDOUT') {
|
|
136
|
+
return { outage: { kind: 'timeout', detail: `GitHub did not answer within ${secondsLabel(timeoutMs)}` } };
|
|
137
|
+
}
|
|
138
|
+
if (result.error.code === 'ENOENT') {
|
|
139
|
+
return { outage: { kind: 'gh_missing', detail: 'gh is not installed or not on the PATH, so nothing could be checked' } };
|
|
140
|
+
}
|
|
141
|
+
return { unknown: oneLine(`gh did not run: ${firstLine(result.error.message)}`, DETAIL_MAX) };
|
|
142
|
+
}
|
|
143
|
+
if (result.status !== 0) {
|
|
144
|
+
if (result.status === null) return { unknown: `gh did not finish${result.signal ? ` (${result.signal})` : ''}` };
|
|
145
|
+
// 127 is "command not found": a shim or wrapper that cannot find gh is the same outage as no gh.
|
|
146
|
+
if (result.status === 127) {
|
|
147
|
+
return { outage: { kind: 'gh_missing', detail: 'gh is not installed or not on the PATH (it exited 127), so nothing could be checked' } };
|
|
148
|
+
}
|
|
149
|
+
const said = firstLine(result.stderr);
|
|
150
|
+
return { unknown: said ? oneLine(`gh said: ${said}`, DETAIL_MAX) : `gh exited ${result.status}` };
|
|
151
|
+
}
|
|
152
|
+
return { stdout: String(result.stdout || '') };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// What gh printed, as a JSON object, or null when it is anything else.
|
|
156
|
+
function jsonObject(text) {
|
|
157
|
+
let data = null;
|
|
158
|
+
try { data = JSON.parse(text); } catch { data = null; }
|
|
159
|
+
return data && typeof data === 'object' && !Array.isArray(data) ? data : null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// What GitHub says about one pull request, in the shape the sweep reads. Exactly one of:
|
|
163
|
+
// { answer } state is open, merged or closed, with the number, the link, the repository GitHub's
|
|
164
|
+
// own link puts it in, its head branch and the repository that branch lives in, the
|
|
165
|
+
// branch it went (or goes) to, and for a merge its commit, time and merger (null
|
|
166
|
+
// where GitHub gave none or gave nonsense)
|
|
167
|
+
// { outage } GitHub did not answer in time, or gh is missing: stop asking
|
|
168
|
+
// { unknown } one plain sentence on why this one pull request could not be read
|
|
169
|
+
// `run` and `timeoutMs` are for tests.
|
|
170
|
+
function askGitHub(url, { run = spawnSync, timeoutMs = ASK_TIMEOUT_MS } = {}) {
|
|
171
|
+
const ran = runGh(['pr', 'view', url, '--json', FIELDS], { run, timeoutMs });
|
|
172
|
+
if (ran.outage || ran.unknown) return ran;
|
|
173
|
+
const data = jsonObject(ran.stdout);
|
|
174
|
+
if (!data) return { unknown: "GitHub's answer could not be read" };
|
|
175
|
+
const number = Number(data.number);
|
|
176
|
+
if (!Number.isInteger(number) || number <= 0) {
|
|
177
|
+
return { unknown: "GitHub's answer could not be read: it names no pull request number" };
|
|
178
|
+
}
|
|
179
|
+
const rawState = String(data.state || '').trim();
|
|
180
|
+
const state = STATES[rawState.toUpperCase()];
|
|
181
|
+
if (!state) {
|
|
182
|
+
return { unknown: oneLine(`GitHub's answer has a state it does not know (${rawState || 'none'})`, DETAIL_MAX) };
|
|
183
|
+
}
|
|
184
|
+
const commit = data.mergeCommit && typeof data.mergeCommit === 'object' ? String(data.mergeCommit.oid || '').trim() : '';
|
|
185
|
+
const login = data.mergedBy && typeof data.mergedBy === 'object' ? String(data.mergedBy.login || '').trim() : '';
|
|
186
|
+
const mergedAt = typeof data.mergedAt === 'string' ? data.mergedAt.trim() : '';
|
|
187
|
+
const link = typeof data.url === 'string' ? data.url.trim() : '';
|
|
188
|
+
const headRef = typeof data.headRefName === 'string' ? data.headRefName.trim() : '';
|
|
189
|
+
const baseRef = typeof data.baseRefName === 'string' ? data.baseRefName.trim() : '';
|
|
190
|
+
const headOwner = data.headRepositoryOwner && typeof data.headRepositoryOwner === 'object' ? data.headRepositoryOwner.login : null;
|
|
191
|
+
const headName = data.headRepository && typeof data.headRepository === 'object' ? data.headRepository.name : null;
|
|
192
|
+
return {
|
|
193
|
+
answer: {
|
|
194
|
+
state,
|
|
195
|
+
number,
|
|
196
|
+
url: /^https?:\/\//i.test(link) ? link : url,
|
|
197
|
+
// The repository is read only from the link GitHub gave, never from the one that was asked.
|
|
198
|
+
repo: repoOfPullRequestLink(link),
|
|
199
|
+
// The repository the head branch lives in, which is not the pull request's own repository
|
|
200
|
+
// when the branch is in a fork.
|
|
201
|
+
head_repo: repoOfParts(headOwner, headName),
|
|
202
|
+
base_ref: baseRef || null,
|
|
203
|
+
head_ref: headRef || null,
|
|
204
|
+
merge_commit: COMMIT.test(commit) ? commit : null,
|
|
205
|
+
merged_at: mergedAt && Number.isFinite(Date.parse(mergedAt)) ? mergedAt : null,
|
|
206
|
+
merged_by: LOGIN.test(login) ? login : null,
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// The branch a repository lands on by default, for the link of the repository itself. Exactly one of:
|
|
212
|
+
// { branch } the default branch's name
|
|
213
|
+
// { outage } as for askGitHub: stop asking
|
|
214
|
+
// { unknown } one plain sentence on why it could not be read
|
|
215
|
+
// `run` and `timeoutMs` are for tests.
|
|
216
|
+
function askDefaultBranch(repoLink, { run = spawnSync, timeoutMs = ASK_TIMEOUT_MS } = {}) {
|
|
217
|
+
const ran = runGh(['repo', 'view', repoLink, '--json', 'defaultBranchRef'], { run, timeoutMs });
|
|
218
|
+
if (ran.outage || ran.unknown) return ran;
|
|
219
|
+
const data = jsonObject(ran.stdout);
|
|
220
|
+
if (!data) return { unknown: "GitHub's answer could not be read" };
|
|
221
|
+
const ref = data.defaultBranchRef;
|
|
222
|
+
const name = ref && typeof ref === 'object' && typeof ref.name === 'string' ? ref.name.trim() : '';
|
|
223
|
+
if (!name) return { unknown: "GitHub's answer does not say which branch is the repository's default" };
|
|
224
|
+
return { branch: name };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const NOT_ASKED = 'not asked, because the run stopped when GitHub could not be reached';
|
|
228
|
+
|
|
229
|
+
// The variables a hook or a parent git leaves behind that point git at some other repository.
|
|
230
|
+
const GIT_REPOSITORY_VARIABLES = [
|
|
231
|
+
'GIT_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE', 'GIT_COMMON_DIR', 'GIT_OBJECT_DIRECTORY',
|
|
232
|
+
'GIT_ALTERNATE_OBJECT_DIRECTORIES', 'GIT_NAMESPACE', 'GIT_PREFIX',
|
|
233
|
+
];
|
|
234
|
+
|
|
235
|
+
// Which repository a workspace came from: the owner and name its origin remote points at, asked of
|
|
236
|
+
// git in the workspace's own folder. Null when git cannot say (no folder, no origin) or the remote
|
|
237
|
+
// does not read as a GitHub repository.
|
|
238
|
+
function originRepoOf(root, { run = spawnSync } = {}) {
|
|
239
|
+
if (!root) return null;
|
|
240
|
+
const env = { ...process.env };
|
|
241
|
+
for (const key of GIT_REPOSITORY_VARIABLES) delete env[key];
|
|
242
|
+
const done = run('git', ['-C', String(root), 'remote', 'get-url', 'origin'], {
|
|
243
|
+
encoding: 'utf8',
|
|
244
|
+
env,
|
|
245
|
+
timeout: 10000,
|
|
246
|
+
killSignal: 'SIGKILL',
|
|
247
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
248
|
+
}) || {};
|
|
249
|
+
if (done.error || done.status !== 0) return null;
|
|
250
|
+
return repoOfRemote(done.stdout);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Why a pull request GitHub says is merged or closed is not this job's own build, in a plain
|
|
254
|
+
// phrase, or null when it is. The repository comes from GitHub's own link for the pull request
|
|
255
|
+
// and must be the one the workspace's origin remote points at, and so must the repository the
|
|
256
|
+
// head branch lives in (a fork's branch is not this workspace's build); the head branch must
|
|
257
|
+
// carry the job's row id, and no other.
|
|
258
|
+
function notBuildReason(row, answer, originRepo) {
|
|
259
|
+
if (!originRepo) return "this workspace's origin remote could not be read as a GitHub repository";
|
|
260
|
+
if (!answer.repo) return "GitHub's answer does not say which repository it is in";
|
|
261
|
+
if (answer.repo !== originRepo) return `it is in ${answer.repo}, and this workspace's origin is ${originRepo}`;
|
|
262
|
+
if (!answer.head_repo) return "GitHub's answer does not say which repository its branch is in";
|
|
263
|
+
if (answer.head_repo !== originRepo) return `its branch is in ${answer.head_repo}, and this workspace's origin is ${originRepo}`;
|
|
264
|
+
if (!branchNamesJob(answer.head_ref, row.id)) {
|
|
265
|
+
const branch = answer.head_ref ? oneLine(answer.head_ref, DETAIL_MAX) : 'none given';
|
|
266
|
+
return jobIdsIn(answer.head_ref).length > 1
|
|
267
|
+
? `its branch (${branch}) carries more than one job id`
|
|
268
|
+
: `its branch (${branch}) does not carry this job's id`;
|
|
269
|
+
}
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Why a merge cannot be this job's current attempt, or null when it can: the claim now on the job
|
|
274
|
+
// began before the merge. A job reopened and claimed again must not be credited for a merge that
|
|
275
|
+
// happened before its new claim, and a missing claim time proves nothing.
|
|
276
|
+
function mergeBeforeClaimReason(row, answer) {
|
|
277
|
+
const claimedAt = row.claimed_at == null ? NaN : Number(row.claimed_at);
|
|
278
|
+
if (!Number.isFinite(claimedAt)) return 'this job has no claim time to compare the merge with';
|
|
279
|
+
if (!(Date.parse(answer.merged_at) > claimedAt)) {
|
|
280
|
+
return `it was merged at ${answer.merged_at}, not after this job's claim began at ${new Date(claimedAt).toISOString()}`;
|
|
281
|
+
}
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Why a store refused to settle a job the sweep had read, in a plain sentence.
|
|
286
|
+
function leftAloneDetail(reason) {
|
|
287
|
+
if (reason === 'not_held') return 'it no longer waits on a pull request, so it was left alone';
|
|
288
|
+
if (/^already_/.test(String(reason))) return `it is now ${String(reason).slice('already_'.length)}, so it was left alone`;
|
|
289
|
+
return 'it changed while GitHub was being asked, so it was left alone';
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Check every held job against GitHub and, unless dryRun, write down what happened.
|
|
293
|
+
//
|
|
294
|
+
// taskStore lib/task-db
|
|
295
|
+
// db an open connection (read only is enough for a dry run)
|
|
296
|
+
// workspaceRoot the workspace being swept; null sweeps every workspace
|
|
297
|
+
// actor who ran the check; the events carry this name, never the builder's
|
|
298
|
+
// ask how to ask GitHub about a link (askGitHub); asked once per link per run
|
|
299
|
+
// askBranch how to ask GitHub for a repository's default branch (askDefaultBranch); asked
|
|
300
|
+
// once per repository per run, and only for a pull request that was merged
|
|
301
|
+
//
|
|
302
|
+
// Returns { ok, action, dry_run, scope, actor, outage, summary, results }. ok is false only on
|
|
303
|
+
// an outage. Each result is { task_id, ref, title, builder, workspace_root, pull_request,
|
|
304
|
+
// outcome, action, detail } where outcome is what GitHub said (merged, closed, open) or
|
|
305
|
+
// unknown or not_asked, and action is what was done: landed, released, would_land,
|
|
306
|
+
// would_release, none, left_alone, or refused (the pull request is not this job's build: nothing
|
|
307
|
+
// was written, and detail is the plain line saying why).
|
|
308
|
+
function sweepHeldJobs(taskStore, db, { workspaceRoot = null, actor, dryRun = false, ask = askGitHub, askBranch = askDefaultBranch } = {}) {
|
|
309
|
+
if (!actor) throw new Error('actor required');
|
|
310
|
+
const scope = workspaceRoot ? 'workspace' : 'everywhere';
|
|
311
|
+
// No task list yet means nothing is held.
|
|
312
|
+
const inScope = db ? taskStore.listTasks(db, { workspaceRoot: workspaceRoot || null }) : [];
|
|
313
|
+
const held = inScope
|
|
314
|
+
.filter((row) => row.status === 'review' && taskStore.heldPullRequestOf(row))
|
|
315
|
+
.sort((a, b) => (Number(a.created_at || 0) - Number(b.created_at || 0)) || String(a.id).localeCompare(String(b.id)));
|
|
316
|
+
const named = taskStore.withTaskDisplayRefs(held, inScope);
|
|
317
|
+
|
|
318
|
+
// Each question goes to GitHub once per run, whatever the answer: a failure is not asked again.
|
|
319
|
+
const answers = new Map();
|
|
320
|
+
const askOnce = (url) => {
|
|
321
|
+
if (!answers.has(url)) {
|
|
322
|
+
let asked;
|
|
323
|
+
try {
|
|
324
|
+
asked = ask(url);
|
|
325
|
+
} catch (err) {
|
|
326
|
+
asked = { unknown: oneLine(`could not ask GitHub: ${firstLine(err && err.message) || 'unknown error'}`, DETAIL_MAX) };
|
|
327
|
+
}
|
|
328
|
+
answers.set(url, asked && typeof asked === 'object' ? asked : { unknown: "GitHub's answer could not be read" });
|
|
329
|
+
}
|
|
330
|
+
return answers.get(url);
|
|
331
|
+
};
|
|
332
|
+
const branches = new Map();
|
|
333
|
+
const defaultBranchOf = (repoLink) => {
|
|
334
|
+
const key = repoLink.toLowerCase();
|
|
335
|
+
if (!branches.has(key)) {
|
|
336
|
+
let asked;
|
|
337
|
+
try {
|
|
338
|
+
asked = askBranch(repoLink);
|
|
339
|
+
} catch (err) {
|
|
340
|
+
asked = { unknown: oneLine(`could not ask GitHub: ${firstLine(err && err.message) || 'unknown error'}`, DETAIL_MAX) };
|
|
341
|
+
}
|
|
342
|
+
branches.set(key, asked && typeof asked === 'object' ? asked : { unknown: "GitHub's answer could not be read" });
|
|
343
|
+
}
|
|
344
|
+
return branches.get(key);
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
// A workspace's origin is read once per run, and only if a pull request needs it.
|
|
348
|
+
const origins = new Map();
|
|
349
|
+
const originOf = (root) => {
|
|
350
|
+
if (!origins.has(root)) origins.set(root, originRepoOf(root));
|
|
351
|
+
return origins.get(root);
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
// The name a person reads for a job, in its own workspace's numbering.
|
|
355
|
+
const refOfJob = (id) => {
|
|
356
|
+
const task = taskStore.getTask(db, id);
|
|
357
|
+
if (!task) return String(id);
|
|
358
|
+
const [shown] = taskStore.withTaskDisplayRefs([task], taskStore.listTasks(db, { workspaceRoot: task.workspace_root || null }));
|
|
359
|
+
return (shown && (shown.display_id || shown.legacy_ref || shown.id)) || String(id);
|
|
360
|
+
};
|
|
361
|
+
// Why this pull request is not this job's to settle, when another job has it.
|
|
362
|
+
const takenReason = (taken, answer) => (taken.how === 'landed'
|
|
363
|
+
? `pull request ${answer.number} in ${answer.repo} already landed with ${refOfJob(taken.id)}`
|
|
364
|
+
: `another job (${refOfJob(taken.id)}) still points at pull request ${answer.number} in ${answer.repo}`);
|
|
365
|
+
|
|
366
|
+
let outage = null;
|
|
367
|
+
const results = [];
|
|
368
|
+
for (const row of named) {
|
|
369
|
+
const pullRequest = taskStore.heldPullRequestOf(row);
|
|
370
|
+
const result = {
|
|
371
|
+
task_id: row.id,
|
|
372
|
+
ref: row.display_id || row.legacy_ref || row.id,
|
|
373
|
+
title: oneLine(row.title, TITLE_MAX),
|
|
374
|
+
builder: row.claimed_by,
|
|
375
|
+
workspace_root: row.workspace_root,
|
|
376
|
+
pull_request: { number: pullRequest.number, url: pullRequest.url },
|
|
377
|
+
outcome: 'unknown',
|
|
378
|
+
action: 'none',
|
|
379
|
+
detail: null,
|
|
380
|
+
};
|
|
381
|
+
results.push(result);
|
|
382
|
+
if (outage) {
|
|
383
|
+
result.outcome = 'not_asked';
|
|
384
|
+
result.detail = NOT_ASKED;
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
if (!pullRequest.url) {
|
|
388
|
+
result.detail = `the job names pull request ${pullRequest.number} but no link, so there is nothing to ask`;
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
const asked = askOnce(pullRequest.url);
|
|
392
|
+
if (asked.outage) {
|
|
393
|
+
outage = asked.outage;
|
|
394
|
+
result.detail = outage.detail;
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
if (!asked.answer) {
|
|
398
|
+
result.detail = asked.unknown || "GitHub's answer could not be read";
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
const answer = asked.answer;
|
|
402
|
+
if (pullRequest.number && answer.number !== pullRequest.number) {
|
|
403
|
+
result.detail = `GitHub answered for pull request ${answer.number}, not ${pullRequest.number}`;
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
const expected = { updatedAt: row.updated_at, claimedBy: row.claimed_by, pullRequestUrl: pullRequest.url };
|
|
407
|
+
if (answer.state === 'open') {
|
|
408
|
+
result.outcome = 'open';
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
// Merged or closed: before a merge is credited or a claim let go, the pull request must be
|
|
412
|
+
// this job's own build. If it is not, nothing is written and the sweep goes on.
|
|
413
|
+
result.outcome = answer.state;
|
|
414
|
+
const refuse = (why) => {
|
|
415
|
+
result.action = 'refused';
|
|
416
|
+
result.detail = `${result.ref}'s recorded pull request is not this job's build: ${why}`;
|
|
417
|
+
};
|
|
418
|
+
const notBuild = notBuildReason(row, answer, originOf(row.workspace_root));
|
|
419
|
+
if (notBuild) {
|
|
420
|
+
refuse(notBuild);
|
|
421
|
+
continue;
|
|
422
|
+
}
|
|
423
|
+
// The pull request this job would settle, in the words the store compares them by.
|
|
424
|
+
const identity = { repo: answer.repo, number: answer.number };
|
|
425
|
+
// The same question is asked of the store again inside the write, where nothing can slip in
|
|
426
|
+
// between; this look lets a refusal made from the board alone skip the questions below.
|
|
427
|
+
const takenBefore = () => taskStore.pullRequestTakenBy(db, { ...identity, exceptId: row.id });
|
|
428
|
+
if (answer.state === 'merged') {
|
|
429
|
+
if (!answer.merge_commit) {
|
|
430
|
+
result.outcome = 'unknown';
|
|
431
|
+
result.detail = `GitHub says pull request ${answer.number} is merged but gave no merge commit`;
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
if (!answer.merged_at) {
|
|
435
|
+
result.outcome = 'unknown';
|
|
436
|
+
result.detail = `GitHub says pull request ${answer.number} is merged but gave no usable merge time`;
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
439
|
+
const early = mergeBeforeClaimReason(row, answer);
|
|
440
|
+
if (early) {
|
|
441
|
+
refuse(early);
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
444
|
+
const takenNow = takenBefore();
|
|
445
|
+
if (takenNow) {
|
|
446
|
+
refuse(takenReason(takenNow, answer));
|
|
447
|
+
continue;
|
|
448
|
+
}
|
|
449
|
+
// A merge counts only on the repository's default branch. The default branch is asked of
|
|
450
|
+
// GitHub once for each repository in the run, from the repository's own address.
|
|
451
|
+
if (!answer.base_ref) {
|
|
452
|
+
refuse("GitHub's answer does not say which branch it was merged into");
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
455
|
+
const repository = pullRequestOfLink(answer.url);
|
|
456
|
+
const defaults = repository ? defaultBranchOf(repository.repoLink) : { unknown: "GitHub's answer does not say which repository it is in" };
|
|
457
|
+
if (defaults.outage) {
|
|
458
|
+
outage = defaults.outage;
|
|
459
|
+
result.outcome = 'unknown';
|
|
460
|
+
result.detail = outage.detail;
|
|
461
|
+
continue;
|
|
462
|
+
}
|
|
463
|
+
if (!defaults.branch) {
|
|
464
|
+
result.outcome = 'unknown';
|
|
465
|
+
result.detail = defaults.unknown || "GitHub's answer could not be read";
|
|
466
|
+
continue;
|
|
467
|
+
}
|
|
468
|
+
if (answer.base_ref !== defaults.branch) {
|
|
469
|
+
refuse(`it was merged into ${answer.base_ref}, not the repository's default branch ${defaults.branch}`);
|
|
470
|
+
continue;
|
|
471
|
+
}
|
|
472
|
+
const sentence = `pull request ${answer.number} was merged${answer.merged_by ? ` by ${answer.merged_by}` : ''}`;
|
|
473
|
+
if (dryRun) {
|
|
474
|
+
result.action = 'would_land';
|
|
475
|
+
result.detail = `${sentence}; ${row.claimed_by} would be credited`;
|
|
476
|
+
continue;
|
|
477
|
+
}
|
|
478
|
+
const landed = taskStore.landHeldMerge(db, {
|
|
479
|
+
id: row.id,
|
|
480
|
+
actor,
|
|
481
|
+
workspaceRoot: workspaceRoot || row.workspace_root,
|
|
482
|
+
expected,
|
|
483
|
+
pullRequest: identity,
|
|
484
|
+
merge: {
|
|
485
|
+
merge_commit: answer.merge_commit,
|
|
486
|
+
number: answer.number,
|
|
487
|
+
url: answer.url,
|
|
488
|
+
merged_at: answer.merged_at,
|
|
489
|
+
merged_by: answer.merged_by,
|
|
490
|
+
},
|
|
491
|
+
proof: sentence,
|
|
492
|
+
});
|
|
493
|
+
if (landed.landed) {
|
|
494
|
+
result.action = 'landed';
|
|
495
|
+
result.detail = `${sentence}; ${row.claimed_by} is credited`;
|
|
496
|
+
} else if (landed.reason === 'pull_request_taken') {
|
|
497
|
+
refuse(takenReason({ id: landed.taken_by, how: landed.taken_how }, answer));
|
|
498
|
+
} else if (landed.reason === 'invalid_merge_record') {
|
|
499
|
+
result.outcome = 'unknown';
|
|
500
|
+
result.detail = `GitHub's merge commit for pull request ${answer.number} could not be used`;
|
|
501
|
+
} else {
|
|
502
|
+
result.action = 'left_alone';
|
|
503
|
+
result.detail = leftAloneDetail(landed.reason);
|
|
504
|
+
}
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
// closed without merging, whatever branch it was aimed at
|
|
508
|
+
const takenNow = takenBefore();
|
|
509
|
+
if (takenNow) {
|
|
510
|
+
refuse(takenReason(takenNow, answer));
|
|
511
|
+
continue;
|
|
512
|
+
}
|
|
513
|
+
const reason = `pull request closed without landing: ${pullRequest.url}`;
|
|
514
|
+
if (dryRun) {
|
|
515
|
+
result.action = 'would_release';
|
|
516
|
+
result.detail = reason;
|
|
517
|
+
continue;
|
|
518
|
+
}
|
|
519
|
+
const released = taskStore.releaseClosedPullRequest(db, {
|
|
520
|
+
id: row.id,
|
|
521
|
+
actor,
|
|
522
|
+
workspaceRoot: workspaceRoot || row.workspace_root,
|
|
523
|
+
expected,
|
|
524
|
+
pullRequest: identity,
|
|
525
|
+
reason,
|
|
526
|
+
});
|
|
527
|
+
if (released.released) {
|
|
528
|
+
result.action = 'released';
|
|
529
|
+
result.detail = reason;
|
|
530
|
+
} else if (released.reason === 'pull_request_taken') {
|
|
531
|
+
refuse(takenReason({ id: released.taken_by, how: released.taken_how }, answer));
|
|
532
|
+
} else {
|
|
533
|
+
result.action = 'left_alone';
|
|
534
|
+
result.detail = leftAloneDetail(released.reason);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
const count = (test) => results.filter(test).length;
|
|
539
|
+
const summary = {
|
|
540
|
+
checked: count((r) => r.outcome !== 'not_asked'),
|
|
541
|
+
landed: count((r) => r.action === 'landed'),
|
|
542
|
+
released: count((r) => r.action === 'released'),
|
|
543
|
+
would_land: count((r) => r.action === 'would_land'),
|
|
544
|
+
would_release: count((r) => r.action === 'would_release'),
|
|
545
|
+
refused: count((r) => r.action === 'refused'),
|
|
546
|
+
open: count((r) => r.outcome === 'open'),
|
|
547
|
+
unknown: count((r) => r.outcome === 'unknown'),
|
|
548
|
+
left_alone: count((r) => r.action === 'left_alone'),
|
|
549
|
+
not_asked: count((r) => r.outcome === 'not_asked'),
|
|
550
|
+
};
|
|
551
|
+
return {
|
|
552
|
+
ok: outage === null,
|
|
553
|
+
action: 'sweep_held',
|
|
554
|
+
dry_run: Boolean(dryRun),
|
|
555
|
+
scope,
|
|
556
|
+
actor,
|
|
557
|
+
outage,
|
|
558
|
+
summary,
|
|
559
|
+
results,
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// The plain lines a person reads, one per job that moved or could not be read, then a count of
|
|
564
|
+
// the ones still open.
|
|
565
|
+
function describeHeldSweep(report) {
|
|
566
|
+
if (!report.results.length) return ['no held jobs: nothing is waiting on an open pull request'];
|
|
567
|
+
const lines = [`held jobs: ${report.summary.checked} checked against GitHub${report.dry_run ? ' (dry run, nothing written)' : ''}`];
|
|
568
|
+
const verbs = {
|
|
569
|
+
landed: 'landed',
|
|
570
|
+
released: 'released',
|
|
571
|
+
would_land: 'would land',
|
|
572
|
+
would_release: 'would release',
|
|
573
|
+
left_alone: 'left alone',
|
|
574
|
+
};
|
|
575
|
+
for (const result of report.results) {
|
|
576
|
+
if (result.outcome === 'open') continue;
|
|
577
|
+
// A refusal names the job and says why in one line of its own.
|
|
578
|
+
if (result.action === 'refused') {
|
|
579
|
+
lines.push(result.detail);
|
|
580
|
+
continue;
|
|
581
|
+
}
|
|
582
|
+
const verb = verbs[result.action] || (result.outcome === 'not_asked' ? 'not asked' : 'could not check');
|
|
583
|
+
lines.push(`${verb} ${result.ref} ${result.title}: ${result.detail}`);
|
|
584
|
+
}
|
|
585
|
+
if (report.summary.open) lines.push(`still open: ${report.summary.open}`);
|
|
586
|
+
return lines;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
module.exports = { ASK_TIMEOUT_MS, askGitHub, askDefaultBranch, branchNamesJob, originRepoOf, sweepHeldJobs, describeHeldSweep };
|
package/lib/known-commands.js
CHANGED
|
@@ -4,7 +4,7 @@ const knownCommands = ['init', 'guide', 'doctor', 'doc-health', 'install', 'work
|
|
|
4
4
|
'activate', '_activate', 'agent', 'team', 'chat', 'fast', 'ax', 'console', 'serve', 'login', 'logout', 'whoami', 'switch', 'use', 'accounts', '_resolve', '_profile-email', '_switch-session', 'shell-init', 'update', 'upgrade', 'version', 'help', 'next', 'atris',
|
|
5
5
|
'clean', 'close', 'harvest', 'verify', 'recover', 'search', 'scout', 'skill', 'member', 'codex-goal', 'app', 'apps', 'learn', 'lesson', 'taste', 'teach', 'plugin', 'experiments', 'bench', 'router', 'tree', 'receipt', 'proof', 'openclaw', 'pull', 'push', 'watch', 'cloud', 'live', 'align', 'terminal', 'computer', 'diff', 'business', 'sync', 'youtube', 'x-search',
|
|
6
6
|
'ingest', 'query', 'lint', 'loop', 'pulse', 'task', 'mission', 'decide', 'campaign', 'agents', 'probe', 'worktree', 'land', 'caretaker', 'autoland', 'drive', 'aeo', 'slop', 'voice', 'strings', 'write', 'security-review', 'secure', 'deck', 'site', 'theme', 'card', 'reel', 'improve', 'study', 'rainmaker', 'xp', 'play', 'gm', 'game', 'x', 'recap', 'report', 'signup', 'clarity', 'interview', 'meet', 'moves', 'unknowns', 'avail', 'scoreboard', 'scorecard', 'rating', 'sync-checkout', 'zen',
|
|
7
|
-
'ci', 'github', 'vercel', 'supabase', 'linear', 'stripe', 'balance', 'usage', 'spend', 'api-key', 'agent-client', 'topup', 'buy', 'transactions', 'hire', 'hires', 'mail', 'design', 'mcp', 'gmail', 'calendar', 'twitter', 'slack', 'imessage', 'integrations', 'setup', 'clean-workspace', 'cw',
|
|
7
|
+
'ci', 'github', 'vercel', 'supabase', 'linear', 'stripe', 'balance', 'usage', 'spend', 'api-key', 'agent-client', 'topup', 'buy', 'transactions', 'hire', 'hires', 'mail', 'design', 'mcp', 'gmail', 'google', 'calendar', 'twitter', 'slack', 'imessage', 'integrations', 'setup', 'clean-workspace', 'cw',
|
|
8
8
|
'fork', 'browse', 'publish', 'pack', 'sleep', 'wake', 'feedback', 'errors', 'wiki', 'code-review', 'cr', 'soul', 'fleet', 'fleet-report', 'loops', 'self-improve', 'compile', 'spaceship', 'truth', 'sign', 'engine', 'engines', 'link', 'playbook', 'feed', 'brief', 'social', 'host', 'people', 'follow', 'unfollow', 'friends', 'msg', 'inbox', 'invite', 'join', 'rsi', 'pay', 'trust'];
|
|
9
9
|
|
|
10
10
|
// Damerau-Levenshtein edit distance between two short strings. Counts an
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// A pull request link, read the one way everything that compares links reads it. The task store
|
|
4
|
+
// (which pull request a job points at) and the held-job sweep (which pull request GitHub answered
|
|
5
|
+
// for) both use it, so the two cannot disagree about what a link names.
|
|
6
|
+
//
|
|
7
|
+
// The link is https://<host>/<owner>/<name>/pull/<number>, however a person decorated it: a
|
|
8
|
+
// trailing slash, /files, a query string, a fragment, any case. A link with no number, or a number
|
|
9
|
+
// that is not a positive whole number, names no pull request.
|
|
10
|
+
//
|
|
11
|
+
// repo the owner and name, in lower case
|
|
12
|
+
// number the pull request number
|
|
13
|
+
// repoLink the repository's own address, as the link spells it
|
|
14
|
+
|
|
15
|
+
const PULL_REQUEST_LINK = /^(https?:\/\/[^/\s]+\/([^/\s]+)\/([^/\s]+))\/pull\/(\d+)(?:[/?#]\S*)?$/i;
|
|
16
|
+
|
|
17
|
+
function pullRequestOfLink(link) {
|
|
18
|
+
const match = PULL_REQUEST_LINK.exec(String(link || '').trim());
|
|
19
|
+
if (!match) return null;
|
|
20
|
+
const number = Number(match[4]);
|
|
21
|
+
if (!Number.isSafeInteger(number) || number <= 0) return null;
|
|
22
|
+
return { repo: `${match[2]}/${match[3]}`.toLowerCase(), number, repoLink: match[1] };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
module.exports = { pullRequestOfLink };
|