@1aboveio/skills 1.0.21 → 1.0.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.codex-plugin/plugin.json +1 -1
- package/.codex-plugin/resources.json +150 -147
- package/package.json +4 -4
- package/skills/app-dev/airflow/SKILL.md +1 -1
- package/skills/app-dev/android-development/SKILL.md +1 -1
- package/skills/app-dev/app-debug/SKILL.md +1 -1
- package/skills/app-dev/better-auth/SKILL.md +1 -1
- package/skills/app-dev/data-catalog/SKILL.md +1 -1
- package/skills/app-dev/data-quality/SKILL.md +1 -1
- package/skills/app-dev/ios-foundation/SKILL.md +1 -1
- package/skills/app-dev/maestro/SKILL.md +1 -1
- package/skills/app-dev/monorepo/SKILL.md +1 -1
- package/skills/app-dev/nextjs-fullstack/SKILL.md +1 -1
- package/skills/app-dev/prisma-setup/SKILL.md +1 -1
- package/skills/app-dev/pyspark/SKILL.md +1 -1
- package/skills/app-dev/python-backend/SKILL.md +1 -1
- package/skills/app-dev/shadcn/SKILL.md +1 -1
- package/skills/app-dev/zod-v4/SKILL.md +1 -1
- package/skills/cicd/cloud-build/SKILL.md +1 -1
- package/skills/cicd/cloud-debug/SKILL.md +1 -1
- package/skills/cicd/cloud-deploy/SKILL.md +1 -1
- package/skills/cicd/docker/SKILL.md +1 -1
- package/skills/cicd/github-actions/SKILL.md +4 -2
- package/skills/cicd/github-actions/references/debugging.md +21 -0
- package/skills/cicd/github-actions/references/post-deploy-and-publishing.md +7 -0
- package/skills/cicd/github-actions/scripts/watch-run.mjs +511 -0
- package/skills/cicd/github-flow/SKILL.md +35 -9
- package/skills/cicd/github-flow/scripts/watch-checks.mjs +561 -0
- package/skills/cicd/google-cloud/SKILL.md +1 -1
- package/skills/cicd/mergify/SKILL.md +2 -2
- package/skills/cicd/mergify/references/diagnosis.md +6 -1
- package/skills/cicd/mergify/references/watch-contract.md +10 -7
- package/skills/cicd/mergify/scripts/watch-pr-delivery-core.mjs +15 -30
- package/skills/cicd/podman/SKILL.md +1 -1
- package/skills/content/article-review/SKILL.md +1 -1
- package/skills/content/article-to-image/SKILL.md +1 -1
- package/skills/content/flowchart-svg/SKILL.md +1 -1
- package/skills/content/image-gen/SKILL.md +1 -1
- package/skills/content/mermaid/SKILL.md +1 -1
- package/skills/content/wechat-article-writer/SKILL.md +1 -1
- package/skills/content/wechat-publisher/SKILL.md +1 -1
- package/skills/content/wechat-search/SKILL.md +1 -1
- package/skills/content/xai-search/SKILL.md +1 -1
- package/skills/core/distill-writing/SKILL.md +1 -1
- package/skills/core/doc-to-md/SKILL.md +1 -1
- package/skills/core/editorial-review/SKILL.md +1 -1
- package/skills/core/house-style/SKILL.md +1 -1
- package/skills/core/plain-language/SKILL.md +1 -1
- package/skills/core/polish-cn/SKILL.md +1 -1
- package/skills/core/reg-parser/SKILL.md +1 -1
- package/skills/core/terminology/SKILL.md +1 -1
- package/skills/core/write-doc/SKILL.md +1 -1
- package/skills/core/write-report/SKILL.md +1 -1
- package/skills/engineering/better-review/SKILL.md +1 -1
- package/skills/engineering/e2e-test/SKILL.md +1 -1
- package/skills/engineering/engineering/SKILL.md +1 -1
- package/skills/engineering/engineering-runtime/SKILL.md +1 -1
- package/skills/engineering/engineering-runtime/coherence/workflow.json +13 -13
- package/skills/engineering/ensure-coverage/SKILL.md +1 -1
- package/skills/engineering/golden-path/SKILL.md +1 -1
- package/skills/engineering/harness-runtime/SKILL.md +2 -1
- package/skills/engineering/harness-runtime/contracts.md +2 -0
- package/skills/engineering/harness-runtime/references/subagent-sessions.md +75 -0
- package/skills/engineering/resolve-issues/SKILL.md +2 -2
- package/skills/engineering/resolve-issues/references/ci.md +2 -4
- package/skills/engineering/resolve-issues/references/delivery-progress.md +1 -1
- package/skills/engineering/resolve-issues/references/done.md +3 -6
- package/skills/engineering/resolve-issues/references/expire.md +2 -2
- package/skills/engineering/resolve-issues/references/implementation.md +52 -41
- package/skills/engineering/resolve-issues/references/review.md +18 -19
- package/skills/engineering/resolve-issues/references/shared-modules.md +7 -1
- package/skills/engineering/resolve-issues/references/task-plan.md +18 -10
- package/skills/engineering/resolve-issues/scripts/next-actions.mjs +42 -32
- package/skills/engineering/resolve-release/SKILL.md +1 -1
- package/skills/engineering/resolve-release/references/publish.md +12 -4
- package/skills/engineering/resolve-release/references/workspace-npm.md +6 -4
- package/skills/engineering/rush-issues/SKILL.md +1 -1
- package/skills/engineering/rush-release/SKILL.md +1 -1
- package/skills/engineering/test-quality/SKILL.md +1 -1
- package/skills/risk-compliance/compliance-audit/SKILL.md +1 -1
- package/skills/risk-compliance/contract-compare/SKILL.md +1 -1
- package/skills/risk-compliance/contract-review/SKILL.md +1 -1
- package/skills/risk-compliance/doc-authenticity/SKILL.md +1 -1
- package/skills/risk-compliance/fraud-detection/SKILL.md +1 -1
- package/skills/risk-compliance/payment-analysis/SKILL.md +1 -1
- package/skills/risk-compliance/portfolio-payment-analysis/SKILL.md +1 -1
- package/skills/risk-compliance/similar-domain/SKILL.md +1 -1
- package/skills/risk-compliance/tld-detection/SKILL.md +1 -1
|
@@ -0,0 +1,561 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// watch-checks — wait for a PR's checks to settle, and prove the merge when asked.
|
|
3
|
+
// Read-only: it only reads `gh pr view`.
|
|
4
|
+
//
|
|
5
|
+
// node watch-checks.mjs --pr <number> [--repo <owner>/<name>] [--until checks|merged]
|
|
6
|
+
// [--interval-seconds 45] [--stall-minutes 15] [--max-minutes 60]
|
|
7
|
+
// [--no-checks-minutes 5] [--slow-check-minutes 5] [--read-retries 2]
|
|
8
|
+
// [--unreadable-polls 3]
|
|
9
|
+
//
|
|
10
|
+
// Why this exists instead of `gh pr checks --watch --required --fail-fast`:
|
|
11
|
+
//
|
|
12
|
+
// The delivery bar stays unchanged: required checks, or all checks when none are
|
|
13
|
+
// required. Monitoring must not turn optional checks into new merge gates.
|
|
14
|
+
// · `--watch` repaints its table — and its elapsed clock — every 30 seconds. Any
|
|
15
|
+
// supervisor that decides "the command printed something, so it is progressing"
|
|
16
|
+
// from output activity is fooled by that repaint: a job stuck in one step for
|
|
17
|
+
// twenty minutes looks exactly like a job making progress, and the moment of
|
|
18
|
+
// decision is spent on a status line rather than on the step that stopped.
|
|
19
|
+
//
|
|
20
|
+
// So this polls discrete JSON reads and defines progress the only way that survives a
|
|
21
|
+
// log: the canonical state of the PR's checks actually changed. It carries no
|
|
22
|
+
// repainting clock, so a stationary phase is quiet and a real transition speaks.
|
|
23
|
+
//
|
|
24
|
+
// Streams:
|
|
25
|
+
// stdout exactly one terminal JSON verdict, at exit.
|
|
26
|
+
// stderr one compact JSON record per observed check-state change (`kind:"transition"`,
|
|
27
|
+
// `progress:true`), one `{"warning":"slow-ci-check"}` record per slow check run,
|
|
28
|
+
// one `{"kind":"transition","progress":false,...}` the first time the provider
|
|
29
|
+
// becomes unreadable, one more (`progress:false`) when it recovers without the
|
|
30
|
+
// checks having moved, and one closing `{"summary":true}` line after the verdict.
|
|
31
|
+
// Readability is health, not CI progress: a health record never moves the clock
|
|
32
|
+
// the stall bound is measured from.
|
|
33
|
+
//
|
|
34
|
+
// The record's projection is `state` — the canonical check set, sorted, with no clock
|
|
35
|
+
// fields. `at`, `elapsedMs` and `observations` are for humans reading the log; a
|
|
36
|
+
// background-task predicate watches `progress` and `state`, never log size or mtime.
|
|
37
|
+
//
|
|
38
|
+
// Exit codes: 0 checks passed / no checks / merged. 4 terminal but not deliverable
|
|
39
|
+
// (failing checks, stalled, unreadable, unknown check state, closed unmerged).
|
|
40
|
+
// 2 usage error.
|
|
41
|
+
|
|
42
|
+
import { execFileSync } from 'node:child_process'
|
|
43
|
+
|
|
44
|
+
const PASSING = new Set(['SUCCESS', 'NEUTRAL', 'SKIPPED'])
|
|
45
|
+
const FAILING = new Set(['FAILURE', 'ERROR', 'TIMED_OUT', 'ACTION_REQUIRED', 'STARTUP_FAILURE', 'CANCELLED', 'STALE'])
|
|
46
|
+
const PENDING = new Set(['QUEUED', 'IN_PROGRESS', 'PENDING', 'WAITING', 'REQUESTED', 'EXPECTED'])
|
|
47
|
+
const MERGE_GATE = new Set(['BLOCKED', 'BEHIND', 'DIRTY', 'DRAFT'])
|
|
48
|
+
const MERGEABLE = new Set(['CLEAN', 'HAS_HOOKS'])
|
|
49
|
+
const PR_FIELDS = 'state,mergedAt,mergeCommit,mergeStateStatus,statusCheckRollup,autoMergeRequest,url,headRefOid'
|
|
50
|
+
const MAX_MINUTES_CEILING = 240
|
|
51
|
+
const MAX_READ_BYTES = 8 * 1024 * 1024
|
|
52
|
+
|
|
53
|
+
function help() {
|
|
54
|
+
return `watch-checks --pr <number> [options]
|
|
55
|
+
|
|
56
|
+
Wait for required PR checks, falling back to all checks when none are required.
|
|
57
|
+
Read-only; never merges,
|
|
58
|
+
never comments, never edits a PR.
|
|
59
|
+
|
|
60
|
+
--pr <number> PR to watch (required)
|
|
61
|
+
--repo <owner>/<name> repository; defaults to the current checkout
|
|
62
|
+
--until checks|merged stop when the checks settle (default), or after proving
|
|
63
|
+
the PR actually merged. 'merged' is never a success without
|
|
64
|
+
a merge: a PR with no checks waits for the merge itself, and
|
|
65
|
+
an unrecognised mergeStateStatus ends as UNKNOWN
|
|
66
|
+
--interval-seconds <n> poll interval (default 45)
|
|
67
|
+
--stall-minutes <n> no-observable-change bound (default 15). Suspended while a
|
|
68
|
+
check is pending, and while an --until merged PR is
|
|
69
|
+
mergeable and awaiting the provider: running CI and a
|
|
70
|
+
provider-side merge are progress, not a stall. Also
|
|
71
|
+
bounds a required context that stays pending with no start
|
|
72
|
+
time (reason: check-never-started), suspended while other
|
|
73
|
+
checks are still running
|
|
74
|
+
--max-minutes <n> absolute ceiling (default 60, max ${MAX_MINUTES_CEILING})
|
|
75
|
+
--no-checks-minutes <n> with --until checks, a PR with no checks for this long has
|
|
76
|
+
no CI (default 5)
|
|
77
|
+
--slow-check-minutes <n> warn once per check run longer than this (default 5)
|
|
78
|
+
--read-retries <n> extra attempts inside one poll (default 2)
|
|
79
|
+
--unreadable-polls <n> consecutive unreadable polls before UNKNOWN (default 3)
|
|
80
|
+
|
|
81
|
+
Outcome values: CHECKS_PASSED, NO_CHECKS, MERGED, CHECKS_FAILED, CLOSED_UNMERGED,
|
|
82
|
+
STALLED, UNKNOWN.
|
|
83
|
+
|
|
84
|
+
Exit 0: CHECKS_PASSED, NO_CHECKS, MERGED. Exit 4: every other outcome. Exit 2: usage.`
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function parseArgs(argv) {
|
|
88
|
+
const args = {
|
|
89
|
+
intervalSeconds: 45,
|
|
90
|
+
stallMinutes: 15,
|
|
91
|
+
maxMinutes: 60,
|
|
92
|
+
noChecksMinutes: 5,
|
|
93
|
+
slowCheckMinutes: 5,
|
|
94
|
+
readRetries: 2,
|
|
95
|
+
unreadablePolls: 3,
|
|
96
|
+
until: 'checks',
|
|
97
|
+
}
|
|
98
|
+
const numbers = {
|
|
99
|
+
'--interval-seconds': 'intervalSeconds',
|
|
100
|
+
'--stall-minutes': 'stallMinutes',
|
|
101
|
+
'--max-minutes': 'maxMinutes',
|
|
102
|
+
'--no-checks-minutes': 'noChecksMinutes',
|
|
103
|
+
'--slow-check-minutes': 'slowCheckMinutes',
|
|
104
|
+
'--read-retries': 'readRetries',
|
|
105
|
+
'--unreadable-polls': 'unreadablePolls',
|
|
106
|
+
}
|
|
107
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
108
|
+
const arg = argv[i]
|
|
109
|
+
if (arg === '--pr') args.pr = argv[++i]
|
|
110
|
+
else if (arg === '--repo') args.repo = argv[++i]
|
|
111
|
+
else if (arg === '--until') args.until = argv[++i]
|
|
112
|
+
else if (numbers[arg]) args[numbers[arg]] = Number(argv[++i])
|
|
113
|
+
else if (arg === '--help' || arg === '-h') args.help = true
|
|
114
|
+
else args.unknown = arg
|
|
115
|
+
}
|
|
116
|
+
return args
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function validate(args) {
|
|
120
|
+
if (args.unknown) return `unknown argument ${args.unknown}`
|
|
121
|
+
if (!/^[1-9][0-9]*$/.test(String(args.pr ?? ''))) return '--pr must be a positive integer'
|
|
122
|
+
if (args.repo != null && !/^[A-Za-z0-9][A-Za-z0-9_.-]*\/[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(String(args.repo))) {
|
|
123
|
+
return '--repo must be owner/name'
|
|
124
|
+
}
|
|
125
|
+
if (args.until !== 'checks' && args.until !== 'merged') return '--until must be checks or merged'
|
|
126
|
+
for (const key of ['intervalSeconds', 'stallMinutes', 'maxMinutes', 'noChecksMinutes', 'slowCheckMinutes']) {
|
|
127
|
+
if (!Number.isFinite(args[key]) || args[key] <= 0) return `${key} must be a positive number`
|
|
128
|
+
}
|
|
129
|
+
for (const key of ['readRetries', 'unreadablePolls']) {
|
|
130
|
+
if (!Number.isInteger(args[key]) || args[key] < 0) return `${key} must be a non-negative integer`
|
|
131
|
+
}
|
|
132
|
+
if (args.unreadablePolls < 1) return '--unreadable-polls must be at least 1'
|
|
133
|
+
if (args.maxMinutes > MAX_MINUTES_CEILING) return `--max-minutes exceeds the ${MAX_MINUTES_CEILING}-minute ceiling`
|
|
134
|
+
return null
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const sleepMs = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
138
|
+
|
|
139
|
+
// A bounded per-read timeout: long enough for a slow-but-working API call, short enough
|
|
140
|
+
// that one hung read cannot outlive the bound that will fire STALLED on this poll.
|
|
141
|
+
function readTimeoutMs({ intervalSeconds, maxMinutes, elapsedMs }) {
|
|
142
|
+
const perRead = Math.max(1000, intervalSeconds * 3000)
|
|
143
|
+
const remaining = Math.max(1000, maxMinutes * 60_000 - elapsedMs)
|
|
144
|
+
return Math.min(perRead, remaining, 60_000)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function ghJson(ghArgs, timeoutMs, array = false) {
|
|
148
|
+
try {
|
|
149
|
+
const stdout = execFileSync('gh', ghArgs, {
|
|
150
|
+
encoding: 'utf8',
|
|
151
|
+
timeout: timeoutMs,
|
|
152
|
+
killSignal: 'SIGKILL',
|
|
153
|
+
maxBuffer: MAX_READ_BYTES,
|
|
154
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
155
|
+
env: { ...process.env, GH_PROMPT_DISABLED: '1' },
|
|
156
|
+
})
|
|
157
|
+
const data = JSON.parse(stdout)
|
|
158
|
+
if (!data || typeof data !== 'object' || Array.isArray(data) !== array) return { ok: false, error: 'gh returned an unexpected JSON shape' }
|
|
159
|
+
return { ok: true, data }
|
|
160
|
+
} catch (error) {
|
|
161
|
+
const stderr = String(error?.stderr ?? '').trim()
|
|
162
|
+
if (array && [1, 8].includes(error?.status)) {
|
|
163
|
+
// `gh pr checks` returns JSON with non-zero status for red/pending checks.
|
|
164
|
+
try {
|
|
165
|
+
const data = JSON.parse(String(error.stdout))
|
|
166
|
+
if (Array.isArray(data)) return { ok: true, data }
|
|
167
|
+
} catch {}
|
|
168
|
+
if (/no required checks/i.test(stderr)) return { ok: true, data: [] }
|
|
169
|
+
}
|
|
170
|
+
return { ok: false, error: stderr || String(error?.message ?? 'gh pr view failed') }
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function readPr(args, timeoutMs) {
|
|
175
|
+
const ghArgs = ['pr', 'view', String(args.pr), '--json', PR_FIELDS]
|
|
176
|
+
if (args.repo) ghArgs.push('--repo', args.repo)
|
|
177
|
+
const pr = ghJson(ghArgs, Math.max(1, Math.floor(timeoutMs / 2)))
|
|
178
|
+
if (!pr.ok) return pr
|
|
179
|
+
const requiredArgs = ['pr', 'checks', String(args.pr), '--required', '--json', 'name']
|
|
180
|
+
if (args.repo) requiredArgs.push('--repo', args.repo)
|
|
181
|
+
const required = ghJson(requiredArgs, Math.max(1, Math.floor(timeoutMs / 2)), true)
|
|
182
|
+
if (!required.ok) return required
|
|
183
|
+
if (required.data.some((check) => typeof check?.name !== 'string' || !check.name)) {
|
|
184
|
+
return { ok: false, error: 'required checks returned an invalid name' }
|
|
185
|
+
}
|
|
186
|
+
const names = new Set(required.data.map((check) => check.name))
|
|
187
|
+
if (names.size > 0) {
|
|
188
|
+
const rollup = Array.isArray(pr.data.statusCheckRollup) ? pr.data.statusCheckRollup : []
|
|
189
|
+
const selected = rollup.filter((check) => names.has(check?.name ?? check?.context))
|
|
190
|
+
if ([...names].some((name) => !selected.some((check) => (check.name ?? check.context) === name))) {
|
|
191
|
+
return { ok: false, error: 'required checks and PR rollup disagree; retrying the observation' }
|
|
192
|
+
}
|
|
193
|
+
pr.data.statusCheckRollup = selected
|
|
194
|
+
}
|
|
195
|
+
return pr
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// One rollup entry -> {name, phase}. `statusCheckRollup` mixes CheckRun (status +
|
|
199
|
+
// conclusion) and StatusContext (state only) shapes, and `gh` does not normalise them.
|
|
200
|
+
// An entry that matches neither fails closed as `unknown` rather than reading as pending
|
|
201
|
+
// or passing.
|
|
202
|
+
function normalizeCheck(entry) {
|
|
203
|
+
const name = entry?.name || entry?.context || 'unnamed-check'
|
|
204
|
+
const status = String(entry?.status ?? entry?.state ?? '').toUpperCase()
|
|
205
|
+
const conclusion = String(entry?.conclusion ?? '').toUpperCase()
|
|
206
|
+
let phase = 'unknown'
|
|
207
|
+
if (conclusion) phase = PASSING.has(conclusion) ? 'success' : FAILING.has(conclusion) ? 'failure' : 'unknown'
|
|
208
|
+
else if (PENDING.has(status)) phase = 'pending'
|
|
209
|
+
else if (PASSING.has(status)) phase = 'success'
|
|
210
|
+
else if (FAILING.has(status)) phase = 'failure'
|
|
211
|
+
const started = Date.parse(entry?.startedAt ?? entry?.createdAt ?? '')
|
|
212
|
+
const completed = Date.parse(entry?.completedAt ?? '')
|
|
213
|
+
return {
|
|
214
|
+
name,
|
|
215
|
+
phase,
|
|
216
|
+
status: status || null,
|
|
217
|
+
conclusion: conclusion || null,
|
|
218
|
+
startedMs: Number.isFinite(started) && started > 0 ? started : null,
|
|
219
|
+
completedMs: Number.isFinite(completed) && completed > 0 ? completed : null,
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// The canonical projection. Sorted, no clocks, no per-poll drift — this is the value a
|
|
224
|
+
// change is defined against, and the value a background task can predicate on.
|
|
225
|
+
function project(data) {
|
|
226
|
+
const checks = (Array.isArray(data.statusCheckRollup) ? data.statusCheckRollup : [])
|
|
227
|
+
.filter(Boolean)
|
|
228
|
+
.map(normalizeCheck)
|
|
229
|
+
.sort((a, b) => (a.name === b.name ? (a.phase < b.phase ? -1 : a.phase > b.phase ? 1 : 0) : a.name < b.name ? -1 : 1))
|
|
230
|
+
const names = (phase) => checks.filter((check) => check.phase === phase).map((check) => check.name)
|
|
231
|
+
return {
|
|
232
|
+
checks,
|
|
233
|
+
state: {
|
|
234
|
+
pr: {
|
|
235
|
+
state: data.state ?? null,
|
|
236
|
+
mergeStateStatus: data.mergeStateStatus ?? null,
|
|
237
|
+
mergedSha: data.mergeCommit?.oid ?? null,
|
|
238
|
+
autoMerge: Boolean(data.autoMergeRequest),
|
|
239
|
+
},
|
|
240
|
+
checks: checks.map(({ name, phase }) => ({ name, phase })),
|
|
241
|
+
failing: names('failure'),
|
|
242
|
+
pending: names('pending'),
|
|
243
|
+
unknown: names('unknown'),
|
|
244
|
+
},
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const keyOf = (state) => JSON.stringify(state)
|
|
249
|
+
|
|
250
|
+
function record(payload) {
|
|
251
|
+
return JSON.stringify(payload)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function slowCheckRecords(checks, { thresholdMs, warned, nowMs, url }) {
|
|
255
|
+
const out = []
|
|
256
|
+
for (const check of checks) {
|
|
257
|
+
if (!check.startedMs) continue
|
|
258
|
+
const key = `${check.name}|${check.startedMs}`
|
|
259
|
+
if (warned.has(key)) continue
|
|
260
|
+
const durationMs = (check.completedMs ?? nowMs) - check.startedMs
|
|
261
|
+
if (durationMs <= thresholdMs) continue
|
|
262
|
+
warned.add(key)
|
|
263
|
+
out.push(record({
|
|
264
|
+
warning: 'slow-ci-check',
|
|
265
|
+
name: check.name,
|
|
266
|
+
phase: check.phase,
|
|
267
|
+
durationSec: Math.round(durationMs / 1000),
|
|
268
|
+
thresholdSec: Math.round(thresholdMs / 1000),
|
|
269
|
+
url,
|
|
270
|
+
}))
|
|
271
|
+
}
|
|
272
|
+
return out
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async function main() {
|
|
276
|
+
const args = parseArgs(process.argv.slice(2))
|
|
277
|
+
if (args.help) {
|
|
278
|
+
console.log(help())
|
|
279
|
+
return 0
|
|
280
|
+
}
|
|
281
|
+
const invalid = validate(args)
|
|
282
|
+
if (invalid) {
|
|
283
|
+
console.error(`watch-checks: ${invalid}`)
|
|
284
|
+
return 2
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const startedMs = Date.now()
|
|
288
|
+
const slowThresholdMs = args.slowCheckMinutes * 60_000
|
|
289
|
+
const warnedSlow = new Set()
|
|
290
|
+
let lastSemanticKey = null
|
|
291
|
+
let lastUnreadableKey = null
|
|
292
|
+
let lastState = null
|
|
293
|
+
let unreadablePolls = 0
|
|
294
|
+
let observations = 0
|
|
295
|
+
let transitions = 0
|
|
296
|
+
let lastError = null
|
|
297
|
+
let noChecksSince = null
|
|
298
|
+
let gateStreak = 0
|
|
299
|
+
let lastReadable = true
|
|
300
|
+
let changedAtMs = startedMs
|
|
301
|
+
let lastUrl = null
|
|
302
|
+
// A pending check that never acquires a start time is the "expected forever" shape: a
|
|
303
|
+
// required context GitHub will never run. Tracked by name so the bound outlives the
|
|
304
|
+
// check's own appearance and disappearance from the rollup.
|
|
305
|
+
const neverStartedSince = new Map()
|
|
306
|
+
|
|
307
|
+
const finish = (result, state) => {
|
|
308
|
+
const endMs = Date.now()
|
|
309
|
+
const verdict = {
|
|
310
|
+
outcome: result.outcome,
|
|
311
|
+
terminal: true,
|
|
312
|
+
reason: result.reason ?? null,
|
|
313
|
+
detail: result.detail ?? null,
|
|
314
|
+
until: args.until,
|
|
315
|
+
pr: {
|
|
316
|
+
state: state?.pr?.state ?? null,
|
|
317
|
+
mergeStateStatus: state?.pr?.mergeStateStatus ?? null,
|
|
318
|
+
mergedSha: state?.pr?.mergedSha ?? null,
|
|
319
|
+
url: lastUrl,
|
|
320
|
+
},
|
|
321
|
+
checks: state?.checks?.map(({ name, phase }) => ({ name, phase })) ?? [],
|
|
322
|
+
failing: state?.failing ?? [],
|
|
323
|
+
pending: state?.pending ?? [],
|
|
324
|
+
unknown: state?.unknown ?? [],
|
|
325
|
+
slowChecks: [...warnedSlow].map((key) => key.slice(0, key.lastIndexOf('|'))),
|
|
326
|
+
observations,
|
|
327
|
+
transitions,
|
|
328
|
+
elapsedMs: endMs - startedMs,
|
|
329
|
+
}
|
|
330
|
+
console.log(JSON.stringify(verdict, null, 2))
|
|
331
|
+
console.error(record({
|
|
332
|
+
summary: true,
|
|
333
|
+
outcome: verdict.outcome,
|
|
334
|
+
reason: verdict.reason,
|
|
335
|
+
failing: verdict.failing,
|
|
336
|
+
pending: verdict.pending,
|
|
337
|
+
slowChecks: verdict.slowChecks,
|
|
338
|
+
checks: verdict.checks.length,
|
|
339
|
+
}))
|
|
340
|
+
return result.exitCode
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
for (;;) {
|
|
344
|
+
observations += 1
|
|
345
|
+
const elapsedMs = Date.now() - startedMs
|
|
346
|
+
const timeoutMs = readTimeoutMs({ intervalSeconds: args.intervalSeconds, maxMinutes: args.maxMinutes, elapsedMs })
|
|
347
|
+
|
|
348
|
+
let read = null
|
|
349
|
+
for (let attempt = 0; attempt <= args.readRetries; attempt += 1) {
|
|
350
|
+
read = readPr(args, timeoutMs)
|
|
351
|
+
if (read.ok) break
|
|
352
|
+
if (attempt < args.readRetries) await sleepMs(Math.min(1000, args.intervalSeconds * 1000))
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (!read.ok) {
|
|
356
|
+
unreadablePolls += 1
|
|
357
|
+
lastError = read.error
|
|
358
|
+
// An unreadable provider is information, not progress, and it is not a failed
|
|
359
|
+
// check either: it must not settle a verdict except through its own bound. It never
|
|
360
|
+
// moves the change clock — a provider we cannot read says nothing about the checks.
|
|
361
|
+
const unreadableKey = `unreadable|${read.error}`
|
|
362
|
+
if (lastReadable || unreadableKey !== lastUnreadableKey) {
|
|
363
|
+
lastUnreadableKey = unreadableKey
|
|
364
|
+
lastReadable = false
|
|
365
|
+
transitions += 1
|
|
366
|
+
console.error(record({
|
|
367
|
+
kind: 'transition',
|
|
368
|
+
progress: false,
|
|
369
|
+
reason: 'provider-unreadable',
|
|
370
|
+
at: new Date().toISOString(),
|
|
371
|
+
state: { readable: false, error: read.error },
|
|
372
|
+
}))
|
|
373
|
+
}
|
|
374
|
+
if (unreadablePolls >= args.unreadablePolls) {
|
|
375
|
+
return finish({
|
|
376
|
+
outcome: 'UNKNOWN',
|
|
377
|
+
reason: 'unreadable',
|
|
378
|
+
detail: `gh pr view failed ${unreadablePolls} polls in a row: ${lastError}`,
|
|
379
|
+
exitCode: 4,
|
|
380
|
+
}, lastState)
|
|
381
|
+
}
|
|
382
|
+
if (Date.now() - startedMs >= args.maxMinutes * 60_000) {
|
|
383
|
+
return finish({
|
|
384
|
+
outcome: 'UNKNOWN',
|
|
385
|
+
reason: 'max-minutes',
|
|
386
|
+
detail: `watch reached its ${args.maxMinutes}m ceiling while the provider was unreadable: ${lastError}`,
|
|
387
|
+
exitCode: 4,
|
|
388
|
+
}, lastState)
|
|
389
|
+
}
|
|
390
|
+
await sleepMs(args.intervalSeconds * 1000)
|
|
391
|
+
continue
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
unreadablePolls = 0
|
|
395
|
+
const { checks, state } = project(read.data)
|
|
396
|
+
lastUrl = read.data.url ?? null
|
|
397
|
+
lastState = state
|
|
398
|
+
const merged = state.pr.state === 'MERGED' || Boolean(read.data.mergedAt)
|
|
399
|
+
// An accepted auto-merge request means the provider owns the next step: the queue may
|
|
400
|
+
// hold the PR at BLOCKED/BEHIND while it does its own work, so that is not a human gate.
|
|
401
|
+
const providerHoldsMerge = state.pr.autoMerge === true
|
|
402
|
+
|
|
403
|
+
lastUnreadableKey = null
|
|
404
|
+
const wasReadable = lastReadable
|
|
405
|
+
lastReadable = true
|
|
406
|
+
|
|
407
|
+
const semanticKey = keyOf(state)
|
|
408
|
+
if (semanticKey !== lastSemanticKey) {
|
|
409
|
+
const reason = lastSemanticKey === null ? 'check-state-initial' : 'check-state'
|
|
410
|
+
lastSemanticKey = semanticKey
|
|
411
|
+
changedAtMs = Date.now()
|
|
412
|
+
transitions += 1
|
|
413
|
+
console.error(record({ kind: 'transition', progress: true, reason, at: new Date().toISOString(), state }))
|
|
414
|
+
} else if (!wasReadable) {
|
|
415
|
+
// Readable again and the checks are exactly where they were: health recovered, the
|
|
416
|
+
// semantic state did not change. Speak, but claim no progress and leave the change
|
|
417
|
+
// clock alone, so a provider outage cannot buy the PR another stall window.
|
|
418
|
+
transitions += 1
|
|
419
|
+
console.error(record({ kind: 'transition', progress: false, reason: 'provider-readable', at: new Date().toISOString(), state }))
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
for (const warning of slowCheckRecords(checks, { thresholdMs: slowThresholdMs, warned: warnedSlow, nowMs: Date.now(), url: lastUrl })) {
|
|
423
|
+
console.error(warning)
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
if (merged) return finish({ outcome: 'MERGED', reason: 'merged', exitCode: 0 }, state)
|
|
427
|
+
if (state.pr.state === 'CLOSED') {
|
|
428
|
+
return finish({ outcome: 'CLOSED_UNMERGED', reason: 'closed-without-merge', exitCode: 4 }, state)
|
|
429
|
+
}
|
|
430
|
+
// An unrecognised rollup entry is not a pass and not a pending check: stop and say
|
|
431
|
+
// which, rather than waiting for a state that will never settle.
|
|
432
|
+
if (state.unknown.length > 0) {
|
|
433
|
+
return finish({
|
|
434
|
+
outcome: 'UNKNOWN',
|
|
435
|
+
reason: 'unknown-check-state',
|
|
436
|
+
detail: `unrecognised check state: ${state.unknown.join(', ')}`,
|
|
437
|
+
exitCode: 4,
|
|
438
|
+
}, state)
|
|
439
|
+
}
|
|
440
|
+
if (state.failing.length > 0) {
|
|
441
|
+
return finish({
|
|
442
|
+
outcome: 'CHECKS_FAILED',
|
|
443
|
+
reason: 'checks-failed',
|
|
444
|
+
detail: state.failing.join(', '),
|
|
445
|
+
exitCode: 4,
|
|
446
|
+
}, state)
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// "No checks" only answers the question `--until checks` asked. For `--until merged`
|
|
450
|
+
// an empty rollup means there is nothing to wait for on the CI side, and the merge
|
|
451
|
+
// itself is the whole remaining question: settling that as a success would report a
|
|
452
|
+
// green watch for a PR that never merged.
|
|
453
|
+
if (checks.length === 0 && args.until === 'checks') {
|
|
454
|
+
noChecksSince ??= Date.now()
|
|
455
|
+
if (Date.now() - noChecksSince >= args.noChecksMinutes * 60_000) {
|
|
456
|
+
return finish({
|
|
457
|
+
outcome: 'NO_CHECKS',
|
|
458
|
+
reason: 'no-checks-reported',
|
|
459
|
+
detail: `no checks reported for ${args.noChecksMinutes}m; verify repository CI policy before proceeding`,
|
|
460
|
+
exitCode: 0,
|
|
461
|
+
}, state)
|
|
462
|
+
}
|
|
463
|
+
} else if (checks.length > 0) {
|
|
464
|
+
noChecksSince = null
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// Never-started contexts: a required context GitHub will never run is the "expected
|
|
468
|
+
// forever" shape, so bound it instead of waiting for a settlement that cannot come.
|
|
469
|
+
// While sibling checks are actually running the provider has simply not got to this
|
|
470
|
+
// one yet, so the diagnosis is suspended — and its clock is cleared, so the bound
|
|
471
|
+
// measures sustained no-CI time instead of time that belongs to a running suite.
|
|
472
|
+
const ciActivelyRunning = checks.some((check) => check.phase === 'pending' && check.startedMs)
|
|
473
|
+
if (ciActivelyRunning) {
|
|
474
|
+
neverStartedSince.clear()
|
|
475
|
+
} else {
|
|
476
|
+
const notStarted = new Set(checks.filter((check) => check.phase === 'pending' && !check.startedMs).map((check) => check.name))
|
|
477
|
+
for (const name of [...neverStartedSince.keys()]) if (!notStarted.has(name)) neverStartedSince.delete(name)
|
|
478
|
+
for (const name of notStarted) if (!neverStartedSince.has(name)) neverStartedSince.set(name, Date.now())
|
|
479
|
+
const stuck = [...neverStartedSince].filter(([, sinceMs]) => Date.now() - sinceMs >= args.stallMinutes * 60_000).map(([name]) => name)
|
|
480
|
+
if (stuck.length > 0) {
|
|
481
|
+
return finish({
|
|
482
|
+
outcome: 'STALLED',
|
|
483
|
+
reason: 'check-never-started',
|
|
484
|
+
detail: `${stuck.join(', ')} reported pending with no start time for ${args.stallMinutes}m`,
|
|
485
|
+
exitCode: 4,
|
|
486
|
+
}, state)
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// Settled checks. An empty rollup counts as settled only for `--until merged`, where it
|
|
491
|
+
// means "nothing left to run" rather than "the answer is NO_CHECKS".
|
|
492
|
+
const settled = checks.length > 0 ? state.pending.length === 0 : args.until === 'merged'
|
|
493
|
+
if (settled && args.until === 'checks') return finish({ outcome: 'CHECKS_PASSED', reason: 'checks-passed', exitCode: 0 }, state)
|
|
494
|
+
|
|
495
|
+
// Awaiting a provider-side merge. An accepted auto-merge request outranks the status:
|
|
496
|
+
// the queue parks a queued PR at BLOCKED while it works, so that is not a human gate.
|
|
497
|
+
// A merge that cannot happen (review, stale base, conflict, draft) is a hand-back, not
|
|
498
|
+
// a wait to the ceiling, and a mergeStateStatus this tool does not recognise is never
|
|
499
|
+
// read as "mergeable": it is an explicit unknown, so nobody waits on a status the tool
|
|
500
|
+
// cannot name. One read is suspicion, so the hand-back needs two consecutive reads.
|
|
501
|
+
const gateStatus = String(state.pr.mergeStateStatus ?? '').toUpperCase()
|
|
502
|
+
const mergeDisposition = providerHoldsMerge
|
|
503
|
+
? 'provider'
|
|
504
|
+
: MERGEABLE.has(gateStatus)
|
|
505
|
+
? 'mergeable'
|
|
506
|
+
: MERGE_GATE.has(gateStatus)
|
|
507
|
+
? 'gate'
|
|
508
|
+
: 'unknown'
|
|
509
|
+
const mergeAwaitBlocked = args.until === 'merged' && settled
|
|
510
|
+
&& mergeDisposition !== 'provider' && mergeDisposition !== 'mergeable'
|
|
511
|
+
if (mergeAwaitBlocked) {
|
|
512
|
+
gateStreak += 1
|
|
513
|
+
if (gateStreak >= 2) {
|
|
514
|
+
if (mergeDisposition === 'unknown') {
|
|
515
|
+
return finish({
|
|
516
|
+
outcome: 'UNKNOWN',
|
|
517
|
+
reason: 'unknown-mergeability',
|
|
518
|
+
detail: `checks are settled but mergeStateStatus ${JSON.stringify(state.pr.mergeStateStatus)} is not a state this tool knows`,
|
|
519
|
+
exitCode: 4,
|
|
520
|
+
}, state)
|
|
521
|
+
}
|
|
522
|
+
return finish({
|
|
523
|
+
outcome: 'STALLED',
|
|
524
|
+
reason: `merge-gate:${gateStatus.toLowerCase()}`,
|
|
525
|
+
detail: `checks are green and the merge cannot proceed: mergeStateStatus ${state.pr.mergeStateStatus}`,
|
|
526
|
+
exitCode: 4,
|
|
527
|
+
}, state)
|
|
528
|
+
}
|
|
529
|
+
} else {
|
|
530
|
+
gateStreak = 0
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
const awaitingProvider = args.until === 'merged' && settled
|
|
534
|
+
&& (mergeDisposition === 'provider' || mergeDisposition === 'mergeable')
|
|
535
|
+
const ciRunning = state.pending.length > 0
|
|
536
|
+
|
|
537
|
+
if (Date.now() - startedMs >= args.maxMinutes * 60_000) {
|
|
538
|
+
return finish({
|
|
539
|
+
outcome: 'STALLED',
|
|
540
|
+
reason: 'max-minutes',
|
|
541
|
+
detail: `watch reached its ${args.maxMinutes}m ceiling with the provider still reporting ${state.pr.state ?? 'an unknown state'}`,
|
|
542
|
+
exitCode: 4,
|
|
543
|
+
}, state)
|
|
544
|
+
}
|
|
545
|
+
if (!ciRunning && !awaitingProvider && Date.now() - changedAtMs >= args.stallMinutes * 60_000) {
|
|
546
|
+
return finish({
|
|
547
|
+
outcome: 'STALLED',
|
|
548
|
+
reason: 'stall-bound',
|
|
549
|
+
detail: `no observable check-state change for ${args.stallMinutes}m`,
|
|
550
|
+
exitCode: 4,
|
|
551
|
+
}, state)
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
await sleepMs(args.intervalSeconds * 1000)
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
process.exitCode = await main().catch((error) => {
|
|
559
|
+
console.error(record({ error: 'watch-checks-failed', detail: String(error?.message ?? error) }))
|
|
560
|
+
return 4
|
|
561
|
+
})
|
|
@@ -5,7 +5,7 @@ description: >
|
|
|
5
5
|
project management. Use when configuring GCP resources, managing service
|
|
6
6
|
accounts, setting up IAP access, configuring VPC networks, managing secrets,
|
|
7
7
|
or debugging cloud infrastructure.
|
|
8
|
-
release-version: 0.50.
|
|
8
|
+
release-version: 0.50.2
|
|
9
9
|
---
|
|
10
10
|
**Updates:** Once/session run `node <skill-base-dir>/release-check.mjs`.
|
|
11
11
|
# Google Cloud Platform (GCP) Skill
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: mergify
|
|
3
3
|
description: "Mergify merge queue and stacked PRs (`mergify stack`): config, rules, queue status, delivery. Use whenever a merge queue or stack is involved, including plain asks ('watch #123 till it merges', 'where is #412 in the queue', 'push this stack'): `gh pr view`/`gh pr checks` cannot see a dequeue; use `mergify queue show` and the bundled watcher. Also: a hook saying 'use mergify stack push', a missing Change-Id; authoring or debugging `.mergify.yml`, queue rules, merge protections, batch size, the ruleset making merges queue-only; enqueue policy (`@mergifyio queue` vs `auto_merge_conditions`); a push dequeuing a queued PR; bypass actors; freezing a branch. Failures: a PR stuck, queued but never merging, or merged unqueued; CI green on GitHub but unmet in Mergify; a batch going red or bisecting; a queue check missing rather than failing. Outranks the vendor mergify-* skills. NOT for pushing a PR in a repo without stack hooks (github-flow), workflow YAML (github-actions), or GCP deploy pipelines (cloud-deploy)."
|
|
4
|
-
release-version: 0.50.
|
|
4
|
+
release-version: 0.50.2
|
|
5
5
|
---
|
|
6
6
|
**Updates:** Once/session run `node <skill-base-dir>/release-check.mjs`.
|
|
7
7
|
# Mergify Merge Queue
|
|
@@ -23,7 +23,7 @@ Requires mergify CLI **2026.10.5 or newer** ([stacks.md](references/stacks.md#cl
|
|
|
23
23
|
| **Push, open, or update a PR in a stack repo** (hook says "Use 'mergify stack push'", `Change-Id` missing) | [`references/stacks.md`](references/stacks.md) | Push with `scripts/stack-push.mjs`. One commit = one PR; amend to fix. Never `--no-verify`. |
|
|
24
24
|
| A stacked PR gets no CI, conflicts after its parent lands, or is dequeued "could not be retargeted onto … `main`" | [`references/configuration.md`](references/configuration.md#stack-ready), then [`references/traps.md`](references/traps.md) | Make the repo stack-ready (native stacks: Mergify's ruleset bypass `exempt`; `Depends-On` stacks also need PR CI on every base, commits kept, auto-delete head branches); then `mergify stack push --force-rebase` for a stuck tip. |
|
|
25
25
|
| Author or change `.mergify.yml` | [`references/configuration.md`](references/configuration.md) | [Rule 1](#rule-1--fetch-the-docs-before-writing-config): never write config from memory; finish with `validate` **and** `simulate`. |
|
|
26
|
-
| **Watch a PR through the queue** | [`references/watch-contract.md`](references/watch-contract.md) | **[Rule 2](#rule-2--never-hand-roll-a-watcher): run the bundled watcher, never hand-roll one.** A watch observes a *progression*, not one state. |
|
|
26
|
+
| **Watch a PR through the queue** | [`references/watch-contract.md`](references/watch-contract.md) | **[Rule 2](#rule-2--never-hand-roll-a-watcher): run the bundled watcher, never hand-roll one.** A watch observes a *progression*, not one state. It emits the initial snapshot and delivery/readability transitions; repeated identical polls stay quiet. |
|
|
27
27
|
| **The queue feels slow, or is it worth it?** | [`references/diagnosis.md`](references/diagnosis.md#measuring-a-slow-queue) | Measure first-try merge rate and same-SHA requeues from the event log, then fix the flaky queue check. Never move a queue-only check onto the PR lane, and never rerun CI on push to `main` ([rules](references/configuration.md#the-queue-run-is-the-integration-run)). |
|
|
28
28
|
| Check status — "where is my PR?", "why is it stuck?" | [`references/diagnosis.md`](references/diagnosis.md#querying-pr-and-queue-status) | `mergify queue show <number> -v` names the single blocking condition (bare number, never a URL; the CLI takes `--repository <owner/repo>`, never `--repo`; the bundled watcher below takes `--repo`); nothing else in the toolchain does. `gh pr checks` is CI truth, never queue truth. |
|
|
29
29
|
| A PR merged without ever being queued | [`references/branch-protection.md`](references/branch-protection.md) | Make the queue mandatory with the ruleset's `update` rule; a required status check alone does not. |
|
|
@@ -43,11 +43,16 @@ Queue states worth recognising:
|
|
|
43
43
|
|
|
44
44
|
```bash
|
|
45
45
|
gh pr checks <PR> # check-run table, exit 1 if any failed
|
|
46
|
-
gh pr checks <PR> --watch # block until they settle
|
|
47
46
|
gh pr view <PR> --json state,isDraft,mergeable,mergeStateStatus,statusCheckRollup
|
|
48
47
|
gh pr view <PR> --json labels --jq '.labels[].name' # queued / dequeued labels
|
|
49
48
|
```
|
|
50
49
|
|
|
50
|
+
A one-shot table is the right tool here. Waiting is not: `gh pr checks <PR> --watch` repaints
|
|
51
|
+
its table and its elapsed clock every 30 seconds, which reads as progress to anything watching
|
|
52
|
+
the log while the actual CI steps slide out of view. To wait for CI, run the delivery watcher
|
|
53
|
+
([`watch-contract.md`](watch-contract.md)) — or, for a PR outside a queue, the `github-flow`
|
|
54
|
+
skill's `scripts/watch-checks.mjs`, which emits one record per real check-state change.
|
|
55
|
+
|
|
51
56
|
Mergify labels a PR `queued` on entry and `dequeued` on removal
|
|
52
57
|
(`merge_queue.queued_label` / `dequeued_label`). These are **lifecycle** signals,
|
|
53
58
|
not a cheap membership check — what they mean depends on what you saw before
|
|
@@ -49,7 +49,7 @@ Two of the four were written *after* the agent had been told to check for dequeu
|
|
|
49
49
|
|
|
50
50
|
### `phases` — the progression as output
|
|
51
51
|
|
|
52
|
-
The watcher emits `phases`: the ordered list of lifecycle phases actually observed, on both the terminal verdict and every
|
|
52
|
+
The watcher emits `phases`: the ordered list of lifecycle phases actually observed, on both the terminal verdict and every transition record. It resolves the one ambiguity the terminal outcome cannot:
|
|
53
53
|
|
|
54
54
|
| terminal | `phases` | meaning | fix |
|
|
55
55
|
|---|---|---|---|
|
|
@@ -181,7 +181,8 @@ so the newest batch wins over stale comment fallback.
|
|
|
181
181
|
- **Run the bundled script. Do not hand-roll a watcher** — see the progression section above for why the hand-rolled shape cannot hold the contract, whatever care goes into the code. (If you are reading a legacy shell watcher: avoid shell-reserved variable names such as `status`.)
|
|
182
182
|
- Keep enqueue and watch separate. A watch observes only; it does not post `@mergifyio queue`, `@mergifyio dequeue`, or merge.
|
|
183
183
|
- Confirm the PR is queued, or can become queued, *before* launching the watch — see [Enqueue Before You Watch](#enqueue-before-you-watch). A watch that cannot enqueue, on a PR nothing else will enqueue either, can only end `STALLED`.
|
|
184
|
-
- Emit
|
|
184
|
+
- Emit the initial observation, then delivery-state or provider-readability transitions. Repeated identical polls stay quiet. A process supervisor counts new log bytes as activity even when a JSON record says `progress: false`, so labelling every heartbeat is insufficient. The initial record makes a waiting watch visible; the terminal verdict and existing no-change bounds explain how it ended. stdout remains exactly one terminal JSON document; stderr carries transition records and diagnostics.
|
|
185
|
+
- Mark observation records `activity: true`; set `progress`/`changed` only when the canonical delivery state changed. Provider readability changes remain visible without resetting that change clock. `at`, `sinceChangeMs` and `elapsedMs` are diagnostic clocks, never progress. Keep the queue memory, readable-source snapshots, and existing stall bounds across quiet polls.
|
|
185
186
|
- Bound on stalled state, not total elapsed time alone. `waiting_for_previous_batches` can be slow but healthy if position/state changes.
|
|
186
187
|
|
|
187
188
|
## Script
|
|
@@ -203,14 +204,14 @@ Options:
|
|
|
203
204
|
| `--admission-stall-minutes` | `3` | pre-admission terminal stall bound — no observable change *before this process has observed membership* is `STALLED` with `reason: "admission-stall"`. Shorter on purpose: after enqueue, queue entry should appear quickly, and a dead admission path must not wait out the full post-entry bound. Capped at `--stall-minutes` so a caller that shortens the post-entry bound keeps both phases tight. Applies only while no non-Mergify check is pending; while CI runs, the post-entry bound applies, so a CI-gated admission needs no relaunch |
|
|
204
205
|
| `--max-minutes` | `60` | absolute safety ceiling; bounds the watch even while the queue keeps reporting change, so a busy queue whose position keeps moving cannot keep the stall clock resetting forever. **60 minutes is also a hard maximum** — a larger value exits `2` rather than being silently clamped, because a caller that believes it has a four-hour watch and has one hour is worse off than one told plainly to relaunch. An hour without a verdict is a queue to hand back to a human or relaunch. The sibling `resolve-issues` watcher enforces the same ceiling; a cap honoured by one of two watchers is not a cap |
|
|
205
206
|
| `--slow-job-minutes` | `5` | warn when a single CI job runs longer than this (see [CI profile](#ci-profile)) |
|
|
206
|
-
| `--quiet` | off | suppress
|
|
207
|
+
| `--quiet` | off | suppress delivery/readability transition records on stderr (slow-job warnings and the closing profile summary still print) |
|
|
207
208
|
|
|
208
209
|
Output streams:
|
|
209
210
|
|
|
210
211
|
| stream | content |
|
|
211
212
|
|---|---|
|
|
212
213
|
| stdout | exactly one pretty-printed terminal JSON verdict, at exit — carrying `phases`, `seenQueued`, the outcome, and `ciProfile` |
|
|
213
|
-
| stderr | one compact JSON record per
|
|
214
|
+
| stderr | one compact JSON record per delivery/readability transition, marked `activity: true` (an observation was emitted) with `progress` set to whether the delivery state actually changed, plus the live outcome, PR labels, queue membership/position, `phases`, `changed`, and `sinceChangeMs` — the stall clock made visible; plus one `{"warning":"slow-ci-job"}` record per slow CI job; and, last, one `{"ciProfileSummary":true}` line after the verdict |
|
|
214
215
|
|
|
215
216
|
Each regular-poll subprocess read (`gh pr view`, `mergify queue show`) also carries its own bounded
|
|
216
217
|
timeout — a small multiple of `--interval-seconds`, clamped to never exceed the **effective
|
|
@@ -265,9 +266,11 @@ returns the PR's check runs with their start and finish times.
|
|
|
265
266
|
`{"ciProfileSummary":true,"outcome","verdict","jobCount","slowJobMinutes","slowest","slowJobs"}`,
|
|
266
267
|
with at most three slow jobs named as `"<name> <sec>s"`. The verdict is pretty-printed over
|
|
267
268
|
many lines, so a short log tail lands inside `phases` and misses the profile; this line is
|
|
268
|
-
what a 10-line tail shows. A background runner that reports only the exit code
|
|
269
|
-
|
|
270
|
-
|
|
269
|
+
what a 10-line tail shows. A background runner that reports only the exit code shows
|
|
270
|
+
neither: read the log tail when the watch ends, and report the profile. Diagnose movement
|
|
271
|
+
from the emitted `progress`/`outcome` and phases. Repeated polls stay quiet, so a process
|
|
272
|
+
supervisor cannot mistake periodic heartbeat writes for progress. Identity #894
|
|
273
|
+
(2026-09-29) warned about a 338s job that no one read.
|
|
271
274
|
|
|
272
275
|
Profiling is observation only: it never changes the outcome or the exit code. A slow-job
|
|
273
276
|
warning is a lead for whoever owns the CI (split the job, cache, move it to a nightly lane) —
|