devflow-kit 3.8.0 → 3.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +231 -0
- package/README.md +6 -6
- package/dist/agents/code.md +10 -10
- package/dist/agents/fix.md +7 -7
- package/dist/agents/git.md +2 -2
- package/dist/agents/harden.md +7 -6
- package/dist/agents/refactor.md +6 -6
- package/dist/agents/skim.md +1 -1
- package/dist/agents/triage.md +7 -0
- package/dist/cli/commands/ambient.js +10 -2
- package/dist/cli/commands/compliance-prompts.js +9 -4
- package/dist/cli/commands/compliance.js +171 -24
- package/dist/cli/commands/curation.js +79 -23
- package/dist/cli/commands/gate-level-prompts.js +39 -23
- package/dist/cli/commands/gates.js +6 -7
- package/dist/cli/commands/init.js +150 -145
- package/dist/cli/commands/install-report.js +34 -0
- package/dist/cli/commands/legacy-sweep.js +64 -0
- package/dist/cli/commands/memory.js +59 -12
- package/dist/cli/commands/proxy.js +6 -1
- package/dist/cli/commands/rules.js +29 -12
- package/dist/cli/commands/skills.js +42 -9
- package/dist/cli/commands/tracker.js +9 -3
- package/dist/cli/commands/uninstall.js +354 -60
- package/dist/commands/bug-analysis.md +71 -122
- package/dist/commands/code-review.md +11 -11
- package/dist/commands/debug.md +2 -2
- package/dist/commands/dynamic-build.md +25 -25
- package/dist/commands/dynamic-plan.md +2 -2
- package/dist/commands/dynamic-profile.md +2 -2
- package/dist/commands/dynamic-tickets.md +2 -2
- package/dist/commands/explore.md +2 -2
- package/dist/commands/implement.md +48 -34
- package/dist/commands/plan.md +2 -2
- package/dist/commands/release.md +3 -3
- package/dist/commands/research.md +2 -2
- package/dist/commands/resolve.md +50 -46
- package/dist/commands/self-review.md +10 -9
- package/dist/compliance-off/agents/git.md +120 -0
- package/dist/compliance-off/agents/triage.md +180 -0
- package/dist/compliance-off/commands/bug-analysis.md +398 -0
- package/dist/compliance-off/commands/code-review.md +520 -0
- package/dist/compliance-off/commands/dynamic-build.md +1251 -0
- package/dist/compliance-off/commands/dynamic-tickets.md +676 -0
- package/dist/compliance-off/commands/implement.md +736 -0
- package/dist/compliance-off/commands/plan.md +671 -0
- package/dist/compliance-off/commands/release.md +309 -0
- package/dist/compliance-off/commands/resolve.md +742 -0
- package/dist/compliance-off/skills/git/references/decision-markers.md +17 -0
- package/dist/compliance-off/skills/git/references/learn-conventions.md +80 -0
- package/dist/compliance-off/skills/git/references/pr/check-ci-status.md +33 -0
- package/dist/compliance-off/skills/git/references/pr/check-merge-readiness.md +49 -0
- package/dist/compliance-off/skills/git/references/pr/fetch-review-threads.md +52 -0
- package/dist/compliance-off/skills/git/references/pr/validate-branch.md +47 -0
- package/dist/compliance-off/skills/git/references/tracker/_contract.md +33 -0
- package/dist/compliance-off/skills/git/references/tracker/_mcp.md +153 -0
- package/dist/compliance-off/skills/git/references/tracker/github/create-release.md +35 -0
- package/dist/compliance-off/skills/git/references/tracker/github/ensure-pr-ready.md +100 -0
- package/dist/compliance-off/skills/git/references/tracker/github/ensure-traceable-issue.md +74 -0
- package/dist/compliance-off/skills/git/references/tracker/github/fetch-issue.md +69 -0
- package/dist/compliance-off/skills/git/references/tracker/github/fetch-issues-batch.md +65 -0
- package/dist/compliance-off/skills/git/references/tracker/github/gather-release-evidence.md +48 -0
- package/dist/compliance-off/skills/git/references/tracker/github/setup-task.md +77 -0
- package/dist/compliance-off/skills/git/references/tracker/jira/create-release.md +35 -0
- package/dist/compliance-off/skills/git/references/tracker/jira/ensure-pr-ready.md +100 -0
- package/dist/compliance-off/skills/git/references/tracker/jira/ensure-traceable-issue.md +61 -0
- package/dist/compliance-off/skills/git/references/tracker/jira/fetch-issue.md +51 -0
- package/dist/compliance-off/skills/git/references/tracker/jira/fetch-issues-batch.md +63 -0
- package/dist/compliance-off/skills/git/references/tracker/jira/gather-release-evidence.md +47 -0
- package/dist/compliance-off/skills/git/references/tracker/jira/setup-task.md +82 -0
- package/dist/compliance-off/skills/git/references/tracker/linear/create-release.md +35 -0
- package/dist/compliance-off/skills/git/references/tracker/linear/ensure-pr-ready.md +100 -0
- package/dist/compliance-off/skills/git/references/tracker/linear/ensure-traceable-issue.md +61 -0
- package/dist/compliance-off/skills/git/references/tracker/linear/fetch-issue.md +51 -0
- package/dist/compliance-off/skills/git/references/tracker/linear/fetch-issues-batch.md +63 -0
- package/dist/compliance-off/skills/git/references/tracker/linear/gather-release-evidence.md +47 -0
- package/dist/compliance-off/skills/git/references/tracker/linear/setup-task.md +83 -0
- package/dist/compliance-off/skills/git/references/trust-rule.md +7 -0
- package/dist/core/agent-models.js +19 -4
- package/dist/core/area-pause-notice.js +44 -0
- package/dist/core/assets.js +126 -17
- package/dist/core/compliance-off-files.js +27 -0
- package/dist/core/curation-move-skip.js +4 -5
- package/dist/core/curation-queue-cleanup.js +6 -5
- package/dist/core/curation-store.js +1 -0
- package/dist/core/curation-variants.js +460 -112
- package/dist/core/evidence-policy.js +10 -8
- package/dist/core/flags.js +2 -2
- package/dist/core/installed-variants.js +63 -0
- package/dist/core/manifest.js +48 -5
- package/dist/core/mds-variants.js +700 -3
- package/dist/core/orphan-sweep.js +11 -2
- package/dist/core/plugins.js +69 -12
- package/dist/core/tracked-areas.js +138 -14
- package/dist/curation-compliance-off/agents/triage.md +176 -0
- package/dist/curation-compliance-off/commands/bug-analysis.md +349 -0
- package/dist/curation-compliance-off/commands/code-review.md +481 -0
- package/dist/curation-compliance-off/commands/dynamic-build.md +1211 -0
- package/dist/curation-compliance-off/commands/dynamic-tickets.md +634 -0
- package/dist/curation-compliance-off/commands/implement.md +689 -0
- package/dist/curation-compliance-off/commands/plan.md +631 -0
- package/dist/curation-compliance-off/commands/release.md +279 -0
- package/dist/curation-compliance-off/commands/resolve.md +692 -0
- package/dist/curation-off/agents/code.md +10 -10
- package/dist/curation-off/agents/fix.md +7 -7
- package/dist/curation-off/agents/harden.md +7 -6
- package/dist/curation-off/agents/refactor.md +6 -6
- package/dist/curation-off/agents/triage.md +7 -0
- package/dist/curation-off/commands/bug-analysis.md +69 -119
- package/dist/curation-off/commands/code-review.md +11 -11
- package/dist/curation-off/commands/debug.md +2 -2
- package/dist/curation-off/commands/dynamic-build.md +25 -25
- package/dist/curation-off/commands/explore.md +2 -2
- package/dist/curation-off/commands/implement.md +48 -34
- package/dist/curation-off/commands/plan.md +2 -2
- package/dist/curation-off/commands/release.md +1 -1
- package/dist/curation-off/commands/resolve.md +50 -46
- package/dist/curation-off/commands/self-review.md +10 -9
- package/dist/hud/components/curation-counts.js +27 -0
- package/dist/skills/git/references/pr/post-resolution-summary.md +12 -11
- package/dist/skills/git/references/pr/post-review-summary.md +12 -11
- package/dist/skills/git/references/pr/resolve-review-threads.md +1 -1
- package/dist/skills/git/references/pr/update-pr-evidence.md +2 -2
- package/dist/skills/git/references/publication-gate.md +6 -7
- package/dist/skills/git/references/tracker/github/manage-debt.md +6 -2
- package/dist/skills/git/references/tracker/jira/manage-debt.md +6 -2
- package/dist/skills/git/references/tracker/linear/manage-debt.md +6 -2
- package/dist/targets/claude-code/behavior-install.js +23 -9
- package/dist/targets/claude-code/charter-render.js +374 -0
- package/dist/targets/claude-code/compliance-install.js +32 -12
- package/dist/targets/claude-code/compliance-off-files.js +245 -0
- package/dist/targets/claude-code/curation-install.js +570 -100
- package/dist/targets/claude-code/installer.js +467 -62
- package/dist/targets/claude-code/language-stamp.js +6 -0
- package/dist/targets/claude-code/linked-folder.js +182 -0
- package/dist/targets/claude-code/reference-converge.js +118 -0
- package/dist/targets/claude-code/tracker-install.js +21 -8
- package/dist/targets/claude-code/tracker-servers.js +20 -6
- package/dist/targets/claude-code/workflow-folder-install.js +231 -0
- package/dist/variant-axes.json +109 -0
- package/package.json +1 -1
- package/src/assets/agents/_partials/_code.mds +6 -6
- package/src/assets/agents/code.mds +4 -4
- package/src/assets/agents/evaluate.md +7 -13
- package/src/assets/agents/fix.mds +1 -1
- package/src/assets/agents/git.mds +34 -2
- package/src/assets/agents/harden.mds +7 -6
- package/src/assets/agents/skim.mds +1 -1
- package/src/assets/agents/test.md +21 -12
- package/src/assets/agents/triage.mds +9 -0
- package/src/assets/agents/validate.md +27 -34
- package/src/assets/commands/_partials/_compliance.mds +5 -3
- package/src/assets/commands/_partials/_decisions.mds +10 -8
- package/src/assets/commands/_partials/_engine.mds +9 -8
- package/src/assets/commands/_partials/_publication.mds +10 -1
- package/src/assets/commands/_partials/_settings.mds +2 -2
- package/src/assets/commands/bug-analysis.mds +80 -126
- package/src/assets/commands/code-review.mds +52 -9
- package/src/assets/commands/dynamic-build.mds +109 -15
- package/src/assets/commands/dynamic-tickets.mds +13 -0
- package/src/assets/commands/implement.mds +145 -33
- package/src/assets/commands/plan.mds +19 -0
- package/src/assets/commands/release.mds +50 -1
- package/src/assets/commands/resolve.mds +229 -44
- package/src/assets/commands/self-review.mds +8 -7
- package/src/assets/mds/git/_ops.mds +29 -1
- package/src/assets/mds/git/_ops_pr.mds +20 -10
- package/src/assets/mds/git/_ops_steps.mds +27 -0
- package/src/assets/mds/git/_pr.mds +15 -15
- package/src/assets/mds/git/_references.mds +12 -7
- package/src/assets/mds/tracker/_common.mds +4 -0
- package/src/assets/mds/tracker/_github.mds +43 -4
- package/src/assets/mds/tracker/_jira.mds +44 -4
- package/src/assets/mds/tracker/_linear.mds +44 -4
- package/src/assets/rules/quality.md +2 -0
- package/src/assets/scripts/ci-wait.cjs +493 -118
- package/src/assets/scripts/hooks/assets/implement-installed +1 -0
- package/src/assets/scripts/hooks/assets/orchestrator-charter.md +13 -11
- package/src/assets/scripts/hooks/background-memory-update +79 -1
- package/src/assets/scripts/hooks/json-helper.cjs +44 -2
- package/src/assets/scripts/hooks/lib/curation-store.cjs +82 -20
- package/src/assets/scripts/hooks/lib/render-decisions.cjs +9 -1
- package/src/assets/scripts/hooks/lib/tracked-areas.cjs +226 -33
- package/src/assets/scripts/hooks/memory-permission +10 -2
- package/src/assets/scripts/hooks/memory-worker +11 -0
- package/src/assets/scripts/hooks/pre-compact-memory +11 -0
- package/src/assets/scripts/hooks/preamble +18 -8
- package/src/assets/scripts/hooks/queue-append +218 -1
- package/src/assets/scripts/hooks/session-start-context +89 -37
- package/src/assets/scripts/hooks/session-start-memory +39 -0
- package/src/assets/scripts/hooks/session-start-orchestrator +4 -2
- package/src/assets/scripts/pr-evidence.cjs +46 -4
- package/src/assets/scripts/resolve-settings.cjs +244 -30
- package/src/assets/scripts/verify-evidence.cjs +708 -124
- package/src/assets/skills/agent-references/SKILL.md +7 -1
- package/src/assets/skills/agent-references/references/commands/implement-levels.md +6 -4
- package/src/assets/skills/agent-references/references/commands/implement-revalidate.md +3 -3
- package/src/assets/skills/agent-references/references/commands/plan-levels.md +1 -1
- package/src/assets/skills/agent-references/references/commands/resolve-levels.md +3 -2
- package/src/assets/skills/agent-references/references/synthesize/bug-analysis.md +6 -2
- package/src/assets/skills/agent-references/references/synthesize/review.md +4 -4
- package/src/assets/skills/agent-references/references/triage/severity-tiers.md +43 -0
- package/src/assets/skills/agent-references/references/validate/passed-alone.md +14 -0
- package/src/assets/skills/docs-framework/SKILL.md +5 -2
- package/src/assets/skills/git/SKILL.md +3 -3
- package/src/assets/skills/git/references/github-api.md +2 -139
- package/src/assets/skills/git/references/github-posting.md +158 -0
- package/src/assets/skills/review-methodology/SKILL.md +1 -1
- package/src/assets/skills/review-methodology/references/patterns.md +1 -1
- package/src/assets/skills/review-methodology/references/report-template.md +1 -2
- package/src/assets/skills/testing/SKILL.md +2 -4
|
@@ -4,9 +4,13 @@
|
|
|
4
4
|
// The CI wait for one pushed head (Token Diet wave 2, #421). /implement Phase 9 and
|
|
5
5
|
// /resolve Phase 8 call it inline, through the Bash tool, instead of re-spawning the
|
|
6
6
|
// Git agent once a minute: a poll costs no model tokens, so the wait happens here.
|
|
7
|
-
// It reads CI with `gh pr view` and `gh pr checks
|
|
8
|
-
//
|
|
9
|
-
//
|
|
7
|
+
// It reads CI with `gh pr view` and `gh pr checks`, plus a read-only `gh api` probe of
|
|
8
|
+
// the repository's workflow list when the check list is empty or holds external checks
|
|
9
|
+
// only (D-CI-WORKFLOWS): at most one answered probe per run, and its answer is kept for
|
|
10
|
+
// the run; a failed probe is a failed poll, and the next poll probes again. It waits for
|
|
11
|
+
// CI to start until CI_WAIT_MAX_SECONDS, classifies the head it was told about, and
|
|
12
|
+
// prints exactly one line.
|
|
13
|
+
// Installed beside hud.sh and redact-secrets.cjs under ~/.devflow/scripts/.
|
|
10
14
|
//
|
|
11
15
|
// Usage:
|
|
12
16
|
// node ci-wait.cjs --pr <n> --head <sha>
|
|
@@ -24,12 +28,21 @@
|
|
|
24
28
|
// writes it. Exit 0 whenever a line was printed; a reader treats a missing or
|
|
25
29
|
// unparseable line, an unlisted status or a non-zero exit as INDETERMINATE.
|
|
26
30
|
//
|
|
27
|
-
// Design constraints (binding)
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
31
|
+
// Design constraints (binding). The text of each verdict rule is written once, at the
|
|
32
|
+
// marker named below; this list points to it. The workflow-probe rule has two readers,
|
|
33
|
+
// mergeGate and matchedStep, and one reading of the probe value, probeAnswer.
|
|
34
|
+
// - D-CI-WAIT-INLINE: this is plumbing. The only judgment in it is the classifier
|
|
35
|
+
// (D-CI-CLASSIFIER, D-CI-WORKFLOW-RULE) and the merge gate (D-CI-MERGE-STATE,
|
|
36
|
+
// D-CI-WORKFLOWS, D-CI-PR-STATE). Every subprocess is `gh`, spawned through exec()
|
|
37
|
+
// with an argv array and `shell: false`, stdin ignored, a timeout and a maxBuffer.
|
|
38
|
+
// gh output never reaches stdout raw: a check name passes sanitizeName() first.
|
|
39
|
+
// - D-CI-WORKFLOW-RULE: see classifyChecks. PASSING does not prove that the test
|
|
40
|
+
// workflow ran by name.
|
|
41
|
+
// - D-CI-MERGE-STATE, D-CI-WORKFLOWS: see mergeGate (an empty check list) and matchedStep
|
|
42
|
+
// (rows that are all external checks, which read the probe answer themselves). The
|
|
43
|
+
// workflow files are listed, never opened: no workflow YAML is parsed, so which events
|
|
44
|
+
// a workflow reacts to is not judged.
|
|
45
|
+
// - D-CI-PR-STATE, D-CI-HEAD-BINDING: see waitForCi.
|
|
33
46
|
// - D-CI-NO-LITERAL-SLEEP: the wait happens inside node, through clock.sleep(),
|
|
34
47
|
// never through a shell `sleep`. Claude Code 2.1.294 blocks any Bash command
|
|
35
48
|
// that starts with a literal `sleep N`, so no prompt and no script may wait that
|
|
@@ -42,14 +55,31 @@
|
|
|
42
55
|
// name lists), and every wait is capped at CI_WAIT_MAX_SECONDS.
|
|
43
56
|
//
|
|
44
57
|
// gh facts this file rests on, probed on gh 2.88.1 on 2026-10-09:
|
|
45
|
-
// - `gh pr view 999999999 --json headRefOid` exits 1 with stderr
|
|
46
|
-
// not resolve to a PullRequest with the number of 999999999.
|
|
47
|
-
// (repository.pullRequest)` and an empty stdout
|
|
48
|
-
//
|
|
58
|
+
// - `gh pr view 999999999 --json headRefOid,mergeable,state` exits 1 with stderr
|
|
59
|
+
// `GraphQL: Could not resolve to a PullRequest with the number of 999999999.
|
|
60
|
+
// (repository.pullRequest)` and an empty stdout (re-probed with `mergeable` and
|
|
61
|
+
// `state` in the field list on 2026-10-22: the same text). Only that text on exit 1
|
|
62
|
+
// is NO_PR; any other non-zero exit is a gh failure and never PASSING.
|
|
49
63
|
// - `gh pr checks <n> --json` offers exactly bucket, completedAt, description,
|
|
50
64
|
// event, link, name, startedAt, state and workflow. There is no SHA field, and it
|
|
51
65
|
// reports only the PR's current head. So the head is bound by comparing
|
|
52
66
|
// `gh pr view --json headRefOid` with `--head` (D-CI-HEAD-BINDING).
|
|
67
|
+
// - `workflow` names the GitHub Actions workflow of a check, and is "" for a commit
|
|
68
|
+
// status and for a third-party app's check run (probed on public PRs, gh 2.88.1,
|
|
69
|
+
// 2026-10-22).
|
|
70
|
+
// - `gh pr view --json mergeable` is MERGEABLE, CONFLICTING or UNKNOWN. GitHub
|
|
71
|
+
// computes it lazily, so UNKNOWN is common right after a push or a base move. A
|
|
72
|
+
// merged PR reads `state` MERGED with `mergeable` UNKNOWN (probed on gh 2.88.1,
|
|
73
|
+
// 2026-10-22), which is why the poll asks `state` (D-CI-PR-STATE).
|
|
74
|
+
// - `gh api` fills the placeholders `{owner}` and `{repo}` from the repository of the
|
|
75
|
+
// current directory (`gh api --help`), and a call with no field flag is a GET.
|
|
76
|
+
// `repos/{owner}/{repo}/actions/workflows?per_page=100` answers `{ total_count,
|
|
77
|
+
// workflows: [{ path, state, ... }] }`, one row per workflow, with `state` active
|
|
78
|
+
// for a live one and a `path` such as `.github/workflows/ci.yml` (probed on gh
|
|
79
|
+
// 2.88.1, 2026-10-22). It is used rather than `gh workflow list` because
|
|
80
|
+
// `total_count` tells a whole list from a cut one. A row whose path is not under
|
|
81
|
+
// `.github/workflows/`, such as a `dynamic/...` path that GitHub gives its own
|
|
82
|
+
// apps, is not a workflow file of the repository.
|
|
53
83
|
|
|
54
84
|
'use strict';
|
|
55
85
|
|
|
@@ -69,10 +99,13 @@ const STATUSES = Object.freeze(['PASSING', 'FAILING', 'PENDING', 'NO_CI', 'NO_PR
|
|
|
69
99
|
/**
|
|
70
100
|
* D-CI-REASONS: why an INDETERMINATE was returned. `unrecognised-bucket` also covers
|
|
71
101
|
* a set of buckets that gives no verdict (every check `skipping`): the classifier
|
|
72
|
-
* could call neither a pass nor a failure.
|
|
102
|
+
* could call neither a pass nor a failure. The other rule reasons are defined at their
|
|
103
|
+
* markers: `no-workflow-check` (D-CI-WORKFLOW-RULE, D-CI-WORKFLOWS), `merge-conflict`
|
|
104
|
+
* (D-CI-MERGE-STATE) and `not-open` (D-CI-PR-STATE).
|
|
73
105
|
*/
|
|
74
106
|
const REASONS = Object.freeze([
|
|
75
107
|
'gh-failed', 'unparseable', 'unrecognised-bucket', 'head-mismatch', 'deadline', 'usage',
|
|
108
|
+
'no-workflow-check', 'merge-conflict', 'not-open',
|
|
76
109
|
]);
|
|
77
110
|
|
|
78
111
|
/** `gh pr checks` categorises `state` into these buckets (gh 2.88.1, `--help`). */
|
|
@@ -85,10 +118,7 @@ const BUCKETS = Object.freeze(['pass', 'fail', 'pending', 'skipping', 'cancel'])
|
|
|
85
118
|
* Bash timeout of 600000 ms the callers pass
|
|
86
119
|
* POLL_INTERVAL_SECONDS the sleep between polls; polls cost no model tokens,
|
|
87
120
|
* so the cadence is set by gh latency and rate limits
|
|
88
|
-
*
|
|
89
|
-
* persist before it is a verdict (GitHub creates
|
|
90
|
-
* checks and moves the PR head a few seconds after a
|
|
91
|
-
* push)
|
|
121
|
+
* WORKFLOWS_PAGE_ROWS the rows asked of the workflow list in its one page
|
|
92
122
|
* GH_CALL_CAP_SECONDS one gh call's timeout is min(this, time remaining),
|
|
93
123
|
* so the last call ends before the deadline
|
|
94
124
|
* MAX_POLLS the loop bound when the clock does not move
|
|
@@ -96,10 +126,10 @@ const BUCKETS = Object.freeze(['pass', 'fail', 'pending', 'skipping', 'cancel'])
|
|
|
96
126
|
*/
|
|
97
127
|
const CI_WAIT_MAX_SECONDS = 570;
|
|
98
128
|
const POLL_INTERVAL_SECONDS = 20;
|
|
99
|
-
const SETTLE_WINDOW_SECONDS = 90;
|
|
100
129
|
const GH_CALL_CAP_SECONDS = 30;
|
|
101
130
|
const MAX_POLLS = 40;
|
|
102
131
|
const MAX_CONSECUTIVE_GH_FAILURES = 3;
|
|
132
|
+
const WORKFLOWS_PAGE_ROWS = 100;
|
|
103
133
|
|
|
104
134
|
/** A PR with more check rows than this is read as an unparseable reply. */
|
|
105
135
|
const MAX_CHECKS = 1000;
|
|
@@ -143,6 +173,18 @@ const LINE_RE = new RegExp(
|
|
|
143
173
|
|
|
144
174
|
const USAGE_LINE = 'CI INDETERMINATE pr=- head=- waited=0 reason=usage';
|
|
145
175
|
|
|
176
|
+
/**
|
|
177
|
+
* D-CI-WORKFLOWS: the probe, a GET of the repository's workflow list. It is a fixed
|
|
178
|
+
* argv with no field flag, no method flag and no input, and it names neither the PR nor
|
|
179
|
+
* the head; gh fills `{owner}/{repo}` from the working directory.
|
|
180
|
+
*/
|
|
181
|
+
const WORKFLOWS_ARGS = Object.freeze(['api', 'repos/{owner}/{repo}/actions/workflows?per_page=' + WORKFLOWS_PAGE_ROWS]);
|
|
182
|
+
/** A workflow file lives here; the `dynamic/...` paths GitHub adds for its own apps do not. */
|
|
183
|
+
const WORKFLOWS_DIR = '.github/workflows/';
|
|
184
|
+
|
|
185
|
+
/** The fields every `gh pr view` call of a poll asks for. */
|
|
186
|
+
const VIEW_FIELDS = 'headRefOid,mergeable,state';
|
|
187
|
+
|
|
146
188
|
// ---------------------------------------------------------------------------
|
|
147
189
|
// Types
|
|
148
190
|
// ---------------------------------------------------------------------------
|
|
@@ -156,16 +198,34 @@ const USAGE_LINE = 'CI INDETERMINATE pr=- head=- waited=0 reason=usage';
|
|
|
156
198
|
* @typedef {{ now: () => number, sleep: (ms: number) => Promise<void> }} Clock
|
|
157
199
|
* `now` is in milliseconds. Tests inject a clock whose sleep and exec advance it.
|
|
158
200
|
*
|
|
159
|
-
* @typedef {{ name: string, bucket: string }} Check
|
|
201
|
+
* @typedef {{ name: string, bucket: string, workflow?: string }} Check
|
|
202
|
+
* `workflow` is the GitHub Actions workflow name; "" or absent marks an external check.
|
|
160
203
|
* @typedef {{ status: string, reason: string | null, failing: string[], pending: string[] }} Classification
|
|
161
204
|
* @typedef {{ status: string, pr: string, head: string, failing: string[], pending: string[],
|
|
162
205
|
* waited: number, reason: string | null }} CiResult
|
|
163
206
|
* `head` is the first 7 hex characters.
|
|
164
207
|
* @typedef {{ pr: string, head: string, exec: ExecFn, clock: Clock, env?: NodeJS.ProcessEnv }} WaitInput
|
|
208
|
+
* @typedef {{ state: 'deadline', rowsRead: boolean } | { state: 'failed' } | { state: 'pending', pending: string[] }
|
|
209
|
+
* | { state: 'open' }
|
|
210
|
+
* | { state: 'stall', status: string, reason: string | null }
|
|
211
|
+
* | { state: 'done', result: CiResult }} PollStep
|
|
212
|
+
* A `stall` keeps the wait going and names the line to give if the deadline comes first. A
|
|
213
|
+
* `deadline` is a poll that the deadline cut short, so it is no counted poll; `rowsRead` is
|
|
214
|
+
* true when the poll had read check rows, or a reply it could not read, before the cut.
|
|
215
|
+
* @typedef {{ status: string, reason: string | null, pending: string[] }} Last
|
|
216
|
+
* The line the last counted poll gives at the deadline.
|
|
165
217
|
* @typedef {{ exec?: ExecFn, clock?: Clock, env?: NodeJS.ProcessEnv }} MainDeps
|
|
166
218
|
* @typedef {{ code: number, stdout: string }} Outcome
|
|
167
219
|
*/
|
|
168
220
|
|
|
221
|
+
/**
|
|
222
|
+
* The line of a wait that has no verdict at the deadline: the start of the loop, an open or a
|
|
223
|
+
* failed poll, and a loop that ran out of MAX_POLLS. It is shared, so it is frozen.
|
|
224
|
+
*
|
|
225
|
+
* @type {Readonly<Last>}
|
|
226
|
+
*/
|
|
227
|
+
const DEADLINE_LAST = Object.freeze({ status: 'INDETERMINATE', reason: 'deadline', pending: Object.freeze([]) });
|
|
228
|
+
|
|
169
229
|
// ---------------------------------------------------------------------------
|
|
170
230
|
// Argument gate
|
|
171
231
|
// ---------------------------------------------------------------------------
|
|
@@ -224,14 +284,22 @@ function formatNames(names) {
|
|
|
224
284
|
}
|
|
225
285
|
|
|
226
286
|
/**
|
|
227
|
-
* D-CI-CLASSIFIER: check-ci-status step 5
|
|
228
|
-
* priority order: any `pending` is PENDING; else any `fail` or `cancel` is
|
|
229
|
-
* else every check `pass` or `skipping` with at least one `pass` is PASSING;
|
|
230
|
-
* INDETERMINATE. PASSING is a positive conjunction, so an undocumented bucket, an
|
|
231
|
-
* all-`skipping` set or an empty set never reaches it.
|
|
232
|
-
*
|
|
233
|
-
*
|
|
234
|
-
*
|
|
287
|
+
* D-CI-CLASSIFIER: check-ci-status step 5 over `bucket`, plus the stricter workflow
|
|
288
|
+
* rule. In priority order: any `pending` is PENDING; else any `fail` or `cancel` is
|
|
289
|
+
* FAILING; else every check `pass` or `skipping` with at least one `pass` is PASSING;
|
|
290
|
+
* else INDETERMINATE. PASSING is a positive conjunction, so an undocumented bucket, an
|
|
291
|
+
* all-`skipping` set or an empty set never reaches it.
|
|
292
|
+
*
|
|
293
|
+
* D-CI-WORKFLOW-RULE: a set that passes by step 5 is PASSING only when at least one
|
|
294
|
+
* passing check is a GitHub Actions job (a non-empty string `workflow`). A set with
|
|
295
|
+
* passes from external checks alone, a commit status or a third-party app, is
|
|
296
|
+
* INDETERMINATE (no-workflow-check): the repository's workflow may not have run. So
|
|
297
|
+
* PASSING proves that a GitHub Actions check passed and nothing is pending or failing;
|
|
298
|
+
* it does not prove that the test workflow ran by name. merge-readiness.test.ts runs
|
|
299
|
+
* every multiset of 1 to 3 checks through this and through the prose clauses and
|
|
300
|
+
* requires the statuses to agree, or this to be stricter only by no-workflow-check.
|
|
301
|
+
* (The empty set is not classified here: an empty list is "no checks", which
|
|
302
|
+
* waitForCi reads as no classification at all.)
|
|
235
303
|
*
|
|
236
304
|
* @param {readonly Check[]} checks
|
|
237
305
|
* @returns {Classification}
|
|
@@ -240,6 +308,7 @@ function classifyChecks(checks) {
|
|
|
240
308
|
const failing = [];
|
|
241
309
|
const pending = [];
|
|
242
310
|
let pass = 0;
|
|
311
|
+
let actionsPass = 0;
|
|
243
312
|
let unrecognised = 0;
|
|
244
313
|
const count = Math.min(Array.isArray(checks) ? checks.length : 0, MAX_CHECKS);
|
|
245
314
|
for (let i = 0; i < count; i++) {
|
|
@@ -248,14 +317,21 @@ function classifyChecks(checks) {
|
|
|
248
317
|
case 'pending': pending.push(check.name); break;
|
|
249
318
|
case 'fail':
|
|
250
319
|
case 'cancel': failing.push(check.name); break;
|
|
251
|
-
case 'pass':
|
|
320
|
+
case 'pass':
|
|
321
|
+
pass++;
|
|
322
|
+
if (typeof check.workflow === 'string' && check.workflow !== '') actionsPass++;
|
|
323
|
+
break;
|
|
252
324
|
case 'skipping': break;
|
|
253
325
|
default: unrecognised++;
|
|
254
326
|
}
|
|
255
327
|
}
|
|
256
328
|
if (pending.length > 0) return { status: 'PENDING', reason: null, failing, pending };
|
|
257
329
|
if (failing.length > 0) return { status: 'FAILING', reason: null, failing, pending };
|
|
258
|
-
if (unrecognised === 0 && pass > 0)
|
|
330
|
+
if (unrecognised === 0 && pass > 0) {
|
|
331
|
+
return actionsPass > 0
|
|
332
|
+
? { status: 'PASSING', reason: null, failing, pending }
|
|
333
|
+
: { status: 'INDETERMINATE', reason: 'no-workflow-check', failing, pending };
|
|
334
|
+
}
|
|
259
335
|
return { status: 'INDETERMINATE', reason: 'unrecognised-bucket', failing, pending };
|
|
260
336
|
}
|
|
261
337
|
|
|
@@ -338,6 +414,17 @@ function normalize(res) {
|
|
|
338
414
|
};
|
|
339
415
|
}
|
|
340
416
|
|
|
417
|
+
/**
|
|
418
|
+
* Whether a gh call ran out its timeout: spawnSync then reports the error code ETIMEDOUT.
|
|
419
|
+
* A call that failed in any other way, fast or slow, did not.
|
|
420
|
+
*
|
|
421
|
+
* @param {ExecResult | null} res
|
|
422
|
+
* @returns {boolean}
|
|
423
|
+
*/
|
|
424
|
+
function timedOut(res) {
|
|
425
|
+
return res !== null && typeof res === 'object' && Boolean(res.error) && res.error.code === 'ETIMEDOUT';
|
|
426
|
+
}
|
|
427
|
+
|
|
341
428
|
// ---------------------------------------------------------------------------
|
|
342
429
|
// Reply parsers
|
|
343
430
|
// ---------------------------------------------------------------------------
|
|
@@ -355,13 +442,20 @@ function parseJson(text) {
|
|
|
355
442
|
}
|
|
356
443
|
|
|
357
444
|
/**
|
|
445
|
+
* The `gh pr view --json headRefOid,mergeable,state` reply. `headRefOid` and `state` are
|
|
446
|
+
* strict: a head that is not 40 lowercase hex, or a `state` that is not a string, makes the
|
|
447
|
+
* reply unparseable. A `mergeable` that is absent or not a string is read as null, the same
|
|
448
|
+
* as UNKNOWN, and never makes the reply unparseable.
|
|
449
|
+
*
|
|
358
450
|
* @param {string} stdout
|
|
359
|
-
* @returns {string | null
|
|
451
|
+
* @returns {{ oid: string, mergeable: string | null, state: string } | null} null for any other shape
|
|
360
452
|
*/
|
|
361
|
-
function
|
|
453
|
+
function parseView(stdout) {
|
|
362
454
|
const value = parseJson(stdout);
|
|
363
455
|
if (value === null || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
364
|
-
|
|
456
|
+
if (typeof value.headRefOid !== 'string' || !HEAD_RE.test(value.headRefOid)) return null;
|
|
457
|
+
if (typeof value.state !== 'string') return null;
|
|
458
|
+
return { oid: value.headRefOid, mergeable: typeof value.mergeable === 'string' ? value.mergeable : null, state: value.state };
|
|
365
459
|
}
|
|
366
460
|
|
|
367
461
|
/**
|
|
@@ -374,48 +468,287 @@ function parseChecks(stdout) {
|
|
|
374
468
|
const checks = [];
|
|
375
469
|
for (const row of value) {
|
|
376
470
|
if (row === null || typeof row !== 'object' || typeof row.name !== 'string' || typeof row.bucket !== 'string') return null;
|
|
377
|
-
checks.push({ name: row.name, bucket: row.bucket });
|
|
471
|
+
checks.push({ name: row.name, bucket: row.bucket, workflow: typeof row.workflow === 'string' ? row.workflow : '' });
|
|
378
472
|
}
|
|
379
473
|
return checks;
|
|
380
474
|
}
|
|
381
475
|
|
|
476
|
+
/**
|
|
477
|
+
* D-CI-WORKFLOWS: whether the workflow list holds an active workflow file. A row is a
|
|
478
|
+
* workflow file when its `state` is exactly `active` and its `path` starts with
|
|
479
|
+
* `.github/workflows/`; the `dynamic/...` paths of GitHub's own apps and every
|
|
480
|
+
* disabled or deleted row are not. The reply is read whole before it is judged, so a
|
|
481
|
+
* row without a string `path` and `state` makes the reply unreadable even beside an
|
|
482
|
+
* active one. The list is one page: no match is a "no" only when the rows read equal
|
|
483
|
+
* `total_count` (and fit the page), and a page that is not all of the list proves
|
|
484
|
+
* nothing about the rest. The workflow files themselves are never opened.
|
|
485
|
+
*
|
|
486
|
+
* @param {string} stdout the `gh api` reply
|
|
487
|
+
* @returns {boolean | null} true: some active workflow file. false: none, and the
|
|
488
|
+
* list is complete. null: bad JSON, a bad row, or an incomplete page.
|
|
489
|
+
*/
|
|
490
|
+
function parseActiveWorkflows(stdout) {
|
|
491
|
+
const value = parseJson(stdout);
|
|
492
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
493
|
+
const listed = value.workflows;
|
|
494
|
+
const total = value.total_count;
|
|
495
|
+
if (!Array.isArray(listed) || listed.length > WORKFLOWS_PAGE_ROWS) return null;
|
|
496
|
+
if (typeof total !== 'number' || !Number.isInteger(total) || total < 0) return null;
|
|
497
|
+
let active = false;
|
|
498
|
+
for (const row of listed) {
|
|
499
|
+
if (row === null || typeof row !== 'object' || typeof row.path !== 'string' || typeof row.state !== 'string') return null;
|
|
500
|
+
if (row.state === 'active' && row.path.startsWith(WORKFLOWS_DIR)) active = true;
|
|
501
|
+
}
|
|
502
|
+
if (active) return true;
|
|
503
|
+
return listed.length === total ? false : null;
|
|
504
|
+
}
|
|
505
|
+
|
|
382
506
|
// ---------------------------------------------------------------------------
|
|
383
507
|
// The wait
|
|
384
508
|
// ---------------------------------------------------------------------------
|
|
385
509
|
|
|
386
510
|
/**
|
|
387
|
-
*
|
|
388
|
-
*
|
|
389
|
-
*
|
|
390
|
-
*
|
|
511
|
+
* D-CI-MERGE-STATE: whether GitHub reports the PR as conflicting. It is the one rule for a
|
|
512
|
+
* conflict. mergeGate answers 'conflict' by it, and a caller that has read only the merge
|
|
513
|
+
* state asks it before it reads any check.
|
|
514
|
+
*
|
|
515
|
+
* @param {unknown} mergeable the `gh pr view` value in any shape
|
|
516
|
+
* @returns {boolean}
|
|
517
|
+
*/
|
|
518
|
+
function isConflicting(mergeable) {
|
|
519
|
+
return mergeable === 'CONFLICTING';
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* D-CI-WORKFLOWS: what the workflow probe has said, as one answer. It is the one reading
|
|
524
|
+
* of the probe's value, used by mergeGate and by matchedStep alike. Only the booleans
|
|
525
|
+
* true and false are answers; null, left out and any other value ask for the probe.
|
|
526
|
+
*
|
|
527
|
+
* @param {unknown} workflowsActive
|
|
528
|
+
* @returns {'active' | 'none' | 'ask'}
|
|
529
|
+
*/
|
|
530
|
+
function probeAnswer(workflowsActive) {
|
|
531
|
+
if (workflowsActive === true) return 'active';
|
|
532
|
+
return workflowsActive === false ? 'none' : 'ask';
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* D-CI-MERGE-STATE, D-CI-WORKFLOWS: the one merge rule. GitHub computes `mergeable`
|
|
537
|
+
* lazily, and runs no pull request workflow on a conflicting PR, so the checks of a head
|
|
538
|
+
* say nothing about the PR unless GitHub reports it MERGEABLE. An empty check list says
|
|
539
|
+
* nothing either unless the repository has no workflow that could still start.
|
|
540
|
+
* `status` is a classifier status ('NO_CI' for an empty list), or null before any check
|
|
541
|
+
* is read; `mergeable` is the `gh pr view` value in any shape; `workflowsActive` is what
|
|
542
|
+
* the workflow probe found: true (an active workflow file), false (none), or null or
|
|
543
|
+
* left out when it was not made or gave no answer. Each case has its own answer, so a
|
|
544
|
+
* caller never reads `mergeable` again. The probe value has two readers, both through
|
|
545
|
+
* probeAnswer: mergeGate, for an empty list, and matchedStep, for rows that are all external
|
|
546
|
+
* checks:
|
|
547
|
+
* 'conflict' isConflicting(mergeable), whatever the status.
|
|
548
|
+
* 'await-merge-state' PASSING or NO_CI while mergeable is anything but MERGEABLE
|
|
549
|
+
* (UNKNOWN, absent or odd). It is neither a verdict nor a conflict:
|
|
550
|
+
* waitForCi keeps the wait open for another read, and a single read
|
|
551
|
+
* takes it as unknown.
|
|
552
|
+
* 'probe' NO_CI on MERGEABLE with no usable probe answer (null, left out or
|
|
553
|
+
* not a boolean): the workflow list has to be asked.
|
|
554
|
+
* 'workflow-active' NO_CI on MERGEABLE, and the probe found an active workflow file:
|
|
555
|
+
* a run may still start.
|
|
556
|
+
* 'keep' the status stands: PASSING on MERGEABLE, NO_CI on MERGEABLE with a
|
|
557
|
+
* probe that found no workflow file (waitForCi holds that NO_CI until
|
|
558
|
+
* the deadline, so checks that appear later decide), and FAILING,
|
|
559
|
+
* PENDING and INDETERMINATE, which do not depend on the merge state
|
|
560
|
+
* or the probe.
|
|
561
|
+
*
|
|
562
|
+
* @param {string | null} status
|
|
563
|
+
* @param {unknown} mergeable
|
|
564
|
+
* @param {boolean | null} [workflowsActive]
|
|
565
|
+
* @returns {'conflict' | 'await-merge-state' | 'probe' | 'workflow-active' | 'keep'}
|
|
566
|
+
*/
|
|
567
|
+
function mergeGate(status, mergeable, workflowsActive) {
|
|
568
|
+
if (isConflicting(mergeable)) return 'conflict';
|
|
569
|
+
const mergeableNow = mergeable === 'MERGEABLE';
|
|
570
|
+
if (status === 'PASSING') return mergeableNow ? 'keep' : 'await-merge-state';
|
|
571
|
+
if (status !== 'NO_CI') return 'keep';
|
|
572
|
+
if (!mergeableNow) return 'await-merge-state';
|
|
573
|
+
const answer = probeAnswer(workflowsActive);
|
|
574
|
+
if (answer === 'active') return 'workflow-active';
|
|
575
|
+
return answer === 'none' ? 'keep' : 'probe';
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* @param {string} status
|
|
580
|
+
* @param {string | null} reason
|
|
581
|
+
* @param {string[]} [failing]
|
|
582
|
+
* @param {string[]} [pending]
|
|
583
|
+
* @returns {{ state: 'done', status: string, reason: string | null, failing: string[], pending: string[] }}
|
|
584
|
+
*/
|
|
585
|
+
function done(status, reason, failing = [], pending = []) {
|
|
586
|
+
return { state: 'done', status, reason, failing, pending };
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* A step that keeps the wait going and names the line to give if the deadline comes first.
|
|
391
591
|
*
|
|
392
|
-
* @param {
|
|
393
|
-
* @param {
|
|
394
|
-
* @
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
592
|
+
* @param {string} status
|
|
593
|
+
* @param {string | null} reason
|
|
594
|
+
* @returns {{ state: 'stall', status: string, reason: string | null }}
|
|
595
|
+
*/
|
|
596
|
+
function stall(status, reason) {
|
|
597
|
+
return { state: 'stall', status, reason };
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* The outcome of one poll on the named head, as a pure function of what the poll read
|
|
602
|
+
* (the rules are classifyChecks's and mergeGate's). `verdict` is the classification
|
|
603
|
+
* of the rows, or null for an empty list. `workflowsActive` is the probe's answer (see
|
|
604
|
+
* mergeGate). An empty list reads it through mergeGate, and each answer of mergeGate maps
|
|
605
|
+
* to one step. This function reads it itself (probeAnswer) on the external-only path only:
|
|
606
|
+
* rows that are all external checks (no-workflow-check), where a workflow run that has not
|
|
607
|
+
* started yet could still change the verdict. It never reads `mergeable` itself. The
|
|
608
|
+
* outcome is one of:
|
|
609
|
+
* pending keep waiting: a pending check
|
|
610
|
+
* open a pass or an empty list while the gate awaits the merge state
|
|
611
|
+
* probe an empty list on MERGEABLE, or external-only rows, and the probe has no
|
|
612
|
+
* answer yet: the caller makes the workflow probe, then asks again with its
|
|
613
|
+
* answer
|
|
614
|
+
* stall keep waiting, with a line for the deadline: NO_CI for an empty list when the
|
|
615
|
+
* repository has no workflow file; INDETERMINATE (no-workflow-check) for an empty
|
|
616
|
+
* list or external-only rows when it has an active one, since a run may still
|
|
617
|
+
* start. A later poll that reads checks decides instead.
|
|
618
|
+
* done a final status and reason, with the failing and pending names
|
|
619
|
+
*
|
|
620
|
+
* @param {Classification | null} verdict
|
|
621
|
+
* @param {unknown} mergeable
|
|
622
|
+
* @param {boolean | null} [workflowsActive]
|
|
623
|
+
* @returns {{ state: 'pending', pending: string[] } | { state: 'open' } | { state: 'probe' }
|
|
624
|
+
* | { state: 'stall', status: 'INDETERMINATE', reason: 'no-workflow-check' }
|
|
625
|
+
* | { state: 'stall', status: 'NO_CI', reason: null }
|
|
626
|
+
* | ReturnType<typeof done>}
|
|
627
|
+
*/
|
|
628
|
+
function matchedStep(verdict, mergeable, workflowsActive) {
|
|
629
|
+
const gate = mergeGate(verdict === null ? 'NO_CI' : verdict.status, mergeable, workflowsActive);
|
|
630
|
+
if (gate === 'conflict') return done('INDETERMINATE', 'merge-conflict');
|
|
631
|
+
if (verdict === null) {
|
|
632
|
+
if (gate === 'keep') return stall('NO_CI', null);
|
|
633
|
+
if (gate === 'workflow-active') return stall('INDETERMINATE', 'no-workflow-check');
|
|
634
|
+
return gate === 'probe' ? { state: 'probe' } : { state: 'open' };
|
|
635
|
+
}
|
|
636
|
+
if (verdict.reason === 'no-workflow-check') {
|
|
637
|
+
const answer = probeAnswer(workflowsActive);
|
|
638
|
+
if (answer === 'ask') return { state: 'probe' };
|
|
639
|
+
return answer === 'none' ? done('INDETERMINATE', 'no-workflow-check') : stall('INDETERMINATE', 'no-workflow-check');
|
|
640
|
+
}
|
|
641
|
+
if (verdict.status === 'PENDING') return { state: 'pending', pending: verdict.pending };
|
|
642
|
+
if (gate !== 'keep') return { state: 'open' };
|
|
643
|
+
return done(verdict.status, verdict.reason, verdict.failing, verdict.pending);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* The line a counted poll leaves for the deadline: PENDING with its names, a stall with its
|
|
648
|
+
* status and reason, and INDETERMINATE (deadline) for an open or a failed poll, which
|
|
649
|
+
* clears an earlier stall. A step that is not a counted poll never gets here.
|
|
650
|
+
*
|
|
651
|
+
* @param {PollStep} step
|
|
652
|
+
* @returns {Last}
|
|
653
|
+
*/
|
|
654
|
+
function lastOf(step) {
|
|
655
|
+
if (step.state === 'pending') return { status: 'PENDING', reason: null, pending: step.pending };
|
|
656
|
+
if (step.state === 'stall') return { status: step.status, reason: step.reason, pending: [] };
|
|
657
|
+
return DEADLINE_LAST;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* The line at the end of the loop. NO_CI stands only when the deadline ended the loop:
|
|
662
|
+
* a loop that ran out of MAX_POLLS before the deadline has not waited out CI, so it reads
|
|
663
|
+
* INDETERMINATE (deadline).
|
|
664
|
+
*
|
|
665
|
+
* @param {Last} last
|
|
666
|
+
* @param {boolean} reachedDeadline
|
|
667
|
+
* @returns {Last}
|
|
668
|
+
*/
|
|
669
|
+
function tailOf(last, reachedDeadline) {
|
|
670
|
+
return last.status === 'NO_CI' && !reachedDeadline ? DEADLINE_LAST : last;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* D-CI-WORKFLOWS: the workflow probe, as one result. `gh` is waitForCi's bounded gh
|
|
675
|
+
* call, so the probe gets its timeout and, with no time left, no call at all. The result is
|
|
676
|
+
* one of:
|
|
677
|
+
* deadline no time was left, so no call was made
|
|
678
|
+
* failed the call was not answered or exited non-zero: a failed poll
|
|
679
|
+
* unparseable the reply cannot be read, or is one page of many (parseActiveWorkflows)
|
|
680
|
+
* answer `active`: true for some active workflow file, false for none
|
|
681
|
+
*
|
|
682
|
+
* @param {(args: readonly string[]) => Promise<GhAnswer | null>} gh
|
|
683
|
+
* @returns {Promise<{ kind: 'deadline' } | { kind: 'failed' } | { kind: 'unparseable' }
|
|
684
|
+
* | { kind: 'answer', active: boolean }>}
|
|
685
|
+
*/
|
|
686
|
+
async function askWorkflows(gh) {
|
|
687
|
+
const probe = await gh(WORKFLOWS_ARGS);
|
|
688
|
+
if (probe === null) return { kind: 'deadline' };
|
|
689
|
+
if (!probe.answered || probe.status !== 0) return { kind: 'failed' };
|
|
690
|
+
const active = parseActiveWorkflows(probe.stdout);
|
|
691
|
+
return active === null ? { kind: 'unparseable' } : { kind: 'answer', active };
|
|
400
692
|
}
|
|
401
693
|
|
|
402
694
|
/**
|
|
403
695
|
* D-CI-HEAD-BINDING: wait for the CI verdict of `head`, and of nothing else. `gh pr
|
|
404
696
|
* checks` has no SHA field and reports the PR's current head, so each poll reads
|
|
405
697
|
* `headRefOid` first. A different value means the checks on offer belong to another
|
|
406
|
-
* head: they are not read, and the poll is
|
|
407
|
-
*
|
|
408
|
-
*
|
|
409
|
-
*
|
|
410
|
-
*
|
|
698
|
+
* head: they are not read, the merge state is not used, and the poll is a stall
|
|
699
|
+
* (INDETERMINATE, head-mismatch) that the wait polls through until the deadline (GitHub
|
|
700
|
+
* moves the head and creates checks after a push, and CI can start much later). A poll
|
|
701
|
+
* that sees `head` again is decided by its checks. The poll reads `headRefOid` once more
|
|
702
|
+
* after the checks call: the checks count only when it is still `head`. A head that moved
|
|
703
|
+
* leaves the poll open (no verdict); a second read that fails or cannot be read is a failed
|
|
704
|
+
* poll. A PASSING needs a poll that saw this head before and after its checks and passed
|
|
705
|
+
* the rules of classifyChecks (D-CI-WORKFLOW-RULE) and mergeGate (D-CI-MERGE-STATE).
|
|
706
|
+
*
|
|
707
|
+
* D-CI-PR-STATE: GitHub reports `mergeable` UNKNOWN for a merged PR for good (gh 2.88.1,
|
|
708
|
+
* probed on 2026-10-22), so a PR that is no longer open would wait to the deadline for a
|
|
709
|
+
* merge state that never comes. The first read of a poll also asks `state`: anything but
|
|
710
|
+
* the exact string OPEN ends the run at once as INDETERMINATE (not-open), before the head
|
|
711
|
+
* is compared, and a `state` that is not a string is unparseable. Never PASSING.
|
|
712
|
+
*
|
|
713
|
+
* D-CI-MERGE-STATE, D-CI-WORKFLOWS: the rules are mergeGate's and matchedStep's; this loop
|
|
714
|
+
* acts on their answers. On a matching head, `mergeable` CONFLICTING is INDETERMINATE
|
|
715
|
+
* (merge-conflict) at once, before the checks are read. An open step (the gate awaits the
|
|
716
|
+
* merge state) is not PENDING, so a head that never reads MERGEABLE ends INDETERMINATE
|
|
717
|
+
* (deadline). An empty list on MERGEABLE, and rows that are all external checks, need the
|
|
718
|
+
* workflow probe. Rows that are all external checks end INDETERMINATE (no-workflow-check) at
|
|
719
|
+
* once when the repository has no active workflow file. In the other cases a workflow run
|
|
720
|
+
* may still start, so the step is a stall that keeps the wait going: an empty list with no
|
|
721
|
+
* workflow file stalls as NO_CI, and an empty list or external-only rows with an active
|
|
722
|
+
* workflow file stall as INDETERMINATE (no-workflow-check). Checks that appear decide.
|
|
723
|
+
* A step that asks for the probe gets it from askWorkflows, `gh api` over WORKFLOWS_ARGS,
|
|
724
|
+
* through the same bounded gh call (timeout min(GH_CALL_CAP_SECONDS, time left), none when
|
|
725
|
+
* no time is left, which ends the loop as for any call). At most one probe is answered per
|
|
726
|
+
* run: the first answer is kept and goes to every later poll, so no later poll asks again.
|
|
727
|
+
* A probe whose gh call fails is a failed poll, and the next poll probes again; a reply
|
|
728
|
+
* parseActiveWorkflows cannot read is INDETERMINATE (unparseable) at once. No other path
|
|
729
|
+
* makes the probe.
|
|
730
|
+
*
|
|
731
|
+
* Per poll: `gh pr view <n> --json headRefOid,mergeable,state`, then `gh pr checks <n>
|
|
732
|
+
* --json name,state,bucket,workflow` (exit 0 or 8 are results, as in check-ci-status
|
|
733
|
+
* step 3), then the same view call again (a bounded gh call like the others, not made
|
|
734
|
+
* when no time is left), then, on the paths that need it and only while the probe has no
|
|
735
|
+
* answer, the workflow probe. A PENDING, open or stall poll sleeps min(POLL_INTERVAL_SECONDS,
|
|
736
|
+
* remaining) and polls again; any other result returns. A poll whose gh call fails is not a
|
|
737
|
+
* result: three failed polls in a row are INDETERMINATE (gh-failed), fewer are retried; a
|
|
738
|
+
* pending, open or stall poll in between clears the count. A gh call that the time left cut
|
|
739
|
+
* below GH_CALL_CAP_SECONDS and that ran out that timeout is the deadline, not a failed poll:
|
|
740
|
+
* that poll does not count. Any other failure of that call is a failed poll.
|
|
411
741
|
*
|
|
412
|
-
*
|
|
413
|
-
*
|
|
414
|
-
*
|
|
415
|
-
*
|
|
416
|
-
*
|
|
417
|
-
* the
|
|
418
|
-
*
|
|
742
|
+
* The loop stops at the deadline or after MAX_POLLS. The last counted poll gives the line:
|
|
743
|
+
* PENDING stays PENDING, a stall gives its status and reason, and an open or a failed poll,
|
|
744
|
+
* which clears an earlier stall, gives INDETERMINATE (deadline). NO_CI stands only when the
|
|
745
|
+
* deadline ended the loop; a loop that ran out of MAX_POLLS first is INDETERMINATE
|
|
746
|
+
* (deadline). One cut poll changes the line: when the deadline cuts the second head read or
|
|
747
|
+
* the workflow probe after the checks call gave rows (or a reply that cannot be read), those
|
|
748
|
+
* rows contradict an earlier stall, so the stall does not survive and the line is
|
|
749
|
+
* INDETERMINATE (deadline). An earlier PENDING stands. A cut before any row came back (the
|
|
750
|
+
* first head read, the checks call, or a later call after an empty list) leaves the line of
|
|
751
|
+
* the last counted poll.
|
|
419
752
|
*
|
|
420
753
|
* It never throws: an exec that throws is a gh failure.
|
|
421
754
|
*
|
|
@@ -427,7 +760,6 @@ async function waitForCi(input) {
|
|
|
427
760
|
const env = input.env || childEnv();
|
|
428
761
|
const startedAt = clock.now();
|
|
429
762
|
const deadline = startedAt + CI_WAIT_MAX_SECONDS * 1000;
|
|
430
|
-
const settleMs = SETTLE_WINDOW_SECONDS * 1000;
|
|
431
763
|
|
|
432
764
|
/** @returns {CiResult} */
|
|
433
765
|
const finish = (status, reason, failing, pending) => ({
|
|
@@ -441,8 +773,10 @@ async function waitForCi(input) {
|
|
|
441
773
|
});
|
|
442
774
|
|
|
443
775
|
/**
|
|
444
|
-
* One gh call, its timeout min(GH_CALL_CAP_SECONDS, time left)
|
|
445
|
-
* is left
|
|
776
|
+
* One gh call, its timeout min(GH_CALL_CAP_SECONDS, time left). It is null, with no
|
|
777
|
+
* call made, when no time is left. It is also null for a call that the time left held
|
|
778
|
+
* below GH_CALL_CAP_SECONDS and that ran out that timeout: the deadline cut it short, so
|
|
779
|
+
* it is no failed poll. A call that fails in any other way is a failed poll.
|
|
446
780
|
*
|
|
447
781
|
* @param {string[]} args
|
|
448
782
|
* @returns {Promise<GhAnswer | null>}
|
|
@@ -463,81 +797,116 @@ async function waitForCi(input) {
|
|
|
463
797
|
} catch (_) {
|
|
464
798
|
res = null;
|
|
465
799
|
}
|
|
466
|
-
|
|
800
|
+
const cutShort = left < GH_CALL_CAP_SECONDS * 1000 && timedOut(res);
|
|
801
|
+
return cutShort ? null : normalize(res);
|
|
467
802
|
};
|
|
468
803
|
|
|
469
|
-
|
|
470
|
-
let
|
|
471
|
-
let firstEmptyAt = null;
|
|
472
|
-
/** @type {{ status: string, pending: string[] }} */
|
|
473
|
-
let last = { status: 'INDETERMINATE', pending: [] };
|
|
804
|
+
/** The probe's first answer, kept for the run; null until a probe answers. */
|
|
805
|
+
let workflowsActive = null;
|
|
474
806
|
|
|
475
|
-
|
|
476
|
-
|
|
807
|
+
/**
|
|
808
|
+
* A step that ends the run with a result.
|
|
809
|
+
*
|
|
810
|
+
* @param {string} status
|
|
811
|
+
* @param {string | null} reason
|
|
812
|
+
* @param {string[]} failing
|
|
813
|
+
* @param {string[]} pending
|
|
814
|
+
* @returns {PollStep}
|
|
815
|
+
*/
|
|
816
|
+
const resultStep = (status, reason, failing, pending) => ({ state: 'done', result: finish(status, reason, failing, pending) });
|
|
817
|
+
|
|
818
|
+
/**
|
|
819
|
+
* The step for what matchedStep decided. A probe request cannot be a final step: it is
|
|
820
|
+
* no verdict, and the wait stays open.
|
|
821
|
+
*
|
|
822
|
+
* @param {ReturnType<typeof matchedStep>} next
|
|
823
|
+
* @returns {PollStep}
|
|
824
|
+
*/
|
|
825
|
+
const stepOf = next => {
|
|
826
|
+
if (next.state === 'done') return resultStep(next.status, next.reason, next.failing, next.pending);
|
|
827
|
+
return next.state === 'probe' ? { state: 'open' } : next;
|
|
828
|
+
};
|
|
477
829
|
|
|
478
|
-
|
|
479
|
-
if (view === null) break;
|
|
480
|
-
if (view.answered && view.status === 1 && NO_PR_RE.test(view.stderr)) return finish('NO_PR', null, [], []);
|
|
830
|
+
const viewArgs = ['pr', 'view', pr, '--json', VIEW_FIELDS];
|
|
481
831
|
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
832
|
+
/**
|
|
833
|
+
* One poll: the view, then, on a matching head that does not conflict, the checks, then
|
|
834
|
+
* the view again, then, on the paths that need it while the probe has no answer, the
|
|
835
|
+
* workflow probe.
|
|
836
|
+
*
|
|
837
|
+
* @returns {Promise<PollStep>}
|
|
838
|
+
*/
|
|
839
|
+
const readPoll = async () => {
|
|
840
|
+
const view = await gh(viewArgs);
|
|
841
|
+
if (view === null) return { state: 'deadline', rowsRead: false };
|
|
842
|
+
if (view.answered && view.status === 1 && NO_PR_RE.test(view.stderr)) return resultStep('NO_PR', null, [], []);
|
|
843
|
+
if (!view.answered || view.status !== 0) return { state: 'failed' };
|
|
844
|
+
const parsed = parseView(view.stdout);
|
|
845
|
+
if (parsed === null) return resultStep('INDETERMINATE', 'unparseable', [], []);
|
|
846
|
+
if (parsed.state !== 'OPEN') return resultStep('INDETERMINATE', 'not-open', [], []);
|
|
847
|
+
if (parsed.oid !== head) return stall('INDETERMINATE', 'head-mismatch');
|
|
848
|
+
if (isConflicting(parsed.mergeable)) return resultStep('INDETERMINATE', 'merge-conflict', [], []);
|
|
849
|
+
|
|
850
|
+
const checks = await gh(['pr', 'checks', pr, '--json', 'name,state,bucket,workflow']);
|
|
851
|
+
if (checks === null) return { state: 'deadline', rowsRead: false };
|
|
852
|
+
const isResult = checks.answered && (checks.status === 0 || checks.status === 8);
|
|
853
|
+
const isEmpty = checks.answered && checks.status === 1 && NO_CHECKS_RE.test(checks.stderr);
|
|
854
|
+
if (!isResult && !isEmpty) return { state: 'failed' };
|
|
855
|
+
const rows = isResult ? parseChecks(checks.stdout) : [];
|
|
856
|
+
const second = await gh(viewArgs);
|
|
857
|
+
if (second === null) return { state: 'deadline', rowsRead: rows === null || rows.length > 0 };
|
|
858
|
+
const after = second.answered && second.status === 0 ? parseView(second.stdout) : null;
|
|
859
|
+
if (after === null) return { state: 'failed' };
|
|
860
|
+
if (after.oid !== head) return { state: 'open' };
|
|
861
|
+
if (rows === null) return resultStep('INDETERMINATE', 'unparseable', [], []);
|
|
862
|
+
const verdict = rows.length > 0 ? classifyChecks(rows) : null;
|
|
863
|
+
|
|
864
|
+
const next = matchedStep(verdict, parsed.mergeable, workflowsActive);
|
|
865
|
+
if (next.state !== 'probe') return stepOf(next);
|
|
866
|
+
const asked = await askWorkflows(gh);
|
|
867
|
+
if (asked.kind === 'deadline') return { state: 'deadline', rowsRead: verdict !== null };
|
|
868
|
+
if (asked.kind === 'failed') return { state: 'failed' };
|
|
869
|
+
if (asked.kind === 'unparseable') return resultStep('INDETERMINATE', 'unparseable', [], []);
|
|
870
|
+
workflowsActive = asked.active;
|
|
871
|
+
return stepOf(matchedStep(verdict, parsed.mergeable, workflowsActive));
|
|
872
|
+
};
|
|
873
|
+
|
|
874
|
+
let failedPolls = 0;
|
|
875
|
+
let reachedDeadline = false;
|
|
876
|
+
/** @type {Readonly<Last>} */
|
|
877
|
+
let last = DEADLINE_LAST;
|
|
878
|
+
|
|
879
|
+
for (let poll = 0; poll < MAX_POLLS; poll++) {
|
|
880
|
+
if (clock.now() >= deadline) {
|
|
881
|
+
reachedDeadline = true;
|
|
882
|
+
break;
|
|
521
883
|
}
|
|
522
884
|
|
|
885
|
+
const step = await readPoll();
|
|
886
|
+
if (step.state === 'deadline') {
|
|
887
|
+
reachedDeadline = true;
|
|
888
|
+
if (step.rowsRead && last.status !== 'PENDING') last = DEADLINE_LAST;
|
|
889
|
+
break;
|
|
890
|
+
}
|
|
523
891
|
if (step.state === 'done') return step.result;
|
|
524
892
|
if (step.state === 'failed') {
|
|
525
893
|
failedPolls++;
|
|
526
894
|
if (failedPolls >= MAX_CONSECUTIVE_GH_FAILURES) return finish('INDETERMINATE', 'gh-failed', [], []);
|
|
527
|
-
last = { status: 'INDETERMINATE', pending: [] };
|
|
528
895
|
} else {
|
|
529
896
|
failedPolls = 0;
|
|
530
|
-
last = { status: 'PENDING', pending: step.pending };
|
|
531
897
|
}
|
|
898
|
+
last = lastOf(step);
|
|
532
899
|
|
|
533
900
|
const left = deadline - clock.now();
|
|
534
|
-
if (left <= 0)
|
|
901
|
+
if (left <= 0) {
|
|
902
|
+
reachedDeadline = true;
|
|
903
|
+
break;
|
|
904
|
+
}
|
|
535
905
|
await clock.sleep(Math.min(POLL_INTERVAL_SECONDS * 1000, left));
|
|
536
906
|
}
|
|
537
907
|
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
: finish('INDETERMINATE', 'deadline', [], []);
|
|
908
|
+
const tail = tailOf(last, reachedDeadline);
|
|
909
|
+
return finish(tail.status, tail.reason, [], tail.pending);
|
|
541
910
|
}
|
|
542
911
|
|
|
543
912
|
// ---------------------------------------------------------------------------
|
|
@@ -634,7 +1003,6 @@ module.exports = Object.freeze({
|
|
|
634
1003
|
BUCKETS,
|
|
635
1004
|
CI_WAIT_MAX_SECONDS,
|
|
636
1005
|
POLL_INTERVAL_SECONDS,
|
|
637
|
-
SETTLE_WINDOW_SECONDS,
|
|
638
1006
|
GH_CALL_CAP_SECONDS,
|
|
639
1007
|
MAX_POLLS,
|
|
640
1008
|
MAX_CONSECUTIVE_GH_FAILURES,
|
|
@@ -643,10 +1011,17 @@ module.exports = Object.freeze({
|
|
|
643
1011
|
NAMES_LISTED,
|
|
644
1012
|
LINE_RE,
|
|
645
1013
|
USAGE_LINE,
|
|
1014
|
+
WORKFLOWS_ARGS,
|
|
646
1015
|
parseArgs,
|
|
647
1016
|
sanitizeName,
|
|
648
1017
|
formatNames,
|
|
649
1018
|
classifyChecks,
|
|
1019
|
+
isConflicting,
|
|
1020
|
+
mergeGate,
|
|
1021
|
+
matchedStep,
|
|
1022
|
+
parseChecks,
|
|
1023
|
+
parseActiveWorkflows,
|
|
1024
|
+
askWorkflows,
|
|
650
1025
|
renderLine,
|
|
651
1026
|
checkLine,
|
|
652
1027
|
waitForCi,
|