@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.
Files changed (214) hide show
  1. package/.codex-plugin/plugin.json +13 -0
  2. package/.codex-plugin/resources.json +1827 -0
  3. package/README.md +22 -0
  4. package/package.json +9 -2
  5. package/skills/app-dev/airflow/SKILL.md +1 -1
  6. package/skills/app-dev/airflow/release-check.mjs +3 -1
  7. package/skills/app-dev/android-foundation/SKILL.md +1 -1
  8. package/skills/app-dev/android-foundation/release-check.mjs +3 -1
  9. package/skills/app-dev/app-debug/SKILL.md +1 -1
  10. package/skills/app-dev/app-debug/release-check.mjs +3 -1
  11. package/skills/app-dev/better-auth/SKILL.md +1 -1
  12. package/skills/app-dev/better-auth/release-check.mjs +3 -1
  13. package/skills/app-dev/data-catalog/SKILL.md +1 -1
  14. package/skills/app-dev/data-catalog/release-check.mjs +3 -1
  15. package/skills/app-dev/data-quality/SKILL.md +1 -1
  16. package/skills/app-dev/data-quality/release-check.mjs +3 -1
  17. package/skills/app-dev/ios-foundation/SKILL.md +1 -1
  18. package/skills/app-dev/ios-foundation/release-check.mjs +3 -1
  19. package/skills/app-dev/monorepo/SKILL.md +1 -1
  20. package/skills/app-dev/monorepo/release-check.mjs +3 -1
  21. package/skills/app-dev/nextjs-fullstack/SKILL.md +1 -1
  22. package/skills/app-dev/nextjs-fullstack/release-check.mjs +3 -1
  23. package/skills/app-dev/prisma-setup/SKILL.md +1 -1
  24. package/skills/app-dev/prisma-setup/release-check.mjs +3 -1
  25. package/skills/app-dev/pyspark/SKILL.md +1 -1
  26. package/skills/app-dev/pyspark/release-check.mjs +3 -1
  27. package/skills/app-dev/python-backend/SKILL.md +1 -1
  28. package/skills/app-dev/python-backend/release-check.mjs +3 -1
  29. package/skills/app-dev/shadcn/SKILL.md +1 -1
  30. package/skills/app-dev/shadcn/release-check.mjs +3 -1
  31. package/skills/app-dev/zod-v4/SKILL.md +1 -1
  32. package/skills/app-dev/zod-v4/release-check.mjs +3 -1
  33. package/skills/cicd/cloud-build/SKILL.md +1 -1
  34. package/skills/cicd/cloud-build/release-check.mjs +3 -1
  35. package/skills/cicd/cloud-debug/SKILL.md +1 -1
  36. package/skills/cicd/cloud-debug/release-check.mjs +3 -1
  37. package/skills/cicd/cloud-deploy/SKILL.md +1 -1
  38. package/skills/cicd/cloud-deploy/release-check.mjs +3 -1
  39. package/skills/cicd/docker/SKILL.md +1 -1
  40. package/skills/cicd/docker/release-check.mjs +3 -1
  41. package/skills/cicd/github-actions/SKILL.md +1 -1
  42. package/skills/cicd/github-actions/release-check.mjs +3 -1
  43. package/skills/cicd/github-flow/SKILL.md +1 -1
  44. package/skills/cicd/github-flow/release-check.mjs +3 -1
  45. package/skills/cicd/google-cloud/SKILL.md +1 -1
  46. package/skills/cicd/google-cloud/release-check.mjs +3 -1
  47. package/skills/cicd/mergify/SKILL.md +1 -1
  48. package/skills/cicd/mergify/release-check.mjs +3 -1
  49. package/skills/cicd/podman/SKILL.md +1 -1
  50. package/skills/cicd/podman/release-check.mjs +3 -1
  51. package/skills/content/article-review/SKILL.md +1 -1
  52. package/skills/content/article-review/release-check.mjs +3 -1
  53. package/skills/content/article-to-image/SKILL.md +1 -1
  54. package/skills/content/article-to-image/release-check.mjs +3 -1
  55. package/skills/content/flowchart-svg/SKILL.md +1 -1
  56. package/skills/content/flowchart-svg/release-check.mjs +3 -1
  57. package/skills/content/image-gen/SKILL.md +1 -1
  58. package/skills/content/image-gen/release-check.mjs +3 -1
  59. package/skills/content/mermaid/SKILL.md +1 -1
  60. package/skills/content/mermaid/release-check.mjs +3 -1
  61. package/skills/content/wechat-article-writer/SKILL.md +1 -1
  62. package/skills/content/wechat-article-writer/release-check.mjs +3 -1
  63. package/skills/content/wechat-publisher/SKILL.md +1 -1
  64. package/skills/content/wechat-publisher/release-check.mjs +3 -1
  65. package/skills/content/wechat-search/SKILL.md +1 -1
  66. package/skills/content/wechat-search/release-check.mjs +3 -1
  67. package/skills/content/xai-search/SKILL.md +88 -13
  68. package/skills/content/xai-search/release-check.mjs +3 -1
  69. package/skills/content/xai-search/scripts/xai_search.sh +125 -149
  70. package/skills/core/distill-writing/SKILL.md +1 -1
  71. package/skills/core/distill-writing/release-check.mjs +3 -1
  72. package/skills/core/doc-to-md/SKILL.md +1 -1
  73. package/skills/core/doc-to-md/release-check.mjs +3 -1
  74. package/skills/core/editorial-review/SKILL.md +1 -1
  75. package/skills/core/editorial-review/release-check.mjs +3 -1
  76. package/skills/core/house-style/SKILL.md +1 -1
  77. package/skills/core/house-style/release-check.mjs +3 -1
  78. package/skills/core/plain-language/SKILL.md +1 -1
  79. package/skills/core/plain-language/release-check.mjs +3 -1
  80. package/skills/core/polish-cn/SKILL.md +1 -1
  81. package/skills/core/polish-cn/release-check.mjs +3 -1
  82. package/skills/core/reg-parser/SKILL.md +1 -1
  83. package/skills/core/reg-parser/release-check.mjs +3 -1
  84. package/skills/core/terminology/SKILL.md +1 -1
  85. package/skills/core/terminology/release-check.mjs +3 -1
  86. package/skills/core/write-doc/SKILL.md +1 -1
  87. package/skills/core/write-doc/release-check.mjs +3 -1
  88. package/skills/engineering/better-review/SKILL.md +1 -1
  89. package/skills/engineering/better-review/release-check.mjs +3 -1
  90. package/skills/engineering/contributing/SKILL.md +1 -1
  91. package/skills/engineering/contributing/release-check.mjs +3 -1
  92. package/skills/engineering/e2e-test/SKILL.md +1 -1
  93. package/skills/engineering/e2e-test/release-check.mjs +3 -1
  94. package/skills/engineering/engineering-runtime/SKILL.md +1 -1
  95. package/skills/engineering/engineering-runtime/coherence/workflow.json +13 -13
  96. package/skills/engineering/engineering-runtime/release-check.mjs +3 -1
  97. package/skills/engineering/engineering-runtime/scripts/installation-resolver.mjs +191 -0
  98. package/skills/engineering/engineering-runtime/scripts/workflow-coherence.mjs +51 -10
  99. package/skills/engineering/ensure-coverage/SKILL.md +1 -1
  100. package/skills/engineering/ensure-coverage/release-check.mjs +3 -1
  101. package/skills/engineering/golden-path/SKILL.md +1 -1
  102. package/skills/engineering/golden-path/release-check.mjs +3 -1
  103. package/skills/engineering/harness-runtime/SKILL.md +1 -1
  104. package/skills/engineering/harness-runtime/release-check.mjs +3 -1
  105. package/skills/engineering/resolve-issues/SKILL.md +1 -1
  106. package/skills/engineering/resolve-issues/references/preflight.md +7 -0
  107. package/skills/engineering/resolve-issues/references/shared-modules.md +27 -0
  108. package/skills/engineering/resolve-issues/references/task-plan.md +5 -1
  109. package/skills/engineering/resolve-issues/release-check.mjs +3 -1
  110. package/skills/engineering/resolve-issues/scripts/active-installation.mjs +32 -0
  111. package/skills/engineering/resolve-issues/scripts/install-hooks.mjs +3 -0
  112. package/skills/engineering/resolve-issues/scripts/rehydrate.mjs +17 -1
  113. package/skills/engineering/resolve-release/SKILL.md +1 -1
  114. package/skills/engineering/resolve-release/release-check.mjs +3 -1
  115. package/skills/engineering/rush-issues/SKILL.md +1 -1
  116. package/skills/engineering/rush-issues/release-check.mjs +3 -1
  117. package/skills/engineering/rush-release/SKILL.md +1 -1
  118. package/skills/engineering/rush-release/release-check.mjs +3 -1
  119. package/skills/engineering/test-quality/SKILL.md +1 -1
  120. package/skills/engineering/test-quality/release-check.mjs +3 -1
  121. package/skills/risk-compliance/compliance-audit/SKILL.md +1 -1
  122. package/skills/risk-compliance/compliance-audit/release-check.mjs +3 -1
  123. package/skills/risk-compliance/contract-compare/SKILL.md +1 -1
  124. package/skills/risk-compliance/contract-compare/release-check.mjs +3 -1
  125. package/skills/risk-compliance/contract-review/SKILL.md +1 -1
  126. package/skills/risk-compliance/contract-review/release-check.mjs +3 -1
  127. package/skills/risk-compliance/doc-authenticity/SKILL.md +1 -1
  128. package/skills/risk-compliance/doc-authenticity/release-check.mjs +3 -1
  129. package/skills/risk-compliance/fraud-analysis/SKILL.md +1 -1
  130. package/skills/risk-compliance/fraud-analysis/release-check.mjs +3 -1
  131. package/skills/risk-compliance/payment-analysis/SKILL.md +1 -1
  132. package/skills/risk-compliance/payment-analysis/release-check.mjs +3 -1
  133. package/skills/risk-compliance/similar-domain/SKILL.md +1 -1
  134. package/skills/risk-compliance/similar-domain/release-check.mjs +3 -1
  135. package/skills/risk-compliance/tld-detection/SKILL.md +1 -1
  136. package/skills/risk-compliance/tld-detection/release-check.mjs +3 -1
  137. package/skills/app-dev/android-foundation/scripts/device-run.test.mjs +0 -436
  138. package/skills/app-dev/data-catalog/scripts/data-catalog.test.mjs +0 -36
  139. package/skills/app-dev/data-quality/scripts/data-quality.test.mjs +0 -12
  140. package/skills/cicd/github-actions/scripts/ci-run-audit.test.mjs +0 -637
  141. package/skills/cicd/github-flow/scripts/flow.test.mjs +0 -114
  142. package/skills/cicd/github-flow/scripts/persist-conventions.test.mjs +0 -108
  143. package/skills/cicd/mergify/scripts/audit.test.mjs +0 -201
  144. package/skills/cicd/mergify/scripts/entrypoints.test.mjs +0 -106
  145. package/skills/cicd/mergify/scripts/pr-620-mergify-comments.fixture.json +0 -30
  146. package/skills/cicd/mergify/scripts/stack-push.test.mjs +0 -233
  147. package/skills/cicd/mergify/scripts/watch-pr-delivery.test.mjs +0 -1826
  148. package/skills/content/image-gen/scripts/credentials.test.mjs +0 -212
  149. package/skills/content/image-gen/scripts/options.test.mjs +0 -245
  150. package/skills/content/xai-search/tests/smoke_test.sh +0 -36
  151. package/skills/core/polish-cn/scripts/polish-cn.test.mjs +0 -244
  152. package/skills/core/reg-parser/scripts/reg-parser.test.mjs +0 -165
  153. package/skills/engineering/better-review/scripts/pr-context.test.mjs +0 -168
  154. package/skills/engineering/better-review/scripts/rereview-scope.test.mjs +0 -587
  155. package/skills/engineering/better-review/scripts/scan-diff.test.mjs +0 -414
  156. package/skills/engineering/better-review/scripts/vendor-sync.test.mjs +0 -96
  157. package/skills/engineering/contributing/scripts/persist-conventions.test.mjs +0 -118
  158. package/skills/engineering/e2e-test/scripts/presentation-checks.test.mjs +0 -106
  159. package/skills/engineering/e2e-test/scripts/presentation-perceivability.test.mjs +0 -117
  160. package/skills/engineering/e2e-test/scripts/presentation-reachability.test.mjs +0 -69
  161. package/skills/engineering/e2e-test/scripts/presentation-render-health.test.mjs +0 -98
  162. package/skills/engineering/e2e-test/scripts/presentation-sweep.test.mjs +0 -71
  163. package/skills/engineering/e2e-test/scripts/presentation-temporal.test.mjs +0 -59
  164. package/skills/engineering/e2e-test/scripts/project-route-manifest.test.mjs +0 -76
  165. package/skills/engineering/engineering-runtime/engineering-runtime.test.mjs +0 -68
  166. package/skills/engineering/engineering-runtime/scripts/invocation-policy.test.mjs +0 -95
  167. package/skills/engineering/engineering-runtime/scripts/main-module.test.mjs +0 -288
  168. package/skills/engineering/engineering-runtime/scripts/required-check-plan.test.mjs +0 -96
  169. package/skills/engineering/ensure-coverage/scripts/adapters/nextjs-inventory.test.mjs +0 -96
  170. package/skills/engineering/ensure-coverage/scripts/check-quarantine-expiry.test.mjs +0 -47
  171. package/skills/engineering/ensure-coverage/scripts/ci-audit.test.mjs +0 -86
  172. package/skills/engineering/ensure-coverage/scripts/coverage-ledger.test.mjs +0 -447
  173. package/skills/engineering/ensure-coverage/scripts/design-parity.test.mjs +0 -552
  174. package/skills/engineering/ensure-coverage/scripts/lint-tests.test.mjs +0 -289
  175. package/skills/engineering/ensure-coverage/scripts/mock-policy-config.test.mjs +0 -119
  176. package/skills/engineering/golden-path/scripts/golden-path.test.mjs +0 -1457
  177. package/skills/engineering/harness-runtime/discover-models.test.mjs +0 -363
  178. package/skills/engineering/harness-runtime/harness-runtime.test.mjs +0 -967
  179. package/skills/engineering/harness-runtime/model-catalog-seed.test.mjs +0 -829
  180. package/skills/engineering/harness-runtime/model-catalog.test.mjs +0 -366
  181. package/skills/engineering/resolve-issues/scripts/cleanup-runs.test.mjs +0 -280
  182. package/skills/engineering/resolve-issues/scripts/dropbox.test.mjs +0 -346
  183. package/skills/engineering/resolve-issues/scripts/findings-reuse.test.mjs +0 -88
  184. package/skills/engineering/resolve-issues/scripts/github-reviews.test.mjs +0 -264
  185. package/skills/engineering/resolve-issues/scripts/install-hooks.test.mjs +0 -79
  186. package/skills/engineering/resolve-issues/scripts/issue-tree.test.mjs +0 -107
  187. package/skills/engineering/resolve-issues/scripts/manual-invocation.test.mjs +0 -14
  188. package/skills/engineering/resolve-issues/scripts/next-actions-capacity.test.mjs +0 -149
  189. package/skills/engineering/resolve-issues/scripts/next-actions.test.mjs +0 -915
  190. package/skills/engineering/resolve-issues/scripts/plan-sync.test.mjs +0 -156
  191. package/skills/engineering/resolve-issues/scripts/preflight-models.test.mjs +0 -448
  192. package/skills/engineering/resolve-issues/scripts/ready-check.test.mjs +0 -217
  193. package/skills/engineering/resolve-issues/scripts/rehydrate.test.mjs +0 -91
  194. package/skills/engineering/resolve-issues/scripts/review-verdict.test.mjs +0 -1060
  195. package/skills/engineering/resolve-issues/scripts/size-check.test.mjs +0 -458
  196. package/skills/engineering/resolve-release/scripts/apply.test.mjs +0 -99
  197. package/skills/engineering/resolve-release/scripts/dark-flags.test.mjs +0 -100
  198. package/skills/engineering/resolve-release/scripts/flow.test.mjs +0 -340
  199. package/skills/engineering/resolve-release/scripts/green-head.test.mjs +0 -101
  200. package/skills/engineering/resolve-release/scripts/npm-version-absence.test.mjs +0 -426
  201. package/skills/engineering/resolve-release/scripts/plan-npm.test.mjs +0 -659
  202. package/skills/engineering/resolve-release/scripts/plan-toml-boundary.test.mjs +0 -317
  203. package/skills/engineering/resolve-release/scripts/plan-uv.test.mjs +0 -291
  204. package/skills/engineering/resolve-release/scripts/plan.test.mjs +0 -428
  205. package/skills/engineering/test-quality/scripts/lint-tests.test.mjs +0 -591
  206. package/skills/engineering/test-quality/scripts/mock-policy-config.test.mjs +0 -119
  207. package/skills/engineering/test-quality/scripts/persist-conventions.test.mjs +0 -104
  208. package/skills/risk-compliance/compliance-audit/scripts/compliance-audit.test.mjs +0 -14
  209. package/skills/risk-compliance/doc-authenticity/manual-invocation.test.mjs +0 -12
  210. package/skills/risk-compliance/doc-authenticity/scripts/validate_verdict.test.mjs +0 -212
  211. package/skills/risk-compliance/tld-detection/scripts/detect_ladders.test.mjs +0 -14
  212. package/skills/risk-compliance/tld-detection/scripts/detect_streams.test.mjs +0 -14
  213. package/skills/risk-compliance/tld-detection/tests/test_detect_ladders.py +0 -134
  214. 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
- })