@1aboveio/skills 1.0.8 → 1.0.9
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/.codex-plugin/plugin.json +13 -0
- package/.codex-plugin/resources.json +1827 -0
- package/README.md +22 -0
- package/package.json +9 -2
- package/skills/app-dev/airflow/SKILL.md +1 -1
- package/skills/app-dev/airflow/release-check.mjs +3 -1
- package/skills/app-dev/android-foundation/SKILL.md +1 -1
- package/skills/app-dev/android-foundation/release-check.mjs +3 -1
- package/skills/app-dev/app-debug/SKILL.md +1 -1
- package/skills/app-dev/app-debug/release-check.mjs +3 -1
- package/skills/app-dev/better-auth/SKILL.md +1 -1
- package/skills/app-dev/better-auth/release-check.mjs +3 -1
- package/skills/app-dev/data-catalog/SKILL.md +1 -1
- package/skills/app-dev/data-catalog/release-check.mjs +3 -1
- package/skills/app-dev/data-quality/SKILL.md +1 -1
- package/skills/app-dev/data-quality/release-check.mjs +3 -1
- package/skills/app-dev/ios-foundation/SKILL.md +1 -1
- package/skills/app-dev/ios-foundation/release-check.mjs +3 -1
- package/skills/app-dev/monorepo/SKILL.md +1 -1
- package/skills/app-dev/monorepo/release-check.mjs +3 -1
- package/skills/app-dev/nextjs-fullstack/SKILL.md +1 -1
- package/skills/app-dev/nextjs-fullstack/release-check.mjs +3 -1
- package/skills/app-dev/prisma-setup/SKILL.md +1 -1
- package/skills/app-dev/prisma-setup/release-check.mjs +3 -1
- package/skills/app-dev/pyspark/SKILL.md +1 -1
- package/skills/app-dev/pyspark/release-check.mjs +3 -1
- package/skills/app-dev/python-backend/SKILL.md +1 -1
- package/skills/app-dev/python-backend/release-check.mjs +3 -1
- package/skills/app-dev/shadcn/SKILL.md +1 -1
- package/skills/app-dev/shadcn/release-check.mjs +3 -1
- package/skills/app-dev/zod-v4/SKILL.md +1 -1
- package/skills/app-dev/zod-v4/release-check.mjs +3 -1
- package/skills/cicd/cloud-build/SKILL.md +1 -1
- package/skills/cicd/cloud-build/release-check.mjs +3 -1
- package/skills/cicd/cloud-debug/SKILL.md +1 -1
- package/skills/cicd/cloud-debug/release-check.mjs +3 -1
- package/skills/cicd/cloud-deploy/SKILL.md +1 -1
- package/skills/cicd/cloud-deploy/release-check.mjs +3 -1
- package/skills/cicd/docker/SKILL.md +1 -1
- package/skills/cicd/docker/release-check.mjs +3 -1
- package/skills/cicd/github-actions/SKILL.md +1 -1
- package/skills/cicd/github-actions/release-check.mjs +3 -1
- package/skills/cicd/github-flow/SKILL.md +1 -1
- package/skills/cicd/github-flow/release-check.mjs +3 -1
- package/skills/cicd/google-cloud/SKILL.md +1 -1
- package/skills/cicd/google-cloud/release-check.mjs +3 -1
- package/skills/cicd/mergify/SKILL.md +1 -1
- package/skills/cicd/mergify/release-check.mjs +3 -1
- package/skills/cicd/podman/SKILL.md +1 -1
- package/skills/cicd/podman/release-check.mjs +3 -1
- package/skills/content/article-review/SKILL.md +1 -1
- package/skills/content/article-review/release-check.mjs +3 -1
- package/skills/content/article-to-image/SKILL.md +1 -1
- package/skills/content/article-to-image/release-check.mjs +3 -1
- package/skills/content/flowchart-svg/SKILL.md +1 -1
- package/skills/content/flowchart-svg/release-check.mjs +3 -1
- package/skills/content/image-gen/SKILL.md +1 -1
- package/skills/content/image-gen/release-check.mjs +3 -1
- package/skills/content/mermaid/SKILL.md +1 -1
- package/skills/content/mermaid/release-check.mjs +3 -1
- package/skills/content/wechat-article-writer/SKILL.md +1 -1
- package/skills/content/wechat-article-writer/release-check.mjs +3 -1
- package/skills/content/wechat-publisher/SKILL.md +1 -1
- package/skills/content/wechat-publisher/release-check.mjs +3 -1
- package/skills/content/wechat-search/SKILL.md +1 -1
- package/skills/content/wechat-search/release-check.mjs +3 -1
- package/skills/content/xai-search/SKILL.md +88 -13
- package/skills/content/xai-search/release-check.mjs +3 -1
- package/skills/content/xai-search/scripts/xai_search.sh +125 -149
- package/skills/core/distill-writing/SKILL.md +1 -1
- package/skills/core/distill-writing/release-check.mjs +3 -1
- package/skills/core/doc-to-md/SKILL.md +1 -1
- package/skills/core/doc-to-md/release-check.mjs +3 -1
- package/skills/core/editorial-review/SKILL.md +1 -1
- package/skills/core/editorial-review/release-check.mjs +3 -1
- package/skills/core/house-style/SKILL.md +1 -1
- package/skills/core/house-style/release-check.mjs +3 -1
- package/skills/core/plain-language/SKILL.md +1 -1
- package/skills/core/plain-language/release-check.mjs +3 -1
- package/skills/core/polish-cn/SKILL.md +1 -1
- package/skills/core/polish-cn/release-check.mjs +3 -1
- package/skills/core/reg-parser/SKILL.md +1 -1
- package/skills/core/reg-parser/release-check.mjs +3 -1
- package/skills/core/terminology/SKILL.md +1 -1
- package/skills/core/terminology/release-check.mjs +3 -1
- package/skills/core/write-doc/SKILL.md +1 -1
- package/skills/core/write-doc/release-check.mjs +3 -1
- package/skills/engineering/better-review/SKILL.md +1 -1
- package/skills/engineering/better-review/release-check.mjs +3 -1
- package/skills/engineering/contributing/SKILL.md +1 -1
- package/skills/engineering/contributing/release-check.mjs +3 -1
- package/skills/engineering/e2e-test/SKILL.md +1 -1
- package/skills/engineering/e2e-test/release-check.mjs +3 -1
- package/skills/engineering/engineering-runtime/SKILL.md +1 -1
- package/skills/engineering/engineering-runtime/coherence/workflow.json +13 -13
- package/skills/engineering/engineering-runtime/release-check.mjs +3 -1
- package/skills/engineering/engineering-runtime/scripts/installation-resolver.mjs +191 -0
- package/skills/engineering/engineering-runtime/scripts/workflow-coherence.mjs +51 -10
- package/skills/engineering/ensure-coverage/SKILL.md +1 -1
- package/skills/engineering/ensure-coverage/release-check.mjs +3 -1
- package/skills/engineering/golden-path/SKILL.md +1 -1
- package/skills/engineering/golden-path/release-check.mjs +3 -1
- package/skills/engineering/harness-runtime/SKILL.md +1 -1
- package/skills/engineering/harness-runtime/release-check.mjs +3 -1
- package/skills/engineering/resolve-issues/SKILL.md +1 -1
- package/skills/engineering/resolve-issues/references/preflight.md +7 -0
- package/skills/engineering/resolve-issues/references/shared-modules.md +27 -0
- package/skills/engineering/resolve-issues/references/task-plan.md +5 -1
- package/skills/engineering/resolve-issues/release-check.mjs +3 -1
- package/skills/engineering/resolve-issues/scripts/active-installation.mjs +32 -0
- package/skills/engineering/resolve-issues/scripts/install-hooks.mjs +3 -0
- package/skills/engineering/resolve-issues/scripts/rehydrate.mjs +17 -1
- package/skills/engineering/resolve-release/SKILL.md +1 -1
- package/skills/engineering/resolve-release/release-check.mjs +3 -1
- package/skills/engineering/rush-issues/SKILL.md +1 -1
- package/skills/engineering/rush-issues/release-check.mjs +3 -1
- package/skills/engineering/rush-release/SKILL.md +1 -1
- package/skills/engineering/rush-release/release-check.mjs +3 -1
- package/skills/engineering/test-quality/SKILL.md +1 -1
- package/skills/engineering/test-quality/release-check.mjs +3 -1
- package/skills/risk-compliance/compliance-audit/SKILL.md +1 -1
- package/skills/risk-compliance/compliance-audit/release-check.mjs +3 -1
- package/skills/risk-compliance/contract-compare/SKILL.md +1 -1
- package/skills/risk-compliance/contract-compare/release-check.mjs +3 -1
- package/skills/risk-compliance/contract-review/SKILL.md +1 -1
- package/skills/risk-compliance/contract-review/release-check.mjs +3 -1
- package/skills/risk-compliance/doc-authenticity/SKILL.md +1 -1
- package/skills/risk-compliance/doc-authenticity/release-check.mjs +3 -1
- package/skills/risk-compliance/fraud-analysis/SKILL.md +1 -1
- package/skills/risk-compliance/fraud-analysis/release-check.mjs +3 -1
- package/skills/risk-compliance/payment-analysis/SKILL.md +1 -1
- package/skills/risk-compliance/payment-analysis/release-check.mjs +3 -1
- package/skills/risk-compliance/similar-domain/SKILL.md +1 -1
- package/skills/risk-compliance/similar-domain/release-check.mjs +3 -1
- package/skills/risk-compliance/tld-detection/SKILL.md +1 -1
- package/skills/risk-compliance/tld-detection/release-check.mjs +3 -1
- package/skills/app-dev/android-foundation/scripts/device-run.test.mjs +0 -436
- package/skills/app-dev/data-catalog/scripts/data-catalog.test.mjs +0 -36
- package/skills/app-dev/data-quality/scripts/data-quality.test.mjs +0 -12
- package/skills/cicd/github-actions/scripts/ci-run-audit.test.mjs +0 -637
- package/skills/cicd/github-flow/scripts/flow.test.mjs +0 -114
- package/skills/cicd/github-flow/scripts/persist-conventions.test.mjs +0 -108
- package/skills/cicd/mergify/scripts/audit.test.mjs +0 -201
- package/skills/cicd/mergify/scripts/entrypoints.test.mjs +0 -106
- package/skills/cicd/mergify/scripts/pr-620-mergify-comments.fixture.json +0 -30
- package/skills/cicd/mergify/scripts/stack-push.test.mjs +0 -233
- package/skills/cicd/mergify/scripts/watch-pr-delivery.test.mjs +0 -1826
- package/skills/content/image-gen/scripts/credentials.test.mjs +0 -212
- package/skills/content/image-gen/scripts/options.test.mjs +0 -245
- package/skills/content/xai-search/tests/smoke_test.sh +0 -36
- package/skills/core/polish-cn/scripts/polish-cn.test.mjs +0 -244
- package/skills/core/reg-parser/scripts/reg-parser.test.mjs +0 -165
- package/skills/engineering/better-review/scripts/pr-context.test.mjs +0 -168
- package/skills/engineering/better-review/scripts/rereview-scope.test.mjs +0 -587
- package/skills/engineering/better-review/scripts/scan-diff.test.mjs +0 -414
- package/skills/engineering/better-review/scripts/vendor-sync.test.mjs +0 -96
- package/skills/engineering/contributing/scripts/persist-conventions.test.mjs +0 -118
- package/skills/engineering/e2e-test/scripts/presentation-checks.test.mjs +0 -106
- package/skills/engineering/e2e-test/scripts/presentation-perceivability.test.mjs +0 -117
- package/skills/engineering/e2e-test/scripts/presentation-reachability.test.mjs +0 -69
- package/skills/engineering/e2e-test/scripts/presentation-render-health.test.mjs +0 -98
- package/skills/engineering/e2e-test/scripts/presentation-sweep.test.mjs +0 -71
- package/skills/engineering/e2e-test/scripts/presentation-temporal.test.mjs +0 -59
- package/skills/engineering/e2e-test/scripts/project-route-manifest.test.mjs +0 -76
- package/skills/engineering/engineering-runtime/engineering-runtime.test.mjs +0 -68
- package/skills/engineering/engineering-runtime/scripts/invocation-policy.test.mjs +0 -95
- package/skills/engineering/engineering-runtime/scripts/main-module.test.mjs +0 -288
- package/skills/engineering/engineering-runtime/scripts/required-check-plan.test.mjs +0 -96
- package/skills/engineering/ensure-coverage/scripts/adapters/nextjs-inventory.test.mjs +0 -96
- package/skills/engineering/ensure-coverage/scripts/check-quarantine-expiry.test.mjs +0 -47
- package/skills/engineering/ensure-coverage/scripts/ci-audit.test.mjs +0 -86
- package/skills/engineering/ensure-coverage/scripts/coverage-ledger.test.mjs +0 -447
- package/skills/engineering/ensure-coverage/scripts/design-parity.test.mjs +0 -552
- package/skills/engineering/ensure-coverage/scripts/lint-tests.test.mjs +0 -289
- package/skills/engineering/ensure-coverage/scripts/mock-policy-config.test.mjs +0 -119
- package/skills/engineering/golden-path/scripts/golden-path.test.mjs +0 -1457
- package/skills/engineering/harness-runtime/discover-models.test.mjs +0 -363
- package/skills/engineering/harness-runtime/harness-runtime.test.mjs +0 -967
- package/skills/engineering/harness-runtime/model-catalog-seed.test.mjs +0 -829
- package/skills/engineering/harness-runtime/model-catalog.test.mjs +0 -366
- package/skills/engineering/resolve-issues/scripts/cleanup-runs.test.mjs +0 -280
- package/skills/engineering/resolve-issues/scripts/dropbox.test.mjs +0 -346
- package/skills/engineering/resolve-issues/scripts/findings-reuse.test.mjs +0 -88
- package/skills/engineering/resolve-issues/scripts/github-reviews.test.mjs +0 -264
- package/skills/engineering/resolve-issues/scripts/install-hooks.test.mjs +0 -79
- package/skills/engineering/resolve-issues/scripts/issue-tree.test.mjs +0 -107
- package/skills/engineering/resolve-issues/scripts/manual-invocation.test.mjs +0 -14
- package/skills/engineering/resolve-issues/scripts/next-actions-capacity.test.mjs +0 -149
- package/skills/engineering/resolve-issues/scripts/next-actions.test.mjs +0 -915
- package/skills/engineering/resolve-issues/scripts/plan-sync.test.mjs +0 -156
- package/skills/engineering/resolve-issues/scripts/preflight-models.test.mjs +0 -448
- package/skills/engineering/resolve-issues/scripts/ready-check.test.mjs +0 -217
- package/skills/engineering/resolve-issues/scripts/rehydrate.test.mjs +0 -91
- package/skills/engineering/resolve-issues/scripts/review-verdict.test.mjs +0 -1060
- package/skills/engineering/resolve-issues/scripts/size-check.test.mjs +0 -458
- package/skills/engineering/resolve-release/scripts/apply.test.mjs +0 -99
- package/skills/engineering/resolve-release/scripts/dark-flags.test.mjs +0 -100
- package/skills/engineering/resolve-release/scripts/flow.test.mjs +0 -340
- package/skills/engineering/resolve-release/scripts/green-head.test.mjs +0 -101
- package/skills/engineering/resolve-release/scripts/npm-version-absence.test.mjs +0 -426
- package/skills/engineering/resolve-release/scripts/plan-npm.test.mjs +0 -659
- package/skills/engineering/resolve-release/scripts/plan-toml-boundary.test.mjs +0 -317
- package/skills/engineering/resolve-release/scripts/plan-uv.test.mjs +0 -291
- package/skills/engineering/resolve-release/scripts/plan.test.mjs +0 -428
- package/skills/engineering/test-quality/scripts/lint-tests.test.mjs +0 -591
- package/skills/engineering/test-quality/scripts/mock-policy-config.test.mjs +0 -119
- package/skills/engineering/test-quality/scripts/persist-conventions.test.mjs +0 -104
- package/skills/risk-compliance/compliance-audit/scripts/compliance-audit.test.mjs +0 -14
- package/skills/risk-compliance/doc-authenticity/manual-invocation.test.mjs +0 -12
- package/skills/risk-compliance/doc-authenticity/scripts/validate_verdict.test.mjs +0 -212
- package/skills/risk-compliance/tld-detection/scripts/detect_ladders.test.mjs +0 -14
- package/skills/risk-compliance/tld-detection/scripts/detect_streams.test.mjs +0 -14
- package/skills/risk-compliance/tld-detection/tests/test_detect_ladders.py +0 -134
- package/skills/risk-compliance/tld-detection/tests/test_detect_streams.py +0 -520
|
@@ -1,1826 +0,0 @@
|
|
|
1
|
-
import { test } from 'node:test'
|
|
2
|
-
import assert from 'node:assert/strict'
|
|
3
|
-
import { mkdtempSync, rmSync, writeFileSync, chmodSync, readFileSync } from 'node:fs'
|
|
4
|
-
import { tmpdir } from 'node:os'
|
|
5
|
-
import { join } from 'node:path'
|
|
6
|
-
|
|
7
|
-
// @characterizes mergify-watch-pr-delivery-cli
|
|
8
|
-
// @covers mergify-watch-pr-delivery-core
|
|
9
|
-
// @level integration
|
|
10
|
-
import { DEFAULT_ADMISSION_STALL_MINUTES, DEFAULT_INTERVAL_SECONDS, DEFAULT_MAX_MINUTES, DEFAULT_SLOW_JOB_MINUTES, DEFAULT_STALL_MINUTES, MAX_MINUTES_CEILING, checkTimings, ciProfile, ciProfileSummary, mergeCiJobs, newSlowJobs, nextPhaseTimings, slowJobWarning, classifyDequeue, classifyObservation, dequeueInfoFromComments, dequeuedAtFromComments, effectiveStallMinutes, mergifyQueueShowArgs, nextMissingActiveReads, nextPhases, nextStallObservation, observationKey, parseArgs, progressRecord, readTimeoutForArgs, readTimeoutMs, run, runCli, stallVerdict, summarizeStatusChecks } from './watch-pr-delivery-core.mjs'
|
|
11
|
-
|
|
12
|
-
// ---- fake `gh`/`mergify` on PATH, for loop-level (runCli-driven) regression tests ----
|
|
13
|
-
//
|
|
14
|
-
// The pure-function tests above pin `nextMissingActiveReads`/`readTimeoutMs` in isolation —
|
|
15
|
-
// review of PR #612 proved that is not enough: reverting the poll loop's wiring of either
|
|
16
|
-
// helper back to its pre-fix inline form (helpers still exported, just unused) left the
|
|
17
|
-
// whole suite green, because nothing exercised `runCli` itself. These tests drive the real
|
|
18
|
-
// CLI against fake `gh`/`mergify` executables placed first on `PATH`, so a wiring regression
|
|
19
|
-
// at the call site — not just in the helper — turns them red.
|
|
20
|
-
function makeFakeBin(dir, name, script) {
|
|
21
|
-
const p = join(dir, name)
|
|
22
|
-
writeFileSync(p, `#!/bin/sh\n${script}\n`)
|
|
23
|
-
chmodSync(p, 0o755)
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
async function withFakeBinsOnPath(fn) {
|
|
27
|
-
const dir = mkdtempSync(join(tmpdir(), 'watch-pr-delivery-fakebin-'))
|
|
28
|
-
const originalPath = process.env.PATH
|
|
29
|
-
process.env.PATH = `${dir}:${originalPath}`
|
|
30
|
-
try {
|
|
31
|
-
return await fn(dir)
|
|
32
|
-
} finally {
|
|
33
|
-
process.env.PATH = originalPath
|
|
34
|
-
rmSync(dir, { recursive: true, force: true })
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
// A `gh` fake that emits arbitrary JSON via a quoted heredoc rather than `echo '...'`. Needed
|
|
39
|
-
// whenever the JSON contains a comment body with a real embedded newline (the Mergify status-
|
|
40
|
-
// comment payload block below is multi-line) — `echo`'ing a JS string's `\n` escape sequence
|
|
41
|
-
// verbatim round-trips through macOS `/bin/sh` (bash in POSIX/XSI mode) as an ACTUAL newline,
|
|
42
|
-
// which is an unescaped control character inside a JSON string and makes the observation
|
|
43
|
-
// silently `readable: false` instead of exercising the logic under test (discovered while
|
|
44
|
-
// testing issue #629's first fix; see the regression tests below). A heredoc with a quoted
|
|
45
|
-
// delimiter (`<<'EOF'`) is never escape-processed by the shell, so it reproduces the file's
|
|
46
|
-
// real newlines byte for byte.
|
|
47
|
-
function makeFakeGhReturning(dir, jsonObj) {
|
|
48
|
-
const json = JSON.stringify(jsonObj)
|
|
49
|
-
const p = join(dir, 'gh')
|
|
50
|
-
writeFileSync(p, `#!/bin/sh\ncat <<'GH_FIXTURE_EOF'\n${json}\nGH_FIXTURE_EOF\n`)
|
|
51
|
-
chmodSync(p, 0o755)
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
// Captures BOTH streams. stderr matters as much as stdout now that the loop emits a per-poll
|
|
55
|
-
// progress heartbeat there: tests that only watched stdout would let the heartbeat regress to
|
|
56
|
-
// silence (the original defect) while staying green, and would also spray heartbeats over the
|
|
57
|
-
// test runner's own output.
|
|
58
|
-
async function runCliCapturingLastPayload(argv) {
|
|
59
|
-
const lines = []
|
|
60
|
-
const errLines = []
|
|
61
|
-
const originalLog = console.log
|
|
62
|
-
const originalError = console.error
|
|
63
|
-
console.log = (value) => lines.push(String(value))
|
|
64
|
-
console.error = (value) => errLines.push(String(value))
|
|
65
|
-
let code
|
|
66
|
-
try {
|
|
67
|
-
code = await runCli(argv)
|
|
68
|
-
} finally {
|
|
69
|
-
console.log = originalLog
|
|
70
|
-
console.error = originalError
|
|
71
|
-
}
|
|
72
|
-
return { code, payload: JSON.parse(lines[lines.length - 1]), stdoutLines: lines, progressLines: errLines }
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
// Formats a Date as the "YYYY-MM-DD HH:MM UTC" shape Mergify's status comment uses, for
|
|
76
|
-
// building fixture comment bodies whose timestamp is guaranteed FRESH relative to `Date.now()`
|
|
77
|
-
// at test time — a fixed clock-independent offset (`+minutesFromNow`), not "now" truncated to
|
|
78
|
-
// the minute, which would flake depending on where in the minute the test happens to run.
|
|
79
|
-
function mergifyTimestamp(minutesFromNow) {
|
|
80
|
-
const d = new Date(Date.now() + minutesFromNow * 60_000)
|
|
81
|
-
const pad = (n) => String(n).padStart(2, '0')
|
|
82
|
-
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())} UTC`
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
// Builds a REALISTIC Mergify status-comment body: the machine-readable payload block
|
|
86
|
-
// `dequeuedAtFromComments` now keys off (BLOCK-1/2 fix, review on PR #637), plus the human-
|
|
87
|
-
// rendered "Entered queue"/"Left the queue" lines it still parses for the actual timestamp.
|
|
88
|
-
// Shape verified against the real PR #620 payload — see `pr-620-mergify-comments.fixture.json`.
|
|
89
|
-
function mergifyStatusComment({ state, enteredAt, leftAt, batchPrNumber = null, rawReason = null, failingChecks = [] }) {
|
|
90
|
-
const payload = JSON.stringify({
|
|
91
|
-
version: 1, state, queue_rule_name: 'default',
|
|
92
|
-
queued_at: '2026-01-01T00:00:00.000000+00:00', estimated_time_of_merge: null,
|
|
93
|
-
speculative_check_pr: null, required_conditions: [],
|
|
94
|
-
})
|
|
95
|
-
const lines = [
|
|
96
|
-
'<!---', 'DO NOT EDIT', '-*- Mergify Payload -*-', payload, '-*- Mergify Payload End -*-', '-->',
|
|
97
|
-
'', '# Merge Queue Status', '',
|
|
98
|
-
`- ✅ **Entered queue** — \`${enteredAt}\` · Rule: \`default\``,
|
|
99
|
-
]
|
|
100
|
-
if (batchPrNumber) lines.push(`- ❌ **Checks failed** · on draft #${batchPrNumber}`)
|
|
101
|
-
if (leftAt) lines.push(`- 🚫 **Left the queue** — \`${leftAt}\` · at \`abc123\``)
|
|
102
|
-
if (rawReason) lines.push('', '## Reason', '', rawReason)
|
|
103
|
-
if (failingChecks.length > 0) {
|
|
104
|
-
lines.push('', 'Failing checks:')
|
|
105
|
-
for (const check of failingChecks) {
|
|
106
|
-
const icon = check.status === 'CANCELLED' ? '🟠' : (check.status === 'TIMED_OUT' ? '🛑' : '❌')
|
|
107
|
-
lines.push(`- ${icon} [${check.name}](${check.detailsUrl || 'https://example.test/check'})`)
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
return lines.join('\n')
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
function makeFakeGhForBatch(dir, { original, batches = {} }) {
|
|
114
|
-
const cases = [
|
|
115
|
-
`1) cat <<'GH_ORIGINAL_EOF'\n${JSON.stringify(original)}\nGH_ORIGINAL_EOF\n;;`,
|
|
116
|
-
...Object.entries(batches).map(([number, value]) => value
|
|
117
|
-
? `${number}) cat <<'GH_BATCH_${number}_EOF'\n${JSON.stringify(value)}\nGH_BATCH_${number}_EOF\n;;`
|
|
118
|
-
: `${number}) exit 1 ;;`),
|
|
119
|
-
]
|
|
120
|
-
makeFakeBin(dir, 'gh', `case "$3" in\n${cases.join('\n')}\n*) exit 1 ;;\nesac`)
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
async function runSyntheticBatchDequeue({ queueBatchNumbers, batches, rawReason, providerChecks, commentBatchNumber = null }) {
|
|
124
|
-
return withFakeBinsOnPath(async (dir) => {
|
|
125
|
-
const renderedBatchNumber = commentBatchNumber ?? queueBatchNumbers[queueBatchNumbers.length - 1]
|
|
126
|
-
makeFakeGhForBatch(dir, {
|
|
127
|
-
original: {
|
|
128
|
-
state: 'OPEN', mergedAt: null, mergeCommit: null, headRefOid: 'unchanged-head',
|
|
129
|
-
labels: [{ name: 'dequeued' }], url: 'https://example.test/original', statusCheckRollup: [],
|
|
130
|
-
comments: [{
|
|
131
|
-
author: { login: 'mergify' },
|
|
132
|
-
body: mergifyStatusComment({
|
|
133
|
-
state: 'dequeued', enteredAt: mergifyTimestamp(1), leftAt: mergifyTimestamp(2),
|
|
134
|
-
batchPrNumber: renderedBatchNumber, rawReason, failingChecks: providerChecks,
|
|
135
|
-
}),
|
|
136
|
-
}],
|
|
137
|
-
},
|
|
138
|
-
batches,
|
|
139
|
-
})
|
|
140
|
-
const counter = join(dir, 'batch-poll-count')
|
|
141
|
-
const activeCases = queueBatchNumbers.map((number, index) => (
|
|
142
|
-
`${index}) echo '${JSON.stringify({ number: 1, position: 0, queue_rule_name: 'main', mergeability_check: { state: 'pending', queue_pull_request_number: number } })}' ;;`
|
|
143
|
-
))
|
|
144
|
-
makeFakeBin(dir, 'mergify', [
|
|
145
|
-
`n=$(cat ${counter} 2>/dev/null || echo 0)`,
|
|
146
|
-
`echo $((n + 1)) > ${counter}`,
|
|
147
|
-
'case "$n" in',
|
|
148
|
-
...activeCases,
|
|
149
|
-
'*) echo \'{"number":1,"queued":false}\' ;;',
|
|
150
|
-
'esac',
|
|
151
|
-
].join('\n'))
|
|
152
|
-
return runCliCapturingLastPayload([
|
|
153
|
-
'--repo', 'o/r', '--pr', '1',
|
|
154
|
-
'--interval-seconds', '0.01', '--stall-minutes', '5', '--max-minutes', '5',
|
|
155
|
-
])
|
|
156
|
-
})
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
// A non-status mergify comment — a `@mergifyio queue` command acknowledgement, matching PR
|
|
160
|
-
// #620's actual last comment verbatim in shape — used to prove the fix does not simply pick
|
|
161
|
-
// "the last mergify comment" (BLOCK-1/2's exact defect).
|
|
162
|
-
const MERGIFY_COMMAND_ACK_BODY = '> queue\n\n#### ☑️ Command `queue` ignored because it is already running from a previous command.'
|
|
163
|
-
|
|
164
|
-
// The real PR #620 comments payload (`gh pr view --json comments`, mergify-authored only) —
|
|
165
|
-
// issue #629 reproduced on this PR, and review of PR #637 verified live that the first fix's
|
|
166
|
-
// `dequeuedAtFromComments` returned `null` against it. Committed as a fixture because it is
|
|
167
|
-
// the one input that distinguishes "last mergify comment" from "last comment whose payload
|
|
168
|
-
// state is dequeued" (NOTE-1).
|
|
169
|
-
//
|
|
170
|
-
// issue #639 (p3): this fixture is the SOLE proof `dequeuedAtFromComments` reads real Mergify
|
|
171
|
-
// output rather than an idealised shape, and nothing re-checks it against a live PR. It
|
|
172
|
-
// depends on two rendering details Mergify controls, not this repo: the
|
|
173
|
-
// `<!--- ... -*- Mergify Payload -*- ... -->` marker block, and the minute-precision
|
|
174
|
-
// "Left the queue — YYYY-MM-DD HH:MM UTC" text line. If either changes, this suite stays
|
|
175
|
-
// green while the production parser silently degrades to `null` for every PR, forever.
|
|
176
|
-
//
|
|
177
|
-
// Deliberately left undetected rather than given a periodic re-fetch script, in or out of
|
|
178
|
-
// CI, for three reasons: (1) a meaningful check needs a CURRENTLY-dequeuing PR, not #620 —
|
|
179
|
-
// #620 is merged, so re-fetching only catches GitHub retroactively rewriting old comment
|
|
180
|
-
// text, not Mergify changing its rendering going forward, and finding "the current live
|
|
181
|
-
// dequeue" well enough to assert against is real ongoing infrastructure, not a fixture
|
|
182
|
-
// refresh; (2) this repo has no gh-authenticated, network-reachable cron to run it from —
|
|
183
|
-
// a script nobody invokes is a check that LOOKS like coverage while providing none, which is
|
|
184
|
-
// worse than documenting the gap plainly; (3) the failure mode is fail-safe by construction —
|
|
185
|
-
// `dequeuedAtFromComments` returning `null` makes `classifyObservation`/`classifyPrState` fail
|
|
186
|
-
// toward WAITING (see NOTE-2 below), never toward a false terminal verdict, so silent drift
|
|
187
|
-
// costs a watch running out its stall/max-minutes bound on a live PR, not a wrong answer
|
|
188
|
-
// reported as fact. That bounded, visible cost — not a green check nobody wrote — is the
|
|
189
|
-
// actual signal: if Mergify's rendering ever changes, a real delivery watch stalls instead of
|
|
190
|
-
// settling promptly, which is noticeable in exactly the place someone would look. Refresh
|
|
191
|
-
// this fixture from that PR's real comments (`gh pr view <pr> --json comments -q
|
|
192
|
-
// '[.comments[] | select(.author.login=="mergify")]'`) if that happens.
|
|
193
|
-
const PR_620_MERGIFY_COMMENTS = JSON.parse(readFileSync(new URL('./pr-620-mergify-comments.fixture.json', import.meta.url), 'utf8'))
|
|
194
|
-
|
|
195
|
-
test('merged PR returns MERGED with the merge commit SHA', () => {
|
|
196
|
-
const verdict = classifyObservation({ pr: { state: 'MERGED', mergedAt: '2026-07-27T00:00:00Z', mergeCommitOid: 'abc123' } })
|
|
197
|
-
assert.equal(verdict.terminal, true)
|
|
198
|
-
assert.equal(verdict.outcome, 'MERGED')
|
|
199
|
-
assert.equal(verdict.mergedSha, 'abc123')
|
|
200
|
-
})
|
|
201
|
-
|
|
202
|
-
test('merged PR without merge SHA is terminal but not a proved merge', () => {
|
|
203
|
-
const verdict = classifyObservation({ pr: { state: 'MERGED', mergedAt: '2026-07-27T00:00:00Z' } })
|
|
204
|
-
assert.equal(verdict.terminal, true)
|
|
205
|
-
assert.equal(verdict.outcome, 'CLOSED_UNMERGED')
|
|
206
|
-
assert.equal(verdict.reason, 'merged-without-sha')
|
|
207
|
-
})
|
|
208
|
-
|
|
209
|
-
test('dequeued label is a terminal queue exit when applied at or after this watch started', () => {
|
|
210
|
-
const verdict = classifyObservation({ pr: { state: 'OPEN', labels: ['dequeued'] }, startedMs: 1000, dequeuedAtMs: 1000 })
|
|
211
|
-
assert.equal(verdict.terminal, true)
|
|
212
|
-
assert.equal(verdict.outcome, 'DEQUEUED')
|
|
213
|
-
assert.equal(verdict.reason, 'dequeued-label')
|
|
214
|
-
})
|
|
215
|
-
|
|
216
|
-
test('NOTE-2: a dequeue timestamped up to 60s BEFORE watch start still settles terminal — the minute-truncation grace period', () => {
|
|
217
|
-
// `dequeuedAtMs` is minute-truncated (no seconds in Mergify's rendered text) while
|
|
218
|
-
// `startedMs` is millisecond-precision, so without a grace period a genuine dequeue landing
|
|
219
|
-
// in the SAME wall-clock minute as watch start always parses to a value below `startedMs`
|
|
220
|
-
// and reads as stale — a blind spot for the first minute of every watch. 59_999ms before
|
|
221
|
-
// start is the tightest case still inside the grace window; 60_001ms before start is just
|
|
222
|
-
// outside it.
|
|
223
|
-
const insideGrace = classifyObservation({ pr: { state: 'OPEN', labels: ['dequeued'] }, startedMs: 100_000, dequeuedAtMs: 100_000 - 59_999 })
|
|
224
|
-
assert.equal(insideGrace.terminal, true, 'a dequeue 59999ms before start must still settle, or every watch has a blind first minute')
|
|
225
|
-
assert.equal(insideGrace.outcome, 'DEQUEUED')
|
|
226
|
-
|
|
227
|
-
const outsideGrace = classifyObservation({ pr: { state: 'OPEN', labels: ['dequeued'] }, startedMs: 100_000, dequeuedAtMs: 100_000 - 60_001 })
|
|
228
|
-
assert.notEqual(outsideGrace.outcome, 'DEQUEUED', 'a dequeue over 60s before start is genuinely stale, not a truncation artifact')
|
|
229
|
-
})
|
|
230
|
-
|
|
231
|
-
// ---- issue #629: a `dequeued` label is a lifecycle mirror, not the queue itself, and it is
|
|
232
|
-
// never removed on re-enqueue — so a PR dequeued once, fixed, and re-enqueued still carries
|
|
233
|
-
// the label into a watch launched for the second attempt. Reproduced live on PR #620: a
|
|
234
|
-
// watcher returned terminal DEQUEUED in 3.9s while the PR was in fact queued and later merged.
|
|
235
|
-
|
|
236
|
-
test('an active queue (queued label OR queue tool) wins over even a freshly-timestamped dequeued label', () => {
|
|
237
|
-
// startedMs/dequeuedAtMs are deliberately FRESH here (would settle DEQUEUED on their own) —
|
|
238
|
-
// this isolates the "prefer the queue tool" guard from the freshness guard: if the `!active`
|
|
239
|
-
// check were dropped, this alone would still read DEQUEUED even though the queue is active.
|
|
240
|
-
for (const input of [
|
|
241
|
-
{ pr: { state: 'OPEN', labels: ['dequeued', 'queued'] }, startedMs: 1000, dequeuedAtMs: 2000 },
|
|
242
|
-
{ pr: { state: 'OPEN', labels: ['dequeued'] }, queue: { queued: true }, startedMs: 1000, dequeuedAtMs: 2000 },
|
|
243
|
-
]) {
|
|
244
|
-
const verdict = classifyObservation(input)
|
|
245
|
-
assert.equal(verdict.terminal, false, JSON.stringify(input))
|
|
246
|
-
assert.equal(verdict.outcome, 'QUEUED', JSON.stringify(input))
|
|
247
|
-
}
|
|
248
|
-
})
|
|
249
|
-
|
|
250
|
-
test('a dequeued label whose timestamp predates this watch is not this attempt\'s outcome', () => {
|
|
251
|
-
// The gap must exceed the NOTE-2 60s grace period (see the guard's comment in the source),
|
|
252
|
-
// or this would pass for the wrong reason — a genuinely stale label inside the grace window
|
|
253
|
-
// reads as fresh by design, so the fixture needs an unambiguous, much-larger gap.
|
|
254
|
-
const verdict = classifyObservation({ pr: { state: 'OPEN', labels: ['dequeued'] }, startedMs: 10 * 60_000, dequeuedAtMs: 0 })
|
|
255
|
-
assert.notEqual(verdict.outcome, 'DEQUEUED')
|
|
256
|
-
assert.equal(verdict.terminal, false)
|
|
257
|
-
})
|
|
258
|
-
|
|
259
|
-
test('a dequeued label with no reliable timestamp fails toward WAITING, never toward a terminal verdict', () => {
|
|
260
|
-
for (const input of [
|
|
261
|
-
{ pr: { state: 'OPEN', labels: ['dequeued'] }, startedMs: 10 * 60_000, dequeuedAtMs: null },
|
|
262
|
-
{ pr: { state: 'OPEN', labels: ['dequeued'] }, startedMs: null, dequeuedAtMs: 9999 },
|
|
263
|
-
{ pr: { state: 'OPEN', labels: ['dequeued'] } },
|
|
264
|
-
]) {
|
|
265
|
-
const verdict = classifyObservation(input)
|
|
266
|
-
assert.notEqual(verdict.outcome, 'DEQUEUED', JSON.stringify(input))
|
|
267
|
-
}
|
|
268
|
-
})
|
|
269
|
-
|
|
270
|
-
// ---- dequeuedAtFromComments (BLOCK-1/2 fix, review on PR #637) ----
|
|
271
|
-
//
|
|
272
|
-
// The first version read `mergifyComments[mergifyComments.length - 1]` on the premise that
|
|
273
|
-
// "the last mergify comment reflects the most recent cycle" — false on real data: Mergify also
|
|
274
|
-
// posts merge-protection summaries and command acknowledgements from the same account, and
|
|
275
|
-
// those interleave with (and are often more recent than) the actual queue-status comments.
|
|
276
|
-
// Verified live against PR #620 (the PR issue #629 reproduces on): the shipped helper returned
|
|
277
|
-
// `null` against its real payload. The fix scans every mergify comment's embedded
|
|
278
|
-
// `-*- Mergify Payload -*-` block and selects the LAST one whose `state` is `"dequeued"`.
|
|
279
|
-
|
|
280
|
-
test('dequeuedAtFromComments finds the dequeued status comment even with a non-status comment after it', () => {
|
|
281
|
-
// The exact PR #620 shape (NOTE-1's required rewrite): an earlier STATUS comment reporting
|
|
282
|
-
// state:"dequeued" with a "Left the queue" line, then a LATER mergify comment that carries
|
|
283
|
-
// no payload at all (a command acknowledgement) — the old "last comment" logic returned
|
|
284
|
-
// null here; the fix must still recover the timestamp from the correct, earlier comment.
|
|
285
|
-
const ms = dequeuedAtFromComments([
|
|
286
|
-
{ author: { login: 'someone-else' }, body: 'Left the queue** — `2099-01-01 00:00 UTC`' },
|
|
287
|
-
{ author: { login: 'mergify' }, body: mergifyStatusComment({ state: 'dequeued', enteredAt: '2026-07-28 11:02 UTC', leftAt: '2026-07-28 11:04 UTC' }) },
|
|
288
|
-
{ author: { login: 'mergify' }, body: MERGIFY_COMMAND_ACK_BODY },
|
|
289
|
-
])
|
|
290
|
-
assert.equal(ms, Date.UTC(2026, 6, 28, 11, 4))
|
|
291
|
-
})
|
|
292
|
-
|
|
293
|
-
test('dequeuedAtFromComments recovers the real PR #620 payload correctly', () => {
|
|
294
|
-
// The committed fixture is the exact `gh pr view --json comments` output for the PR issue
|
|
295
|
-
// #629 reproduced on — the one input that distinguishes "last mergify comment" (broken) from
|
|
296
|
-
// "last mergify comment whose payload state is dequeued" (this fix). Its 4 mergify comments
|
|
297
|
-
// are: a merge-protections summary, a status comment ending state:"dequeued" (this is the
|
|
298
|
-
// one), a LATER status comment ending state:"merged", and a command-ack with no payload at
|
|
299
|
-
// all as the actual last comment.
|
|
300
|
-
const ms = dequeuedAtFromComments(PR_620_MERGIFY_COMMENTS)
|
|
301
|
-
assert.equal(ms, Date.UTC(2026, 6, 28, 11, 4), 'must recover 2026-07-28 11:04 UTC from the real payload, not null')
|
|
302
|
-
})
|
|
303
|
-
|
|
304
|
-
test('dequeuedAtFromComments does NOT key off "the last status comment, whatever its state" — a later merged state must not be read as dequeued', () => {
|
|
305
|
-
// The deliberate design decision (stated in the source comment): select the most recent
|
|
306
|
-
// comment whose state HAPPENS TO BE "dequeued", not the most recent status comment period.
|
|
307
|
-
// On #620 the last status comment is state:"merged" — keying off "last status comment"
|
|
308
|
-
// would read a freshly-MERGED PR as still dequeued, which is #629's failure mode from the
|
|
309
|
-
// opposite direction. This exercises exactly that shape without the fixture's other noise.
|
|
310
|
-
const ms = dequeuedAtFromComments([
|
|
311
|
-
{ author: { login: 'mergify' }, body: mergifyStatusComment({ state: 'dequeued', enteredAt: '2026-07-28 11:02 UTC', leftAt: '2026-07-28 11:04 UTC' }) },
|
|
312
|
-
{ author: { login: 'mergify' }, body: mergifyStatusComment({ state: 'merged', enteredAt: '2026-07-28 11:20 UTC' }) },
|
|
313
|
-
])
|
|
314
|
-
assert.equal(ms, Date.UTC(2026, 6, 28, 11, 4), 'the earlier DEQUEUED-state comment is the correct answer; a later merged-state comment must not be read as a fresh dequeue')
|
|
315
|
-
})
|
|
316
|
-
|
|
317
|
-
test('dequeuedAtFromComments prefers the LAST dequeued-state comment when there have been multiple dequeue cycles', () => {
|
|
318
|
-
const ms = dequeuedAtFromComments([
|
|
319
|
-
{ author: { login: 'mergify' }, body: mergifyStatusComment({ state: 'dequeued', enteredAt: '2026-01-01 00:00 UTC', leftAt: '2026-01-01 00:05 UTC' }) },
|
|
320
|
-
{ author: { login: 'mergify' }, body: mergifyStatusComment({ state: 'queued', enteredAt: '2026-01-02 00:00 UTC' }) },
|
|
321
|
-
{ author: { login: 'mergify' }, body: mergifyStatusComment({ state: 'dequeued', enteredAt: '2026-01-02 00:00 UTC', leftAt: '2026-01-02 00:10 UTC' }) },
|
|
322
|
-
])
|
|
323
|
-
assert.equal(ms, Date.UTC(2026, 0, 2, 0, 10), 'must recover the SECOND dequeue cycle, not the first')
|
|
324
|
-
})
|
|
325
|
-
|
|
326
|
-
test('dequeuedAtFromComments returns null with no mergify comment, no dequeued-state comment, or a malformed payload', () => {
|
|
327
|
-
assert.equal(dequeuedAtFromComments([]), null)
|
|
328
|
-
assert.equal(dequeuedAtFromComments(undefined), null)
|
|
329
|
-
assert.equal(dequeuedAtFromComments([{ author: { login: 'mergify' }, body: 'nothing relevant here' }]), null)
|
|
330
|
-
assert.equal(
|
|
331
|
-
dequeuedAtFromComments([{ author: { login: 'mergify' }, body: mergifyStatusComment({ state: 'queued', enteredAt: '2026-01-01 00:00 UTC' }) }]),
|
|
332
|
-
null,
|
|
333
|
-
'a status comment whose state is not "dequeued" must not be treated as one',
|
|
334
|
-
)
|
|
335
|
-
assert.equal(
|
|
336
|
-
dequeuedAtFromComments([{ author: { login: 'mergify' }, body: '<!---\nDO NOT EDIT\n-*- Mergify Payload -*-\n{not valid json\n-*- Mergify Payload End -*-\n-->' }]),
|
|
337
|
-
null,
|
|
338
|
-
'an unparseable payload must be skipped, not thrown',
|
|
339
|
-
)
|
|
340
|
-
assert.equal(
|
|
341
|
-
dequeuedAtFromComments([{ author: { login: 'mergify' }, body: mergifyStatusComment({ state: 'dequeued', enteredAt: '2026-01-01 00:00 UTC' }) }]),
|
|
342
|
-
null,
|
|
343
|
-
'a dequeued-state comment with no "Left the queue" line has no timestamp to recover',
|
|
344
|
-
)
|
|
345
|
-
})
|
|
346
|
-
|
|
347
|
-
test('issue #1232: dequeueInfoFromComments retains the provider reason, batch PR, and reported checks', () => {
|
|
348
|
-
const comments = [{
|
|
349
|
-
author: { login: 'mergify' },
|
|
350
|
-
body: mergifyStatusComment({
|
|
351
|
-
state: 'dequeued',
|
|
352
|
-
enteredAt: '2026-08-30 07:00 UTC',
|
|
353
|
-
leftAt: '2026-08-30 07:05 UTC',
|
|
354
|
-
batchPrNumber: 706,
|
|
355
|
-
rawReason: 'The merge conditions cannot be satisfied due to failing checks',
|
|
356
|
-
failingChecks: [
|
|
357
|
-
{ name: 'TypeScript typecheck', status: 'CANCELLED', detailsUrl: 'https://example.test/typecheck' },
|
|
358
|
-
{ name: 'Vitest', status: 'FAILURE', detailsUrl: 'https://example.test/vitest' },
|
|
359
|
-
{ name: 'Full gate', status: 'TIMED_OUT', detailsUrl: 'https://example.test/full-gate' },
|
|
360
|
-
],
|
|
361
|
-
}),
|
|
362
|
-
}]
|
|
363
|
-
const info = dequeueInfoFromComments(comments)
|
|
364
|
-
assert.equal(info.dequeuedAtMs, Date.UTC(2026, 7, 30, 7, 5))
|
|
365
|
-
assert.equal(info.rawReason, 'The merge conditions cannot be satisfied due to failing checks')
|
|
366
|
-
assert.equal(info.batchPrNumber, 706)
|
|
367
|
-
assert.deepEqual(info.providerFailedChecks, [
|
|
368
|
-
{ name: 'TypeScript typecheck', status: 'CANCELLED', detailsUrl: 'https://example.test/typecheck' },
|
|
369
|
-
{ name: 'Vitest', status: 'FAILURE', detailsUrl: 'https://example.test/vitest' },
|
|
370
|
-
{ name: 'Full gate', status: 'TIMED_OUT', detailsUrl: 'https://example.test/full-gate' },
|
|
371
|
-
])
|
|
372
|
-
})
|
|
373
|
-
|
|
374
|
-
test('issue #1232: dequeue routing separates cancellation, hard failure, stale HEAD, conflict, and unknown reasons', () => {
|
|
375
|
-
assert.equal(classifyDequeue({ failedChecks: [{ status: 'CANCELLED' }] }), 'checks-cancelled')
|
|
376
|
-
for (const status of ['FAILURE', 'ERROR', 'TIMED_OUT']) {
|
|
377
|
-
assert.equal(classifyDequeue({ failedChecks: [{ status }] }), 'checks-failed')
|
|
378
|
-
}
|
|
379
|
-
assert.equal(classifyDequeue({ rawReason: 'pull-request-updated' }), 'pull-request-updated')
|
|
380
|
-
assert.equal(classifyDequeue({ rawReason: 'conflict-with-base-branch' }), 'conflict')
|
|
381
|
-
assert.equal(classifyDequeue({ rawReason: 'Pull request #1234 has been dequeued by @user with a `dequeue` command' }), 'manual')
|
|
382
|
-
assert.equal(classifyDequeue({ rawReason: 'provider added a new reason' }), 'unknown')
|
|
383
|
-
})
|
|
384
|
-
|
|
385
|
-
test('issue #1232: a synthetic batch failure reports the retained batch PR and actionable check', async () => {
|
|
386
|
-
const { code, payload, stdoutLines } = await runSyntheticBatchDequeue({
|
|
387
|
-
queueBatchNumbers: [706],
|
|
388
|
-
rawReason: 'The merge conditions cannot be satisfied due to failing checks',
|
|
389
|
-
providerChecks: [{ name: 'TypeScript typecheck', status: 'FAILURE' }],
|
|
390
|
-
batches: {
|
|
391
|
-
706: {
|
|
392
|
-
state: 'CLOSED', url: 'https://example.test/batch/706',
|
|
393
|
-
statusCheckRollup: [{ name: 'TypeScript typecheck', status: 'COMPLETED', conclusion: 'FAILURE', detailsUrl: 'https://example.test/typecheck' }],
|
|
394
|
-
},
|
|
395
|
-
},
|
|
396
|
-
})
|
|
397
|
-
assert.equal(code, 4)
|
|
398
|
-
assert.equal(stdoutLines.length, 1, 'stdout remains exactly one terminal JSON document')
|
|
399
|
-
assert.equal(payload.outcome, 'DEQUEUED')
|
|
400
|
-
assert.equal(payload.reason, 'checks-failed')
|
|
401
|
-
assert.equal(payload.rawReason, 'The merge conditions cannot be satisfied due to failing checks')
|
|
402
|
-
assert.equal(payload.batchPrNumber, 706)
|
|
403
|
-
assert.deepEqual(payload.failedChecks, [{ name: 'TypeScript typecheck', status: 'FAILURE', detailsUrl: 'https://example.test/typecheck' }])
|
|
404
|
-
assert.equal(payload.batchPr.readable, true)
|
|
405
|
-
})
|
|
406
|
-
|
|
407
|
-
test('issue #1232: cancellation during dependency installation is distinct from a hard check failure', async () => {
|
|
408
|
-
const { payload } = await runSyntheticBatchDequeue({
|
|
409
|
-
queueBatchNumbers: [706],
|
|
410
|
-
rawReason: 'The merge conditions cannot be satisfied due to failing checks',
|
|
411
|
-
providerChecks: [{ name: 'TypeScript typecheck', status: 'CANCELLED' }],
|
|
412
|
-
batches: {
|
|
413
|
-
706: {
|
|
414
|
-
state: 'CLOSED', url: 'https://example.test/batch/706',
|
|
415
|
-
statusCheckRollup: [{ name: 'TypeScript typecheck', status: 'COMPLETED', conclusion: 'CANCELLED', detailsUrl: 'https://example.test/typecheck' }],
|
|
416
|
-
},
|
|
417
|
-
},
|
|
418
|
-
})
|
|
419
|
-
assert.equal(payload.reason, 'checks-cancelled')
|
|
420
|
-
assert.deepEqual(payload.failedChecks, [{ name: 'TypeScript typecheck', status: 'CANCELLED', detailsUrl: 'https://example.test/typecheck' }])
|
|
421
|
-
})
|
|
422
|
-
|
|
423
|
-
test('issue #1232: an unavailable batch PR preserves the provider reason and truthful read failure', async () => {
|
|
424
|
-
const { payload } = await runSyntheticBatchDequeue({
|
|
425
|
-
queueBatchNumbers: [706],
|
|
426
|
-
rawReason: 'The merge conditions cannot be satisfied due to failing checks',
|
|
427
|
-
providerChecks: [{ name: 'TypeScript typecheck', status: 'CANCELLED', detailsUrl: 'https://example.test/typecheck' }],
|
|
428
|
-
batches: { 706: null },
|
|
429
|
-
})
|
|
430
|
-
assert.equal(payload.outcome, 'DEQUEUED')
|
|
431
|
-
assert.equal(payload.rawReason, 'The merge conditions cannot be satisfied due to failing checks')
|
|
432
|
-
assert.equal(payload.batchPrNumber, 706)
|
|
433
|
-
assert.equal(payload.batchPr.readable, false)
|
|
434
|
-
assert.match(payload.batchPr.error, /failed|status 1/i)
|
|
435
|
-
assert.deepEqual(payload.failedChecks, [{ name: 'TypeScript typecheck', status: 'CANCELLED', detailsUrl: 'https://example.test/typecheck' }])
|
|
436
|
-
})
|
|
437
|
-
|
|
438
|
-
test('issue #1232: unchanged-SHA requeue replaces stale batch context with the newest active batch', async () => {
|
|
439
|
-
const { payload } = await runSyntheticBatchDequeue({
|
|
440
|
-
queueBatchNumbers: [706, 707],
|
|
441
|
-
commentBatchNumber: 706,
|
|
442
|
-
rawReason: 'The merge conditions cannot be satisfied due to failing checks',
|
|
443
|
-
providerChecks: [{ name: 'old batch check', status: 'FAILURE' }],
|
|
444
|
-
batches: {
|
|
445
|
-
706: { state: 'CLOSED', url: 'https://example.test/batch/706', statusCheckRollup: [{ name: 'old batch check', conclusion: 'FAILURE' }] },
|
|
446
|
-
707: { state: 'CLOSED', url: 'https://example.test/batch/707', statusCheckRollup: [{ name: 'new batch check', conclusion: 'FAILURE' }] },
|
|
447
|
-
},
|
|
448
|
-
})
|
|
449
|
-
assert.equal(payload.pr.headRefOid, 'unchanged-head')
|
|
450
|
-
assert.equal(payload.batchPrNumber, 707, 'the last active queue observation wins over stale comment fallback')
|
|
451
|
-
assert.equal(payload.batchPr.url, 'https://example.test/batch/707')
|
|
452
|
-
assert.equal(payload.failedChecks[0].name, 'new batch check')
|
|
453
|
-
})
|
|
454
|
-
|
|
455
|
-
test('regression #629: the queue tool reporting active must win even over a FRESHLY-timestamped dequeued label', async () => {
|
|
456
|
-
// The comment's "Left the queue" timestamp is deliberately in the future relative to now
|
|
457
|
-
// (so it would read as fresh/this-watch's on its own) — this isolates the "prefer the queue
|
|
458
|
-
// tool" guard: dropping it would let this settle DEQUEUED despite `mergify queue show`
|
|
459
|
-
// simultaneously reporting the PR active.
|
|
460
|
-
await withFakeBinsOnPath(async (dir) => {
|
|
461
|
-
makeFakeGhReturning(dir, {
|
|
462
|
-
state: 'OPEN', mergedAt: null, mergeCommit: null, labels: [{ name: 'dequeued' }], url: 'https://x',
|
|
463
|
-
comments: [{ author: { login: 'mergify' }, body: mergifyStatusComment({ state: 'dequeued', enteredAt: mergifyTimestamp(3), leftAt: mergifyTimestamp(5) }) }],
|
|
464
|
-
})
|
|
465
|
-
makeFakeBin(dir, 'mergify', 'echo \'{"queued":true}\'')
|
|
466
|
-
const { code, payload } = await runCliCapturingLastPayload([
|
|
467
|
-
'--repo', 'o/r', '--pr', '1',
|
|
468
|
-
'--interval-seconds', '0.5', '--stall-minutes', '0.05', '--max-minutes', '0.05',
|
|
469
|
-
])
|
|
470
|
-
assert.equal(code, 4)
|
|
471
|
-
assert.notEqual(payload.outcome, 'DEQUEUED', `the queue tool reporting active must win over the stale dequeued label mirror: ${JSON.stringify(payload)}`)
|
|
472
|
-
assert.notEqual(payload.reason, 'dequeued-label')
|
|
473
|
-
})
|
|
474
|
-
})
|
|
475
|
-
|
|
476
|
-
test('regression #629: a dequeued label whose Mergify comment timestamp predates the watch is not reported as this attempt\'s outcome', async () => {
|
|
477
|
-
await withFakeBinsOnPath(async (dir) => {
|
|
478
|
-
makeFakeGhReturning(dir, {
|
|
479
|
-
state: 'OPEN', mergedAt: null, mergeCommit: null, labels: [{ name: 'dequeued' }], url: 'https://x',
|
|
480
|
-
// Years stale — unambiguously outside the NOTE-2 60s grace period.
|
|
481
|
-
comments: [{ author: { login: 'mergify' }, body: mergifyStatusComment({ state: 'dequeued', enteredAt: '2020-01-01 00:00 UTC', leftAt: '2020-01-01 00:05 UTC' }) }],
|
|
482
|
-
})
|
|
483
|
-
makeFakeBin(dir, 'mergify', 'echo \'{"queued":false}\'')
|
|
484
|
-
const { code, payload } = await runCliCapturingLastPayload([
|
|
485
|
-
'--repo', 'o/r', '--pr', '1',
|
|
486
|
-
'--interval-seconds', '0.5', '--stall-minutes', '0.05', '--max-minutes', '0.05',
|
|
487
|
-
])
|
|
488
|
-
assert.equal(code, 4)
|
|
489
|
-
assert.notEqual(payload.outcome, 'DEQUEUED', `a dequeue timestamped before this watch started must not be reported as this attempt's outcome: ${JSON.stringify(payload)}`)
|
|
490
|
-
})
|
|
491
|
-
})
|
|
492
|
-
|
|
493
|
-
test('regression #629: the REAL PR #620 payload must not settle DEQUEUED immediately (the exact live repro)', async () => {
|
|
494
|
-
// The exact shape review of PR #637 verified live: this fixture's most recent mergify
|
|
495
|
-
// comment (a command acknowledgement) carries no payload, and its most recent STATUS
|
|
496
|
-
// comment is state:"merged" — the actual dequeue happened two comments back. The PR also
|
|
497
|
-
// no longer carries the `dequeued` label in real life (it went on to merge), so this test
|
|
498
|
-
// adds that label back onto the fixture PR deliberately, to pin the specific claim: given
|
|
499
|
-
// this real comment history, a `dequeued` label reads as the FIRST cycle's stale leftover,
|
|
500
|
-
// not a fresh terminal verdict — the mechanism BLOCK-1/2 was about.
|
|
501
|
-
await withFakeBinsOnPath(async (dir) => {
|
|
502
|
-
makeFakeGhReturning(dir, {
|
|
503
|
-
state: 'OPEN', mergedAt: null, mergeCommit: null, labels: [{ name: 'dequeued' }], url: 'https://x',
|
|
504
|
-
comments: PR_620_MERGIFY_COMMENTS,
|
|
505
|
-
})
|
|
506
|
-
makeFakeBin(dir, 'mergify', 'echo \'{"queued":false}\'')
|
|
507
|
-
const { code, payload } = await runCliCapturingLastPayload([
|
|
508
|
-
'--repo', 'o/r', '--pr', '1',
|
|
509
|
-
'--interval-seconds', '0.5', '--stall-minutes', '0.05', '--max-minutes', '0.05',
|
|
510
|
-
])
|
|
511
|
-
assert.equal(code, 4)
|
|
512
|
-
assert.notEqual(payload.outcome, 'DEQUEUED', `the real #620 payload's dequeue (2026-07-28 11:04 UTC) predates this watch and must not settle terminal: ${JSON.stringify(payload)}`)
|
|
513
|
-
})
|
|
514
|
-
})
|
|
515
|
-
|
|
516
|
-
test('regression #629: a GENUINELY fresh dequeue (not stale, not active) still settles DEQUEUED promptly', async () => {
|
|
517
|
-
// The positive-path counterpart to the two tests above: proves `startedMs`/`dequeuedAtMs`
|
|
518
|
-
// are actually WIRED from `runCli` into `classifyObservation`, not just correct in
|
|
519
|
-
// isolation. Without the wiring, this would never settle DEQUEUED and would instead run out
|
|
520
|
-
// the clock to STALLED — which the assertions below catch by requiring the FAST exit path
|
|
521
|
-
// (few observations, well under the stall bound) rather than merely "terminal, eventually".
|
|
522
|
-
await withFakeBinsOnPath(async (dir) => {
|
|
523
|
-
makeFakeGhReturning(dir, {
|
|
524
|
-
state: 'OPEN', mergedAt: null, mergeCommit: null, labels: [{ name: 'dequeued' }], url: 'https://x',
|
|
525
|
-
// +2min, not +1min: `mergifyTimestamp` truncates to the minute, so a +1min value only
|
|
526
|
-
// exceeds `startedMs` if fixture-build and `runCli`'s own `Date.now()` land in the same
|
|
527
|
-
// wall-clock minute — margin as low as -1999ms across a build interval up to 2000ms
|
|
528
|
-
// (BLOCK-3/4, review on PR #637, reproduced deterministically: building at 59500ms into
|
|
529
|
-
// the minute with a 1000ms build interval yields a false STALLED). +2min's worst-case
|
|
530
|
-
// margin across the same interval range is +58001ms — safe for any interval under 60s.
|
|
531
|
-
comments: [{ author: { login: 'mergify' }, body: mergifyStatusComment({ state: 'dequeued', enteredAt: mergifyTimestamp(-2), leftAt: mergifyTimestamp(2) }) }],
|
|
532
|
-
})
|
|
533
|
-
makeFakeBin(dir, 'mergify', 'echo \'{"queued":false}\'')
|
|
534
|
-
const { code, payload } = await runCliCapturingLastPayload([
|
|
535
|
-
'--repo', 'o/r', '--pr', '1',
|
|
536
|
-
'--interval-seconds', '0.5', '--stall-minutes', '0.05', '--max-minutes', '0.05',
|
|
537
|
-
])
|
|
538
|
-
assert.equal(code, 4)
|
|
539
|
-
assert.equal(payload.outcome, 'DEQUEUED', `a dequeue timestamped at/after this watch started must settle as this attempt's outcome: ${JSON.stringify(payload)}`)
|
|
540
|
-
assert.equal(payload.reason, 'dequeued-label')
|
|
541
|
-
assert.equal(payload.observations, 1, 'must settle on the FIRST poll, not by running out the stall clock')
|
|
542
|
-
})
|
|
543
|
-
})
|
|
544
|
-
|
|
545
|
-
// issue #639 (round-2 review of PR #637): the test above uses `mergifyTimestamp(2)`, not `(1)`,
|
|
546
|
-
// but nothing pinned WHY — mutating it back to `(1)` while leaving the NOTE-2 grace period
|
|
547
|
-
// intact left the whole suite green, because the grace period alone already absorbs a `(1)`
|
|
548
|
-
// dequeue at every clock position. This test pins the margin itself, independent of the CLI
|
|
549
|
-
// and the grace period: `mergifyTimestamp` truncates to the minute, so the gap between "now"
|
|
550
|
-
// and the parsed, minute-floored timestamp is `offset*60_000 - s`, where `s` is milliseconds
|
|
551
|
-
// already elapsed in the current minute. For `offset = 2` that gap exceeds 60_000 at every `s`
|
|
552
|
-
// in `[0, 60_000)`; for `offset = 1` it is always below 60_000 (reviewer-measured margins:
|
|
553
|
-
// `(2)` 60100ms-109872ms, `(1)` 100ms-49872ms) — so this assertion discriminates the two at
|
|
554
|
-
// every position in the minute, not just on average.
|
|
555
|
-
test('regression #639: mergifyTimestamp(2)\'s dequeue margin clears 60s at any position in the minute', () => {
|
|
556
|
-
const before = Date.now()
|
|
557
|
-
const body = mergifyStatusComment({ state: 'dequeued', enteredAt: mergifyTimestamp(-2), leftAt: mergifyTimestamp(2) })
|
|
558
|
-
const parsed = dequeuedAtFromComments([{ author: { login: 'mergify' }, body }])
|
|
559
|
-
assert.ok(parsed - before >= 60_000, `mergifyTimestamp(2)'s margin over the 60s grace must hold at every clock position: parsed=${parsed}, before=${before}, margin=${parsed - before}`)
|
|
560
|
-
})
|
|
561
|
-
|
|
562
|
-
test('queued label or queue membership marks active queue membership', () => {
|
|
563
|
-
for (const input of [
|
|
564
|
-
{ pr: { state: 'OPEN', labels: ['queued'] }, queue: { queued: false } },
|
|
565
|
-
{ pr: { state: 'OPEN', labels: [] }, queue: { queued: true, position: 2 } },
|
|
566
|
-
]) {
|
|
567
|
-
const verdict = classifyObservation(input)
|
|
568
|
-
assert.equal(verdict.terminal, false)
|
|
569
|
-
assert.equal(verdict.outcome, 'QUEUED')
|
|
570
|
-
assert.equal(verdict.seenQueued, true)
|
|
571
|
-
}
|
|
572
|
-
})
|
|
573
|
-
|
|
574
|
-
test('queued:false before queue entry is NOT_YET_QUEUED, not a dequeue', () => {
|
|
575
|
-
const verdict = classifyObservation({ pr: { state: 'OPEN', labels: [] }, queue: { queued: false }, seenQueued: false, missingActiveReads: 9 })
|
|
576
|
-
assert.equal(verdict.terminal, false)
|
|
577
|
-
assert.equal(verdict.outcome, 'NOT_YET_QUEUED')
|
|
578
|
-
})
|
|
579
|
-
|
|
580
|
-
test('failed non-Mergify checks stop pre-admission without claiming a dequeue', () => {
|
|
581
|
-
const verdict = classifyObservation({
|
|
582
|
-
pr: {
|
|
583
|
-
state: 'OPEN',
|
|
584
|
-
labels: [],
|
|
585
|
-
failedChecks: [{ name: 'Warehouse ETL tests', status: 'FAILURE', detailsUrl: 'https://ci/job/1' }],
|
|
586
|
-
},
|
|
587
|
-
queue: { queued: false },
|
|
588
|
-
everActive: false,
|
|
589
|
-
})
|
|
590
|
-
assert.equal(verdict.terminal, true)
|
|
591
|
-
assert.equal(verdict.outcome, 'STALLED')
|
|
592
|
-
assert.equal(verdict.reason, 'admission-checks-failed')
|
|
593
|
-
assert.equal(verdict.preAdmission, true)
|
|
594
|
-
assert.deepEqual(verdict.failedChecks.map((check) => check.name), ['Warehouse ETL tests'])
|
|
595
|
-
})
|
|
596
|
-
|
|
597
|
-
test('active queue membership remains authoritative over a failed GitHub check', () => {
|
|
598
|
-
const verdict = classifyObservation({
|
|
599
|
-
pr: { state: 'OPEN', labels: [], failedChecks: [{ name: 'Warehouse ETL tests', status: 'FAILURE' }] },
|
|
600
|
-
queue: { queued: true, position: 1 },
|
|
601
|
-
everActive: true,
|
|
602
|
-
})
|
|
603
|
-
assert.equal(verdict.terminal, false)
|
|
604
|
-
assert.equal(verdict.outcome, 'QUEUED')
|
|
605
|
-
})
|
|
606
|
-
|
|
607
|
-
test('status check summary ignores Mergify lifecycle checks and classifies terminal CI', () => {
|
|
608
|
-
const summary = summarizeStatusChecks([
|
|
609
|
-
{ name: 'Warehouse ETL tests', status: 'COMPLETED', conclusion: 'FAILURE', detailsUrl: 'https://ci/warehouse' },
|
|
610
|
-
{ name: 'Frontend tests', status: 'IN_PROGRESS', conclusion: null, detailsUrl: 'https://ci/frontend' },
|
|
611
|
-
{ name: 'Mergify Merge Protections', status: 'COMPLETED', conclusion: 'FAILURE' },
|
|
612
|
-
{ name: 'Mergify Merge Queue', status: 'COMPLETED', conclusion: 'NEUTRAL' },
|
|
613
|
-
{ context: 'legacy-status', state: 'ERROR', targetUrl: 'https://ci/legacy' },
|
|
614
|
-
])
|
|
615
|
-
assert.deepEqual(summary.failedChecks, [
|
|
616
|
-
{ name: 'Warehouse ETL tests', status: 'FAILURE', detailsUrl: 'https://ci/warehouse' },
|
|
617
|
-
{ name: 'legacy-status', status: 'ERROR', detailsUrl: 'https://ci/legacy' },
|
|
618
|
-
])
|
|
619
|
-
assert.deepEqual(summary.pendingChecks, [
|
|
620
|
-
{ name: 'Frontend tests', status: 'IN_PROGRESS', detailsUrl: 'https://ci/frontend' },
|
|
621
|
-
])
|
|
622
|
-
})
|
|
623
|
-
|
|
624
|
-
test('live loop exits on a failed admission check in the first poll', async () => {
|
|
625
|
-
await withFakeBinsOnPath(async (dir) => {
|
|
626
|
-
makeFakeGhReturning(dir, {
|
|
627
|
-
state: 'OPEN', mergedAt: null, mergeCommit: null, headRefOid: 'head-1', labels: [], url: 'https://x', comments: [],
|
|
628
|
-
statusCheckRollup: [
|
|
629
|
-
{ name: 'Warehouse ETL tests', status: 'COMPLETED', conclusion: 'FAILURE', detailsUrl: 'https://ci/warehouse' },
|
|
630
|
-
],
|
|
631
|
-
})
|
|
632
|
-
makeFakeBin(dir, 'mergify', 'echo \'{"queued":false}\'')
|
|
633
|
-
const { code, payload } = await runCliCapturingLastPayload([
|
|
634
|
-
'--repo', 'o/r', '--pr', '1',
|
|
635
|
-
'--interval-seconds', '1', '--stall-minutes', '1', '--admission-stall-minutes', '1', '--max-minutes', '1',
|
|
636
|
-
])
|
|
637
|
-
assert.equal(code, 4)
|
|
638
|
-
assert.equal(payload.outcome, 'STALLED')
|
|
639
|
-
assert.equal(payload.reason, 'admission-checks-failed')
|
|
640
|
-
assert.equal(payload.observations, 1)
|
|
641
|
-
assert.deepEqual(payload.phases, ['NOT_YET_QUEUED', 'STALLED'])
|
|
642
|
-
assert.equal(payload.pr.headRefOid, 'head-1')
|
|
643
|
-
assert.deepEqual(payload.failedChecks.map((check) => check.name), ['Warehouse ETL tests'])
|
|
644
|
-
assert.deepEqual(payload.pr.failedChecks.map((check) => check.name), ['Warehouse ETL tests'])
|
|
645
|
-
})
|
|
646
|
-
})
|
|
647
|
-
|
|
648
|
-
test('after observed entry, lost queue membership across rereads is DEQUEUED', () => {
|
|
649
|
-
const firstMissing = classifyObservation({ pr: { state: 'OPEN', labels: [] }, queue: { queued: false }, seenQueued: true, missingActiveReads: 1 })
|
|
650
|
-
assert.equal(firstMissing.terminal, false)
|
|
651
|
-
assert.equal(firstMissing.outcome, 'WAITING')
|
|
652
|
-
|
|
653
|
-
const settled = classifyObservation({ pr: { state: 'OPEN', labels: [] }, queue: { queued: false }, seenQueued: true, missingActiveReads: 2 })
|
|
654
|
-
assert.equal(settled.terminal, true)
|
|
655
|
-
assert.equal(settled.outcome, 'DEQUEUED')
|
|
656
|
-
assert.equal(settled.reason, 'lost-queue-membership')
|
|
657
|
-
})
|
|
658
|
-
|
|
659
|
-
test('merge wins over lost queue membership', () => {
|
|
660
|
-
const verdict = classifyObservation({ pr: { state: 'MERGED', mergeCommitOid: 'abc123', labels: [] }, queue: { queued: false }, seenQueued: true, missingActiveReads: 4 })
|
|
661
|
-
assert.equal(verdict.outcome, 'MERGED')
|
|
662
|
-
})
|
|
663
|
-
|
|
664
|
-
// ---- epic-824 #859: --seen-queued before admission must not false-DEQUEUE ----
|
|
665
|
-
//
|
|
666
|
-
// Orchestrators often launch `watch-pr-delivery --seen-queued` in the same turn as
|
|
667
|
-
// `@mergifyio queue`, before Mergify has admitted the PR. The flag is a resume *hint*,
|
|
668
|
-
// not proof this process observed membership. Before the everActive gate, two clean
|
|
669
|
-
// "not in the merge queue" reads settled DEQUEUED/lost-queue-membership while the PR
|
|
670
|
-
// was still pre-admission — and the real merge landed minutes later under a re-watch.
|
|
671
|
-
|
|
672
|
-
test('epic-824: --seen-queued without everActive stays NOT_YET_QUEUED, never DEQUEUED', () => {
|
|
673
|
-
// Explicit everActive=false is what the poll loop passes until membership is seen.
|
|
674
|
-
const first = classifyObservation({
|
|
675
|
-
pr: { state: 'OPEN', labels: [] },
|
|
676
|
-
queue: { queued: false },
|
|
677
|
-
seenQueued: true,
|
|
678
|
-
everActive: false,
|
|
679
|
-
missingActiveReads: 1,
|
|
680
|
-
})
|
|
681
|
-
assert.equal(first.terminal, false)
|
|
682
|
-
assert.equal(first.outcome, 'NOT_YET_QUEUED')
|
|
683
|
-
assert.equal(first.seenQueued, true)
|
|
684
|
-
assert.equal(first.everActive, false)
|
|
685
|
-
|
|
686
|
-
const second = classifyObservation({
|
|
687
|
-
pr: { state: 'OPEN', labels: [] },
|
|
688
|
-
queue: { queued: false },
|
|
689
|
-
seenQueued: true,
|
|
690
|
-
everActive: false,
|
|
691
|
-
missingActiveReads: 2,
|
|
692
|
-
})
|
|
693
|
-
assert.equal(second.terminal, false, 'must not settle DEQUEUED before process-local membership')
|
|
694
|
-
assert.equal(second.outcome, 'NOT_YET_QUEUED')
|
|
695
|
-
assert.notEqual(second.reason, 'lost-queue-membership')
|
|
696
|
-
})
|
|
697
|
-
|
|
698
|
-
test('epic-824: nextMissingActiveReads does not advance when everActive is false', () => {
|
|
699
|
-
assert.equal(
|
|
700
|
-
nextMissingActiveReads({ active: false, everActive: false, seenQueued: true, missingActiveReads: 0, readable: true }),
|
|
701
|
-
0,
|
|
702
|
-
'seeded seenQueued alone must not start the absence counter',
|
|
703
|
-
)
|
|
704
|
-
assert.equal(
|
|
705
|
-
nextMissingActiveReads({ active: false, everActive: false, seenQueued: true, missingActiveReads: 5, readable: true }),
|
|
706
|
-
5,
|
|
707
|
-
'counter is carried unchanged, not advanced, while everActive is false',
|
|
708
|
-
)
|
|
709
|
-
})
|
|
710
|
-
|
|
711
|
-
test('epic-824: after everActive, two clean absences still settle DEQUEUED', () => {
|
|
712
|
-
const waiting = classifyObservation({
|
|
713
|
-
pr: { state: 'OPEN', labels: [] },
|
|
714
|
-
queue: { queued: false },
|
|
715
|
-
seenQueued: true,
|
|
716
|
-
everActive: true,
|
|
717
|
-
missingActiveReads: 1,
|
|
718
|
-
})
|
|
719
|
-
assert.equal(waiting.outcome, 'WAITING')
|
|
720
|
-
|
|
721
|
-
const settled = classifyObservation({
|
|
722
|
-
pr: { state: 'OPEN', labels: [] },
|
|
723
|
-
queue: { queued: false },
|
|
724
|
-
seenQueued: true,
|
|
725
|
-
everActive: true,
|
|
726
|
-
missingActiveReads: 2,
|
|
727
|
-
})
|
|
728
|
-
assert.equal(settled.terminal, true)
|
|
729
|
-
assert.equal(settled.outcome, 'DEQUEUED')
|
|
730
|
-
assert.equal(settled.reason, 'lost-queue-membership')
|
|
731
|
-
})
|
|
732
|
-
|
|
733
|
-
test('epic-824: nextMissingActiveReads advances only after everActive', () => {
|
|
734
|
-
assert.equal(
|
|
735
|
-
nextMissingActiveReads({ active: false, everActive: true, seenQueued: true, missingActiveReads: 0, readable: true }),
|
|
736
|
-
1,
|
|
737
|
-
)
|
|
738
|
-
assert.equal(
|
|
739
|
-
nextMissingActiveReads({ active: true, everActive: false, seenQueued: true, missingActiveReads: 3, readable: true }),
|
|
740
|
-
0,
|
|
741
|
-
'active membership resets the counter regardless of prior everActive',
|
|
742
|
-
)
|
|
743
|
-
})
|
|
744
|
-
|
|
745
|
-
test('legacy callers without everActive keep seenQueued lost-membership semantics', () => {
|
|
746
|
-
// everActive omitted (null default): seenQueued alone still counts, matching pre-fix unit tests.
|
|
747
|
-
const settled = classifyObservation({
|
|
748
|
-
pr: { state: 'OPEN', labels: [] },
|
|
749
|
-
queue: { queued: false },
|
|
750
|
-
seenQueued: true,
|
|
751
|
-
missingActiveReads: 2,
|
|
752
|
-
})
|
|
753
|
-
assert.equal(settled.outcome, 'DEQUEUED')
|
|
754
|
-
assert.equal(settled.reason, 'lost-queue-membership')
|
|
755
|
-
})
|
|
756
|
-
|
|
757
|
-
// ---- issue #611 defect 1: a transient read failure is not a dequeue signal ----
|
|
758
|
-
//
|
|
759
|
-
// `prObservation`/`queueObservation` set `readable: false` on a failed subprocess read, and
|
|
760
|
-
// the resulting snapshot (`labels: []`, `queued: false`) is indistinguishable from a genuine
|
|
761
|
-
// loss of queue membership unless the caller treats an unreadable poll as NO INFORMATION.
|
|
762
|
-
// `nextMissingActiveReads` is the pure decision the poll loop uses to gate the counter.
|
|
763
|
-
|
|
764
|
-
test('nextMissingActiveReads does not advance on an unreadable poll', () => {
|
|
765
|
-
assert.equal(
|
|
766
|
-
nextMissingActiveReads({ active: false, seenQueued: true, missingActiveReads: 0, readable: false }),
|
|
767
|
-
0,
|
|
768
|
-
)
|
|
769
|
-
assert.equal(
|
|
770
|
-
nextMissingActiveReads({ active: false, seenQueued: true, missingActiveReads: 1, readable: false }),
|
|
771
|
-
1,
|
|
772
|
-
'an unreadable poll carries the counter forward unchanged rather than resetting or advancing it',
|
|
773
|
-
)
|
|
774
|
-
})
|
|
775
|
-
|
|
776
|
-
test('nextMissingActiveReads advances only on a clean read that reports absence', () => {
|
|
777
|
-
assert.equal(nextMissingActiveReads({ active: false, seenQueued: true, missingActiveReads: 0, readable: true }), 1)
|
|
778
|
-
assert.equal(nextMissingActiveReads({ active: false, seenQueued: true, missingActiveReads: 1, readable: true }), 2)
|
|
779
|
-
})
|
|
780
|
-
|
|
781
|
-
test('nextMissingActiveReads resets to 0 the moment either source reports active', () => {
|
|
782
|
-
assert.equal(nextMissingActiveReads({ active: true, seenQueued: true, missingActiveReads: 2, readable: true }), 0)
|
|
783
|
-
assert.equal(nextMissingActiveReads({ active: true, seenQueued: true, missingActiveReads: 2, readable: false }), 0)
|
|
784
|
-
})
|
|
785
|
-
|
|
786
|
-
test('nextMissingActiveReads is inert before seenQueued', () => {
|
|
787
|
-
assert.equal(nextMissingActiveReads({ active: false, seenQueued: false, missingActiveReads: 0, readable: true }), 0)
|
|
788
|
-
})
|
|
789
|
-
|
|
790
|
-
test('regression #611: two consecutive unreadable polls after seenQueued never settle DEQUEUED', () => {
|
|
791
|
-
let seenQueued = true
|
|
792
|
-
let missingActiveReads = 0
|
|
793
|
-
for (let i = 0; i < 2; i += 1) {
|
|
794
|
-
const pr = { readable: false }
|
|
795
|
-
const queue = { readable: false }
|
|
796
|
-
missingActiveReads = nextMissingActiveReads({ active: false, seenQueued, missingActiveReads, readable: false })
|
|
797
|
-
const verdict = classifyObservation({ pr, queue, seenQueued, missingActiveReads })
|
|
798
|
-
assert.notEqual(verdict.outcome, 'DEQUEUED', `poll ${i + 1} must not be classified DEQUEUED on an unreadable read`)
|
|
799
|
-
seenQueued = verdict.seenQueued
|
|
800
|
-
}
|
|
801
|
-
})
|
|
802
|
-
|
|
803
|
-
test('one source failing to read does not block the other from counting a clean absence', () => {
|
|
804
|
-
// pr read failed, but the queue read succeeded and reported not-queued — that IS information.
|
|
805
|
-
const missing = nextMissingActiveReads({ active: false, seenQueued: true, missingActiveReads: 0, readable: true })
|
|
806
|
-
assert.equal(missing, 1)
|
|
807
|
-
})
|
|
808
|
-
|
|
809
|
-
test('BLOCK-1 regression (review #612): the live loop never settles DEQUEUED on two consecutive unreadable gh+mergify reads', async () => {
|
|
810
|
-
// Reproduces the independent review's exact repro: both `gh` and `mergify` failing
|
|
811
|
-
// (exit 1, no stdout) after `--seen-queued`. Before this fix, `queueObservation` parsed
|
|
812
|
-
// the failed mergify call's empty stdout as `{}` (truthy) and reported `readable: true`
|
|
813
|
-
// regardless of the failure — the one place in this file that still treated an unreadable
|
|
814
|
-
// read as informative, defeating `nextMissingActiveReads` entirely.
|
|
815
|
-
await withFakeBinsOnPath(async (dir) => {
|
|
816
|
-
makeFakeBin(dir, 'gh', 'exit 1')
|
|
817
|
-
makeFakeBin(dir, 'mergify', 'exit 1')
|
|
818
|
-
const { code, payload } = await runCliCapturingLastPayload([
|
|
819
|
-
'--repo', 'o/r', '--pr', '1', '--seen-queued',
|
|
820
|
-
'--interval-seconds', '0.1', '--stall-minutes', '0.02', '--max-minutes', '0.05',
|
|
821
|
-
])
|
|
822
|
-
assert.equal(code, 4)
|
|
823
|
-
assert.notEqual(payload.outcome, 'DEQUEUED', `must not falsely dequeue on unreadable reads: ${JSON.stringify(payload)}`)
|
|
824
|
-
assert.notEqual(payload.reason, 'lost-queue-membership', `must not settle via the missing-active-reads counter: ${JSON.stringify(payload)}`)
|
|
825
|
-
assert.equal(payload.missingActiveReads, 0, 'an unreadable read must never advance the counter')
|
|
826
|
-
assert.equal(payload.pr.readable, false)
|
|
827
|
-
assert.equal(payload.queue.readable, false, 'BLOCK-1: a failed mergify read must report readable:false, not the empty-stdout-parses-truthy false positive')
|
|
828
|
-
assert.equal(payload.reason, 'unreadable', 'the terminal stall must be attributed to unreadable reads, not a generic stall-bound')
|
|
829
|
-
})
|
|
830
|
-
})
|
|
831
|
-
|
|
832
|
-
test('a merge hidden by a failed PR read is not a dequeue (ai #1762)', async () => {
|
|
833
|
-
// ai #1762 (2026-10-17): the PR merged while `gh pr view` was rate limited. Mergify read
|
|
834
|
-
// "not in the merge queue" twice, which counted as two readable absences, and the watch
|
|
835
|
-
// reported DEQUEUED for a merged PR. Only the PR read can show a merge, so an absence
|
|
836
|
-
// counts only when that read succeeded.
|
|
837
|
-
await withFakeBinsOnPath(async (dir) => {
|
|
838
|
-
const ghCount = join(dir, 'gh-count')
|
|
839
|
-
const queueCount = join(dir, 'queue-count')
|
|
840
|
-
makeFakeBin(dir, 'gh', [
|
|
841
|
-
`count=$(cat '${ghCount}' 2>/dev/null || echo 0)`,
|
|
842
|
-
'count=$((count + 1))',
|
|
843
|
-
`echo "$count" > '${ghCount}'`,
|
|
844
|
-
'[ "$count" -eq 1 ] && { echo \'{"state":"OPEN","mergedAt":null,"mergeCommit":null,"headRefOid":"h","labels":["queued"],"url":"https://x","comments":[]}\'; exit 0; }',
|
|
845
|
-
'[ "$count" -le 4 ] && { echo "GraphQL: API rate limit already exceeded" >&2; exit 1; }',
|
|
846
|
-
'echo \'{"state":"MERGED","mergedAt":"2026-10-17T04:20:34Z","mergeCommit":{"oid":"ee212fdc"},"headRefOid":"h","labels":[],"url":"https://x","comments":[]}\'',
|
|
847
|
-
].join('\n'))
|
|
848
|
-
makeFakeBin(dir, 'mergify', [
|
|
849
|
-
`count=$(cat '${queueCount}' 2>/dev/null || echo 0)`,
|
|
850
|
-
'count=$((count + 1))',
|
|
851
|
-
`echo "$count" > '${queueCount}'`,
|
|
852
|
-
'[ "$count" -eq 1 ] && { echo \'{"queued":true,"status":"checking"}\'; exit 0; }',
|
|
853
|
-
'echo "PR #1 is not in the merge queue"; exit 1',
|
|
854
|
-
].join('\n'))
|
|
855
|
-
const { code, payload } = await runCliCapturingLastPayload([
|
|
856
|
-
'--repo', 'o/r', '--pr', '1', '--quiet',
|
|
857
|
-
'--interval-seconds', '0.02', '--stall-minutes', '1', '--max-minutes', '1',
|
|
858
|
-
])
|
|
859
|
-
assert.equal(payload.outcome, 'MERGED', JSON.stringify(payload))
|
|
860
|
-
assert.equal(code, 0)
|
|
861
|
-
assert.equal(payload.mergedSha, 'ee212fdc')
|
|
862
|
-
})
|
|
863
|
-
})
|
|
864
|
-
|
|
865
|
-
test('BLOCK-2/3 regression (review #612): the live loop actually wires the per-read timeout at both call sites, not just run() in isolation', async () => {
|
|
866
|
-
// Both fake binaries hang well past the clamped per-read timeout (`--stall-minutes 0.02`
|
|
867
|
-
// clamps the read timeout to ~1.2s here, far under the 8s hang). If the timeout option
|
|
868
|
-
// were stripped from either `prObservation`'s or `queueObservation`'s `run()` call, this
|
|
869
|
-
// test would take at least 8s per hung read instead of finishing in ~1-2s.
|
|
870
|
-
await withFakeBinsOnPath(async (dir) => {
|
|
871
|
-
makeFakeBin(dir, 'gh', 'sleep 8\nexit 1')
|
|
872
|
-
makeFakeBin(dir, 'mergify', 'sleep 8\nexit 1')
|
|
873
|
-
const startedMs = Date.now()
|
|
874
|
-
const { code } = await runCliCapturingLastPayload([
|
|
875
|
-
'--repo', 'o/r', '--pr', '1', '--seen-queued',
|
|
876
|
-
'--interval-seconds', '0.1', '--stall-minutes', '0.02', '--max-minutes', '0.05',
|
|
877
|
-
])
|
|
878
|
-
const elapsedMs = Date.now() - startedMs
|
|
879
|
-
assert.equal(code, 4)
|
|
880
|
-
assert.ok(elapsedMs < 6000, `expected the clamped per-read timeout to bound both hung reads well under the 8s+8s an unwired call would take, got ${elapsedMs}ms`)
|
|
881
|
-
})
|
|
882
|
-
})
|
|
883
|
-
|
|
884
|
-
test('observation key changes on queue position and ignores label order', () => {
|
|
885
|
-
const a = observationKey({ pr: { state: 'OPEN', labels: ['queued', 'urgent'] }, queue: { queued: true, queueState: 'pending', position: 3 } })
|
|
886
|
-
const same = observationKey({ pr: { state: 'OPEN', labels: ['urgent', 'queued'] }, queue: { queued: true, queueState: 'pending', position: 3 } })
|
|
887
|
-
const moved = observationKey({ pr: { state: 'OPEN', labels: ['queued', 'urgent'] }, queue: { queued: true, queueState: 'pending', position: 2 } })
|
|
888
|
-
assert.equal(a, same)
|
|
889
|
-
assert.notEqual(a, moved)
|
|
890
|
-
})
|
|
891
|
-
|
|
892
|
-
test('observation key changes when HEAD or admission checks change', () => {
|
|
893
|
-
const base = observationKey({
|
|
894
|
-
pr: { state: 'OPEN', headRefOid: 'aaa', failedChecks: [], pendingChecks: [{ name: 'CI', status: 'IN_PROGRESS' }] },
|
|
895
|
-
queue: { queued: false },
|
|
896
|
-
})
|
|
897
|
-
const completed = observationKey({
|
|
898
|
-
pr: { state: 'OPEN', headRefOid: 'aaa', failedChecks: [{ name: 'CI', status: 'FAILURE' }], pendingChecks: [] },
|
|
899
|
-
queue: { queued: false },
|
|
900
|
-
})
|
|
901
|
-
const newHead = observationKey({
|
|
902
|
-
pr: { state: 'OPEN', headRefOid: 'bbb', failedChecks: [], pendingChecks: [{ name: 'CI', status: 'IN_PROGRESS' }] },
|
|
903
|
-
queue: { queued: false },
|
|
904
|
-
})
|
|
905
|
-
assert.notEqual(base, completed)
|
|
906
|
-
assert.notEqual(base, newHead)
|
|
907
|
-
})
|
|
908
|
-
|
|
909
|
-
const STALL_BASE_PR = {
|
|
910
|
-
readable: true,
|
|
911
|
-
state: 'OPEN',
|
|
912
|
-
headRefOid: 'head-1',
|
|
913
|
-
labels: [],
|
|
914
|
-
failedChecks: [],
|
|
915
|
-
pendingChecks: [{ name: 'CI', status: 'IN_PROGRESS' }],
|
|
916
|
-
}
|
|
917
|
-
const STALL_BASE_QUEUE = { readable: true, queued: true, queueState: 'checking', position: 3 }
|
|
918
|
-
|
|
919
|
-
function advanceStall(cache, pr, queue) {
|
|
920
|
-
return nextStallObservation({ cache, pr, queue })
|
|
921
|
-
}
|
|
922
|
-
|
|
923
|
-
test('stall observation retains both sources across a fully unreadable poll and recovery', () => {
|
|
924
|
-
const baseline = advanceStall(null, STALL_BASE_PR, STALL_BASE_QUEUE)
|
|
925
|
-
assert.equal(baseline.changed, true)
|
|
926
|
-
|
|
927
|
-
const unreadable = advanceStall(baseline.cache, { readable: false }, { readable: false })
|
|
928
|
-
assert.equal(unreadable.changed, false)
|
|
929
|
-
assert.equal(unreadable.key, baseline.key)
|
|
930
|
-
|
|
931
|
-
const recovered = advanceStall(unreadable.cache, STALL_BASE_PR, STALL_BASE_QUEUE)
|
|
932
|
-
assert.equal(recovered.changed, false)
|
|
933
|
-
assert.equal(recovered.key, baseline.key)
|
|
934
|
-
})
|
|
935
|
-
|
|
936
|
-
test('stall observation retains the PR contribution when PR is unreadable and queue is stable', () => {
|
|
937
|
-
const baseline = advanceStall(null, STALL_BASE_PR, STALL_BASE_QUEUE)
|
|
938
|
-
const partial = advanceStall(baseline.cache, { readable: false }, STALL_BASE_QUEUE)
|
|
939
|
-
const recovered = advanceStall(partial.cache, STALL_BASE_PR, STALL_BASE_QUEUE)
|
|
940
|
-
|
|
941
|
-
assert.equal(partial.changed, false)
|
|
942
|
-
assert.equal(partial.key, baseline.key)
|
|
943
|
-
assert.equal(recovered.changed, false)
|
|
944
|
-
})
|
|
945
|
-
|
|
946
|
-
test('stall observation retains the queue contribution when queue is unreadable and PR is stable', () => {
|
|
947
|
-
const baseline = advanceStall(null, STALL_BASE_PR, STALL_BASE_QUEUE)
|
|
948
|
-
const partial = advanceStall(baseline.cache, STALL_BASE_PR, { readable: false })
|
|
949
|
-
const recovered = advanceStall(partial.cache, STALL_BASE_PR, STALL_BASE_QUEUE)
|
|
950
|
-
|
|
951
|
-
assert.equal(partial.changed, false)
|
|
952
|
-
assert.equal(partial.key, baseline.key)
|
|
953
|
-
assert.equal(recovered.changed, false)
|
|
954
|
-
})
|
|
955
|
-
|
|
956
|
-
test('stall observation retains a genuine PR change made while queue is unreadable', () => {
|
|
957
|
-
const baseline = advanceStall(null, STALL_BASE_PR, STALL_BASE_QUEUE)
|
|
958
|
-
const changedPr = { ...STALL_BASE_PR, headRefOid: 'head-2' }
|
|
959
|
-
const changed = advanceStall(baseline.cache, changedPr, { readable: false })
|
|
960
|
-
const recovered = advanceStall(changed.cache, changedPr, STALL_BASE_QUEUE)
|
|
961
|
-
|
|
962
|
-
assert.equal(changed.changed, true)
|
|
963
|
-
assert.equal(changed.cache.pr.headRefOid, 'head-2')
|
|
964
|
-
assert.equal(recovered.changed, false)
|
|
965
|
-
assert.equal(recovered.key, changed.key)
|
|
966
|
-
})
|
|
967
|
-
|
|
968
|
-
test('stall observation retains a genuine queue change made while PR is unreadable', () => {
|
|
969
|
-
const baseline = advanceStall(null, STALL_BASE_PR, STALL_BASE_QUEUE)
|
|
970
|
-
const changedQueue = { ...STALL_BASE_QUEUE, position: 2 }
|
|
971
|
-
const changed = advanceStall(baseline.cache, { readable: false }, changedQueue)
|
|
972
|
-
const recovered = advanceStall(changed.cache, STALL_BASE_PR, changedQueue)
|
|
973
|
-
|
|
974
|
-
assert.equal(changed.changed, true)
|
|
975
|
-
assert.equal(changed.cache.queue.position, 2)
|
|
976
|
-
assert.equal(recovered.changed, false)
|
|
977
|
-
assert.equal(recovered.key, changed.key)
|
|
978
|
-
})
|
|
979
|
-
|
|
980
|
-
test('stall observation still detects later fully readable changes', () => {
|
|
981
|
-
const baseline = advanceStall(null, STALL_BASE_PR, STALL_BASE_QUEUE)
|
|
982
|
-
const partial = advanceStall(baseline.cache, STALL_BASE_PR, { readable: false })
|
|
983
|
-
const recovered = advanceStall(partial.cache, STALL_BASE_PR, STALL_BASE_QUEUE)
|
|
984
|
-
const laterChange = advanceStall(
|
|
985
|
-
recovered.cache,
|
|
986
|
-
{ ...STALL_BASE_PR, pendingChecks: [] },
|
|
987
|
-
{ ...STALL_BASE_QUEUE, queueState: 'ready', position: 1 },
|
|
988
|
-
)
|
|
989
|
-
|
|
990
|
-
assert.equal(laterChange.changed, true)
|
|
991
|
-
assert.notEqual(laterChange.key, recovered.key)
|
|
992
|
-
})
|
|
993
|
-
|
|
994
|
-
test('stall verdict is based on last observable change', () => {
|
|
995
|
-
const now = 1000 * 60 * 60
|
|
996
|
-
assert.equal(stallVerdict({ lastChangeMs: now - 5 * 60_000, nowMs: now, stallMinutes: 30 }).stalled, false)
|
|
997
|
-
assert.equal(stallVerdict({ lastChangeMs: now - 31 * 60_000, nowMs: now, stallMinutes: 30 }).stalled, true)
|
|
998
|
-
})
|
|
999
|
-
|
|
1000
|
-
test('invalid stall bound does not instantly stall', () => {
|
|
1001
|
-
for (const stallMinutes of [0, -1, NaN, 'soon', undefined]) {
|
|
1002
|
-
assert.equal(stallVerdict({ lastChangeMs: 0, nowMs: 999999, stallMinutes }).stalled, false, String(stallMinutes))
|
|
1003
|
-
}
|
|
1004
|
-
})
|
|
1005
|
-
|
|
1006
|
-
test('pre-admission with a pending CI check uses the full bound: waiting on CI is not a dead admission path', () => {
|
|
1007
|
-
// 2026-10-16: fmm-express #1816, identity #1378 and kyc #283 were enqueued, their required CI
|
|
1008
|
-
// waited on busy runners, and each watch ended admission-stall after 3 minutes.
|
|
1009
|
-
assert.deepEqual(effectiveStallMinutes({ everActive: false, stallMinutes: 45, admissionStallMinutes: 3, ciPending: true }), { minutes: 45, phase: 'admission-ci' })
|
|
1010
|
-
assert.deepEqual(effectiveStallMinutes({ everActive: false, stallMinutes: 45, admissionStallMinutes: 3, ciPending: false }), { minutes: 3, phase: 'admission' })
|
|
1011
|
-
assert.deepEqual(effectiveStallMinutes({ everActive: true, stallMinutes: 45, admissionStallMinutes: 3, ciPending: true }), { minutes: 45, phase: 'queued' })
|
|
1012
|
-
})
|
|
1013
|
-
|
|
1014
|
-
test('effectiveStallMinutes is phase-aware: shorter pre-admission, full post-entry', () => {
|
|
1015
|
-
assert.deepEqual(
|
|
1016
|
-
effectiveStallMinutes({ everActive: false, stallMinutes: 10, admissionStallMinutes: 3 }),
|
|
1017
|
-
{ minutes: 3, phase: 'admission' },
|
|
1018
|
-
)
|
|
1019
|
-
assert.deepEqual(
|
|
1020
|
-
effectiveStallMinutes({ everActive: true, stallMinutes: 10, admissionStallMinutes: 3 }),
|
|
1021
|
-
{ minutes: 10, phase: 'queued' },
|
|
1022
|
-
)
|
|
1023
|
-
// A short --stall-minutes keeps both phases tight; admission never exceeds post-entry.
|
|
1024
|
-
assert.deepEqual(
|
|
1025
|
-
effectiveStallMinutes({ everActive: false, stallMinutes: 0.02, admissionStallMinutes: 3 }),
|
|
1026
|
-
{ minutes: 0.02, phase: 'admission' },
|
|
1027
|
-
)
|
|
1028
|
-
assert.deepEqual(
|
|
1029
|
-
effectiveStallMinutes({ everActive: true, stallMinutes: 0.02, admissionStallMinutes: 3 }),
|
|
1030
|
-
{ minutes: 0.02, phase: 'queued' },
|
|
1031
|
-
)
|
|
1032
|
-
})
|
|
1033
|
-
|
|
1034
|
-
test('readTimeoutForArgs tracks the phase stall bound, not a permanent min of both', () => {
|
|
1035
|
-
// Pre-admission: clamp to admission (3m) so a hung poll cannot outlive admission-stall.
|
|
1036
|
-
// Post-entry: clamp to the full stall (10m) — permanently min(admission, stall) would leave
|
|
1037
|
-
// every post-entry poll clamped to 3m forever, which is not the phase-aware design.
|
|
1038
|
-
const pre = readTimeoutForArgs({
|
|
1039
|
-
intervalSeconds: 90,
|
|
1040
|
-
stallMinutes: 10,
|
|
1041
|
-
admissionStallMinutes: 3,
|
|
1042
|
-
maxMinutes: 60,
|
|
1043
|
-
everActive: false,
|
|
1044
|
-
})
|
|
1045
|
-
const post = readTimeoutForArgs({
|
|
1046
|
-
intervalSeconds: 90,
|
|
1047
|
-
stallMinutes: 10,
|
|
1048
|
-
admissionStallMinutes: 3,
|
|
1049
|
-
maxMinutes: 60,
|
|
1050
|
-
everActive: true,
|
|
1051
|
-
})
|
|
1052
|
-
assert.equal(pre, readTimeoutMs(90, 3, 2))
|
|
1053
|
-
assert.equal(post, readTimeoutMs(90, 10, 2))
|
|
1054
|
-
assert.ok(post > pre, 'post-entry reads must be allowed the longer phase bound')
|
|
1055
|
-
})
|
|
1056
|
-
|
|
1057
|
-
// ---- issue #611 defect 2: a hung child must not outlive the poll bounds ----
|
|
1058
|
-
|
|
1059
|
-
test('readTimeoutMs scales with the poll interval and floors at 10s', () => {
|
|
1060
|
-
assert.equal(readTimeoutMs(30), 60_000)
|
|
1061
|
-
assert.equal(readTimeoutMs(2), 10_000, 'a tiny interval still gets a sane floor, not a near-instant timeout')
|
|
1062
|
-
assert.equal(readTimeoutMs(undefined), readTimeoutMs(DEFAULT_INTERVAL_SECONDS), 'a missing interval falls back to the documented default')
|
|
1063
|
-
})
|
|
1064
|
-
|
|
1065
|
-
test('readTimeoutMs clamps to boundMinutes so a single read cannot outlive the stall/ceiling bound it sits inside of', () => {
|
|
1066
|
-
assert.equal(readTimeoutMs(30, 0.01), 600, 'a 0.01-minute (600ms) bound wins over the usual 60s computed timeout')
|
|
1067
|
-
assert.equal(readTimeoutMs(2, 1), 10_000, 'a generous bound (60s) never shrinks a timeout that is already under it')
|
|
1068
|
-
assert.equal(readTimeoutMs(30, 0), 60_000, 'a non-positive bound is ignored, not treated as an instant timeout')
|
|
1069
|
-
assert.equal(readTimeoutMs(30, -5), 60_000)
|
|
1070
|
-
assert.equal(readTimeoutMs(30, NaN), 60_000)
|
|
1071
|
-
})
|
|
1072
|
-
|
|
1073
|
-
// ---- issue #615 item 1: the clamp must divide by reads-per-poll, not clamp to the whole bound ----
|
|
1074
|
-
//
|
|
1075
|
-
// #612's clamp bounded a single read's timeout to `min(--stall-minutes, --max-minutes)`, but
|
|
1076
|
-
// this watcher makes TWO reads per poll (`gh pr view` + `mergify queue show`) and the bounds
|
|
1077
|
-
// are only evaluated after a poll returns — so a single poll could still take up to
|
|
1078
|
-
// `2 x bound` before the loop ever noticed, which the round-2 reviewer measured live as 58s
|
|
1079
|
-
// against a 12s stall bound (4.8x overrun). Dividing the per-read clamp by the reads-per-poll
|
|
1080
|
-
// count keeps a poll's reads, summed, inside the bound.
|
|
1081
|
-
|
|
1082
|
-
test('readTimeoutMs divides the clamp by readsPerPoll, so N reads together cannot outlive the bound', () => {
|
|
1083
|
-
assert.equal(readTimeoutMs(30, 1, 2), 30_000, 'a 1-minute bound with 2 reads per poll clamps each read to half the bound')
|
|
1084
|
-
assert.equal(readTimeoutMs(30, 1), 60_000, 'omitting readsPerPoll keeps the pre-existing single-read behaviour (divide by 1)')
|
|
1085
|
-
assert.equal(readTimeoutMs(2, 1, 4), 10_000, 'the 10s floor still wins over an even smaller divided clamp')
|
|
1086
|
-
assert.equal(readTimeoutMs(30, 0, 2), 60_000, 'a non-positive bound is still ignored regardless of readsPerPoll')
|
|
1087
|
-
assert.equal(readTimeoutMs(30, 1, 0), 60_000, 'a non-positive readsPerPoll falls back to 1 rather than dividing by zero')
|
|
1088
|
-
})
|
|
1089
|
-
|
|
1090
|
-
test('regression (issue #615 item 1): a hung read must not consume the whole stall bound when two reads happen per poll', async () => {
|
|
1091
|
-
// Mirrors the round-2 reviewer's live repro (58s against a 12s stall bound), scaled down to
|
|
1092
|
-
// finish quickly. Both `gh` and `mergify` hang well past either candidate per-read clamp
|
|
1093
|
-
// (600ms fixed vs 1200ms unfixed), so the timeout always cuts them off — the assertion is
|
|
1094
|
-
// on the RESULTING WALL-CLOCK TIME to reach STALLED, not on which code path was taken.
|
|
1095
|
-
// With the item-1 fix, each read is clamped to half the 1200ms stall bound (~600ms), so two
|
|
1096
|
-
// polls' worth of hung reads settle STALLED in a measured, stable ~2.52s across repeat runs.
|
|
1097
|
-
// Reverting the `/ readsPerPoll` division (or the `READS_PER_POLL` wiring at the call site)
|
|
1098
|
-
// clamps each read to the whole 1200ms bound instead, measured equally stable at ~4.92s —
|
|
1099
|
-
// confirmed by running both variants 3x each before picking the threshold below, so it sits
|
|
1100
|
-
// with real margin on both sides rather than a loose bound that passes either way.
|
|
1101
|
-
await withFakeBinsOnPath(async (dir) => {
|
|
1102
|
-
makeFakeBin(dir, 'gh', 'sleep 8\nexit 1')
|
|
1103
|
-
makeFakeBin(dir, 'mergify', 'sleep 8\nexit 1')
|
|
1104
|
-
const startedMs = Date.now()
|
|
1105
|
-
const { code } = await runCliCapturingLastPayload([
|
|
1106
|
-
'--repo', 'o/r', '--pr', '1',
|
|
1107
|
-
'--interval-seconds', '0.1', '--stall-minutes', '0.02', '--max-minutes', '1',
|
|
1108
|
-
])
|
|
1109
|
-
const elapsedMs = Date.now() - startedMs
|
|
1110
|
-
assert.equal(code, 4)
|
|
1111
|
-
assert.ok(
|
|
1112
|
-
elapsedMs < 3800,
|
|
1113
|
-
`expected two hung-read polls to settle STALLED in ~2.5s (measured stable, fixed); reverting the readsPerPoll division measures ~4.9s (also stable) — well over this threshold, got ${elapsedMs}ms`,
|
|
1114
|
-
)
|
|
1115
|
-
})
|
|
1116
|
-
})
|
|
1117
|
-
|
|
1118
|
-
test('regression (issue #615 item 2): a clean, unchanging read settles STALLED with reason stall-bound, not just unreadable', () => {
|
|
1119
|
-
// Round-2 NOTE-4: the `readable ? 'stall-bound' : 'unreadable'` branch at the STALLED site
|
|
1120
|
-
// had only its `unreadable` side exercised. This drives the loop with reads that succeed
|
|
1121
|
-
// every poll and report a stable (never-queued) snapshot, so the watcher stalls on a clean
|
|
1122
|
-
// read rather than an unreadable one.
|
|
1123
|
-
return withFakeBinsOnPath(async (dir) => {
|
|
1124
|
-
makeFakeBin(dir, 'gh', 'echo \'{"state":"OPEN","mergedAt":null,"mergeCommit":null,"labels":[],"url":"https://x"}\'')
|
|
1125
|
-
makeFakeBin(dir, 'mergify', 'echo \'{"queued":false}\'')
|
|
1126
|
-
// Bounds are deliberately not the smallest that would work. `readTimeoutMs` clamps each
|
|
1127
|
-
// read to `min(--stall-minutes, --max-minutes) / READS_PER_POLL`, so on this watcher the
|
|
1128
|
-
// per-read timeout is exactly **half the stall bound** — the two are the same number, and a
|
|
1129
|
-
// "fast" bound silently buys a fragile test. At the original `--stall-minutes 0.01` each
|
|
1130
|
-
// read got 300ms, which is under the spawn jitter of `sh` + `echo` when `node --test` is
|
|
1131
|
-
// running ~77 files in parallel: the fake `gh` was killed, `readable` flipped false, and
|
|
1132
|
-
// both assertions below failed. Measured 2 of 6 full-gate runs red (#630).
|
|
1133
|
-
//
|
|
1134
|
-
// 0.05min gives each read 1500ms — 5x the headroom — for 3s of runtime, and the interval is
|
|
1135
|
-
// 10x longer so the loop spawns ~12 children instead of ~120, which is itself less to
|
|
1136
|
-
// schedule. If you tighten these, work out the derived timeout first: it is bound/2 here,
|
|
1137
|
-
// bound/1 on the sibling watcher.
|
|
1138
|
-
const { code, payload } = await runCliCapturingLastPayload([
|
|
1139
|
-
'--repo', 'o/r', '--pr', '1',
|
|
1140
|
-
'--interval-seconds', '0.5', '--stall-minutes', '0.05', '--max-minutes', '0.5',
|
|
1141
|
-
])
|
|
1142
|
-
assert.equal(code, 4)
|
|
1143
|
-
assert.equal(payload.outcome, 'STALLED')
|
|
1144
|
-
assert.equal(payload.pr.readable, true)
|
|
1145
|
-
assert.equal(payload.queue.readable, true)
|
|
1146
|
-
assert.equal(payload.reason, 'admission-stall', 'a stable, readable never-admitted snapshot must settle as admission-stall, not unreadable or post-entry stall-bound')
|
|
1147
|
-
})
|
|
1148
|
-
})
|
|
1149
|
-
|
|
1150
|
-
test('an admitted then frozen watch settles STALLED with reason stall-bound, not admission-stall', async () => {
|
|
1151
|
-
// Phase-aware bound: once everActive, the post-entry --stall-minutes applies and the reason
|
|
1152
|
-
// must not be the pre-admission class — callers can distinguish dead admission from a wedged
|
|
1153
|
-
// queue without reconstructing phases alone.
|
|
1154
|
-
await withFakeBinsOnPath(async (dir) => {
|
|
1155
|
-
makeFakeBin(dir, 'gh', 'echo \'{"state":"OPEN","mergedAt":null,"mergeCommit":null,"labels":[{"name":"queued"}],"url":"https://x","comments":[]}\'')
|
|
1156
|
-
makeFakeBin(dir, 'mergify', 'echo \'{"queue_rule_name":"default","position":1,"status":"waiting_for_batch"}\'')
|
|
1157
|
-
const { code, payload } = await runCliCapturingLastPayload([
|
|
1158
|
-
'--repo', 'o/r', '--pr', '1',
|
|
1159
|
-
'--interval-seconds', '0.5', '--stall-minutes', '0.05', '--admission-stall-minutes', '0.05', '--max-minutes', '0.5',
|
|
1160
|
-
])
|
|
1161
|
-
assert.equal(code, 4)
|
|
1162
|
-
assert.equal(payload.outcome, 'STALLED')
|
|
1163
|
-
assert.equal(payload.everActive, true)
|
|
1164
|
-
assert.equal(payload.reason, 'stall-bound', 'post-entry freeze must report stall-bound, not admission-stall')
|
|
1165
|
-
assert.ok(payload.phases.includes('QUEUED'), `phases must record admission: ${JSON.stringify(payload.phases)}`)
|
|
1166
|
-
})
|
|
1167
|
-
})
|
|
1168
|
-
|
|
1169
|
-
test('pre-admission watch uses the short admission bound, not the full post-entry stall', async () => {
|
|
1170
|
-
// With admission=0.05m and stall=5m, a never-admitted PR must hand back on the short bound
|
|
1171
|
-
// (seconds), not burn the 5-minute post-entry clock. Wall time is the signal that the phase
|
|
1172
|
-
// switch is live in the loop, not only in the pure helper.
|
|
1173
|
-
await withFakeBinsOnPath(async (dir) => {
|
|
1174
|
-
makeFakeBin(dir, 'gh', 'echo \'{"state":"OPEN","mergedAt":null,"mergeCommit":null,"labels":[],"url":"https://x","comments":[]}\'')
|
|
1175
|
-
makeFakeBin(dir, 'mergify', 'echo \'{"queued":false}\'')
|
|
1176
|
-
const startedMs = Date.now()
|
|
1177
|
-
const { code, payload } = await runCliCapturingLastPayload([
|
|
1178
|
-
'--repo', 'o/r', '--pr', '1',
|
|
1179
|
-
'--interval-seconds', '0.2',
|
|
1180
|
-
'--stall-minutes', '5',
|
|
1181
|
-
'--admission-stall-minutes', '0.05',
|
|
1182
|
-
'--max-minutes', '5',
|
|
1183
|
-
])
|
|
1184
|
-
const elapsedMs = Date.now() - startedMs
|
|
1185
|
-
assert.equal(code, 4)
|
|
1186
|
-
assert.equal(payload.outcome, 'STALLED')
|
|
1187
|
-
assert.equal(payload.reason, 'admission-stall')
|
|
1188
|
-
assert.deepEqual(payload.phases, ['NOT_YET_QUEUED', 'STALLED'])
|
|
1189
|
-
assert.ok(
|
|
1190
|
-
elapsedMs < 15_000,
|
|
1191
|
-
`dead admission must hand back on the short bound (~3s), not the 5m post-entry stall; got ${elapsedMs}ms`,
|
|
1192
|
-
)
|
|
1193
|
-
})
|
|
1194
|
-
})
|
|
1195
|
-
|
|
1196
|
-
test('regression (issue #615 items 3-4): gh exiting 0 with empty stdout is not manufactured into a readable observation', async () => {
|
|
1197
|
-
// Round-2 NOTE-1: `prObservation` called the bare `parseJson(result.stdout)`, and
|
|
1198
|
-
// `parseJson` was `JSON.parse(text || '{}')` — an empty string parsed to a truthy `{}`,
|
|
1199
|
-
// manufacturing a `readable: true` observation from a `gh` that printed nothing. Unreachable
|
|
1200
|
-
// via any current caller (a failed/timed-out `gh` is already caught by `if (!result.ok)`
|
|
1201
|
-
// one line earlier), but the same invariant class BLOCK-1 closed for `queueObservation`.
|
|
1202
|
-
await withFakeBinsOnPath(async (dir) => {
|
|
1203
|
-
makeFakeBin(dir, 'gh', 'exit 0')
|
|
1204
|
-
makeFakeBin(dir, 'mergify', 'echo \'{"queued":false}\'\nexit 0')
|
|
1205
|
-
const { code, payload } = await runCliCapturingLastPayload([
|
|
1206
|
-
'--repo', 'o/r', '--pr', '1',
|
|
1207
|
-
'--interval-seconds', '0.05', '--stall-minutes', '0.02', '--max-minutes', '0.05',
|
|
1208
|
-
])
|
|
1209
|
-
assert.equal(code, 4)
|
|
1210
|
-
assert.equal(payload.pr.readable, false, 'gh exiting 0 with empty stdout must not be treated as a readable, empty-but-valid observation')
|
|
1211
|
-
})
|
|
1212
|
-
})
|
|
1213
|
-
|
|
1214
|
-
test('run() bounds a hung child on the timeout instead of blocking forever', () => {
|
|
1215
|
-
const startedMs = Date.now()
|
|
1216
|
-
const result = run('sleep', ['5'], { timeout: 200 })
|
|
1217
|
-
const elapsedMs = Date.now() - startedMs
|
|
1218
|
-
assert.equal(result.ok, false)
|
|
1219
|
-
assert.ok(elapsedMs < 4000, `expected the timeout to cut the 5s sleep short, took ${elapsedMs}ms`)
|
|
1220
|
-
})
|
|
1221
|
-
|
|
1222
|
-
test('Mergify queue query is scoped to the requested repository', () => {
|
|
1223
|
-
assert.deepEqual(
|
|
1224
|
-
mergifyQueueShowArgs({ repo: '1aboveio/skyee-ai-risk', pr: 1302 }),
|
|
1225
|
-
['queue', '--repository', '1aboveio/skyee-ai-risk', 'show', '1302', '--json'],
|
|
1226
|
-
)
|
|
1227
|
-
})
|
|
1228
|
-
|
|
1229
|
-
test('CLI help documents the dequeue contract and exits 0', async () => {
|
|
1230
|
-
const lines = []
|
|
1231
|
-
const original = console.log
|
|
1232
|
-
console.log = (value) => lines.push(String(value))
|
|
1233
|
-
try {
|
|
1234
|
-
assert.equal(await runCli(['--help']), 0)
|
|
1235
|
-
} finally {
|
|
1236
|
-
console.log = original
|
|
1237
|
-
}
|
|
1238
|
-
const help = lines.join('\n')
|
|
1239
|
-
assert.match(help, /DEQUEUED/)
|
|
1240
|
-
assert.match(help, /queued:false is a queue exit/)
|
|
1241
|
-
assert.match(help, /ancestry proof/)
|
|
1242
|
-
})
|
|
1243
|
-
|
|
1244
|
-
test('CLI validates required arguments and numeric bounds', async () => {
|
|
1245
|
-
assert.equal(await runCli(['--repo', 'owner/repo']), 2)
|
|
1246
|
-
assert.equal(await runCli(['--pr', '1']), 2)
|
|
1247
|
-
// A URL, `#n`, or a full repo URL is refused before any read, not watched into STALLED.
|
|
1248
|
-
for (const pr of ['https://github.com/owner/repo/pull/1', '#1', '1a']) assert.equal(await runCli(['--repo', 'owner/repo', '--pr', pr]), 2)
|
|
1249
|
-
assert.equal(await runCli(['--repo', 'https://github.com/owner/repo', '--pr', '1']), 2)
|
|
1250
|
-
assert.equal(await runCli(['--repo', 'owner/repo', '--pr', '1', '--interval-seconds', '0']), 2)
|
|
1251
|
-
assert.equal(await runCli(['--repo', 'owner/repo', '--pr', '1', '--stall-minutes', '-1']), 2)
|
|
1252
|
-
assert.equal(await runCli(['--repo', 'owner/repo', '--pr', '1', '--admission-stall-minutes', '0']), 2)
|
|
1253
|
-
assert.equal(await runCli(['--repo', 'owner/repo', '--pr', '1', '--max-minutes', 'later']), 2)
|
|
1254
|
-
})
|
|
1255
|
-
|
|
1256
|
-
// ---- the silent-watcher defect: a non-terminal watch must not write zero bytes ----
|
|
1257
|
-
//
|
|
1258
|
-
// Reproduced live on PR #1396 (1aboveio/skyee-ai-risk): the loop's only output sites were the
|
|
1259
|
-
// terminal verdicts, so a 25-minute watch of a never-queued PR produced a log containing
|
|
1260
|
-
// nothing but the supervisor's own spawn banner. The caller cannot tell that from a hung or
|
|
1261
|
-
// dead process — and the process was in fact working correctly the whole time. These tests pin
|
|
1262
|
-
// the heartbeat at the LOOP level, because the defect was never in a helper: `progressRecord`
|
|
1263
|
-
// could be perfect and unused and the watcher would still be mute.
|
|
1264
|
-
|
|
1265
|
-
test('every non-terminal poll emits progress on stderr and one terminal verdict on stdout', async () => {
|
|
1266
|
-
await withFakeBinsOnPath(async (dir) => {
|
|
1267
|
-
// NOT_YET_QUEUED forever — the exact #1396 shape: open, unlabelled, never in the queue.
|
|
1268
|
-
makeFakeGhReturning(dir, { state: 'OPEN', mergedAt: null, mergeCommit: null, labels: [], url: 'https://x', comments: [] })
|
|
1269
|
-
makeFakeBin(dir, 'mergify', 'echo \'{"queued":false}\'')
|
|
1270
|
-
const { code, payload, stdoutLines, progressLines } = await runCliCapturingLastPayload([
|
|
1271
|
-
'--repo', 'o/r', '--pr', '1',
|
|
1272
|
-
'--interval-seconds', '0.05', '--stall-minutes', '0.05', '--max-minutes', '0.05',
|
|
1273
|
-
])
|
|
1274
|
-
assert.equal(code, 4)
|
|
1275
|
-
assert.equal(payload.outcome, 'STALLED')
|
|
1276
|
-
assert.ok(progressLines.length >= 1, 'a watch that polls at all must say so at least once, not run mute to its stall bound')
|
|
1277
|
-
|
|
1278
|
-
const first = JSON.parse(progressLines[0])
|
|
1279
|
-
assert.equal(first.progress, true, 'heartbeats must be distinguishable from the terminal verdict')
|
|
1280
|
-
assert.equal(first.terminal, false)
|
|
1281
|
-
assert.equal(first.outcome, 'NOT_YET_QUEUED', 'the heartbeat must carry the live state, not just a liveness ping')
|
|
1282
|
-
assert.equal(first.queue.queued, false)
|
|
1283
|
-
assert.equal(typeof first.sinceChangeMs, 'number', 'how long the picture has been frozen is the stall bound made visible')
|
|
1284
|
-
assert.match(first.at, /^\d{4}-\d{2}-\d{2}T/)
|
|
1285
|
-
assert.ok(!progressLines[0].includes('\n'), 'heartbeats are one NDJSON record per line, not pretty-printed')
|
|
1286
|
-
|
|
1287
|
-
// stdout is the machine contract: callers `JSON.parse` it whole. Interleaving heartbeats
|
|
1288
|
-
// there would break every one of them, so the stream split is load-bearing, not cosmetic.
|
|
1289
|
-
assert.equal(stdoutLines.length, 1, `stdout must carry exactly the terminal verdict: ${JSON.stringify(stdoutLines)}`)
|
|
1290
|
-
assert.doesNotThrow(() => JSON.parse(stdoutLines[0]), 'stdout must remain parseable as one whole JSON document')
|
|
1291
|
-
})
|
|
1292
|
-
})
|
|
1293
|
-
|
|
1294
|
-
test('--quiet suppresses the heartbeat but never the terminal verdict', async () => {
|
|
1295
|
-
await withFakeBinsOnPath(async (dir) => {
|
|
1296
|
-
makeFakeGhReturning(dir, { state: 'OPEN', mergedAt: null, mergeCommit: null, labels: [], url: 'https://x', comments: [] })
|
|
1297
|
-
makeFakeBin(dir, 'mergify', 'echo \'{"queued":false}\'')
|
|
1298
|
-
const { code, payload, progressLines } = await runCliCapturingLastPayload([
|
|
1299
|
-
'--repo', 'o/r', '--pr', '1', '--quiet',
|
|
1300
|
-
'--interval-seconds', '0.05', '--stall-minutes', '0.05', '--max-minutes', '0.05',
|
|
1301
|
-
])
|
|
1302
|
-
const records = progressLines.map((line) => JSON.parse(line))
|
|
1303
|
-
assert.equal(records.filter((record) => record.progress).length, 0, 'no heartbeat under --quiet')
|
|
1304
|
-
assert.deepEqual(records.map((record) => Object.keys(record)[0]), ['ciProfileSummary'], 'only the closing profile line')
|
|
1305
|
-
assert.equal(code, 4)
|
|
1306
|
-
assert.equal(payload.outcome, 'STALLED', 'opting out of progress must not opt out of the answer')
|
|
1307
|
-
})
|
|
1308
|
-
})
|
|
1309
|
-
|
|
1310
|
-
test('a queued PR heartbeats its queue position, so a healthy slow queue is visibly alive', async () => {
|
|
1311
|
-
// The other half of #1396: after the enqueue landed, the PR sat legitimately QUEUED at
|
|
1312
|
-
// position 0 for minutes. QUEUED is non-terminal, so that healthy progress was just as
|
|
1313
|
-
// invisible as the unqueued case.
|
|
1314
|
-
await withFakeBinsOnPath(async (dir) => {
|
|
1315
|
-
makeFakeGhReturning(dir, { state: 'OPEN', mergedAt: null, mergeCommit: null, labels: [{ name: 'queued' }], url: 'https://x', comments: [] })
|
|
1316
|
-
// The real `mergify queue show --json` shape for a QUEUED PR carries no `queued` field at
|
|
1317
|
-
// all — membership is inferred from `queue_rule_name`. Verified live against PR #1396.
|
|
1318
|
-
makeFakeBin(dir, 'mergify', 'echo \'{"number":1,"position":0,"queue_rule_name":"main","queued_at":"2026-07-29T06:18:48Z"}\'')
|
|
1319
|
-
const { progressLines } = await runCliCapturingLastPayload([
|
|
1320
|
-
'--repo', 'o/r', '--pr', '1',
|
|
1321
|
-
'--interval-seconds', '0.05', '--stall-minutes', '0.05', '--max-minutes', '0.05',
|
|
1322
|
-
])
|
|
1323
|
-
const first = JSON.parse(progressLines[0])
|
|
1324
|
-
assert.equal(first.outcome, 'QUEUED')
|
|
1325
|
-
assert.equal(first.queue.queued, true, 'a payload with queue_rule_name but no `queued` field is still queue membership')
|
|
1326
|
-
assert.equal(first.queue.position, 0)
|
|
1327
|
-
assert.equal(first.seenQueued, true)
|
|
1328
|
-
})
|
|
1329
|
-
})
|
|
1330
|
-
|
|
1331
|
-
test('the heartbeat reports whether the picture changed since the last poll', async () => {
|
|
1332
|
-
await withFakeBinsOnPath(async (dir) => {
|
|
1333
|
-
makeFakeGhReturning(dir, { state: 'OPEN', mergedAt: null, mergeCommit: null, labels: [], url: 'https://x', comments: [] })
|
|
1334
|
-
makeFakeBin(dir, 'mergify', 'echo \'{"queued":false}\'')
|
|
1335
|
-
const { progressLines } = await runCliCapturingLastPayload([
|
|
1336
|
-
'--repo', 'o/r', '--pr', '1',
|
|
1337
|
-
'--interval-seconds', '0.01', '--stall-minutes', '0.05', '--max-minutes', '0.05',
|
|
1338
|
-
])
|
|
1339
|
-
const records = progressLines.map((line) => JSON.parse(line)).filter((record) => record.progress === true)
|
|
1340
|
-
assert.ok(records.length >= 2, 'need at least two polls to compare')
|
|
1341
|
-
assert.equal(records[0].changed, true, 'the first observation is a change from nothing')
|
|
1342
|
-
assert.equal(records[records.length - 1].changed, false, 'an unchanging queue picture must report itself as unchanged')
|
|
1343
|
-
assert.ok(
|
|
1344
|
-
records[records.length - 1].sinceChangeMs >= records[1].sinceChangeMs,
|
|
1345
|
-
'sinceChangeMs is the stall clock, so it must climb while nothing moves',
|
|
1346
|
-
)
|
|
1347
|
-
})
|
|
1348
|
-
})
|
|
1349
|
-
|
|
1350
|
-
test('the live stall clock survives an unreadable poll and readable recovery', async () => {
|
|
1351
|
-
await withFakeBinsOnPath(async (dir) => {
|
|
1352
|
-
const ghCount = join(dir, 'gh-count')
|
|
1353
|
-
const queueCount = join(dir, 'queue-count')
|
|
1354
|
-
makeFakeBin(dir, 'gh', [
|
|
1355
|
-
`count=$(cat '${ghCount}' 2>/dev/null || echo 0)`,
|
|
1356
|
-
'count=$((count + 1))',
|
|
1357
|
-
`echo "$count" > '${ghCount}'`,
|
|
1358
|
-
'[ "$count" -eq 2 ] && exit 1',
|
|
1359
|
-
'echo \'{"state":"OPEN","mergedAt":null,"mergeCommit":null,"headRefOid":"head-1","labels":[],"url":"https://x","comments":[]}\'',
|
|
1360
|
-
].join('\n'))
|
|
1361
|
-
makeFakeBin(dir, 'mergify', [
|
|
1362
|
-
`count=$(cat '${queueCount}' 2>/dev/null || echo 0)`,
|
|
1363
|
-
'count=$((count + 1))',
|
|
1364
|
-
`echo "$count" > '${queueCount}'`,
|
|
1365
|
-
'[ "$count" -eq 2 ] && exit 1',
|
|
1366
|
-
'echo \'{"queued":false,"status":"not-queued"}\'',
|
|
1367
|
-
].join('\n'))
|
|
1368
|
-
|
|
1369
|
-
const { progressLines } = await runCliCapturingLastPayload([
|
|
1370
|
-
'--repo', 'o/r', '--pr', '1',
|
|
1371
|
-
'--interval-seconds', '0.02', '--stall-minutes', '0.02', '--max-minutes', '0.04',
|
|
1372
|
-
])
|
|
1373
|
-
assert.ok(progressLines.length >= 3, 'need baseline, unreadable, and recovered polls')
|
|
1374
|
-
const records = progressLines.slice(0, 3).map((line) => JSON.parse(line))
|
|
1375
|
-
assert.deepEqual(records.map(({ changed }) => changed), [true, false, false])
|
|
1376
|
-
assert.equal(records[1].pr.readable, false)
|
|
1377
|
-
assert.equal(records[1].queue.readable, false)
|
|
1378
|
-
assert.ok(
|
|
1379
|
-
records[2].sinceChangeMs >= records[1].sinceChangeMs,
|
|
1380
|
-
'recovery to the retained baseline must not restart lastChangeMs',
|
|
1381
|
-
)
|
|
1382
|
-
})
|
|
1383
|
-
})
|
|
1384
|
-
|
|
1385
|
-
test('progressRecord marks the record and preserves the observation payload', () => {
|
|
1386
|
-
const payload = { outcome: 'QUEUED', terminal: false, queue: { queued: true, position: 2 } }
|
|
1387
|
-
const record = JSON.parse(progressRecord({ payload, changed: false, sinceChangeMs: 4_000, nowIso: '2026-07-29T06:20:00.000Z' }))
|
|
1388
|
-
assert.equal(record.progress, true)
|
|
1389
|
-
assert.equal(record.changed, false)
|
|
1390
|
-
assert.equal(record.sinceChangeMs, 4_000)
|
|
1391
|
-
assert.equal(record.at, '2026-07-29T06:20:00.000Z')
|
|
1392
|
-
assert.equal(record.outcome, 'QUEUED', 'the observation itself must survive intact')
|
|
1393
|
-
assert.deepEqual(record.queue, { queued: true, position: 2 })
|
|
1394
|
-
})
|
|
1395
|
-
|
|
1396
|
-
// ---- defaults ----
|
|
1397
|
-
|
|
1398
|
-
test('watch defaults are a 90s poll, a 3-minute admission stall, a 45-minute post-entry stall, and a 1-hour ceiling', () => {
|
|
1399
|
-
const args = parseArgs(['--repo', 'o/r', '--pr', '1'])
|
|
1400
|
-
assert.equal(args.intervalSeconds, 90)
|
|
1401
|
-
assert.equal(args.admissionStallMinutes, DEFAULT_ADMISSION_STALL_MINUTES)
|
|
1402
|
-
assert.equal(args.admissionStallMinutes, 3)
|
|
1403
|
-
assert.equal(args.stallMinutes, 45)
|
|
1404
|
-
assert.equal(args.maxMinutes, 60)
|
|
1405
|
-
assert.equal(args.quiet, undefined, 'progress is on by default — silence must be opted into, not out of')
|
|
1406
|
-
})
|
|
1407
|
-
|
|
1408
|
-
test('the default poll interval keeps both of a poll\'s reads inside the shortest default stall bound', () => {
|
|
1409
|
-
// The bounds are only evaluated after a poll RETURNS, so two hung reads must not be able to
|
|
1410
|
-
// outlive the stall bound they sit inside of — the #615 item 1 guarantee, re-pinned against
|
|
1411
|
-
// the phase-aware defaults (admission is the shortest and clamps the per-read timeout).
|
|
1412
|
-
const shortest = Math.min(DEFAULT_ADMISSION_STALL_MINUTES, DEFAULT_STALL_MINUTES, DEFAULT_MAX_MINUTES)
|
|
1413
|
-
const perRead = readTimeoutMs(DEFAULT_INTERVAL_SECONDS, shortest, 2)
|
|
1414
|
-
assert.ok(perRead * 2 <= shortest * 60_000, `two reads (${perRead * 2}ms) must fit inside the ${shortest}m shortest stall bound`)
|
|
1415
|
-
})
|
|
1416
|
-
|
|
1417
|
-
test('CLI help documents the heartbeat, the new defaults, and the enqueue-first precondition', async () => {
|
|
1418
|
-
const lines = []
|
|
1419
|
-
const original = console.log
|
|
1420
|
-
console.log = (value) => lines.push(String(value))
|
|
1421
|
-
try {
|
|
1422
|
-
assert.equal(await runCli(['--help']), 0)
|
|
1423
|
-
} finally {
|
|
1424
|
-
console.log = original
|
|
1425
|
-
}
|
|
1426
|
-
const help = lines.join('\n')
|
|
1427
|
-
assert.match(help, /--quiet/)
|
|
1428
|
-
assert.match(help, /stderr/)
|
|
1429
|
-
assert.match(help, /default 90/)
|
|
1430
|
-
assert.match(help, /default 45/)
|
|
1431
|
-
assert.match(help, /default 3/, 'pre-admission stall default must be documented')
|
|
1432
|
-
assert.match(help, /--admission-stall-minutes/)
|
|
1433
|
-
assert.match(help, /admission-stall/)
|
|
1434
|
-
assert.match(help, /While a check is pending, the\s+post-entry bound applies/, 'help says CI-gated admission needs no relaunch')
|
|
1435
|
-
assert.doesNotMatch(help, /relaunch/, 'no manual relaunch advice: the watcher handles CI-gated admission itself')
|
|
1436
|
-
assert.match(help, /Enqueue first/, 'the watch cannot enqueue, and a watch on an unqueued manual-delivery PR can only end STALLED')
|
|
1437
|
-
})
|
|
1438
|
-
|
|
1439
|
-
// ---- progression, not a single state poke (pi session 019fae8f) ----
|
|
1440
|
-
//
|
|
1441
|
-
// The incident these pin: an agent asked to "watch #1407" handed a background-task runner a
|
|
1442
|
-
// one-shot shell predicate — `state ∈ {MERGED, CLOSED}` — re-evaluated from scratch each tick.
|
|
1443
|
-
// That shape cannot see a dequeue at all (a dequeued PR is plain `OPEN`), and its successors
|
|
1444
|
-
// settled terminal on a SINGLE absent read while defaulting a failed `mergify` invocation to
|
|
1445
|
-
// `{"queued":false}`. It reported a mid-`checking` PR as DEQUEUED.
|
|
1446
|
-
//
|
|
1447
|
-
// `phases` is the progression made a first-class output, so "did this watch actually observe a
|
|
1448
|
-
// lifecycle, or just poke one state?" is answerable from the verdict rather than inferred.
|
|
1449
|
-
|
|
1450
|
-
test('nextPhases records a transition once, and repeats collapse', () => {
|
|
1451
|
-
let phases = []
|
|
1452
|
-
phases = nextPhases(phases, 'NOT_YET_QUEUED')
|
|
1453
|
-
phases = nextPhases(phases, 'NOT_YET_QUEUED')
|
|
1454
|
-
phases = nextPhases(phases, 'QUEUED')
|
|
1455
|
-
phases = nextPhases(phases, 'QUEUED')
|
|
1456
|
-
phases = nextPhases(phases, 'MERGED')
|
|
1457
|
-
assert.deepEqual(phases, ['NOT_YET_QUEUED', 'QUEUED', 'MERGED'])
|
|
1458
|
-
})
|
|
1459
|
-
|
|
1460
|
-
test('nextPhases ignores WAITING, so a re-read suspicion never oscillates the progression', () => {
|
|
1461
|
-
// WAITING is the missing-membership re-read state INSIDE queued (classifyObservation's
|
|
1462
|
-
// `queue-membership-missing-reread`). Recording it would turn a healthy queued PR into
|
|
1463
|
-
// QUEUED/WAITING/QUEUED/WAITING noise and destroy the signal this list exists for.
|
|
1464
|
-
let phases = nextPhases([], 'QUEUED')
|
|
1465
|
-
phases = nextPhases(phases, 'WAITING')
|
|
1466
|
-
phases = nextPhases(phases, 'QUEUED')
|
|
1467
|
-
assert.deepEqual(phases, ['QUEUED'])
|
|
1468
|
-
})
|
|
1469
|
-
|
|
1470
|
-
test('nextPhases ignores unknown outcomes and tolerates a non-array', () => {
|
|
1471
|
-
assert.deepEqual(nextPhases(['QUEUED'], 'SOMETHING_NEW'), ['QUEUED'])
|
|
1472
|
-
assert.deepEqual(nextPhases(undefined, 'QUEUED'), ['QUEUED'])
|
|
1473
|
-
assert.deepEqual(nextPhases(null, 'QUEUED'), ['QUEUED'])
|
|
1474
|
-
})
|
|
1475
|
-
|
|
1476
|
-
test('a never-admitted watch is distinguishable from an admitted-then-wedged one by phases alone', async () => {
|
|
1477
|
-
// Both end STALLED with the same exit code. Only the progression separates "nobody enqueued
|
|
1478
|
-
// it" (fix: enqueue) from "it entered the queue and wedged" (fix: read the blocking
|
|
1479
|
-
// condition) — the two diagnoses the pi session conflated.
|
|
1480
|
-
await withFakeBinsOnPath(async (dir) => {
|
|
1481
|
-
makeFakeGhReturning(dir, { state: 'OPEN', mergedAt: null, mergeCommit: null, labels: [], url: 'https://x', comments: [] })
|
|
1482
|
-
makeFakeBin(dir, 'mergify', 'echo \'{"queued":false}\'')
|
|
1483
|
-
// max-minutes must sit above the stall bound: the loop checks the absolute ceiling first, so
|
|
1484
|
-
// equal bounds always report max-minutes and hide the phase-aware reason this test pins.
|
|
1485
|
-
const { code, payload } = await runCliCapturingLastPayload([
|
|
1486
|
-
'--repo', 'o/r', '--pr', '1',
|
|
1487
|
-
'--interval-seconds', '0.05', '--stall-minutes', '0.05', '--admission-stall-minutes', '0.05', '--max-minutes', '0.5',
|
|
1488
|
-
])
|
|
1489
|
-
assert.equal(code, 4)
|
|
1490
|
-
assert.equal(payload.outcome, 'STALLED')
|
|
1491
|
-
assert.equal(payload.reason, 'admission-stall', 'never-admitted freeze is the short admission phase, not post-entry stall-bound')
|
|
1492
|
-
assert.deepEqual(payload.phases, ['NOT_YET_QUEUED', 'STALLED'], 'never admitted: the queue never saw this PR')
|
|
1493
|
-
assert.equal(payload.seenQueued, false)
|
|
1494
|
-
})
|
|
1495
|
-
})
|
|
1496
|
-
|
|
1497
|
-
test('an admitted-then-stalled watch carries the queue entry in its phases', async () => {
|
|
1498
|
-
await withFakeBinsOnPath(async (dir) => {
|
|
1499
|
-
makeFakeGhReturning(dir, { state: 'OPEN', mergedAt: null, mergeCommit: null, labels: [{ name: 'queued' }], url: 'https://x', comments: [] })
|
|
1500
|
-
makeFakeBin(dir, 'mergify', 'echo \'{"number":1,"position":0,"queue_rule_name":"main"}\'')
|
|
1501
|
-
const { code, payload } = await runCliCapturingLastPayload([
|
|
1502
|
-
'--repo', 'o/r', '--pr', '1',
|
|
1503
|
-
'--interval-seconds', '0.05', '--stall-minutes', '0.05', '--admission-stall-minutes', '0.05', '--max-minutes', '0.5',
|
|
1504
|
-
])
|
|
1505
|
-
assert.equal(code, 4)
|
|
1506
|
-
assert.equal(payload.outcome, 'STALLED')
|
|
1507
|
-
assert.equal(payload.reason, 'stall-bound', 'post-entry freeze is stall-bound, not admission-stall')
|
|
1508
|
-
assert.deepEqual(payload.phases, ['QUEUED', 'STALLED'], 'admitted, then wedged inside the queue')
|
|
1509
|
-
assert.equal(payload.seenQueued, true)
|
|
1510
|
-
})
|
|
1511
|
-
})
|
|
1512
|
-
|
|
1513
|
-
test('regression (pi session 019fae8f): a dequeue while the PR stays OPEN is observed as a transition, not a state', async () => {
|
|
1514
|
-
// The exact failure. `gh pr view` reports OPEN on EVERY poll — before entry, during
|
|
1515
|
-
// membership, and after the drop — so no snapshot of PR state distinguishes them. Only the
|
|
1516
|
-
// progression does. A watcher testing `state ∈ {MERGED, CLOSED}` runs to its ceiling here
|
|
1517
|
-
// and reports nothing wrong.
|
|
1518
|
-
await withFakeBinsOnPath(async (dir) => {
|
|
1519
|
-
makeFakeGhReturning(dir, { state: 'OPEN', mergedAt: null, mergeCommit: null, labels: [], url: 'https://x', comments: [] })
|
|
1520
|
-
// Poll 1: queued. Polls 2+: cleanly absent — two readable absences settle the dequeue.
|
|
1521
|
-
const counter = join(dir, 'poll-count')
|
|
1522
|
-
makeFakeBin(dir, 'mergify', [
|
|
1523
|
-
`n=$(cat ${counter} 2>/dev/null || echo 0)`,
|
|
1524
|
-
`echo $((n + 1)) > ${counter}`,
|
|
1525
|
-
'if [ "$n" -lt 1 ]; then',
|
|
1526
|
-
' echo \'{"number":1,"position":0,"queue_rule_name":"main"}\'',
|
|
1527
|
-
'else',
|
|
1528
|
-
' echo \'{"number":1,"queued":false}\'',
|
|
1529
|
-
'fi',
|
|
1530
|
-
].join('\n'))
|
|
1531
|
-
const { code, payload } = await runCliCapturingLastPayload([
|
|
1532
|
-
'--repo', 'o/r', '--pr', '1',
|
|
1533
|
-
'--interval-seconds', '0.01', '--stall-minutes', '5', '--max-minutes', '5',
|
|
1534
|
-
])
|
|
1535
|
-
assert.equal(code, 4)
|
|
1536
|
-
assert.equal(payload.outcome, 'DEQUEUED')
|
|
1537
|
-
assert.equal(payload.reason, 'lost-queue-membership')
|
|
1538
|
-
assert.equal(payload.pr.state, 'OPEN', 'the PR never left OPEN — a state poke has nothing to fire on')
|
|
1539
|
-
assert.deepEqual(payload.phases, ['QUEUED', 'DEQUEUED'], 'the verdict is the transition, and the transition is in the record')
|
|
1540
|
-
})
|
|
1541
|
-
})
|
|
1542
|
-
|
|
1543
|
-
test('the heartbeat carries the progression too, so a live watch is auditable mid-flight', async () => {
|
|
1544
|
-
await withFakeBinsOnPath(async (dir) => {
|
|
1545
|
-
makeFakeGhReturning(dir, { state: 'OPEN', mergedAt: null, mergeCommit: null, labels: [], url: 'https://x', comments: [] })
|
|
1546
|
-
makeFakeBin(dir, 'mergify', 'echo \'{"queued":false}\'')
|
|
1547
|
-
const { progressLines } = await runCliCapturingLastPayload([
|
|
1548
|
-
'--repo', 'o/r', '--pr', '1',
|
|
1549
|
-
'--interval-seconds', '0.05', '--stall-minutes', '0.05', '--max-minutes', '0.05',
|
|
1550
|
-
])
|
|
1551
|
-
assert.deepEqual(JSON.parse(progressLines[0]).phases, ['NOT_YET_QUEUED'])
|
|
1552
|
-
})
|
|
1553
|
-
})
|
|
1554
|
-
|
|
1555
|
-
test('CLI help states that this watches a progression and documents phases', async () => {
|
|
1556
|
-
const lines = []
|
|
1557
|
-
const original = console.log
|
|
1558
|
-
console.log = (value) => lines.push(String(value))
|
|
1559
|
-
try {
|
|
1560
|
-
assert.equal(await runCli(['--help']), 0)
|
|
1561
|
-
} finally {
|
|
1562
|
-
console.log = original
|
|
1563
|
-
}
|
|
1564
|
-
const help = lines.join('\n')
|
|
1565
|
-
assert.match(help, /progression/i, 'the one-line reason a hand-rolled state poke is wrong must survive in the help')
|
|
1566
|
-
assert.match(help, /phases/)
|
|
1567
|
-
})
|
|
1568
|
-
|
|
1569
|
-
// ---- the one-hour watch ceiling ----
|
|
1570
|
-
//
|
|
1571
|
-
// `--max-minutes` is the only bound standing between a busy-but-unfinishing queue and a watch
|
|
1572
|
-
// that outlives the session that launched it: `--stall-minutes` cannot catch that case by
|
|
1573
|
-
// design, because any movement resets it. So the ceiling is enforced rather than advised.
|
|
1574
|
-
|
|
1575
|
-
test('the default absolute ceiling is one hour, and it is the hard maximum too', () => {
|
|
1576
|
-
assert.equal(DEFAULT_MAX_MINUTES, 60)
|
|
1577
|
-
assert.equal(MAX_MINUTES_CEILING, 60)
|
|
1578
|
-
assert.equal(parseArgs(['--repo', 'o/r', '--pr', '1']).maxMinutes, MAX_MINUTES_CEILING,
|
|
1579
|
-
'the default must sit AT the ceiling — a default above it would make every no-flag run a usage error')
|
|
1580
|
-
})
|
|
1581
|
-
|
|
1582
|
-
test('--max-minutes above the ceiling is refused, not silently clamped', async () => {
|
|
1583
|
-
const errors = []
|
|
1584
|
-
const original = console.error
|
|
1585
|
-
console.error = (v) => errors.push(String(v))
|
|
1586
|
-
let code
|
|
1587
|
-
try {
|
|
1588
|
-
code = await runCli(['--repo', 'o/r', '--pr', '1', '--max-minutes', '240'])
|
|
1589
|
-
} finally {
|
|
1590
|
-
console.error = original
|
|
1591
|
-
}
|
|
1592
|
-
assert.equal(code, 2, 'an over-ceiling bound is a usage error')
|
|
1593
|
-
const message = errors.join('\n')
|
|
1594
|
-
assert.match(message, /240/, 'the message must quote what was asked for')
|
|
1595
|
-
assert.match(message, /60/, 'and the ceiling it exceeded')
|
|
1596
|
-
// Clamping is the tempting alternative and the wrong one: the caller would believe it had a
|
|
1597
|
-
// four-hour watch while getting one hour, and neither stream here can carry a warning
|
|
1598
|
-
// (stdout is exactly one terminal verdict, stderr is one NDJSON heartbeat per poll).
|
|
1599
|
-
assert.equal(errors.filter((l) => l.startsWith('{')).length, 0, 'the refusal must not be emitted as a heartbeat record')
|
|
1600
|
-
})
|
|
1601
|
-
|
|
1602
|
-
test('--max-minutes exactly at the ceiling is accepted', async () => {
|
|
1603
|
-
// Boundary: `>` not `>=`, so the documented maximum is usable rather than off-by-one.
|
|
1604
|
-
await withFakeBinsOnPath(async (dir) => {
|
|
1605
|
-
makeFakeGhReturning(dir, { state: 'MERGED', mergedAt: '2026-01-01T00:00:00Z', mergeCommit: { oid: 'abc123' }, labels: [], url: 'https://x', comments: [] })
|
|
1606
|
-
makeFakeBin(dir, 'mergify', 'echo \'{"queued":false}\'')
|
|
1607
|
-
const { code } = await runCliCapturingLastPayload(['--repo', 'o/r', '--pr', '1', '--max-minutes', String(MAX_MINUTES_CEILING)])
|
|
1608
|
-
assert.equal(code, 0)
|
|
1609
|
-
})
|
|
1610
|
-
})
|
|
1611
|
-
|
|
1612
|
-
test('CLI help documents the hard maximum, not just the default', async () => {
|
|
1613
|
-
const lines = []
|
|
1614
|
-
const original = console.log
|
|
1615
|
-
console.log = (value) => lines.push(String(value))
|
|
1616
|
-
try {
|
|
1617
|
-
assert.equal(await runCli(['--help']), 0)
|
|
1618
|
-
} finally {
|
|
1619
|
-
console.log = original
|
|
1620
|
-
}
|
|
1621
|
-
assert.match(lines.join('\n'), /hard maximum 60/,
|
|
1622
|
-
'a ceiling a caller only discovers by tripping it is a ceiling that reads as a bug')
|
|
1623
|
-
})
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
// ---- CI profiling ---------------------------------------------------------------------
|
|
1627
|
-
|
|
1628
|
-
const isoAt = (ms) => new Date(ms).toISOString()
|
|
1629
|
-
const T0 = Date.parse('2026-09-29T10:00:00Z')
|
|
1630
|
-
// An unfinished job is IN_PROGRESS with GitHub's 0001-01-01 completedAt sentinel, as real
|
|
1631
|
-
// rollups report it; a finished one is COMPLETED.
|
|
1632
|
-
const check = (name, startMin, endMin, extra = {}) => ({
|
|
1633
|
-
__typename: 'CheckRun', name, workflowName: 'CI',
|
|
1634
|
-
status: endMin === null ? 'IN_PROGRESS' : 'COMPLETED', conclusion: endMin === null ? null : 'SUCCESS',
|
|
1635
|
-
startedAt: isoAt(T0 + startMin * 60_000),
|
|
1636
|
-
completedAt: endMin === null ? '0001-01-01T00:00:00Z' : isoAt(T0 + endMin * 60_000),
|
|
1637
|
-
detailsUrl: `https://example.test/${name}`, ...extra,
|
|
1638
|
-
})
|
|
1639
|
-
|
|
1640
|
-
test('checkTimings times completed and still-running jobs and skips Mergify and untimed checks', () => {
|
|
1641
|
-
const jobs = checkTimings([
|
|
1642
|
-
check('unit', 0, 2),
|
|
1643
|
-
check('e2e', 0, null, { status: 'IN_PROGRESS', conclusion: null }),
|
|
1644
|
-
check('Mergify Merge Queue', 0, 30),
|
|
1645
|
-
{ __typename: 'StatusContext', context: 'legacy', state: 'SUCCESS' },
|
|
1646
|
-
], { source: 'pr', nowMs: T0 + 7 * 60_000 })
|
|
1647
|
-
assert.deepEqual(jobs.map((j) => [j.name, j.running, j.durationMs / 60_000]), [['unit', false, 2], ['e2e', true, 7]])
|
|
1648
|
-
assert.equal(jobs[1].completedAt, null)
|
|
1649
|
-
assert.equal(jobs[0].source, 'pr')
|
|
1650
|
-
})
|
|
1651
|
-
|
|
1652
|
-
test('a slow job is warned about once, even across polls and while still running', () => {
|
|
1653
|
-
const thresholdMs = 5 * 60_000
|
|
1654
|
-
const warned = new Set()
|
|
1655
|
-
let jobs = mergeCiJobs({}, checkTimings([check('e2e', 0, null)], { nowMs: T0 + 4 * 60_000 }))
|
|
1656
|
-
assert.equal(newSlowJobs(jobs, { thresholdMs, warned }).length, 0, 'under the threshold: no warning yet')
|
|
1657
|
-
jobs = mergeCiJobs(jobs, checkTimings([check('e2e', 0, null)], { nowMs: T0 + 6 * 60_000 }))
|
|
1658
|
-
const first = newSlowJobs(jobs, { thresholdMs, warned })
|
|
1659
|
-
assert.equal(first.length, 1, 'crossing the threshold while running warns')
|
|
1660
|
-
first.forEach(({ key }) => warned.add(key))
|
|
1661
|
-
jobs = mergeCiJobs(jobs, checkTimings([check('e2e', 0, 8)], { nowMs: T0 + 9 * 60_000 }))
|
|
1662
|
-
assert.equal(Object.keys(jobs).length, 1, 'the finished reading replaces the running one')
|
|
1663
|
-
assert.equal(newSlowJobs(jobs, { thresholdMs, warned }).length, 0, 'the same job run is not warned twice')
|
|
1664
|
-
const line = JSON.parse(slowJobWarning(first[0].job, { thresholdMs, nowIso: 'now' }))
|
|
1665
|
-
assert.equal(line.warning, 'slow-ci-job')
|
|
1666
|
-
assert.equal(line.thresholdSec, 300)
|
|
1667
|
-
})
|
|
1668
|
-
|
|
1669
|
-
test('exactly the threshold is not slow; one second over is', () => {
|
|
1670
|
-
const thresholdMs = 5 * 60_000
|
|
1671
|
-
const at = (sec) => mergeCiJobs({}, [{ source: 'pr', name: 'j', startedAt: 'x', durationMs: sec * 1000 }])
|
|
1672
|
-
assert.equal(newSlowJobs(at(300), { thresholdMs }).length, 0)
|
|
1673
|
-
assert.equal(newSlowJobs(at(301), { thresholdMs }).length, 1)
|
|
1674
|
-
})
|
|
1675
|
-
|
|
1676
|
-
test('ciProfile reports slow jobs slowest-first, a verdict, and how long each phase lasted', () => {
|
|
1677
|
-
const jobs = mergeCiJobs({}, checkTimings([check('unit', 0, 2), check('e2e', 0, 6), check('lint', 0, 1)], { nowMs: T0 + 10 * 60_000 }))
|
|
1678
|
-
const timings = nextPhaseTimings(nextPhaseTimings([], ['NOT_YET_QUEUED'], T0), ['NOT_YET_QUEUED', 'QUEUED'], T0 + 3 * 60_000)
|
|
1679
|
-
const profile = ciProfile(jobs, {
|
|
1680
|
-
thresholdMs: 5 * 60_000,
|
|
1681
|
-
phaseTimings: nextPhaseTimings(timings, ['NOT_YET_QUEUED', 'QUEUED', 'MERGED'], T0 + 10 * 60_000),
|
|
1682
|
-
startedMs: T0, endMs: T0 + 10 * 60_000,
|
|
1683
|
-
})
|
|
1684
|
-
assert.equal(profile.verdict, 'slow')
|
|
1685
|
-
assert.deepEqual(profile.jobs.map((j) => j.name), ['e2e', 'unit', 'lint'])
|
|
1686
|
-
assert.deepEqual(profile.slowJobs.map((j) => [j.name, j.durationSec]), [['e2e', 360]])
|
|
1687
|
-
assert.equal(profile.slowestJob.name, 'e2e')
|
|
1688
|
-
assert.deepEqual(profile.phases, [
|
|
1689
|
-
{ phase: 'NOT_YET_QUEUED', atSec: 0, durationSec: 180 },
|
|
1690
|
-
{ phase: 'QUEUED', atSec: 180, durationSec: 420 },
|
|
1691
|
-
{ phase: 'MERGED', atSec: 600, durationSec: null },
|
|
1692
|
-
])
|
|
1693
|
-
assert.equal(ciProfile({}, { thresholdMs: 1 }).verdict, 'no-timing-data')
|
|
1694
|
-
assert.equal(ciProfile(mergeCiJobs({}, checkTimings([check('unit', 0, 2)])), { thresholdMs: 5 * 60_000 }).verdict, 'ok')
|
|
1695
|
-
})
|
|
1696
|
-
|
|
1697
|
-
test('the slow-job threshold defaults to 5 minutes and is a validated flag', async () => {
|
|
1698
|
-
assert.equal(DEFAULT_SLOW_JOB_MINUTES, 5)
|
|
1699
|
-
assert.equal(parseArgs(['--repo', 'o/r', '--pr', '1']).slowJobMinutes, 5)
|
|
1700
|
-
assert.equal(parseArgs(['--slow-job-minutes', '8']).slowJobMinutes, 8)
|
|
1701
|
-
const originalError = console.error
|
|
1702
|
-
console.error = () => {}
|
|
1703
|
-
try {
|
|
1704
|
-
assert.equal(await runCli(['--repo', 'o/r', '--pr', '1', '--slow-job-minutes', '0']), 2)
|
|
1705
|
-
} finally {
|
|
1706
|
-
console.error = originalError
|
|
1707
|
-
}
|
|
1708
|
-
})
|
|
1709
|
-
|
|
1710
|
-
function mergedPrWithChecks(checks) {
|
|
1711
|
-
return {
|
|
1712
|
-
state: 'MERGED', mergedAt: isoAt(T0 + 20 * 60_000), mergeCommit: { oid: 'abc' }, headRefOid: 'h',
|
|
1713
|
-
labels: [], url: 'https://x', comments: [], statusCheckRollup: checks,
|
|
1714
|
-
}
|
|
1715
|
-
}
|
|
1716
|
-
|
|
1717
|
-
test('a watch warns on stderr for a CI job over the threshold and carries ciProfile on the verdict', async () => {
|
|
1718
|
-
await withFakeBinsOnPath(async (dir) => {
|
|
1719
|
-
makeFakeGhReturning(dir, mergedPrWithChecks([check('e2e', 0, 6), check('unit', 0, 2), check('Mergify Merge Queue', 0, 20)]))
|
|
1720
|
-
makeFakeBin(dir, 'mergify', 'echo \'{"queued":false}\'')
|
|
1721
|
-
const { code, payload, progressLines } = await runCliCapturingLastPayload(['--repo', 'o/r', '--pr', '1', '--quiet'])
|
|
1722
|
-
assert.equal(code, 0, 'profiling never changes the exit code')
|
|
1723
|
-
assert.equal(payload.outcome, 'MERGED')
|
|
1724
|
-
const warnings = progressLines.map((l) => JSON.parse(l)).filter((l) => l.warning === 'slow-ci-job')
|
|
1725
|
-
assert.deepEqual(warnings.map((w) => [w.name, w.durationSec]), [['e2e', 360]], 'warned once, even under --quiet')
|
|
1726
|
-
assert.equal(payload.ciProfile.verdict, 'slow')
|
|
1727
|
-
assert.deepEqual(payload.ciProfile.slowJobs.map((j) => j.name), ['e2e'])
|
|
1728
|
-
assert.ok(!payload.ciProfile.jobs.some((j) => j.name.startsWith('Mergify')), 'Mergify queue wait is not a CI job')
|
|
1729
|
-
const summary = JSON.parse(progressLines[progressLines.length - 1])
|
|
1730
|
-
assert.deepEqual(summary, {
|
|
1731
|
-
ciProfileSummary: true, outcome: 'MERGED', verdict: 'slow', jobCount: 2, slowJobMinutes: 5,
|
|
1732
|
-
slowest: 'e2e 360s', slowJobs: ['e2e 360s'],
|
|
1733
|
-
}, 'the last stderr line carries the profile, even under --quiet')
|
|
1734
|
-
})
|
|
1735
|
-
})
|
|
1736
|
-
|
|
1737
|
-
test('regression (identity #894): the profile summary is the last line of a short log tail', async () => {
|
|
1738
|
-
// Pi's bg_task_status reports only the exit code and bg_task_log tails 10 lines, which landed
|
|
1739
|
-
// inside the pretty-printed verdict's `phases`; the slow job was warned about and never read.
|
|
1740
|
-
await withFakeBinsOnPath(async (dir) => {
|
|
1741
|
-
makeFakeGhReturning(dir, mergedPrWithChecks([check('Vitest unit + integration tests', 0, 338 / 60), check('unit', 0, 2)]))
|
|
1742
|
-
makeFakeBin(dir, 'mergify', 'echo \'{"queued":false}\'')
|
|
1743
|
-
const combined = []
|
|
1744
|
-
const originalLog = console.log
|
|
1745
|
-
const originalError = console.error
|
|
1746
|
-
console.log = (value) => combined.push(...String(value).split('\n'))
|
|
1747
|
-
console.error = (value) => combined.push(...String(value).split('\n'))
|
|
1748
|
-
try {
|
|
1749
|
-
assert.equal(await runCli(['--repo', 'o/r', '--pr', '1']), 0)
|
|
1750
|
-
} finally {
|
|
1751
|
-
console.log = originalLog
|
|
1752
|
-
console.error = originalError
|
|
1753
|
-
}
|
|
1754
|
-
const tail = combined.slice(-10).join('\n')
|
|
1755
|
-
assert.match(tail, /"ciProfileSummary":true/)
|
|
1756
|
-
assert.match(tail, /Vitest unit \+ integration tests 338s/)
|
|
1757
|
-
})
|
|
1758
|
-
})
|
|
1759
|
-
|
|
1760
|
-
test('ciProfileSummary names at most three slow jobs and counts the rest', () => {
|
|
1761
|
-
const slowJobs = ['a', 'b', 'c', 'd', 'e'].map((name, i) => ({ name, durationSec: 600 - i }))
|
|
1762
|
-
const line = JSON.parse(ciProfileSummary({ outcome: 'DEQUEUED' }, { verdict: 'slow', jobCount: 9, slowJobMinutes: 5, slowestJob: slowJobs[0], slowJobs }))
|
|
1763
|
-
assert.deepEqual(line.slowJobs, ['a 600s', 'b 599s', 'c 598s', '+2 more'])
|
|
1764
|
-
assert.equal(line.outcome, 'DEQUEUED')
|
|
1765
|
-
assert.deepEqual(JSON.parse(ciProfileSummary({ outcome: 'STALLED' }, { verdict: 'no-timing-data', jobCount: 0, slowestJob: null, slowJobs: [] })).slowJobs, [])
|
|
1766
|
-
})
|
|
1767
|
-
|
|
1768
|
-
test('a higher --slow-job-minutes silences the warning but keeps the profile', async () => {
|
|
1769
|
-
await withFakeBinsOnPath(async (dir) => {
|
|
1770
|
-
makeFakeGhReturning(dir, mergedPrWithChecks([check('e2e', 0, 6)]))
|
|
1771
|
-
makeFakeBin(dir, 'mergify', 'echo \'{"queued":false}\'')
|
|
1772
|
-
const { payload, progressLines } = await runCliCapturingLastPayload(['--repo', 'o/r', '--pr', '1', '--quiet', '--slow-job-minutes', '10'])
|
|
1773
|
-
assert.equal(progressLines.filter((l) => l.includes('slow-ci-job')).length, 0)
|
|
1774
|
-
assert.equal(payload.ciProfile.verdict, 'ok')
|
|
1775
|
-
assert.equal(payload.ciProfile.jobs[0].durationSec, 360)
|
|
1776
|
-
})
|
|
1777
|
-
})
|
|
1778
|
-
|
|
1779
|
-
test('the queue batch PR checks are profiled once at the end of a merged watch', async () => {
|
|
1780
|
-
await withFakeBinsOnPath(async (dir) => {
|
|
1781
|
-
const prOpen = { state: 'OPEN', mergedAt: null, mergeCommit: null, headRefOid: 'h', labels: [], url: 'https://x', comments: [], statusCheckRollup: [check('unit', 0, 2)] }
|
|
1782
|
-
const prMerged = { ...prOpen, state: 'MERGED', mergedAt: isoAt(T0), mergeCommit: { oid: 'abc' } }
|
|
1783
|
-
const batch = { state: 'CLOSED', url: 'https://x/42', statusCheckRollup: [check('queue-suite', 0, 7)] }
|
|
1784
|
-
const counter = join(dir, 'gh-count')
|
|
1785
|
-
const write = (name, obj) => writeFileSync(join(dir, name), JSON.stringify(obj))
|
|
1786
|
-
write('open.json', prOpen); write('merged.json', prMerged); write('batch.json', batch)
|
|
1787
|
-
makeFakeBin(dir, 'gh', [
|
|
1788
|
-
`if [ "$3" = "42" ]; then cat ${join(dir, 'batch.json')}; exit 0; fi`,
|
|
1789
|
-
`n=$(cat ${counter} 2>/dev/null || echo 0); echo $((n + 1)) > ${counter}`,
|
|
1790
|
-
`if [ "$n" -lt 1 ]; then cat ${join(dir, 'open.json')}; else cat ${join(dir, 'merged.json')}; fi`,
|
|
1791
|
-
].join('\n'))
|
|
1792
|
-
makeFakeBin(dir, 'mergify', 'echo \'{"queued":true,"position":0,"queue_rule_name":"default","mergeability_check":{"queue_pull_request_number":42}}\'')
|
|
1793
|
-
const { code, payload, progressLines } = await runCliCapturingLastPayload(['--repo', 'o/r', '--pr', '1', '--interval-seconds', '0.01', '--quiet'])
|
|
1794
|
-
assert.equal(code, 0)
|
|
1795
|
-
assert.deepEqual(payload.phases, ['QUEUED', 'MERGED'])
|
|
1796
|
-
const batchJob = payload.ciProfile.jobs.find((j) => j.source === 'queue-batch')
|
|
1797
|
-
assert.equal(batchJob?.name, 'queue-suite')
|
|
1798
|
-
assert.deepEqual(progressLines.map((l) => JSON.parse(l)).filter((l) => l.warning).map((l) => [l.source, l.name]), [['queue-batch', 'queue-suite']])
|
|
1799
|
-
})
|
|
1800
|
-
})
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
test('regression (PR #1483): a skipped job with completedAt before startedAt is not a running slow job', () => {
|
|
1804
|
-
// The exact rollup entry GitHub returned for the canary on #1483: SKIPPED, completed 8s
|
|
1805
|
-
// BEFORE it "started". Reading out-of-order timestamps as "still running" timed it for the
|
|
1806
|
-
// whole watch (580s) and raised a false slow-ci-job warning.
|
|
1807
|
-
const skipped = {
|
|
1808
|
-
__typename: 'CheckRun', name: 'external-source-canary', workflowName: 'CI', status: 'COMPLETED', conclusion: 'SKIPPED',
|
|
1809
|
-
startedAt: '2026-09-28T16:24:06Z', completedAt: '2026-09-28T16:23:58Z', detailsUrl: 'https://x',
|
|
1810
|
-
}
|
|
1811
|
-
const nowMs = Date.parse('2026-09-28T16:34:00Z')
|
|
1812
|
-
assert.deepEqual(checkTimings([skipped], { nowMs }), [], 'a job that did no work is not profiled')
|
|
1813
|
-
const outOfOrder = { ...skipped, conclusion: 'SUCCESS' }
|
|
1814
|
-
const [job] = checkTimings([outOfOrder], { nowMs })
|
|
1815
|
-
assert.equal(job.running, false, 'COMPLETED status decides, not the timestamps')
|
|
1816
|
-
assert.equal(job.durationMs, 0)
|
|
1817
|
-
assert.equal(newSlowJobs(mergeCiJobs({}, [job]), { thresholdMs: 5 * 60_000 }).length, 0)
|
|
1818
|
-
})
|
|
1819
|
-
|
|
1820
|
-
test('a job is running only while GitHub reports it is not COMPLETED', () => {
|
|
1821
|
-
const nowMs = T0 + 6 * 60_000
|
|
1822
|
-
const queued = { ...check('e2e', 0, null), status: 'QUEUED', conclusion: null }
|
|
1823
|
-
const [job] = checkTimings([queued], { nowMs })
|
|
1824
|
-
assert.equal(job.running, true)
|
|
1825
|
-
assert.equal(job.durationMs, 6 * 60_000)
|
|
1826
|
-
})
|