forge-workflow 0.1.0-beta.3 → 0.1.0-beta.5
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/AGENTS.md +14 -7
- package/CHANGELOG.md +43 -1
- package/README.md +6 -2
- package/bin/forge-cmd.js +21 -1
- package/bin/forge.js +16 -369
- package/docs/INDEX.md +1 -1
- package/docs/guides/BEADS_GITHUB_SYNC.md +2 -31
- package/docs/guides/MIGRATION.md +4 -4
- package/docs/guides/SETUP.md +16 -16
- package/docs/reference/COMMANDS.md +9 -4
- package/docs/reference/INSIGHTS_RECAP.md +9 -20
- package/docs/reference/RELEASE.md +5 -3
- package/docs/reference/TOOLCHAIN.md +8 -0
- package/docs/reference/protected-state-surfaces.md +4 -4
- package/docs/reference/shepherd.md +117 -17
- package/lefthook.yml +12 -0
- package/lib/activation/ensure-forge-home.js +33 -15
- package/lib/adapters/greptile-review-adapter.js +1 -1
- package/lib/adapters/pr-state-adapter.js +397 -100
- package/lib/agents-config.js +5 -0
- package/lib/audit-evidence.js +71 -110
- package/lib/capped-jsonl-log.js +236 -0
- package/lib/commands/_issue.js +31 -46
- package/lib/commands/_manifest.js +1 -1
- package/lib/commands/_registry.js +2 -2
- package/lib/commands/_resolve-command-opts.js +36 -29
- package/lib/commands/claim.js +2 -4
- package/lib/commands/clean.js +196 -32
- package/lib/commands/dev.js +4 -33
- package/lib/commands/hooks.js +358 -13
- package/lib/commands/insights.js +8 -3
- package/lib/commands/merge.js +600 -40
- package/lib/commands/plan.js +23 -115
- package/lib/commands/pr.js +1 -1
- package/lib/commands/preflight.js +11 -2
- package/lib/commands/prime.js +23 -3
- package/lib/commands/push.js +41 -51
- package/lib/commands/recall.js +60 -16
- package/lib/commands/recap.js +6 -1
- package/lib/commands/release.js +18 -4
- package/lib/commands/serve.js +5 -2
- package/lib/commands/setup.js +191 -95
- package/lib/commands/shepherd.js +49 -4
- package/lib/commands/ship.js +22 -23
- package/lib/commands/skill.js +383 -0
- package/lib/commands/status.js +54 -33
- package/lib/commands/test.js +56 -34
- package/lib/commands/worktree.js +247 -43
- package/lib/core/runtime-graph.js +89 -15
- package/lib/doc-assertions.js +297 -0
- package/lib/existing-tdd-gate.js +253 -0
- package/lib/forge-context.js +1 -4
- package/lib/forge-issues.js +64 -491
- package/lib/git-defaults.js +56 -0
- package/lib/harness-capability-matrix.js +5 -5
- package/lib/hook-renderer.js +147 -16
- package/lib/insights.js +96 -80
- package/lib/issue-backend.js +42 -3
- package/lib/kernel/backing-issue.js +14 -2
- package/lib/kernel/broker.js +44 -0
- package/lib/kernel/cli-broker-factory.js +12 -1
- package/lib/kernel/close-on-merge.js +154 -0
- package/lib/kernel/fs-class.js +42 -25
- package/lib/kernel/migrations.js +30 -2
- package/lib/kernel/schema.js +35 -0
- package/lib/kernel/sqlite-driver.js +292 -18
- package/lib/lefthook-wiring.js +21 -1
- package/lib/memory/router.js +16 -1
- package/lib/memory-digest.js +47 -15
- package/lib/memory-recall-events.js +145 -0
- package/lib/memory-recall.js +212 -0
- package/lib/merge-rules.js +8 -4
- package/lib/npm-publish-workflow.js +272 -0
- package/lib/orientation.js +371 -49
- package/lib/plugin-catalog.js +14 -4
- package/lib/pr-bundle.js +9 -6
- package/lib/pr-monitor/journal.js +18 -2
- package/lib/pr-monitor/reconcile-executor.js +842 -0
- package/lib/pr-monitor/reconcile-tick.js +138 -0
- package/lib/pr-monitor/reconcile.js +0 -0
- package/lib/pr-monitor/render-summary.js +196 -0
- package/lib/pr-monitor/shepherd-lease.js +252 -0
- package/lib/pr-monitor/watch-lifecycle.js +14 -2
- package/lib/pr-pull.js +98 -24
- package/lib/pr-shepherd.js +34 -8
- package/lib/preflight/gates.js +65 -18
- package/lib/preflight/runner.js +5 -0
- package/lib/project-memory.js +40 -0
- package/lib/protected-state-authority.js +305 -0
- package/lib/protected-state-surfaces.js +64 -44
- package/lib/release-readiness.js +51 -4
- package/lib/rules-sync.js +4 -0
- package/lib/runtime-health.js +15 -46
- package/lib/shell-utils.js +1 -1
- package/lib/skill-eval.js +750 -0
- package/lib/skills-sync.js +6 -3
- package/lib/smart-merge.js +28 -4
- package/lib/status/identity.js +46 -0
- package/lib/status/presenter.js +0 -35
- package/lib/status/snapshot.js +11 -16
- package/lib/symlink-utils.js +74 -26
- package/lib/upgrade-safety.js +47 -9
- package/lib/using-forge.js +328 -0
- package/lib/workflow/enforce-stage.js +5 -5
- package/lib/workflow/state-manager.js +23 -23
- package/package.json +6 -7
- package/rules/using-forge.md +24 -0
- package/scripts/doc-asserting-tests.js +158 -0
- package/scripts/forge-team/index.sh +0 -5
- package/scripts/forge-team/tests/dispatcher.test.sh +1 -1
- package/scripts/forge-team/tests/workflow-integration.test.sh +0 -1
- package/scripts/lib/behavioral-eval-runner.js +310 -0
- package/scripts/lib/behavioral-eval-runtime.js +456 -0
- package/scripts/lib/eval-evidence.js +328 -0
- package/scripts/lib/eval-runner.js +81 -41
- package/scripts/lib/immutable-eval-corpus.js +309 -0
- package/scripts/lib/promotion-evidence-loader.js +94 -0
- package/scripts/lib/promotion-scorecard.js +314 -0
- package/scripts/npm-release-receipt.js +134 -0
- package/scripts/process-tree.js +761 -0
- package/scripts/protected-state-check.js +47 -22
- package/scripts/run-command-eval.js +29 -1
- package/scripts/sync-d20-audit.js +172 -0
- package/scripts/test-full-suite.js +249 -37
- package/scripts/test.js +184 -44
- package/skills/claim-safety/SKILL.md +4 -0
- package/skills/claim-safety/evals/scorecard.json +41 -0
- package/skills/coverage.json +83 -0
- package/skills/dev/SKILL.md +4 -0
- package/skills/dev/evals/scorecard.json +41 -0
- package/skills/gates/SKILL.md +80 -0
- package/skills/gates/evals/evals.json +38 -0
- package/skills/gates/evals/scorecard.json +41 -0
- package/skills/hermes-forge/SKILL.md +1 -0
- package/skills/hermes-forge/evals/scorecard.json +41 -0
- package/skills/issue-basics/SKILL.md +1 -0
- package/skills/issue-basics/evals/scorecard.json +41 -0
- package/skills/kernel/SKILL.md +38 -0
- package/skills/kernel/evals/scorecard.json +41 -0
- package/skills/memory/SKILL.md +16 -1
- package/skills/memory/evals/scorecard.json +41 -0
- package/skills/parallel-deep-research/SKILL.md +1 -0
- package/skills/parallel-deep-research/evals/scorecard.json +41 -0
- package/skills/plan/SKILL.md +6 -0
- package/skills/plan/evals/scorecard.json +41 -0
- package/skills/portability/SKILL.md +47 -0
- package/skills/portability/evals/evals.json +34 -0
- package/skills/portability/evals/scorecard.json +41 -0
- package/skills/research/SKILL.md +1 -0
- package/skills/research/evals/scorecard.json +41 -0
- package/skills/review/SKILL.md +10 -11
- package/skills/review/evals/scorecard.json +41 -0
- package/skills/rollback/SKILL.md +5 -11
- package/skills/rollback/evals/scorecard.json +41 -0
- package/skills/setup/SKILL.md +91 -0
- package/skills/setup/evals/evals.json +42 -0
- package/skills/setup/evals/scorecard.json +41 -0
- package/skills/shepherd/SKILL.md +84 -38
- package/skills/shepherd/evals/evals.json +21 -9
- package/skills/shepherd/evals/scorecard.json +41 -0
- package/skills/ship/SKILL.md +10 -12
- package/skills/ship/evals/scorecard.json +41 -0
- package/skills/smith/SKILL.md +8 -0
- package/skills/smith/evals/scorecard.json +41 -0
- package/skills/sonarcloud/SKILL.md +1 -0
- package/skills/sonarcloud/evals/scorecard.json +41 -0
- package/skills/sonarcloud-analysis/SKILL.md +1 -0
- package/skills/sonarcloud-analysis/evals/scorecard.json +41 -0
- package/skills/status/SKILL.md +3 -0
- package/skills/status/evals/scorecard.json +41 -0
- package/skills/triage-ready/SKILL.md +2 -0
- package/skills/triage-ready/evals/scorecard.json +41 -0
- package/skills/using-forge/SKILL.md +104 -0
- package/skills/using-forge/evals/scorecard.json +41 -0
- package/skills/validate/SKILL.md +4 -0
- package/skills/validate/evals/scorecard.json +41 -0
- package/skills/verify/SKILL.md +4 -0
- package/skills/verify/evals/scorecard.json +41 -0
- package/skills/worktree/SKILL.md +92 -0
- package/skills/worktree/evals/evals.json +38 -0
- package/skills/worktree/evals/scorecard.json +41 -0
- package/lib/adapters/beads-issue-adapter.js +0 -127
- package/lib/beads-nudge.js +0 -91
- package/lib/beads-setup.js +0 -538
- package/lib/beads-sync-scaffold.js +0 -189
- package/lib/commands/board.js +0 -64
- package/lib/pat-setup.js +0 -207
- package/lib/pr-monitor/render-sticky.js +0 -192
- package/lib/pr-monitor/upsert-sticky.js +0 -169
- package/lib/status/beads-snapshot.js +0 -145
- package/scripts/beads-context.sh +0 -577
- package/scripts/beads-migrate-to-dolt.sh +0 -7
- package/scripts/beads-upgrade-smoke.sh +0 -284
- package/scripts/forge-team/lib/dashboard.sh +0 -316
- package/scripts/forge-team/tests/dashboard.test.sh +0 -155
- package/scripts/lib/beads-migrate-to-dolt.mjs +0 -503
package/lib/commands/merge.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* merge command — opt-in conditional auto-merge.
|
|
5
5
|
*
|
|
6
|
-
* `forge merge --auto <pr>` is the ONLY path by which Forge will merge a PR on
|
|
6
|
+
* `forge merge --auto <pr> --expect-head <sha> --issue <id>` is the ONLY path by which Forge will merge a PR on
|
|
7
7
|
* its own, and it stays OFF unless the user has explicitly opted in. It reads
|
|
8
8
|
* the `merge.auto` section of `.forge/config.yaml`:
|
|
9
9
|
*
|
|
@@ -43,6 +43,381 @@ const { execFileSync } = require('node:child_process');
|
|
|
43
43
|
|
|
44
44
|
const { loadRawConfig } = require('../config-writer');
|
|
45
45
|
const { evaluateMergeRules } = require('../merge-rules');
|
|
46
|
+
const { runIssueOperation } = require('../forge-issues');
|
|
47
|
+
const { PrStateAdapter } = require('../adapters/pr-state-adapter');
|
|
48
|
+
const { stripGlobalFlags } = require('../global-flags');
|
|
49
|
+
|
|
50
|
+
const FULL_HEAD_SHA = /^[0-9a-f]{40}$/i;
|
|
51
|
+
const FORGE_ISSUE_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
52
|
+
const POSITIVE_PR_NUMBER = /^[1-9][0-9]*$/;
|
|
53
|
+
const REPOSITORY_NAME = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
|
54
|
+
const CHECK_RUN_STATUSES = new Set(['QUEUED', 'IN_PROGRESS', 'COMPLETED', 'WAITING', 'PENDING', 'REQUESTED']);
|
|
55
|
+
const CHECK_RUN_CONCLUSIONS = new Set([
|
|
56
|
+
'', 'SUCCESS', 'FAILURE', 'NEUTRAL', 'CANCELLED', 'SKIPPED', 'TIMED_OUT',
|
|
57
|
+
'ACTION_REQUIRED', 'STALE', 'STARTUP_FAILURE',
|
|
58
|
+
]);
|
|
59
|
+
const SAFE_TERMINAL_CONCLUSIONS = new Set(['SUCCESS', 'NEUTRAL', 'SKIPPED']);
|
|
60
|
+
const STATUS_CONTEXT_STATES = new Set(['ERROR', 'EXPECTED', 'FAILURE', 'PENDING', 'SUCCESS']);
|
|
61
|
+
const REVIEW_ACTOR_TYPENAMES = new Set([
|
|
62
|
+
'Bot', 'EnterpriseUserAccount', 'Mannequin', 'Organization', 'User',
|
|
63
|
+
]);
|
|
64
|
+
const REVIEW_STATES = new Set([
|
|
65
|
+
'APPROVED', 'CHANGES_REQUESTED', 'COMMENTED', 'DISMISSED', 'PENDING',
|
|
66
|
+
]);
|
|
67
|
+
|
|
68
|
+
function normalizeFullHeadSha(value) {
|
|
69
|
+
return typeof value === 'string' && FULL_HEAD_SHA.test(value) ? value.toLowerCase() : null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function normalizePrNumber(value) {
|
|
73
|
+
if (typeof value !== 'string' || !POSITIVE_PR_NUMBER.test(value)) return null;
|
|
74
|
+
const parsed = Number(value);
|
|
75
|
+
return Number.isSafeInteger(parsed) && parsed > 0 ? String(parsed) : null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function normalizeKernelPrNumber(value) {
|
|
79
|
+
if (Number.isSafeInteger(value) && value > 0) return String(value);
|
|
80
|
+
return typeof value === 'string' ? normalizePrNumber(value) : null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function normalizeRepository(value) {
|
|
84
|
+
return typeof value === 'string' && REPOSITORY_NAME.test(value) ? value.toLowerCase() : null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function parseMergeArgs(argv) {
|
|
88
|
+
const values = { auto: false, pr: null, expectedHead: null, issueId: null, error: null };
|
|
89
|
+
const seen = new Set();
|
|
90
|
+
const input = Array.isArray(argv) ? argv : [];
|
|
91
|
+
|
|
92
|
+
for (let index = 0; index < input.length; index += 1) {
|
|
93
|
+
const raw = String(input[index]);
|
|
94
|
+
if (raw === '--auto') {
|
|
95
|
+
values.auto = true;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
let option = null;
|
|
100
|
+
let inlineValue = null;
|
|
101
|
+
if (raw === '--expect-head' || raw === '--issue') option = raw;
|
|
102
|
+
else if (raw.startsWith('--expect-head=')) {
|
|
103
|
+
option = '--expect-head';
|
|
104
|
+
inlineValue = raw.slice('--expect-head='.length);
|
|
105
|
+
} else if (raw.startsWith('--issue=')) {
|
|
106
|
+
option = '--issue';
|
|
107
|
+
inlineValue = raw.slice('--issue='.length);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (option) {
|
|
111
|
+
if (seen.has(option)) {
|
|
112
|
+
values.error = `Duplicate ${option} is not allowed.`;
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
seen.add(option);
|
|
116
|
+
let value = inlineValue;
|
|
117
|
+
if (value === null) {
|
|
118
|
+
const candidate = input[index + 1];
|
|
119
|
+
if (candidate === undefined || String(candidate).startsWith('--')) {
|
|
120
|
+
values.error = `${option} requires a value.`;
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
value = String(candidate);
|
|
124
|
+
index += 1;
|
|
125
|
+
}
|
|
126
|
+
if (!value) {
|
|
127
|
+
values.error = `${option} requires a value.`;
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
if (option === '--expect-head') values.expectedHead = normalizeFullHeadSha(value);
|
|
131
|
+
else values.issueId = FORGE_ISSUE_ID.test(value) ? value.toLowerCase() : null;
|
|
132
|
+
if ((option === '--expect-head' && !values.expectedHead)
|
|
133
|
+
|| (option === '--issue' && !values.issueId)) {
|
|
134
|
+
values.error = `${option} has an invalid value.`;
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (raw.startsWith('--')) {
|
|
141
|
+
values.error = `Unknown merge option: ${raw}`;
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
if (values.pr !== null) {
|
|
145
|
+
values.error = 'Exactly one PR number is required.';
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
values.pr = normalizePrNumber(raw);
|
|
149
|
+
if (!values.pr) {
|
|
150
|
+
values.error = 'PR selector must be one positive decimal PR number.';
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return values;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function resolveOwnershipActor(env = process.env) {
|
|
159
|
+
return (typeof env.FORGE_ACTOR === 'string' && env.FORGE_ACTOR.trim())
|
|
160
|
+
|| (typeof env.FORGE_SESSION_ID === 'string' && env.FORGE_SESSION_ID.trim())
|
|
161
|
+
|| null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function defaultVerifyIssueOwnership({
|
|
165
|
+
issueId, projectRoot, actor: expectedActor, env = process.env, runIssue = runIssueOperation,
|
|
166
|
+
}) {
|
|
167
|
+
const actor = expectedActor || resolveOwnershipActor(env);
|
|
168
|
+
if (!actor) return { owned: false, actor: null, error: 'FORGE_ACTOR or FORGE_SESSION_ID is required.' };
|
|
169
|
+
const frozenEnv = { ...env, FORGE_ACTOR: actor };
|
|
170
|
+
const result = await runIssue('owns', [issueId], projectRoot, { env: frozenEnv });
|
|
171
|
+
const data = result && result.data;
|
|
172
|
+
const owned = result && result.ok === true && data && data.owned === true
|
|
173
|
+
&& data.expired === false && data.actor === actor && data.claimed_by === actor;
|
|
174
|
+
return {
|
|
175
|
+
owned: Boolean(owned),
|
|
176
|
+
actor,
|
|
177
|
+
claimedBy: data && data.claimed_by,
|
|
178
|
+
expired: data && typeof data.expired === 'boolean' ? data.expired : null,
|
|
179
|
+
error: result && result.error,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function defaultVerifyPrIssueBinding({
|
|
184
|
+
issueId,
|
|
185
|
+
pr,
|
|
186
|
+
projectRoot,
|
|
187
|
+
prContext,
|
|
188
|
+
buildBroker,
|
|
189
|
+
}) {
|
|
190
|
+
const number = normalizePrNumber(String(pr));
|
|
191
|
+
const repository = normalizeRepository(prContext && prContext.repository);
|
|
192
|
+
if (!number || !repository || !prContext || prContext.number !== Number(number)) {
|
|
193
|
+
return { bound: false, error: 'PR identity is unreadable or does not match the requested PR number.' };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
let built;
|
|
197
|
+
const ownsDriver = !buildBroker;
|
|
198
|
+
try {
|
|
199
|
+
if (buildBroker) {
|
|
200
|
+
built = await buildBroker({ projectRoot });
|
|
201
|
+
} else {
|
|
202
|
+
const { resolveGitCommonDir } = require('../kernel/broker');
|
|
203
|
+
const { buildMigratedKernelIssueDeps } = require('../kernel/cli-broker-factory');
|
|
204
|
+
const gitCommonDir = resolveGitCommonDir(projectRoot);
|
|
205
|
+
const deps = await buildMigratedKernelIssueDeps({ projectRoot, gitCommonDir });
|
|
206
|
+
built = { gitCommonDir, broker: deps.kernelBroker, driver: deps.kernelDriver };
|
|
207
|
+
}
|
|
208
|
+
if (!built || !built.broker || typeof built.broker.listOpenPrs !== 'function'
|
|
209
|
+
|| typeof built.gitCommonDir !== 'string' || !built.gitCommonDir) {
|
|
210
|
+
return { bound: false, error: 'Kernel PR linkage reader is unavailable.' };
|
|
211
|
+
}
|
|
212
|
+
const rows = await built.broker.listOpenPrs(built.gitCommonDir);
|
|
213
|
+
if (!Array.isArray(rows)) return { bound: false, error: 'Kernel PR linkage is unreadable.' };
|
|
214
|
+
const sameRepositoryRows = rows.filter((row) => row && normalizeRepository(row.repo) === repository);
|
|
215
|
+
if (sameRepositoryRows.some((row) => !normalizeKernelPrNumber(row.number))) {
|
|
216
|
+
return { bound: false, error: 'Kernel PR linkage contains a malformed PR number.' };
|
|
217
|
+
}
|
|
218
|
+
const matches = sameRepositoryRows.filter((row) => row
|
|
219
|
+
&& normalizeKernelPrNumber(row.number) === number);
|
|
220
|
+
if (matches.length !== 1) {
|
|
221
|
+
return { bound: false, error: 'Kernel PR linkage is missing or ambiguous.' };
|
|
222
|
+
}
|
|
223
|
+
const row = matches[0];
|
|
224
|
+
const bound = row.state === 'open' && row.issue_id === issueId;
|
|
225
|
+
return {
|
|
226
|
+
bound,
|
|
227
|
+
repository,
|
|
228
|
+
number: Number(number),
|
|
229
|
+
issueId: row.issue_id || null,
|
|
230
|
+
error: bound ? null : 'Kernel PR row is not open or is linked to a different issue.',
|
|
231
|
+
};
|
|
232
|
+
} catch (err) {
|
|
233
|
+
return { bound: false, error: `Kernel PR linkage verification failed: ${err.message}` };
|
|
234
|
+
} finally {
|
|
235
|
+
if (ownsDriver && built && built.driver && typeof built.driver.close === 'function') {
|
|
236
|
+
try { await built.driver.close(); } catch { /* cleanup must not mask the linkage verdict */ }
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function strictCheckSuccess(check) {
|
|
242
|
+
if (!check || typeof check !== 'object') return false;
|
|
243
|
+
const hasStatus = Object.prototype.hasOwnProperty.call(check, 'status');
|
|
244
|
+
const hasConclusion = Object.prototype.hasOwnProperty.call(check, 'conclusion');
|
|
245
|
+
const hasState = Object.prototype.hasOwnProperty.call(check, 'state');
|
|
246
|
+
if (hasState) {
|
|
247
|
+
return !hasStatus && !hasConclusion && String(check.state || '').toUpperCase() === 'SUCCESS';
|
|
248
|
+
}
|
|
249
|
+
return hasStatus && hasConclusion
|
|
250
|
+
&& String(check.status || '').toUpperCase() === 'COMPLETED'
|
|
251
|
+
&& String(check.conclusion || '').toUpperCase() === 'SUCCESS';
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Mandatory preflight accepts only terminal check-run conclusions that are safe
|
|
256
|
+
* to classify as optional. Required checks are still evaluated with
|
|
257
|
+
* strictCheckSuccess below, and status contexts never gain NEUTRAL/SKIPPED
|
|
258
|
+
* semantics because their only successful terminal state is SUCCESS.
|
|
259
|
+
*/
|
|
260
|
+
function safeTerminalCheck(check) {
|
|
261
|
+
if (!check || typeof check !== 'object') return false;
|
|
262
|
+
if (Object.prototype.hasOwnProperty.call(check, 'state')) return strictCheckSuccess(check);
|
|
263
|
+
return Object.prototype.hasOwnProperty.call(check, 'status')
|
|
264
|
+
&& Object.prototype.hasOwnProperty.call(check, 'conclusion')
|
|
265
|
+
&& String(check.status || '').toUpperCase() === 'COMPLETED'
|
|
266
|
+
&& SAFE_TERMINAL_CONCLUSIONS.has(String(check.conclusion || '').toUpperCase());
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function malformedCheckObservation(check) {
|
|
270
|
+
if (!check || typeof check !== 'object') return true;
|
|
271
|
+
const name = check.name || check.context;
|
|
272
|
+
if (typeof name !== 'string' || !name.trim()) return true;
|
|
273
|
+
if (!Object.prototype.hasOwnProperty.call(check, 'appId')) return true;
|
|
274
|
+
const hasStatus = Object.prototype.hasOwnProperty.call(check, 'status');
|
|
275
|
+
const hasConclusion = Object.prototype.hasOwnProperty.call(check, 'conclusion');
|
|
276
|
+
const hasState = Object.prototype.hasOwnProperty.call(check, 'state');
|
|
277
|
+
const status = String(check.status || '').toUpperCase();
|
|
278
|
+
const conclusion = String(check.conclusion || '').toUpperCase();
|
|
279
|
+
const state = String(check.state || '').toUpperCase();
|
|
280
|
+
if (hasState) {
|
|
281
|
+
return hasStatus || hasConclusion || check.appId !== null
|
|
282
|
+
|| typeof check.state !== 'string' || !STATUS_CONTEXT_STATES.has(state);
|
|
283
|
+
}
|
|
284
|
+
if (!hasStatus || !hasConclusion || !Number.isInteger(check.appId) || check.appId <= 0) return true;
|
|
285
|
+
if (hasStatus && (typeof check.status !== 'string' || !CHECK_RUN_STATUSES.has(status))) return true;
|
|
286
|
+
if (hasConclusion && check.conclusion !== null && typeof check.conclusion !== 'string') return true;
|
|
287
|
+
if (hasConclusion && !CHECK_RUN_CONCLUSIONS.has(conclusion)) return true;
|
|
288
|
+
if (hasStatus && status === 'COMPLETED' && !conclusion) return true;
|
|
289
|
+
if (hasStatus && status !== 'COMPLETED' && conclusion) return true;
|
|
290
|
+
return ['status', 'conclusion', 'state']
|
|
291
|
+
.some((key) => String(check[key] || '').toUpperCase() === 'UNREADABLE');
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function normalizeRequiredEntry(entry) {
|
|
295
|
+
if (!entry || typeof entry.context !== 'string' || !entry.context.trim()) return null;
|
|
296
|
+
if (!Object.prototype.hasOwnProperty.call(entry, 'appId')) return null;
|
|
297
|
+
if (entry.appId !== null && (!Number.isInteger(entry.appId) || entry.appId <= 0)) return null;
|
|
298
|
+
return { context: entry.context, appId: entry.appId };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function normalizeRollupObservation(entry) {
|
|
302
|
+
if (!entry || typeof entry !== 'object') return null;
|
|
303
|
+
if (entry.__typename === 'StatusContext') {
|
|
304
|
+
return { name: entry.context, state: entry.state };
|
|
305
|
+
}
|
|
306
|
+
if (entry.__typename === 'CheckRun') {
|
|
307
|
+
return { name: entry.name, status: entry.status, conclusion: entry.conclusion };
|
|
308
|
+
}
|
|
309
|
+
return null;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function malformedRollupObservation(observation) {
|
|
313
|
+
if (!observation || typeof observation.name !== 'string' || !observation.name.trim()) return true;
|
|
314
|
+
const hasState = Object.prototype.hasOwnProperty.call(observation, 'state');
|
|
315
|
+
if (hasState) {
|
|
316
|
+
return Object.prototype.hasOwnProperty.call(observation, 'status')
|
|
317
|
+
|| Object.prototype.hasOwnProperty.call(observation, 'conclusion')
|
|
318
|
+
|| typeof observation.state !== 'string'
|
|
319
|
+
|| !STATUS_CONTEXT_STATES.has(observation.state.toUpperCase());
|
|
320
|
+
}
|
|
321
|
+
const status = String(observation.status || '').toUpperCase();
|
|
322
|
+
const conclusion = String(observation.conclusion || '').toUpperCase();
|
|
323
|
+
if (typeof observation.status !== 'string' || !CHECK_RUN_STATUSES.has(status)) return true;
|
|
324
|
+
if (observation.conclusion !== null && typeof observation.conclusion !== 'string') return true;
|
|
325
|
+
if (!CHECK_RUN_CONCLUSIONS.has(conclusion)) return true;
|
|
326
|
+
if (status === 'COMPLETED' && !conclusion) return true;
|
|
327
|
+
return status !== 'COMPLETED' && Boolean(conclusion);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function evaluateProtectedRequiredChecks(context) {
|
|
331
|
+
if (!context || context.requiredCheckSource !== 'protection'
|
|
332
|
+
|| !Array.isArray(context.requiredChecks) || !Array.isArray(context.checks)) {
|
|
333
|
+
return { allowed: false, reason: 'Protected required-check policy is unreadable or non-authoritative.' };
|
|
334
|
+
}
|
|
335
|
+
const required = context.requiredChecks.map(normalizeRequiredEntry);
|
|
336
|
+
if (required.some((entry) => !entry)) {
|
|
337
|
+
return { allowed: false, reason: 'Protected required-check policy contains malformed entries.' };
|
|
338
|
+
}
|
|
339
|
+
const policyApps = new Map();
|
|
340
|
+
for (const entry of required) {
|
|
341
|
+
if (!policyApps.has(entry.context)) policyApps.set(entry.context, new Set());
|
|
342
|
+
policyApps.get(entry.context).add(entry.appId === null ? '*' : String(entry.appId));
|
|
343
|
+
}
|
|
344
|
+
if ([...policyApps.values()].some((apps) => apps.size > 1)) {
|
|
345
|
+
return { allowed: false, reason: 'Protected required-check policy contains conflicting application identities.' };
|
|
346
|
+
}
|
|
347
|
+
if (context.checks.some(malformedCheckObservation)) {
|
|
348
|
+
return { allowed: false, reason: 'Check-run observation collection contains malformed entries.' };
|
|
349
|
+
}
|
|
350
|
+
const missing = [];
|
|
351
|
+
const nonSuccess = [];
|
|
352
|
+
for (const entry of required) {
|
|
353
|
+
const matching = context.checks.filter((check) => check
|
|
354
|
+
&& (check.name || check.context) === entry.context
|
|
355
|
+
&& (entry.appId === null || check.appId === entry.appId));
|
|
356
|
+
const label = entry.appId === null ? entry.context : `${entry.context}@app:${entry.appId}`;
|
|
357
|
+
if (matching.length === 0) missing.push(label);
|
|
358
|
+
else if (matching.some((check) => !strictCheckSuccess(check))) nonSuccess.push(label);
|
|
359
|
+
}
|
|
360
|
+
if (missing.length || nonSuccess.length) {
|
|
361
|
+
const parts = [];
|
|
362
|
+
if (missing.length) parts.push(`missing: ${missing.join(', ')}`);
|
|
363
|
+
if (nonSuccess.length) parts.push(`non-success: ${nonSuccess.join(', ')}`);
|
|
364
|
+
return {
|
|
365
|
+
allowed: false,
|
|
366
|
+
reason: `Protected required checks are not successful (${parts.join('; ')}).`,
|
|
367
|
+
details: { missing, nonSuccess },
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
return { allowed: true, details: { missing: [], nonSuccess: [] } };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function mandatoryContextError(context, expectedHead) {
|
|
374
|
+
const observedHead = normalizeFullHeadSha(context && context.headSha);
|
|
375
|
+
if (!observedHead || observedHead !== expectedHead) {
|
|
376
|
+
return 'PR head changed or could not be verified against --expect-head.';
|
|
377
|
+
}
|
|
378
|
+
if (context.state !== 'OPEN') return 'PR lifecycle state is unreadable or is not OPEN.';
|
|
379
|
+
if (context.isDraft !== false) return 'PR draft status is unreadable or the PR is still a draft.';
|
|
380
|
+
if (context.conflicting !== false) return 'PR conflict status is unreadable or conflicting.';
|
|
381
|
+
if (context.unresolvedThreads !== 0) return 'Review-thread state is unreadable or unresolved threads remain.';
|
|
382
|
+
if (context.reviewEvidenceReadable !== true || !Array.isArray(context.reviews)) {
|
|
383
|
+
return 'Review evidence is unreadable.';
|
|
384
|
+
}
|
|
385
|
+
for (const review of context.reviews) {
|
|
386
|
+
const state = review && typeof review.state === 'string' ? review.state.toUpperCase() : '';
|
|
387
|
+
const timestamps = review
|
|
388
|
+
? [review.createdAt, review.updatedAt, review.submittedAt, review.activityAt]
|
|
389
|
+
: [];
|
|
390
|
+
if (!review || typeof review.id !== 'string' || !review.id
|
|
391
|
+
|| typeof review.author !== 'string' || !review.author
|
|
392
|
+
|| !REVIEW_ACTOR_TYPENAMES.has(review.authorTypename)
|
|
393
|
+
|| !REVIEW_STATES.has(state)
|
|
394
|
+
|| typeof review.commitOid !== 'string' || !FULL_HEAD_SHA.test(review.commitOid)
|
|
395
|
+
|| typeof review.body !== 'string'
|
|
396
|
+
|| timestamps.length !== 4
|
|
397
|
+
|| timestamps.some((value) => typeof value !== 'string' || !Number.isFinite(Date.parse(value)))) {
|
|
398
|
+
return 'Review evidence contains malformed identity, state, timestamp, or commit-head data.';
|
|
399
|
+
}
|
|
400
|
+
if (state !== 'DISMISSED' && state !== 'COMMENTED' && review.commitOid.toLowerCase() !== expectedHead) {
|
|
401
|
+
return 'Latest active review evidence is stale for the expected PR head.';
|
|
402
|
+
}
|
|
403
|
+
if (state === 'CHANGES_REQUESTED' || state === 'PENDING') {
|
|
404
|
+
return `Latest review state ${state} does not authorize merging.`;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
if (!Array.isArray(context.checks) || context.checks.some(malformedCheckObservation)
|
|
408
|
+
|| context.checks.some((check) => !safeTerminalCheck(check))) {
|
|
409
|
+
return 'Every check-run and status observation must be complete and have a safe terminal conclusion (SUCCESS, NEUTRAL, or SKIPPED).';
|
|
410
|
+
}
|
|
411
|
+
if (Object.prototype.hasOwnProperty.call(context, 'providerObservations')) {
|
|
412
|
+
if (!Array.isArray(context.providerObservations)
|
|
413
|
+
|| context.providerObservations.some(malformedRollupObservation)
|
|
414
|
+
|| context.providerObservations.some((check) => !safeTerminalCheck(check))) {
|
|
415
|
+
return 'The complete provider rollup must contain only observations with safe terminal conclusions (SUCCESS, NEUTRAL, or SKIPPED).';
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
const protectedGate = evaluateProtectedRequiredChecks(context);
|
|
419
|
+
return protectedGate.allowed ? null : protectedGate.reason;
|
|
420
|
+
}
|
|
46
421
|
|
|
47
422
|
/** Default `gh` runner. Only reached by the default fetch/merge seams (never in unit tests). */
|
|
48
423
|
function defaultGh(args, options = {}) {
|
|
@@ -63,12 +438,9 @@ function ghJson(gh, args) {
|
|
|
63
438
|
* is not a valid `gh pr view --json` field, so this needs a dedicated query.
|
|
64
439
|
* Returns `undefined` on any failure so `threads_resolved` fails closed.
|
|
65
440
|
*/
|
|
66
|
-
function fetchUnresolvedThreadCount(gh, pr) {
|
|
441
|
+
function fetchUnresolvedThreadCount(gh, { owner, repo, pr }) {
|
|
67
442
|
try {
|
|
68
|
-
|
|
69
|
-
const owner = repo && repo.owner && repo.owner.login;
|
|
70
|
-
const name = repo && repo.name;
|
|
71
|
-
if (!owner || !name) return undefined;
|
|
443
|
+
if (!owner || !repo) return undefined;
|
|
72
444
|
// Paginate through ALL review threads — a PR can have >100, and the ones on
|
|
73
445
|
// later pages could be the unresolved/newest ones. Stopping at page 1 would
|
|
74
446
|
// both miss them and make a large PR un-mergeable. Loop on the GraphQL cursor
|
|
@@ -79,15 +451,22 @@ function fetchUnresolvedThreadCount(gh, pr) {
|
|
|
79
451
|
let count = 0;
|
|
80
452
|
for (let page = 0; page < 100; page += 1) { // 100-page cap = 10k threads backstop
|
|
81
453
|
const args = ['api', 'graphql', '-f', `query=${query}`,
|
|
82
|
-
'-F', `o=${owner}`, '-F', `n=${
|
|
454
|
+
'-F', `o=${owner}`, '-F', `n=${repo}`, '-F', `pr=${Number(pr)}`];
|
|
83
455
|
if (after) args.push('-F', `after=${after}`);
|
|
84
456
|
const data = JSON.parse(gh(args) || '{}');
|
|
457
|
+
if (Object.prototype.hasOwnProperty.call(data, 'errors')
|
|
458
|
+
&& (!Array.isArray(data.errors) || data.errors.length > 0)) return undefined;
|
|
85
459
|
const threads = (((data.data || {}).repository || {}).pullRequest || {}).reviewThreads;
|
|
86
|
-
if (!threads
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
460
|
+
if (!threads || !Array.isArray(threads.nodes) || !threads.pageInfo
|
|
461
|
+
|| typeof threads.pageInfo.hasNextPage !== 'boolean'
|
|
462
|
+
|| (threads.pageInfo.endCursor !== null && typeof threads.pageInfo.endCursor !== 'string')
|
|
463
|
+
|| threads.nodes.some((thread) => !thread || typeof thread.isResolved !== 'boolean'
|
|
464
|
+
|| typeof thread.isOutdated !== 'boolean')) return undefined;
|
|
465
|
+
count += threads.nodes.filter((thread) => thread.isResolved === false && thread.isOutdated === false).length;
|
|
466
|
+
if (!threads.pageInfo.hasNextPage) return count;
|
|
467
|
+
const nextCursor = threads.pageInfo.endCursor;
|
|
468
|
+
if (typeof nextCursor !== 'string' || !nextCursor || nextCursor === after) return undefined;
|
|
469
|
+
after = nextCursor;
|
|
91
470
|
}
|
|
92
471
|
return undefined; // exceeded the page cap → fail closed rather than undercount
|
|
93
472
|
} catch (_err) {
|
|
@@ -95,6 +474,47 @@ function fetchUnresolvedThreadCount(gh, pr) {
|
|
|
95
474
|
}
|
|
96
475
|
}
|
|
97
476
|
|
|
477
|
+
function fetchCheckRunObservations(gh, { owner, repo, head }) {
|
|
478
|
+
try {
|
|
479
|
+
const pages = JSON.parse(gh([
|
|
480
|
+
'api', '--paginate', '--slurp',
|
|
481
|
+
`repos/${owner}/${repo}/commits/${head}/check-runs?filter=latest&per_page=100`,
|
|
482
|
+
]) || 'null');
|
|
483
|
+
if (!Array.isArray(pages) || pages.length === 0
|
|
484
|
+
|| pages.some((page) => !page || !Array.isArray(page.check_runs)
|
|
485
|
+
|| !Number.isInteger(page.total_count) || page.total_count < 0)) return null;
|
|
486
|
+
const runs = pages.flatMap((page) => page.check_runs);
|
|
487
|
+
if (pages.some((page) => page.total_count !== pages[0].total_count)
|
|
488
|
+
|| pages[0].total_count !== runs.length) return null;
|
|
489
|
+
if (runs.some((run) => {
|
|
490
|
+
const name = run && typeof run.name === 'string' && run.name.trim() ? run.name : null;
|
|
491
|
+
const appId = run && run.app && Number.isInteger(run.app.id) && run.app.id > 0 ? run.app.id : null;
|
|
492
|
+
const headSha = normalizeFullHeadSha(run && run.head_sha);
|
|
493
|
+
const status = String(run && run.status || '').toUpperCase();
|
|
494
|
+
const conclusion = String(run && run.conclusion || '').toUpperCase();
|
|
495
|
+
return !run || !Number.isInteger(run.id) || run.id <= 0 || !name || !appId || headSha !== head
|
|
496
|
+
|| !CHECK_RUN_STATUSES.has(status)
|
|
497
|
+
|| !Object.prototype.hasOwnProperty.call(run, 'conclusion')
|
|
498
|
+
|| (run.conclusion !== null && typeof run.conclusion !== 'string')
|
|
499
|
+
|| !CHECK_RUN_CONCLUSIONS.has(conclusion)
|
|
500
|
+
|| (status === 'COMPLETED' ? !conclusion : Boolean(conclusion));
|
|
501
|
+
})) return null;
|
|
502
|
+
return runs.map((run) => {
|
|
503
|
+
const name = run.name;
|
|
504
|
+
const appId = run.app.id;
|
|
505
|
+
return {
|
|
506
|
+
id: run.id,
|
|
507
|
+
name,
|
|
508
|
+
appId,
|
|
509
|
+
status: String(run.status).toUpperCase(),
|
|
510
|
+
conclusion: String(run.conclusion || '').toUpperCase(),
|
|
511
|
+
};
|
|
512
|
+
});
|
|
513
|
+
} catch (_err) {
|
|
514
|
+
return null;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
98
518
|
/**
|
|
99
519
|
* Default PR-context fetcher (the network seam). Assembles the shape consumed
|
|
100
520
|
* by `evaluateMergeRules` from `gh`. Anything it cannot read is left absent so
|
|
@@ -103,27 +523,31 @@ function fetchUnresolvedThreadCount(gh, pr) {
|
|
|
103
523
|
*
|
|
104
524
|
* @returns {object} prContext
|
|
105
525
|
*/
|
|
106
|
-
function defaultFetchPrContext({ pr, gh = defaultGh, now = Date.now() }) {
|
|
526
|
+
async function defaultFetchPrContext({ pr, gh = defaultGh, now = Date.now() }) {
|
|
107
527
|
const view = ghJson(gh, ['pr', 'view', String(pr), '--json',
|
|
108
|
-
'number,state,isDraft,mergeable,mergeStateStatus,statusCheckRollup,
|
|
528
|
+
'number,headRefOid,baseRefName,state,isDraft,mergeable,mergeStateStatus,statusCheckRollup,comments,updatedAt']) || {};
|
|
109
529
|
|
|
110
530
|
const rollup = Array.isArray(view.statusCheckRollup) ? view.statusCheckRollup : null;
|
|
111
|
-
const checks = (rollup || []).map((c) => ({
|
|
112
|
-
name: c.name || c.context || '?',
|
|
113
|
-
conclusion: String(c.conclusion || c.state || c.status || ''),
|
|
114
|
-
}));
|
|
115
531
|
|
|
116
532
|
const comments = Array.isArray(view.comments)
|
|
117
533
|
? view.comments.map((c) => ({
|
|
118
534
|
author: (c.author && c.author.login) || '',
|
|
119
|
-
at: c.createdAt
|
|
535
|
+
at: [c.createdAt, c.updatedAt, c.submittedAt]
|
|
536
|
+
.filter(Boolean)
|
|
537
|
+
.sort((left, right) => Date.parse(right) - Date.parse(left))[0] || '',
|
|
120
538
|
}))
|
|
121
539
|
: [];
|
|
122
540
|
|
|
123
|
-
const
|
|
541
|
+
const repoIdentity = ghJson(gh, ['repo', 'view', '--json', 'owner,name']);
|
|
542
|
+
const owner = repoIdentity && repoIdentity.owner && repoIdentity.owner.login;
|
|
543
|
+
const repo = repoIdentity && repoIdentity.name;
|
|
544
|
+
const adapter = owner && repo
|
|
545
|
+
? new PrStateAdapter({ gh: (_cmd, adapterArgs) => gh(adapterArgs) })
|
|
546
|
+
: null;
|
|
547
|
+
const reviews = adapter ? await adapter.readReviews({ owner, repo, pr }) : [];
|
|
124
548
|
const approvals = reviews
|
|
125
549
|
.filter((r) => String(r.state).toUpperCase() === 'APPROVED')
|
|
126
|
-
.map((r) => ({ author:
|
|
550
|
+
.map((r) => ({ author: typeof r.author === 'string' ? r.author : '' }));
|
|
127
551
|
|
|
128
552
|
// Derive from GitHub's mergeStateStatus / mergeable. Only a known set maps to
|
|
129
553
|
// a definite answer; anything else stays undefined so the dependent rule fails
|
|
@@ -146,29 +570,63 @@ function defaultFetchPrContext({ pr, gh = defaultGh, now = Date.now() }) {
|
|
|
146
570
|
|
|
147
571
|
const stamps = [
|
|
148
572
|
...comments.map((c) => c.at),
|
|
149
|
-
...reviews.
|
|
573
|
+
...reviews.flatMap((r) => [r.activityAt, r.createdAt, r.updatedAt, r.submittedAt]),
|
|
150
574
|
view.updatedAt || '',
|
|
151
575
|
].map((s) => Date.parse(s)).filter((n) => !Number.isNaN(n));
|
|
152
576
|
const lastActivityAt = stamps.length ? Math.max(...stamps) : undefined;
|
|
153
577
|
|
|
578
|
+
const base = typeof view.baseRefName === 'string' ? view.baseRefName : null;
|
|
579
|
+
const headSha = normalizeFullHeadSha(view.headRefOid);
|
|
580
|
+
let requiredChecks = null;
|
|
581
|
+
let requiredCheckSource = null;
|
|
582
|
+
let checks = null;
|
|
583
|
+
const providerObservations = rollup ? rollup.map(normalizeRollupObservation) : null;
|
|
584
|
+
if (adapter && base) {
|
|
585
|
+
requiredChecks = await adapter.readRequiredCheckPolicy({ owner, repo, base });
|
|
586
|
+
requiredCheckSource = adapter.lastRequiredSource;
|
|
587
|
+
const checkRuns = headSha ? fetchCheckRunObservations(gh, { owner, repo, head: headSha }) : null;
|
|
588
|
+
if (rollup && checkRuns && providerObservations.every((entry) => entry !== null)) {
|
|
589
|
+
const statuses = rollup
|
|
590
|
+
.filter((entry) => entry && entry.__typename === 'StatusContext')
|
|
591
|
+
.map((entry) => ({
|
|
592
|
+
name: typeof entry.context === 'string' ? entry.context : '',
|
|
593
|
+
appId: null,
|
|
594
|
+
state: String(entry.state || '').toUpperCase(),
|
|
595
|
+
}));
|
|
596
|
+
checks = [...checkRuns, ...statuses];
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
154
600
|
return {
|
|
601
|
+
number: Number.isInteger(view.number) ? view.number : null,
|
|
602
|
+
repository: owner && repo ? `${owner}/${repo}` : null,
|
|
603
|
+
headSha: view.headRefOid || null,
|
|
155
604
|
checks,
|
|
156
|
-
|
|
157
|
-
|
|
605
|
+
providerObservations,
|
|
606
|
+
requiredChecks,
|
|
607
|
+
requiredCheckSource,
|
|
608
|
+
requiredChecksKnown: requiredCheckSource === 'protection' && Array.isArray(requiredChecks),
|
|
609
|
+
unresolvedThreads: fetchUnresolvedThreadCount(gh, { owner, repo, pr }),
|
|
158
610
|
behindBase,
|
|
159
611
|
conflicting,
|
|
160
612
|
isDraft,
|
|
161
613
|
state,
|
|
162
614
|
approvals,
|
|
615
|
+
reviews,
|
|
616
|
+
reviewEvidenceReadable: adapter !== null,
|
|
163
617
|
comments,
|
|
164
618
|
lastActivityAt,
|
|
165
619
|
now,
|
|
166
620
|
};
|
|
167
621
|
}
|
|
168
622
|
|
|
169
|
-
/** Default merge action (squash)
|
|
170
|
-
function defaultMergePr({ pr, gh = defaultGh }) {
|
|
171
|
-
|
|
623
|
+
/** Default merge action (squash), atomically bound to the reviewed remote head. */
|
|
624
|
+
function defaultMergePr({ pr, expectedHead, repository, gh = defaultGh }) {
|
|
625
|
+
const head = normalizeFullHeadSha(expectedHead);
|
|
626
|
+
const repo = normalizeRepository(repository);
|
|
627
|
+
if (!head) throw new Error('A full 40-character expected PR head SHA is required.');
|
|
628
|
+
if (!normalizePrNumber(String(pr)) || !repo) throw new Error('A canonical PR number and repository are required.');
|
|
629
|
+
gh(['pr', 'merge', String(pr), '--repo', repo, '--squash', '--match-head-commit', head]);
|
|
172
630
|
return { merged: true, method: 'squash' };
|
|
173
631
|
}
|
|
174
632
|
|
|
@@ -183,16 +641,16 @@ function defaultMergePr({ pr, gh = defaultGh }) {
|
|
|
183
641
|
*/
|
|
184
642
|
async function handler(args, _flags, projectRoot, deps = {}) {
|
|
185
643
|
const argv = Array.isArray(args) ? args : [];
|
|
186
|
-
const
|
|
187
|
-
const
|
|
188
|
-
const pr = positional[0];
|
|
644
|
+
const parsed = parseMergeArgs(stripGlobalFlags(argv));
|
|
645
|
+
const pr = parsed.pr;
|
|
189
646
|
const root = projectRoot || process.cwd();
|
|
190
647
|
|
|
191
|
-
if (!
|
|
192
|
-
return {
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
648
|
+
if (!parsed.auto || !pr) {
|
|
649
|
+
return {
|
|
650
|
+
success: false,
|
|
651
|
+
merged: false,
|
|
652
|
+
error: 'Usage: forge merge --auto <pr> --expect-head <40-char-sha> --issue <issue-id>',
|
|
653
|
+
};
|
|
196
654
|
}
|
|
197
655
|
|
|
198
656
|
const loadConfig = deps.loadConfig || loadRawConfig;
|
|
@@ -226,8 +684,40 @@ async function handler(args, _flags, projectRoot, deps = {}) {
|
|
|
226
684
|
return { success: false, merged: false, enabled: true, reason };
|
|
227
685
|
}
|
|
228
686
|
|
|
687
|
+
if (parsed.error || !parsed.expectedHead || !parsed.issueId) {
|
|
688
|
+
const reason = parsed.error
|
|
689
|
+
|| 'Enabled auto-merge requires --expect-head <full 40-character SHA> and --issue <Forge issue ID>.';
|
|
690
|
+
return { success: false, merged: false, enabled: true, error: reason };
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
const verifyIssueOwnership = deps.verifyIssueOwnership || defaultVerifyIssueOwnership;
|
|
694
|
+
const ownershipEnv = deps.env || process.env;
|
|
695
|
+
const ownershipActor = resolveOwnershipActor(ownershipEnv);
|
|
696
|
+
const ownershipInput = {
|
|
697
|
+
issueId: parsed.issueId,
|
|
698
|
+
projectRoot: root,
|
|
699
|
+
actor: ownershipActor,
|
|
700
|
+
env: { ...ownershipEnv },
|
|
701
|
+
};
|
|
702
|
+
let ownership;
|
|
703
|
+
try {
|
|
704
|
+
ownership = await verifyIssueOwnership(ownershipInput);
|
|
705
|
+
} catch (err) {
|
|
706
|
+
return { success: false, merged: false, error: `Failed to verify issue ownership: ${err.message}` };
|
|
707
|
+
}
|
|
708
|
+
if (!ownership || ownership.owned !== true || ownership.expired !== false
|
|
709
|
+
|| typeof ownership.actor !== 'string' || ownership.actor !== ownershipActor
|
|
710
|
+
|| typeof ownership.claimedBy !== 'string' || ownership.claimedBy !== ownershipActor) {
|
|
711
|
+
return {
|
|
712
|
+
success: false,
|
|
713
|
+
merged: false,
|
|
714
|
+
error: `Active Kernel ownership claim is required for issue ${parsed.issueId}; refusing to merge.`,
|
|
715
|
+
};
|
|
716
|
+
}
|
|
717
|
+
|
|
229
718
|
const fetchPrContext = deps.fetchPrContext || defaultFetchPrContext;
|
|
230
719
|
const mergePr = deps.mergePr || defaultMergePr;
|
|
720
|
+
const verifyPrIssueBinding = deps.verifyPrIssueBinding || defaultVerifyPrIssueBinding;
|
|
231
721
|
const gh = deps.gh || defaultGh;
|
|
232
722
|
|
|
233
723
|
let prContext;
|
|
@@ -241,12 +731,35 @@ async function handler(args, _flags, projectRoot, deps = {}) {
|
|
|
241
731
|
// command must be an idempotent NO-OP — never an error and never a second merge
|
|
242
732
|
// attempt. (An absent/unknown state falls through to the fail-closed rules.)
|
|
243
733
|
const prState = prContext && prContext.state ? String(prContext.state).toUpperCase() : '';
|
|
244
|
-
if (prState
|
|
734
|
+
if (prState === 'MERGED' || prState === 'CLOSED') {
|
|
245
735
|
const reason = `PR #${pr} is ${prState} (not OPEN) — nothing to merge. No action taken.`;
|
|
246
736
|
process.stdout.write(`${reason}\n`);
|
|
247
737
|
return { success: true, merged: false, enabled: true, state: prState, reason };
|
|
248
738
|
}
|
|
249
739
|
|
|
740
|
+
const mandatoryError = mandatoryContextError(prContext, parsed.expectedHead);
|
|
741
|
+
if (mandatoryError) return { success: false, merged: false, error: mandatoryError };
|
|
742
|
+
const leasedRepository = normalizeRepository(prContext && prContext.repository);
|
|
743
|
+
if (!leasedRepository) {
|
|
744
|
+
return { success: false, merged: false, error: 'PR repository identity is unreadable; refusing to merge.' };
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
let binding;
|
|
748
|
+
try {
|
|
749
|
+
binding = await verifyPrIssueBinding({
|
|
750
|
+
issueId: parsed.issueId,
|
|
751
|
+
pr,
|
|
752
|
+
projectRoot: root,
|
|
753
|
+
prContext,
|
|
754
|
+
buildBroker: deps.buildPrBindingBroker,
|
|
755
|
+
});
|
|
756
|
+
} catch (err) {
|
|
757
|
+
return { success: false, merged: false, error: `Failed to verify PR issue binding: ${err.message}` };
|
|
758
|
+
}
|
|
759
|
+
if (!binding || binding.bound !== true) {
|
|
760
|
+
return { success: false, merged: false, error: 'PR is not authoritatively linked to the supplied Forge issue.' };
|
|
761
|
+
}
|
|
762
|
+
|
|
250
763
|
const { allowed, unmet } = evaluateMergeRules(prContext, rules);
|
|
251
764
|
|
|
252
765
|
if (!allowed) {
|
|
@@ -258,7 +771,7 @@ async function handler(args, _flags, projectRoot, deps = {}) {
|
|
|
258
771
|
}
|
|
259
772
|
|
|
260
773
|
// TOCTOU guard: PR state can change between the first fetch and the merge — a
|
|
261
|
-
// new comment resets settle_min, a required check regresses, a thread opens.
|
|
774
|
+
// new comment resets configured settle_min, a required check regresses, or a thread opens.
|
|
262
775
|
// Re-pull LIVE data and re-evaluate immediately before merging so our custom
|
|
263
776
|
// rules (which GitHub's server-side branch protection does NOT enforce) are
|
|
264
777
|
// honored against the freshest possible state, never a stale snapshot.
|
|
@@ -271,11 +784,17 @@ async function handler(args, _flags, projectRoot, deps = {}) {
|
|
|
271
784
|
// Re-apply the terminal-state guard on the FRESH context: the PR may have been
|
|
272
785
|
// merged or closed between the first fetch and now. Never merge a terminal PR.
|
|
273
786
|
const freshState = freshContext && freshContext.state ? String(freshContext.state).toUpperCase() : '';
|
|
274
|
-
if (freshState
|
|
787
|
+
if (freshState === 'MERGED' || freshState === 'CLOSED') {
|
|
275
788
|
const reason = `PR #${pr} became ${freshState} (not OPEN) before merge — nothing to merge. No action taken.`;
|
|
276
789
|
process.stdout.write(`${reason}\n`);
|
|
277
790
|
return { success: true, merged: false, enabled: true, state: freshState, reason };
|
|
278
791
|
}
|
|
792
|
+
const freshMandatoryError = mandatoryContextError(freshContext, parsed.expectedHead);
|
|
793
|
+
if (freshMandatoryError) return { success: false, merged: false, error: freshMandatoryError };
|
|
794
|
+
const freshRepository = normalizeRepository(freshContext && freshContext.repository);
|
|
795
|
+
if (!freshRepository || freshRepository !== leasedRepository) {
|
|
796
|
+
return { success: false, merged: false, error: 'PR repository identity changed or became unreadable before merge.' };
|
|
797
|
+
}
|
|
279
798
|
const recheck = evaluateMergeRules(freshContext, rules);
|
|
280
799
|
if (!recheck.allowed) {
|
|
281
800
|
process.stdout.write(`Auto-merge ABORTED for PR #${pr} — state changed since first check; ${recheck.unmet.length} rule(s) now unmet:\n`);
|
|
@@ -285,8 +804,43 @@ async function handler(args, _flags, projectRoot, deps = {}) {
|
|
|
285
804
|
return { success: true, merged: false, enabled: true, allowed: false, unmet: recheck.unmet, reason: 'PR state changed before merge (live re-check failed)' };
|
|
286
805
|
}
|
|
287
806
|
|
|
807
|
+
let freshBinding;
|
|
808
|
+
try {
|
|
809
|
+
freshBinding = await verifyPrIssueBinding({
|
|
810
|
+
issueId: parsed.issueId,
|
|
811
|
+
pr,
|
|
812
|
+
projectRoot: root,
|
|
813
|
+
prContext: freshContext,
|
|
814
|
+
buildBroker: deps.buildPrBindingBroker,
|
|
815
|
+
});
|
|
816
|
+
} catch (err) {
|
|
817
|
+
return { success: false, merged: false, error: `Failed to re-verify PR issue binding: ${err.message}` };
|
|
818
|
+
}
|
|
819
|
+
if (!freshBinding || freshBinding.bound !== true) {
|
|
820
|
+
return { success: false, merged: false, error: 'PR issue linkage changed or is unreadable before merge.' };
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
let finalOwnership;
|
|
824
|
+
try {
|
|
825
|
+
finalOwnership = await verifyIssueOwnership(ownershipInput);
|
|
826
|
+
} catch (err) {
|
|
827
|
+
return { success: false, merged: false, error: `Failed to re-verify issue ownership before merge: ${err.message}` };
|
|
828
|
+
}
|
|
829
|
+
if (!finalOwnership || finalOwnership.owned !== true || finalOwnership.expired !== false
|
|
830
|
+
|| typeof finalOwnership.actor !== 'string' || finalOwnership.actor !== ownershipActor
|
|
831
|
+
|| typeof finalOwnership.claimedBy !== 'string' || finalOwnership.claimedBy !== ownershipActor) {
|
|
832
|
+
return { success: false, merged: false, error: 'Kernel ownership changed or expired before merge; refusing to merge.' };
|
|
833
|
+
}
|
|
834
|
+
|
|
288
835
|
try {
|
|
289
|
-
const mergeResult = await mergePr({
|
|
836
|
+
const mergeResult = await mergePr({
|
|
837
|
+
pr,
|
|
838
|
+
expectedHead: parsed.expectedHead,
|
|
839
|
+
repository: leasedRepository,
|
|
840
|
+
issueId: parsed.issueId,
|
|
841
|
+
projectRoot: root,
|
|
842
|
+
gh,
|
|
843
|
+
});
|
|
290
844
|
process.stdout.write(`All ${rules.length} merge rule(s) passed — merged PR #${pr}.\n`);
|
|
291
845
|
return {
|
|
292
846
|
success: true,
|
|
@@ -304,9 +858,15 @@ async function handler(args, _flags, projectRoot, deps = {}) {
|
|
|
304
858
|
module.exports = {
|
|
305
859
|
name: 'merge',
|
|
306
860
|
description: 'Opt-in conditional auto-merge: merge a PR only when all user-configured rules pass (OFF by default)',
|
|
307
|
-
usage: 'Usage: forge merge --auto <pr>',
|
|
861
|
+
usage: 'Usage: forge merge --auto <pr> --expect-head <40-char-sha> --issue <issue-id>',
|
|
308
862
|
handler,
|
|
309
863
|
// Exported seams for testing / reuse.
|
|
310
864
|
defaultFetchPrContext,
|
|
311
865
|
defaultMergePr,
|
|
866
|
+
defaultVerifyPrIssueBinding,
|
|
867
|
+
defaultVerifyIssueOwnership,
|
|
868
|
+
evaluateProtectedRequiredChecks,
|
|
869
|
+
normalizeFullHeadSha,
|
|
870
|
+
normalizePrNumber,
|
|
871
|
+
parseMergeArgs,
|
|
312
872
|
};
|