pstack-claude-mirror 0.9.133 → 0.9.134
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/.claude-plugin/marketplace.json +1 -1
- package/CHANGES.md +8 -0
- package/README.md +23 -7
- package/UPSTREAM-README.md +6 -0
- package/bin/pstack-claude.mjs +278 -130
- package/docs/reference.md +4 -4
- package/package.json +27 -9
- package/plugins/pstack/.antigravity-plugin/plugin.json +1 -1
- package/plugins/pstack/.claude-plugin/plugin.json +1 -1
- package/plugins/pstack/.codex-plugin/plugin.json +1 -1
- package/plugins/pstack/.github/plugin/plugin.json +1 -1
- package/plugins/pstack/.grok-plugin/plugin.json +1 -1
- package/plugins/pstack/.muse-plugin/plugin.json +1 -1
- package/plugins/pstack/LICENSE +22 -0
- package/plugins/pstack/hooks/copilot-hooks.json +4 -2
- package/plugins/pstack/hooks/session-start.ps1 +76 -12
- package/plugins/pstack/hooks/session-start.sh +6 -0
- package/plugins/pstack/models.json +1 -1
- package/plugins/pstack/package.json +1 -1
- package/plugins/pstack/pi/slash.ts +19 -5
- package/plugins/pstack/skills/poteto-mode/SKILL.md +1 -0
- package/plugins/pstack/skills/poteto-mode/playbooks/bug-fix.md +17 -0
- package/plugins/pstack/skills/poteto-mode/references/agy-tools.md +5 -4
- package/plugins/pstack/skills/poteto-mode/references/codex-tools.md +3 -2
- package/plugins/pstack/skills/poteto-mode/references/copilot-tools.md +2 -1
- package/plugins/pstack/skills/poteto-mode/references/pi-tools.md +1 -1
- package/plugins/pstack/skills/poteto-mode/scripts/.prettierignore +1 -1
- package/plugins/pstack/skills/poteto-mode/scripts/codex-session-effort.mjs +35 -7
- package/plugins/pstack/skills/poteto-mode/scripts/watch-pr/github.port.test.ts +427 -0
- package/plugins/pstack/skills/poteto-mode/scripts/watch-pr/github.test.ts +3 -399
- package/plugins/pstack/skills/poteto-mode/scripts/watch-pr/policy.port.test.ts +708 -0
- package/plugins/pstack/skills/poteto-mode/scripts/watch-pr/policy.test.ts +7 -666
- package/plugins/pstack/skills/setup-pstack/SKILL.md +6 -4
- package/plugins/pstack/skills/setup-pstack/references/runtimes.md +1 -1
- package/tools/forks.json +5 -5
|
@@ -0,0 +1,708 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { WatchDeadline } from "./deadline.ts";
|
|
3
|
+
import {
|
|
4
|
+
classifyPr,
|
|
5
|
+
noChecksConfirmer,
|
|
6
|
+
readSnapshot,
|
|
7
|
+
runQueued,
|
|
8
|
+
runSimple,
|
|
9
|
+
selectTierMajorStackDecision,
|
|
10
|
+
} from "./policy.ts";
|
|
11
|
+
import { fakeReader, failedCheck, pendingCheck } from "./fakes.test-helper.ts";
|
|
12
|
+
import type {
|
|
13
|
+
GitHubReader,
|
|
14
|
+
NonEmpty,
|
|
15
|
+
PollingOptions,
|
|
16
|
+
PrContext,
|
|
17
|
+
ProgressVerdict,
|
|
18
|
+
} from "./types.ts";
|
|
19
|
+
import { parsePrNumber } from "./types.ts";
|
|
20
|
+
|
|
21
|
+
const context = (number: number): PrContext => ({
|
|
22
|
+
owner: "owner",
|
|
23
|
+
repo: "repo",
|
|
24
|
+
number: parsePrNumber(number),
|
|
25
|
+
});
|
|
26
|
+
const options = {
|
|
27
|
+
interval: 10,
|
|
28
|
+
sweepInterval: 300,
|
|
29
|
+
timeout: 0,
|
|
30
|
+
maxQueryErrors: 5,
|
|
31
|
+
allowDraft: false,
|
|
32
|
+
} satisfies PollingOptions;
|
|
33
|
+
|
|
34
|
+
describe("review gate", () => {
|
|
35
|
+
it("blocks on a required review instead of reporting a blocked PR ready", async () => {
|
|
36
|
+
const snapshot = await readSnapshot({
|
|
37
|
+
reader: fakeReader({
|
|
38
|
+
facts: {
|
|
39
|
+
reviewDecision: "REVIEW_REQUIRED",
|
|
40
|
+
mergeStateStatus: "BLOCKED",
|
|
41
|
+
},
|
|
42
|
+
}),
|
|
43
|
+
context: context(23),
|
|
44
|
+
pendingHistory: "omit",
|
|
45
|
+
allowDraft: false,
|
|
46
|
+
});
|
|
47
|
+
expect(classifyPr(snapshot)).toEqual({
|
|
48
|
+
kind: "blocker",
|
|
49
|
+
blocker: {
|
|
50
|
+
kind: "merge-gate",
|
|
51
|
+
pr: context(23),
|
|
52
|
+
reason: "review-required",
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
expect(
|
|
56
|
+
selectTierMajorStackDecision([snapshot] as NonEmpty<typeof snapshot>)
|
|
57
|
+
).toMatchObject({
|
|
58
|
+
kind: "blocker",
|
|
59
|
+
blocker: { kind: "merge-gate", reason: "review-required" },
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("blocks when branch protection holds an approved PR with clean CI", async () => {
|
|
64
|
+
const snapshot = await readSnapshot({
|
|
65
|
+
reader: fakeReader({ facts: { mergeStateStatus: "BLOCKED" } }),
|
|
66
|
+
context: context(24),
|
|
67
|
+
pendingHistory: "omit",
|
|
68
|
+
allowDraft: false,
|
|
69
|
+
});
|
|
70
|
+
expect(classifyPr(snapshot)).toMatchObject({
|
|
71
|
+
kind: "blocker",
|
|
72
|
+
blocker: { kind: "merge-gate", reason: "merge-blocked" },
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("waits for pending checks before reporting the review gate", async () => {
|
|
77
|
+
const snapshot = await readSnapshot({
|
|
78
|
+
reader: fakeReader({
|
|
79
|
+
facts: {
|
|
80
|
+
reviewDecision: "REVIEW_REQUIRED",
|
|
81
|
+
mergeStateStatus: "BLOCKED",
|
|
82
|
+
},
|
|
83
|
+
fastPath: { kind: "checks", checks: [pendingCheck()] },
|
|
84
|
+
}),
|
|
85
|
+
context: context(26),
|
|
86
|
+
pendingHistory: "omit",
|
|
87
|
+
allowDraft: false,
|
|
88
|
+
});
|
|
89
|
+
expect(classifyPr(snapshot)).toMatchObject({ kind: "waiting" });
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("still reports changes requested as a merge-gate blocker", async () => {
|
|
93
|
+
const snapshot = await readSnapshot({
|
|
94
|
+
reader: fakeReader({
|
|
95
|
+
facts: {
|
|
96
|
+
reviewDecision: "CHANGES_REQUESTED",
|
|
97
|
+
mergeStateStatus: "BLOCKED",
|
|
98
|
+
},
|
|
99
|
+
}),
|
|
100
|
+
context: context(25),
|
|
101
|
+
pendingHistory: "omit",
|
|
102
|
+
allowDraft: false,
|
|
103
|
+
});
|
|
104
|
+
expect(classifyPr(snapshot)).toMatchObject({
|
|
105
|
+
kind: "blocker",
|
|
106
|
+
blocker: { kind: "merge-gate", reason: "changes-requested" },
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe("facts that change while the snapshot is read", () => {
|
|
112
|
+
const read = (options: Parameters<typeof fakeReader>[0]) =>
|
|
113
|
+
readSnapshot({
|
|
114
|
+
reader: fakeReader(options),
|
|
115
|
+
context: context(29),
|
|
116
|
+
pendingHistory: "include",
|
|
117
|
+
allowDraft: false,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("retries instead of reporting a merge gate the checks read has already cleared", async () => {
|
|
121
|
+
await expect(
|
|
122
|
+
read({
|
|
123
|
+
facts: { mergeStateStatus: "BLOCKED" },
|
|
124
|
+
factsOnReread: { mergeStateStatus: "CLEAN" },
|
|
125
|
+
})
|
|
126
|
+
).rejects.toMatchObject({
|
|
127
|
+
failure: { kind: "snapshot-changed", retryable: true },
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("retries instead of reporting ready when a review lands after the facts read", async () => {
|
|
132
|
+
for (const factsOnReread of [
|
|
133
|
+
{ reviewDecision: "CHANGES_REQUESTED" },
|
|
134
|
+
{ mergeable: "CONFLICTING" },
|
|
135
|
+
{ mergeable: "UNKNOWN" },
|
|
136
|
+
{ isDraft: true },
|
|
137
|
+
] as const)
|
|
138
|
+
await expect(read({ factsOnReread })).rejects.toMatchObject({
|
|
139
|
+
failure: { kind: "snapshot-changed", retryable: true },
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("retries when the PR closes, merges, or renames its head branch between the two reads", async () => {
|
|
144
|
+
for (const factsOnReread of [
|
|
145
|
+
{ state: "CLOSED" },
|
|
146
|
+
{ mergedAt: "2026-07-26T00:00:00Z" },
|
|
147
|
+
{ headRefName: "renamed" },
|
|
148
|
+
] as const)
|
|
149
|
+
await expect(read({ factsOnReread })).rejects.toMatchObject({
|
|
150
|
+
failure: { kind: "snapshot-changed", retryable: true },
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("keeps an unknown first read when GitHub computes mergeability before the re-read", async () => {
|
|
155
|
+
const unknown = {
|
|
156
|
+
mergeable: "UNKNOWN",
|
|
157
|
+
mergeStateStatus: "UNKNOWN",
|
|
158
|
+
} as const;
|
|
159
|
+
expect(
|
|
160
|
+
await read({
|
|
161
|
+
facts: unknown,
|
|
162
|
+
factsOnReread: { mergeable: "MERGEABLE", mergeStateStatus: "CLEAN" },
|
|
163
|
+
})
|
|
164
|
+
).toMatchObject({ kind: "open", facts: unknown });
|
|
165
|
+
await expect(
|
|
166
|
+
read({
|
|
167
|
+
facts: unknown,
|
|
168
|
+
factsOnReread: { reviewDecision: "CHANGES_REQUESTED" },
|
|
169
|
+
})
|
|
170
|
+
).rejects.toMatchObject({ failure: { kind: "snapshot-changed" } });
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
const ticking = (onSleep = () => {}) => {
|
|
175
|
+
let now = 0;
|
|
176
|
+
return {
|
|
177
|
+
now: () => now,
|
|
178
|
+
async sleep(seconds: number) {
|
|
179
|
+
now += seconds;
|
|
180
|
+
// A wait that never ends would spin on this clock without yielding to
|
|
181
|
+
// the test runner's own timeout.
|
|
182
|
+
if (now > 3600) throw new Error("no verdict within an hour of polling");
|
|
183
|
+
onSleep();
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
};
|
|
187
|
+
const dependencies = (
|
|
188
|
+
reader: GitHubReader,
|
|
189
|
+
clock: { now: () => number; sleep: (seconds: number) => Promise<void> },
|
|
190
|
+
emitted: ProgressVerdict[],
|
|
191
|
+
timeout = 0
|
|
192
|
+
) => ({
|
|
193
|
+
reader,
|
|
194
|
+
emit: (verdict: ProgressVerdict) => {
|
|
195
|
+
emitted.push(verdict);
|
|
196
|
+
},
|
|
197
|
+
clock: { ...clock, observedAt: () => "2026-07-26T00:00:00.000Z" },
|
|
198
|
+
deadline: new WatchDeadline(timeout, clock.now),
|
|
199
|
+
});
|
|
200
|
+
const kinds = (emitted: readonly ProgressVerdict[]) =>
|
|
201
|
+
emitted.map((verdict) =>
|
|
202
|
+
verdict.kind === "WAITING"
|
|
203
|
+
? `${verdict.kind}:${verdict.reason.kind}`
|
|
204
|
+
: verdict.kind
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
describe("mergeability", () => {
|
|
208
|
+
const unknown = {
|
|
209
|
+
mergeable: "UNKNOWN",
|
|
210
|
+
mergeStateStatus: "UNKNOWN",
|
|
211
|
+
} as const;
|
|
212
|
+
const read = (options: Parameters<typeof fakeReader>[0]) =>
|
|
213
|
+
readSnapshot({
|
|
214
|
+
reader: fakeReader(options),
|
|
215
|
+
context: context(27),
|
|
216
|
+
pendingHistory: "include",
|
|
217
|
+
allowDraft: false,
|
|
218
|
+
});
|
|
219
|
+
// One query error ends the run, so a wait that spent the budget would exit 7.
|
|
220
|
+
const watch = async (
|
|
221
|
+
reader: GitHubReader,
|
|
222
|
+
polling: Partial<PollingOptions> = {},
|
|
223
|
+
statusOnly = false
|
|
224
|
+
) => {
|
|
225
|
+
const clock = ticking();
|
|
226
|
+
const emitted: ProgressVerdict[] = [];
|
|
227
|
+
const verdict = await runSimple({
|
|
228
|
+
dependencies: dependencies(reader, clock, emitted, polling.timeout),
|
|
229
|
+
contexts: [context(27)],
|
|
230
|
+
mode: "single",
|
|
231
|
+
statusOnly,
|
|
232
|
+
options: { ...options, maxQueryErrors: 1, ...polling },
|
|
233
|
+
});
|
|
234
|
+
return { verdict, emitted: kinds(emitted), elapsed: clock.now() };
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
it("waits while GitHub has not computed mergeability instead of reporting ready", async () => {
|
|
238
|
+
for (const facts of [
|
|
239
|
+
{ mergeable: "UNKNOWN" },
|
|
240
|
+
{ mergeStateStatus: "UNKNOWN" },
|
|
241
|
+
] as const) {
|
|
242
|
+
const snapshot = await read({ facts });
|
|
243
|
+
const waiting = {
|
|
244
|
+
kind: "waiting",
|
|
245
|
+
frontier: context(27),
|
|
246
|
+
reason: { kind: "mergeability-unknown" },
|
|
247
|
+
} as const;
|
|
248
|
+
expect(classifyPr(snapshot)).toEqual(waiting);
|
|
249
|
+
expect(selectTierMajorStackDecision([snapshot])).toEqual(waiting);
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("re-polls at the interval after an unknown first read, without spending the query-error budget", async () => {
|
|
254
|
+
const { verdict, emitted, elapsed } = await watch(
|
|
255
|
+
fakeReader({
|
|
256
|
+
facts: unknown,
|
|
257
|
+
factsOnReread: { mergeable: "MERGEABLE", mergeStateStatus: "CLEAN" },
|
|
258
|
+
})
|
|
259
|
+
);
|
|
260
|
+
expect(emitted).toEqual(["WAITING:mergeability-unknown"]);
|
|
261
|
+
expect(elapsed).toBe(options.interval);
|
|
262
|
+
expect(verdict).toMatchObject({ kind: "READY", exitCode: 0 });
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("times out at the caller's deadline when GitHub never computes it", async () => {
|
|
266
|
+
const { verdict, emitted, elapsed } = await watch(
|
|
267
|
+
fakeReader({ facts: unknown }),
|
|
268
|
+
{ timeout: 25 }
|
|
269
|
+
);
|
|
270
|
+
expect(emitted).toEqual([
|
|
271
|
+
"WAITING:mergeability-unknown",
|
|
272
|
+
"WAITING:mergeability-unknown",
|
|
273
|
+
"WAITING:mergeability-unknown",
|
|
274
|
+
]);
|
|
275
|
+
expect(elapsed).toBe(25);
|
|
276
|
+
expect(verdict).toMatchObject({
|
|
277
|
+
kind: "TIMEOUT",
|
|
278
|
+
exitCode: 5,
|
|
279
|
+
reason: { kind: "mergeability-unknown" },
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("reports an unknown row on a status-only pass and exits 0 without waiting", async () => {
|
|
284
|
+
const { verdict, emitted, elapsed } = await watch(
|
|
285
|
+
fakeReader({ facts: unknown }),
|
|
286
|
+
{},
|
|
287
|
+
true
|
|
288
|
+
);
|
|
289
|
+
expect(emitted).toEqual([]);
|
|
290
|
+
expect(elapsed).toBe(0);
|
|
291
|
+
expect(verdict).toMatchObject({
|
|
292
|
+
kind: "STATUS",
|
|
293
|
+
exitCode: 0,
|
|
294
|
+
rows: [{ kind: "open", facts: unknown, ci: { kind: "ci-clean" } }],
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it("still stops at once on a blocker that does not depend on mergeability", async () => {
|
|
299
|
+
const thread = {
|
|
300
|
+
id: "thread",
|
|
301
|
+
firstComment: null,
|
|
302
|
+
isBugbot: false,
|
|
303
|
+
bugbotReviewPasses: 0,
|
|
304
|
+
};
|
|
305
|
+
const cases = [
|
|
306
|
+
[{ threads: [thread] }, { kind: "review-threads" }],
|
|
307
|
+
[
|
|
308
|
+
{
|
|
309
|
+
fastPath: { kind: "checks", checks: [failedCheck()] },
|
|
310
|
+
commitRollups: [{ oid: "head", state: "FAILURE" }],
|
|
311
|
+
},
|
|
312
|
+
{ kind: "failing-checks" },
|
|
313
|
+
],
|
|
314
|
+
[
|
|
315
|
+
{ facts: { ...unknown, reviewDecision: "CHANGES_REQUESTED" } },
|
|
316
|
+
{ kind: "merge-gate", reason: "changes-requested" },
|
|
317
|
+
],
|
|
318
|
+
] as const;
|
|
319
|
+
for (const [overrides, blocker] of cases)
|
|
320
|
+
expect(
|
|
321
|
+
classifyPr(await read({ facts: unknown, ...overrides }))
|
|
322
|
+
).toMatchObject({ kind: "blocker", blocker });
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it("defers a required review, and names pending checks first, while mergeability is unknown", async () => {
|
|
326
|
+
expect(
|
|
327
|
+
classifyPr(
|
|
328
|
+
await read({ facts: { ...unknown, reviewDecision: "REVIEW_REQUIRED" } })
|
|
329
|
+
)
|
|
330
|
+
).toMatchObject({
|
|
331
|
+
kind: "waiting",
|
|
332
|
+
reason: { kind: "mergeability-unknown" },
|
|
333
|
+
});
|
|
334
|
+
expect(
|
|
335
|
+
classifyPr(
|
|
336
|
+
await read({
|
|
337
|
+
facts: unknown,
|
|
338
|
+
fastPath: { kind: "checks", checks: [pendingCheck()] },
|
|
339
|
+
})
|
|
340
|
+
)
|
|
341
|
+
).toMatchObject({ kind: "waiting", reason: { kind: "pending-checks" } });
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it("does not report a queued frontier blocker-free while its mergeability is unknown", async () => {
|
|
345
|
+
let sleeps = 0;
|
|
346
|
+
const emitted: ProgressVerdict[] = [];
|
|
347
|
+
const running = runQueued({
|
|
348
|
+
dependencies: dependencies(
|
|
349
|
+
fakeReader({ facts: unknown }),
|
|
350
|
+
ticking(() => {
|
|
351
|
+
if (++sleeps === 2) throw new Error("stop after two polls");
|
|
352
|
+
}),
|
|
353
|
+
emitted
|
|
354
|
+
),
|
|
355
|
+
contexts: [context(27)],
|
|
356
|
+
options,
|
|
357
|
+
});
|
|
358
|
+
await expect(running).rejects.toThrow("stop after two polls");
|
|
359
|
+
expect(kinds(emitted)).toEqual([
|
|
360
|
+
"QUEUE",
|
|
361
|
+
"STATUS",
|
|
362
|
+
"WAITING:mergeability-unknown",
|
|
363
|
+
]);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it("gates a branch that is behind its base instead of reporting it ready", async () => {
|
|
367
|
+
const snapshot = await readSnapshot({
|
|
368
|
+
reader: fakeReader({ facts: { mergeStateStatus: "BEHIND" } }),
|
|
369
|
+
context: context(28),
|
|
370
|
+
pendingHistory: "include",
|
|
371
|
+
allowDraft: false,
|
|
372
|
+
});
|
|
373
|
+
expect(classifyPr(snapshot)).toEqual({
|
|
374
|
+
kind: "blocker",
|
|
375
|
+
blocker: { kind: "merge-gate", pr: context(28), reason: "behind-base" },
|
|
376
|
+
});
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it("stops at once on a branch behind its base, without waiting for checks that are pending or unreported", async () => {
|
|
380
|
+
for (const checks of [
|
|
381
|
+
{ fastPath: { kind: "checks", checks: [pendingCheck()] } },
|
|
382
|
+
{
|
|
383
|
+
fastPath: { kind: "none-reported" },
|
|
384
|
+
rollupPages: [{ kind: "no-rollup" }],
|
|
385
|
+
commitRollups: [{ oid: "head", state: null }],
|
|
386
|
+
},
|
|
387
|
+
] as const)
|
|
388
|
+
expect(
|
|
389
|
+
classifyPr(
|
|
390
|
+
await read({ facts: { mergeStateStatus: "BEHIND" }, ...checks })
|
|
391
|
+
)
|
|
392
|
+
).toEqual({
|
|
393
|
+
kind: "blocker",
|
|
394
|
+
blocker: { kind: "merge-gate", pr: context(27), reason: "behind-base" },
|
|
395
|
+
});
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it("reports requested changes before a behind base, and a behind base before a required review", async () => {
|
|
399
|
+
for (const [reviewDecision, reason] of [
|
|
400
|
+
["CHANGES_REQUESTED", "changes-requested"],
|
|
401
|
+
["REVIEW_REQUIRED", "behind-base"],
|
|
402
|
+
] as const)
|
|
403
|
+
expect(
|
|
404
|
+
classifyPr(
|
|
405
|
+
await read({
|
|
406
|
+
facts: { mergeStateStatus: "BEHIND", reviewDecision },
|
|
407
|
+
fastPath: { kind: "checks", checks: [pendingCheck()] },
|
|
408
|
+
})
|
|
409
|
+
)
|
|
410
|
+
).toMatchObject({ kind: "blocker", blocker: { reason } });
|
|
411
|
+
});
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
describe("a PR with no checks configured", () => {
|
|
415
|
+
const noChecks = {
|
|
416
|
+
fastPath: { kind: "none-reported" },
|
|
417
|
+
rollupPages: [{ kind: "no-rollup" }],
|
|
418
|
+
commitRollups: [{ oid: "head", state: null }],
|
|
419
|
+
} as const;
|
|
420
|
+
const read = (
|
|
421
|
+
overrides: Parameters<typeof fakeReader>[0] = {},
|
|
422
|
+
confirmNoChecks = () => true
|
|
423
|
+
) =>
|
|
424
|
+
readSnapshot({
|
|
425
|
+
reader: fakeReader({ ...noChecks, ...overrides }),
|
|
426
|
+
context: context(30),
|
|
427
|
+
pendingHistory: "omit",
|
|
428
|
+
allowDraft: false,
|
|
429
|
+
confirmNoChecks,
|
|
430
|
+
});
|
|
431
|
+
const run = (...args: Parameters<typeof dependencies>) =>
|
|
432
|
+
runSimple({
|
|
433
|
+
dependencies: dependencies(...args),
|
|
434
|
+
contexts: [context(30)],
|
|
435
|
+
mode: "single",
|
|
436
|
+
statusOnly: false,
|
|
437
|
+
options,
|
|
438
|
+
});
|
|
439
|
+
// The suite polls every 10 seconds, so the 60 second floor is six polls.
|
|
440
|
+
const sixPolls = (...perPoll: string[]) =>
|
|
441
|
+
Array.from({ length: 6 }, () => perPoll).flat();
|
|
442
|
+
|
|
443
|
+
it("is ready once the no-checks reading is confirmed and GitHub reports it mergeable", async () => {
|
|
444
|
+
const snapshot = await read({ facts: { reviewDecision: null } });
|
|
445
|
+
expect(classifyPr(snapshot)).toMatchObject({
|
|
446
|
+
kind: "ready",
|
|
447
|
+
pr: { proof: { ci: { kind: "ci-none" } } },
|
|
448
|
+
});
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
it("waits on a first sighting instead of reporting ready or a merge gate", async () => {
|
|
452
|
+
for (const facts of [
|
|
453
|
+
{ reviewDecision: null },
|
|
454
|
+
{ mergeStateStatus: "BLOCKED" },
|
|
455
|
+
{ reviewDecision: "REVIEW_REQUIRED" },
|
|
456
|
+
] as const) {
|
|
457
|
+
const snapshot = await read({ facts }, () => false);
|
|
458
|
+
expect(snapshot).toMatchObject({
|
|
459
|
+
kind: "open",
|
|
460
|
+
ci: { kind: "ci-unreported" },
|
|
461
|
+
});
|
|
462
|
+
expect(classifyPr(snapshot)).toEqual({
|
|
463
|
+
kind: "waiting",
|
|
464
|
+
frontier: context(30),
|
|
465
|
+
reason: { kind: "checks-unreported" },
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
it("confirms no checks only once the same head has shown none for 60 seconds", () => {
|
|
471
|
+
let now = 0;
|
|
472
|
+
const confirm = noChecksConfirmer({ now: () => now });
|
|
473
|
+
const head = { context: context(30), headRefOid: "head" };
|
|
474
|
+
expect(confirm(head)).toBe(false);
|
|
475
|
+
now = 59;
|
|
476
|
+
expect(confirm(head)).toBe(false);
|
|
477
|
+
now = 60;
|
|
478
|
+
expect(confirm(head)).toBe(true);
|
|
479
|
+
expect(confirm({ ...head, headRefOid: "pushed" })).toBe(false);
|
|
480
|
+
now = 119;
|
|
481
|
+
expect(confirm({ ...head, headRefOid: "pushed" })).toBe(false);
|
|
482
|
+
now = 120;
|
|
483
|
+
expect(confirm({ ...head, headRefOid: "pushed" })).toBe(true);
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
it("times each PR from its own first sighting, not from another PR's", () => {
|
|
487
|
+
let now = 0;
|
|
488
|
+
const confirm = noChecksConfirmer({ now: () => now });
|
|
489
|
+
const first = { context: context(30), headRefOid: "head" };
|
|
490
|
+
const second = { context: context(31), headRefOid: "head" };
|
|
491
|
+
expect(confirm(first)).toBe(false);
|
|
492
|
+
now = 60;
|
|
493
|
+
expect(confirm(second)).toBe(false);
|
|
494
|
+
expect(confirm(first)).toBe(true);
|
|
495
|
+
now = 120;
|
|
496
|
+
expect(confirm(second)).toBe(true);
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
it("reads no checks as unreported when the caller supplies no confirmation", async () => {
|
|
500
|
+
expect(
|
|
501
|
+
await readSnapshot({
|
|
502
|
+
reader: fakeReader(noChecks),
|
|
503
|
+
context: context(30),
|
|
504
|
+
pendingHistory: "omit",
|
|
505
|
+
allowDraft: false,
|
|
506
|
+
})
|
|
507
|
+
).toMatchObject({ kind: "open", ci: { kind: "ci-unreported" } });
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
it("reports a repository with no CI ready 60 seconds after the first sighting, however short the interval", async () => {
|
|
511
|
+
const clock = ticking();
|
|
512
|
+
const emitted: ProgressVerdict[] = [];
|
|
513
|
+
const verdict = await run(
|
|
514
|
+
fakeReader({ ...noChecks, facts: { reviewDecision: null } }),
|
|
515
|
+
clock,
|
|
516
|
+
emitted
|
|
517
|
+
);
|
|
518
|
+
expect(kinds(emitted)).toEqual(sixPolls("WAITING:checks-unreported"));
|
|
519
|
+
expect(clock.now()).toBe(60);
|
|
520
|
+
expect(verdict).toMatchObject({
|
|
521
|
+
kind: "READY",
|
|
522
|
+
scope: { pr: { proof: { ci: { kind: "ci-none" } } } },
|
|
523
|
+
});
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
it("stops at the merge gate only once a blocked PR has shown no checks for 60 seconds", async () => {
|
|
527
|
+
const clock = ticking();
|
|
528
|
+
const emitted: ProgressVerdict[] = [];
|
|
529
|
+
const verdict = await run(
|
|
530
|
+
fakeReader({
|
|
531
|
+
...noChecks,
|
|
532
|
+
facts: { mergeStateStatus: "BLOCKED", reviewDecision: null },
|
|
533
|
+
}),
|
|
534
|
+
clock,
|
|
535
|
+
emitted
|
|
536
|
+
);
|
|
537
|
+
expect(kinds(emitted)).toEqual(sixPolls("WAITING:checks-unreported"));
|
|
538
|
+
expect(clock.now()).toBe(60);
|
|
539
|
+
expect(verdict).toMatchObject({
|
|
540
|
+
kind: "BLOCKER",
|
|
541
|
+
exitCode: 6,
|
|
542
|
+
blocker: { kind: "merge-gate", reason: "merge-blocked" },
|
|
543
|
+
});
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
it("waits through a first sighting and honours checks that register on the next poll", async () => {
|
|
547
|
+
const base = fakeReader({
|
|
548
|
+
...noChecks,
|
|
549
|
+
facts: { mergeStateStatus: "BLOCKED", reviewDecision: null },
|
|
550
|
+
});
|
|
551
|
+
let polls = 0;
|
|
552
|
+
const reader = {
|
|
553
|
+
...base,
|
|
554
|
+
async checksFastPath() {
|
|
555
|
+
polls += 1;
|
|
556
|
+
return polls === 1
|
|
557
|
+
? { kind: "none-reported" as const }
|
|
558
|
+
: { kind: "checks" as const, checks: [pendingCheck("required-ci")] };
|
|
559
|
+
},
|
|
560
|
+
} satisfies GitHubReader;
|
|
561
|
+
let sleeps = 0;
|
|
562
|
+
const emitted: ProgressVerdict[] = [];
|
|
563
|
+
const running = run(
|
|
564
|
+
reader,
|
|
565
|
+
ticking(() => {
|
|
566
|
+
if (++sleeps === 2) throw new Error("stop after two polls");
|
|
567
|
+
}),
|
|
568
|
+
emitted
|
|
569
|
+
);
|
|
570
|
+
await expect(running).rejects.toThrow("stop after two polls");
|
|
571
|
+
expect(kinds(emitted)).toEqual([
|
|
572
|
+
"WAITING:checks-unreported",
|
|
573
|
+
"WAITING:pending-checks",
|
|
574
|
+
]);
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
it("times out unconfirmed when the deadline is shorter than the interval or than the 60 seconds", async () => {
|
|
578
|
+
for (const [timeout, polls] of [
|
|
579
|
+
[options.interval / 2, 1],
|
|
580
|
+
[30, 3],
|
|
581
|
+
] as const) {
|
|
582
|
+
const emitted: ProgressVerdict[] = [];
|
|
583
|
+
const verdict = await run(
|
|
584
|
+
fakeReader({ ...noChecks, facts: { reviewDecision: null } }),
|
|
585
|
+
ticking(),
|
|
586
|
+
emitted,
|
|
587
|
+
timeout
|
|
588
|
+
);
|
|
589
|
+
expect(kinds(emitted)).toEqual(
|
|
590
|
+
Array.from({ length: polls }, () => "WAITING:checks-unreported")
|
|
591
|
+
);
|
|
592
|
+
expect(verdict).toMatchObject({
|
|
593
|
+
kind: "TIMEOUT",
|
|
594
|
+
exitCode: 5,
|
|
595
|
+
reason: { kind: "checks-unreported" },
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
it("confirms each PR of a stack on its own sightings", async () => {
|
|
601
|
+
const clock = ticking();
|
|
602
|
+
const emitted: ProgressVerdict[] = [];
|
|
603
|
+
const verdict = await runSimple({
|
|
604
|
+
dependencies: dependencies(
|
|
605
|
+
fakeReader({ ...noChecks, facts: { reviewDecision: null } }),
|
|
606
|
+
clock,
|
|
607
|
+
emitted
|
|
608
|
+
),
|
|
609
|
+
contexts: [context(30), context(31)],
|
|
610
|
+
mode: "stack",
|
|
611
|
+
statusOnly: false,
|
|
612
|
+
options,
|
|
613
|
+
});
|
|
614
|
+
expect(kinds(emitted)).toEqual([
|
|
615
|
+
...sixPolls("STATUS", "WAITING:checks-unreported"),
|
|
616
|
+
"STATUS",
|
|
617
|
+
]);
|
|
618
|
+
expect(clock.now()).toBe(60);
|
|
619
|
+
expect(verdict).toMatchObject({
|
|
620
|
+
kind: "READY",
|
|
621
|
+
scope: {
|
|
622
|
+
kind: "stack",
|
|
623
|
+
prs: [
|
|
624
|
+
{ context: context(30), proof: { ci: { kind: "ci-none" } } },
|
|
625
|
+
{ context: context(31), proof: { ci: { kind: "ci-none" } } },
|
|
626
|
+
],
|
|
627
|
+
},
|
|
628
|
+
});
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
it("holds a queued frontier for the 60 seconds before reporting it blocker-free", async () => {
|
|
632
|
+
const emitted: ProgressVerdict[] = [];
|
|
633
|
+
const emittedAt: number[] = [];
|
|
634
|
+
const clock = ticking(() => {
|
|
635
|
+
if (clock.now() > 60) throw new Error("stop after the confirmation");
|
|
636
|
+
});
|
|
637
|
+
const queued = dependencies(
|
|
638
|
+
fakeReader({ ...noChecks, facts: { reviewDecision: null } }),
|
|
639
|
+
clock,
|
|
640
|
+
emitted
|
|
641
|
+
);
|
|
642
|
+
const running = runQueued({
|
|
643
|
+
dependencies: {
|
|
644
|
+
...queued,
|
|
645
|
+
emit(verdict) {
|
|
646
|
+
emittedAt.push(clock.now());
|
|
647
|
+
queued.emit(verdict);
|
|
648
|
+
},
|
|
649
|
+
},
|
|
650
|
+
contexts: [context(30)],
|
|
651
|
+
options,
|
|
652
|
+
});
|
|
653
|
+
await expect(running).rejects.toThrow("stop after the confirmation");
|
|
654
|
+
expect(kinds(emitted)).toEqual([
|
|
655
|
+
"QUEUE",
|
|
656
|
+
"STATUS",
|
|
657
|
+
"WAITING:checks-unreported",
|
|
658
|
+
"WAITING:merge-queue",
|
|
659
|
+
]);
|
|
660
|
+
expect(emittedAt).toEqual([0, 0, 0, 60]);
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
it("still stops on conflicts, review threads, and merge gates", async () => {
|
|
664
|
+
const thread = {
|
|
665
|
+
id: "thread",
|
|
666
|
+
firstComment: null,
|
|
667
|
+
isBugbot: false,
|
|
668
|
+
bugbotReviewPasses: 0,
|
|
669
|
+
};
|
|
670
|
+
const cases = [
|
|
671
|
+
[{ facts: { mergeable: "CONFLICTING" } }, { kind: "merge-conflicts" }],
|
|
672
|
+
[{ threads: [thread] }, { kind: "review-threads" }],
|
|
673
|
+
[
|
|
674
|
+
{ facts: { reviewDecision: "REVIEW_REQUIRED" } },
|
|
675
|
+
{ kind: "merge-gate", reason: "review-required" },
|
|
676
|
+
],
|
|
677
|
+
[
|
|
678
|
+
{ facts: { mergeStateStatus: "BLOCKED" } },
|
|
679
|
+
{ kind: "merge-gate", reason: "merge-blocked" },
|
|
680
|
+
],
|
|
681
|
+
[
|
|
682
|
+
{ facts: { isDraft: true } },
|
|
683
|
+
{ kind: "merge-gate", reason: "draft-pr" },
|
|
684
|
+
],
|
|
685
|
+
] as const;
|
|
686
|
+
for (const [overrides, blocker] of cases)
|
|
687
|
+
expect(classifyPr(await read(overrides))).toMatchObject({
|
|
688
|
+
kind: "blocker",
|
|
689
|
+
blocker,
|
|
690
|
+
});
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
it("fails closed while the head may not have reported yet", async () => {
|
|
694
|
+
const unsettled = [
|
|
695
|
+
{
|
|
696
|
+
commitRollups: [
|
|
697
|
+
{ oid: "earlier", state: "SUCCESS" },
|
|
698
|
+
{ oid: "head", state: null },
|
|
699
|
+
],
|
|
700
|
+
},
|
|
701
|
+
{ commitRollups: [{ oid: "head", state: "PENDING" }] },
|
|
702
|
+
] as const;
|
|
703
|
+
for (const overrides of unsettled)
|
|
704
|
+
await expect(read(overrides)).rejects.toMatchObject({
|
|
705
|
+
failure: { kind: "checks-unavailable", retryable: true },
|
|
706
|
+
});
|
|
707
|
+
});
|
|
708
|
+
});
|