forge-workflow 0.1.0-beta.4 → 0.1.0-beta.6
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 +18 -7
- package/CHANGELOG.md +79 -1
- package/CLAUDE.md +0 -12
- package/CODING_STANDARDS.md +72 -0
- package/README.md +6 -2
- package/bin/forge-cmd.js +20 -0
- package/bin/forge.js +28 -375
- 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 +8 -5
- package/docs/reference/FORGE_KERNEL_STORAGE_MODEL.md +4 -0
- package/docs/reference/INSIGHTS_RECAP.md +9 -20
- package/docs/reference/INSTALL.md +4 -0
- package/docs/reference/LEGACY_CLAIM_REPAIR.md +112 -0
- package/docs/reference/RELEASE.md +5 -3
- package/docs/reference/TOOLCHAIN.md +8 -0
- package/docs/reference/github-accounts.md +134 -0
- package/docs/reference/protected-state-surfaces.md +4 -4
- package/docs/reference/shepherd.md +114 -35
- package/lefthook.yml +12 -0
- package/lib/activation/ensure-forge-home.js +33 -15
- package/lib/adapters/pr-state-adapter.js +359 -144
- package/lib/audit-evidence.js +71 -110
- package/lib/base-remote.js +138 -0
- package/lib/beta5-compatibility-evidence.js +1093 -0
- package/lib/bun-lockfile-proof.js +413 -0
- package/lib/bun-workflow-pins.js +461 -0
- package/lib/capabilities/index.js +9 -0
- package/lib/capabilities/model.js +141 -0
- package/lib/capabilities/probes.js +347 -0
- package/lib/capped-jsonl-log.js +236 -0
- package/lib/codex-skills.js +2 -2
- package/lib/commands/_manifest.js +1 -0
- package/lib/commands/_registry.js +50 -20
- package/lib/commands/clean.js +252 -32
- package/lib/commands/dev.js +4 -33
- package/lib/commands/doctor.js +37 -6
- package/lib/commands/gate.js +197 -27
- package/lib/commands/github.js +215 -0
- package/lib/commands/hooks.js +276 -30
- package/lib/commands/insights.js +8 -3
- package/lib/commands/memory.js +66 -2
- package/lib/commands/merge.js +1265 -58
- package/lib/commands/plan.js +33 -2
- package/lib/commands/pr.js +3 -1
- package/lib/commands/preflight.js +21 -4
- package/lib/commands/prime.js +21 -8
- package/lib/commands/push.js +146 -54
- package/lib/commands/recall.js +127 -49
- package/lib/commands/recap.js +6 -1
- package/lib/commands/release.js +39 -3
- package/lib/commands/remember.js +28 -4
- package/lib/commands/serve.js +26 -9
- package/lib/commands/setup.js +323 -98
- package/lib/commands/shepherd.js +591 -73
- package/lib/commands/ship.js +36 -91
- package/lib/commands/skill.js +127 -11
- package/lib/commands/status.js +17 -1
- package/lib/commands/team.js +47 -8
- package/lib/commands/test.js +187 -38
- package/lib/commands/validate.js +65 -21
- package/lib/commands/worktree.js +359 -45
- package/lib/core/runtime-graph.js +1 -1
- package/lib/doc-assertions.js +297 -0
- package/lib/existing-tdd-gate.js +253 -0
- package/lib/fixtures/beta5-corpus/v1/README.md +9 -0
- package/lib/fixtures/beta5-corpus/v1/contract/command-contract.json +26 -0
- package/lib/fixtures/beta5-corpus/v1/contract/package-contract.json +13 -0
- package/lib/fixtures/beta5-corpus/v1/contract/workflow-stage-matrix.json +8 -0
- package/lib/fixtures/beta5-corpus/v1/manifest.json +25 -0
- package/lib/fixtures/beta5-corpus/v1/state/comments.jsonl +1 -0
- package/lib/fixtures/beta5-corpus/v1/state/config.yaml +6 -0
- package/lib/fixtures/beta5-corpus/v1/state/dependencies.jsonl +1 -0
- package/lib/fixtures/beta5-corpus/v1/state/issues.jsonl +2 -0
- package/lib/fixtures/beta5-corpus/v1/state/kernel.sql +20 -0
- package/lib/forge-context.js +1 -4
- package/lib/forge-issues.js +134 -32
- package/lib/gate-events.js +98 -10
- package/lib/git-defaults.js +56 -0
- package/lib/github-context.js +308 -0
- package/lib/global-flags.js +1 -0
- package/lib/harness-capability-matrix.js +3 -3
- package/lib/hook-renderer.js +122 -5
- package/lib/insights.js +96 -80
- package/lib/issue-render.js +19 -0
- package/lib/kernel/backing-issue.js +14 -2
- package/lib/kernel/broker.js +739 -31
- package/lib/kernel/claim-reconciler.js +238 -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/lease-enforcer.js +9 -4
- package/lib/kernel/legacy-claim-repair.js +442 -0
- package/lib/kernel/live-claim-projection.js +26 -0
- package/lib/kernel/migrations.js +118 -3
- package/lib/kernel/readiness-model.js +184 -12
- package/lib/kernel/schema.js +49 -1
- package/lib/kernel/sqlite-driver.js +3435 -172
- package/lib/kernel/taxonomy-validator.js +4 -1
- package/lib/kernel/windows-private-acl.js +239 -0
- package/lib/lefthook-wiring.js +21 -1
- package/lib/memory/hygiene.js +191 -0
- package/lib/memory/router.js +110 -28
- package/lib/memory/usage-evidence.js +4 -0
- package/lib/memory-digest.js +106 -15
- package/lib/memory-recall-events.js +145 -0
- package/lib/memory-recall.js +71 -10
- package/lib/merge-rules.js +143 -21
- package/lib/npm-publish-workflow.js +465 -0
- package/lib/orientation.js +68 -43
- package/lib/package-root.js +2 -0
- package/lib/plugin-catalog.js +14 -4
- package/lib/pr-bundle.js +5 -6
- package/lib/pr-monitor/auto-actions.js +169 -28
- package/lib/pr-monitor/differ.js +110 -4
- package/lib/pr-monitor/events.js +0 -0
- package/lib/pr-monitor/flow-monitor.js +1424 -0
- package/lib/pr-monitor/gather.js +251 -44
- package/lib/pr-monitor/journal.js +18 -39
- package/lib/pr-monitor/monitor.js +117 -10
- package/lib/pr-monitor/process-identity.js +117 -0
- package/lib/pr-monitor/reconcile-executor.js +1129 -470
- package/lib/pr-monitor/reconcile.js +0 -0
- package/lib/pr-monitor/render-summary.js +293 -0
- package/lib/pr-monitor/review-preflight.js +269 -0
- package/lib/pr-monitor/shepherd-lease.js +38 -20
- package/lib/pr-monitor/verdict.js +438 -0
- package/lib/pr-monitor/watch-lifecycle.js +145 -27
- package/lib/pr-monitor/watch-owner.js +1414 -0
- package/lib/pr-monitor/watch.js +129 -58
- package/lib/pr-pull.js +33 -14
- package/lib/pr-shepherd.js +51 -11
- package/lib/preflight/gates.js +65 -18
- package/lib/preflight/runner.js +5 -0
- package/lib/project-memory.js +178 -4
- package/lib/protected-state-authority.js +1100 -0
- package/lib/protected-state-surfaces.js +243 -45
- package/lib/release-readiness.js +53 -7
- package/lib/review-adapter.js +65 -0
- package/lib/shell-utils.js +1 -1
- package/lib/skills-sync.js +71 -35
- package/lib/smart-merge.js +28 -4
- package/lib/symlink-utils.js +74 -26
- package/lib/upgrade-safety.js +39 -0
- package/lib/using-forge.js +19 -6
- package/lib/validation/risk-manifest.js +339 -0
- package/lib/workflow/enforce-stage.js +44 -0
- package/lib/workflow/plan-authority.js +225 -0
- package/package.json +12 -9
- package/scripts/commitlint.js +13 -15
- package/scripts/doc-asserting-tests.js +158 -0
- package/scripts/generate-risk-manifest.js +91 -0
- package/scripts/github-context-bridge.sh +10 -0
- package/scripts/legacy-claim-repair.js +145 -0
- package/scripts/lib/behavioral-eval-runner.js +310 -0
- package/scripts/lib/behavioral-eval-runtime.js +457 -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 +773 -0
- package/scripts/protected-state-check.js +479 -31
- package/scripts/run-command-eval.js +29 -1
- package/scripts/sync-agent-skills.js +333 -34
- package/scripts/sync-d20-audit.js +172 -0
- package/scripts/test-full-suite.js +935 -37
- package/scripts/test-profile.js +13 -3
- package/scripts/test.js +271 -57
- package/skills/coverage.json +1 -0
- package/skills/review/SKILL.md +6 -11
- package/skills/review/evals/scorecard.json +4 -4
- package/skills/rollback/SKILL.md +4 -11
- package/skills/rollback/evals/scorecard.json +3 -3
- package/skills/setup/SKILL.md +18 -0
- package/skills/setup/evals/scorecard.json +3 -3
- package/skills/shepherd/SKILL.md +39 -16
- package/skills/shepherd/evals/scorecard.json +4 -4
- package/skills/ship/SKILL.md +4 -12
- package/skills/ship/evals/scorecard.json +3 -3
- package/skills/validate/SKILL.md +3 -0
- package/skills/validate/evals/scorecard.json +1 -1
- package/skills/worktree/SKILL.md +6 -1
- package/skills/worktree/evals/scorecard.json +2 -2
- package/lib/beads-setup.js +0 -538
- package/lib/beads-sync-scaffold.js +0 -189
- package/lib/pat-setup.js +0 -207
- package/lib/pr-monitor/render-sticky.js +0 -206
- package/lib/pr-monitor/upsert-sticky.js +0 -169
- 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/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,834 @@ 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
|
+
const gateCommand = require('./gate');
|
|
50
|
+
const { computeContentHash, validateContractStructure } = require('../../packages/memory-contracts');
|
|
51
|
+
|
|
52
|
+
const FULL_HEAD_SHA = /^[0-9a-f]{40}$/i;
|
|
53
|
+
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;
|
|
54
|
+
const POSITIVE_PR_NUMBER = /^[1-9][0-9]*$/;
|
|
55
|
+
const REPOSITORY_NAME = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
|
56
|
+
const CHECK_RUN_STATUSES = new Set(['QUEUED', 'IN_PROGRESS', 'COMPLETED', 'WAITING', 'PENDING', 'REQUESTED']);
|
|
57
|
+
const CHECK_RUN_CONCLUSIONS = new Set([
|
|
58
|
+
'', 'SUCCESS', 'FAILURE', 'NEUTRAL', 'CANCELLED', 'SKIPPED', 'TIMED_OUT',
|
|
59
|
+
'ACTION_REQUIRED', 'STALE', 'STARTUP_FAILURE',
|
|
60
|
+
]);
|
|
61
|
+
const SAFE_TERMINAL_CONCLUSIONS = new Set(['SUCCESS', 'NEUTRAL', 'SKIPPED']);
|
|
62
|
+
const STATUS_CONTEXT_STATES = new Set(['ERROR', 'EXPECTED', 'FAILURE', 'PENDING', 'SUCCESS']);
|
|
63
|
+
const REVIEW_ACTOR_TYPENAMES = new Set([
|
|
64
|
+
'Bot', 'EnterpriseUserAccount', 'Mannequin', 'Organization', 'User',
|
|
65
|
+
]);
|
|
66
|
+
const REVIEW_STATES = new Set([
|
|
67
|
+
'APPROVED', 'CHANGES_REQUESTED', 'COMMENTED', 'DISMISSED', 'PENDING',
|
|
68
|
+
]);
|
|
69
|
+
const MERGE_GATE = 'gate.merge';
|
|
70
|
+
|
|
71
|
+
function uuidFromSeed(seed) {
|
|
72
|
+
const hash = computeContentHash({ seed });
|
|
73
|
+
return `${hash.slice(0, 8)}-${hash.slice(8, 12)}-4${hash.slice(13, 16)}-8${hash.slice(17, 20)}-${hash.slice(20, 32)}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function contractEnvelope(schemaId, objectId, createdAt, actor, payload) {
|
|
77
|
+
const value = {
|
|
78
|
+
schema_id: schemaId,
|
|
79
|
+
schema_version: 1,
|
|
80
|
+
object_id: objectId,
|
|
81
|
+
created_at: createdAt,
|
|
82
|
+
producer: { product_id: 'forge', product_version: '0.1.0', instance_id: actor },
|
|
83
|
+
capabilities_used: [],
|
|
84
|
+
provenance: { source_kind: 'guarded-merge', actor_class: 'system', actor_id: actor },
|
|
85
|
+
payload,
|
|
86
|
+
extensions: {},
|
|
87
|
+
};
|
|
88
|
+
value.content_hash = computeContentHash(value);
|
|
89
|
+
const validation = validateContractStructure(value);
|
|
90
|
+
if (!validation.ok) throw new Error(`${schemaId} validation failed`);
|
|
91
|
+
return value;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function currentTimestamp(now = Date.now) {
|
|
95
|
+
const value = typeof now === 'function' ? now() : now;
|
|
96
|
+
const date = new Date(value);
|
|
97
|
+
if (!Number.isFinite(date.getTime())) throw new Error('Merge decision clock is invalid.');
|
|
98
|
+
return date.toISOString();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function normalizeFullHeadSha(value) {
|
|
102
|
+
return typeof value === 'string' && FULL_HEAD_SHA.test(value) ? value.toLowerCase() : null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function normalizePrNumber(value) {
|
|
106
|
+
if (typeof value !== 'string' || !POSITIVE_PR_NUMBER.test(value)) return null;
|
|
107
|
+
const parsed = Number(value);
|
|
108
|
+
return Number.isSafeInteger(parsed) && parsed > 0 ? String(parsed) : null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function normalizeKernelPrNumber(value) {
|
|
112
|
+
if (Number.isSafeInteger(value) && value > 0) return String(value);
|
|
113
|
+
return typeof value === 'string' ? normalizePrNumber(value) : null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function normalizeRepository(value) {
|
|
117
|
+
return typeof value === 'string' && REPOSITORY_NAME.test(value) ? value.toLowerCase() : null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function defaultResolveLocalRepository({ projectRoot, git = execFileSync }) {
|
|
121
|
+
try {
|
|
122
|
+
const remote = git('git', ['remote', 'get-url', 'origin'], {
|
|
123
|
+
cwd: projectRoot,
|
|
124
|
+
encoding: 'utf8',
|
|
125
|
+
timeout: 3000,
|
|
126
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
127
|
+
windowsHide: true,
|
|
128
|
+
}).trim();
|
|
129
|
+
const scp = /^[A-Za-z]:/.test(remote) || /^(?:git|https?|ssh|file):/i.test(remote) ? null
|
|
130
|
+
: /^(?:[A-Za-z0-9._-]+@)?[A-Za-z0-9][A-Za-z0-9._-]*:([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+?)(?:\.git)?$/.exec(remote);
|
|
131
|
+
if (scp) return normalizeRepository(scp[1]);
|
|
132
|
+
const parsed = new URL(remote);
|
|
133
|
+
if (!['git:', 'http:', 'https:', 'ssh:'].includes(parsed.protocol)
|
|
134
|
+
|| !parsed.hostname || parsed.password || parsed.search || parsed.hash
|
|
135
|
+
|| (['http:', 'https:'].includes(parsed.protocol) && parsed.username)) return null;
|
|
136
|
+
const parts = parsed.pathname.split('/').filter(Boolean);
|
|
137
|
+
if (parts.length !== 2) return null;
|
|
138
|
+
return normalizeRepository(`${parts[0]}/${parts[1].replace(/\.git$/, '')}`);
|
|
139
|
+
} catch {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function terminalEvidenceFromTrace(row, { issueId, repository, number, expectedHead }) {
|
|
145
|
+
const normalizedExpectedHead = normalizeFullHeadSha(expectedHead);
|
|
146
|
+
if (!normalizedExpectedHead || !Array.isArray(row?.iterations)) return { invalid: true };
|
|
147
|
+
const merged = row.iterations.filter(iteration => iteration?.type === 'pr.merged');
|
|
148
|
+
if (merged.length === 0) return null;
|
|
149
|
+
if (merged.length !== 1) return { invalid: true };
|
|
150
|
+
const exact = merged.filter(iteration => iteration.issue_id === issueId
|
|
151
|
+
&& Number.isInteger(iteration.issue_revision) && iteration.issue_revision >= 0
|
|
152
|
+
&& normalizeFullHeadSha(iteration.head_sha) === normalizedExpectedHead
|
|
153
|
+
&& typeof iteration.at === 'string' && Number.isFinite(Date.parse(iteration.at))
|
|
154
|
+
&& /^[0-9a-f]{64}$/.test(iteration.work_packet_hash)
|
|
155
|
+
&& /^[0-9a-f]{64}$/.test(iteration.run_receipt_hash));
|
|
156
|
+
if (exact.length !== 1) return { invalid: true };
|
|
157
|
+
const iteration = exact[0];
|
|
158
|
+
const decisionSeed = `${issueId}:${iteration.issue_revision}:${repository}:${number}:${normalizedExpectedHead}`;
|
|
159
|
+
return {
|
|
160
|
+
decisionId: uuidFromSeed(`merge-packet:${decisionSeed}`),
|
|
161
|
+
receiptId: uuidFromSeed(`merge-receipt:${iteration.work_packet_hash}`),
|
|
162
|
+
receiptHash: iteration.run_receipt_hash,
|
|
163
|
+
occurredAt: iteration.at,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function parseMergeArgs(argv) {
|
|
168
|
+
const values = { auto: false, pr: null, expectedHead: null, issueId: null, error: null };
|
|
169
|
+
const seen = new Set();
|
|
170
|
+
const input = Array.isArray(argv) ? argv : [];
|
|
171
|
+
|
|
172
|
+
for (let index = 0; index < input.length; index += 1) {
|
|
173
|
+
const raw = String(input[index]);
|
|
174
|
+
if (raw === '--auto') {
|
|
175
|
+
values.auto = true;
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
let option = null;
|
|
180
|
+
let inlineValue = null;
|
|
181
|
+
if (raw === '--expect-head' || raw === '--issue') option = raw;
|
|
182
|
+
else if (raw.startsWith('--expect-head=')) {
|
|
183
|
+
option = '--expect-head';
|
|
184
|
+
inlineValue = raw.slice('--expect-head='.length);
|
|
185
|
+
} else if (raw.startsWith('--issue=')) {
|
|
186
|
+
option = '--issue';
|
|
187
|
+
inlineValue = raw.slice('--issue='.length);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (option) {
|
|
191
|
+
if (seen.has(option)) {
|
|
192
|
+
values.error = `Duplicate ${option} is not allowed.`;
|
|
193
|
+
break;
|
|
194
|
+
}
|
|
195
|
+
seen.add(option);
|
|
196
|
+
let value = inlineValue;
|
|
197
|
+
if (value === null) {
|
|
198
|
+
const candidate = input[index + 1];
|
|
199
|
+
if (candidate === undefined || String(candidate).startsWith('--')) {
|
|
200
|
+
values.error = `${option} requires a value.`;
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
value = String(candidate);
|
|
204
|
+
index += 1;
|
|
205
|
+
}
|
|
206
|
+
if (!value) {
|
|
207
|
+
values.error = `${option} requires a value.`;
|
|
208
|
+
break;
|
|
209
|
+
}
|
|
210
|
+
if (option === '--expect-head') values.expectedHead = normalizeFullHeadSha(value);
|
|
211
|
+
else values.issueId = FORGE_ISSUE_ID.test(value) ? value.toLowerCase() : null;
|
|
212
|
+
if ((option === '--expect-head' && !values.expectedHead)
|
|
213
|
+
|| (option === '--issue' && !values.issueId)) {
|
|
214
|
+
values.error = `${option} has an invalid value.`;
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (raw.startsWith('--')) {
|
|
221
|
+
values.error = `Unknown merge option: ${raw}`;
|
|
222
|
+
break;
|
|
223
|
+
}
|
|
224
|
+
if (values.pr !== null) {
|
|
225
|
+
values.error = 'Exactly one PR number is required.';
|
|
226
|
+
break;
|
|
227
|
+
}
|
|
228
|
+
values.pr = normalizePrNumber(raw);
|
|
229
|
+
if (!values.pr) {
|
|
230
|
+
values.error = 'PR selector must be one positive decimal PR number.';
|
|
231
|
+
break;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return values;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function resolveOwnershipActor(env = process.env) {
|
|
239
|
+
return (typeof env.FORGE_ACTOR === 'string' && env.FORGE_ACTOR.trim())
|
|
240
|
+
|| (typeof env.FORGE_SESSION_ID === 'string' && env.FORGE_SESSION_ID.trim())
|
|
241
|
+
|| null;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function defaultVerifyIssueOwnership({
|
|
245
|
+
issueId, projectRoot, actor: expectedActor, env = process.env, runIssue = runIssueOperation,
|
|
246
|
+
}) {
|
|
247
|
+
const actor = expectedActor || resolveOwnershipActor(env);
|
|
248
|
+
if (!actor) return { owned: false, actor: null, error: 'FORGE_ACTOR or FORGE_SESSION_ID is required.' };
|
|
249
|
+
const frozenEnv = { ...env, FORGE_ACTOR: actor };
|
|
250
|
+
const result = await runIssue('owns', [issueId], projectRoot, { env: frozenEnv });
|
|
251
|
+
const data = result?.data;
|
|
252
|
+
const ownershipMatches = result?.ok === true && data?.owned === true
|
|
253
|
+
&& data.expired === false && data.actor === actor && data.claimed_by === actor;
|
|
254
|
+
const expectedSessionId = typeof env.FORGE_SESSION_ID === 'string' && env.FORGE_SESSION_ID.trim()
|
|
255
|
+
? env.FORGE_SESSION_ID.trim()
|
|
256
|
+
: null;
|
|
257
|
+
let sessionId = null;
|
|
258
|
+
if (ownershipMatches && expectedSessionId) {
|
|
259
|
+
const claimsResult = await runIssue('claims', [], projectRoot, { env: frozenEnv });
|
|
260
|
+
const claims = claimsResult?.ok === true && Array.isArray(claimsResult.data?.claims)
|
|
261
|
+
? claimsResult.data.claims
|
|
262
|
+
: [];
|
|
263
|
+
const exactClaims = claims.filter(claim => claim?.issue_id === issueId
|
|
264
|
+
&& claim?.actor === actor && claim?.session_id === expectedSessionId);
|
|
265
|
+
sessionId = exactClaims.length === 1 ? exactClaims[0].session_id : null;
|
|
266
|
+
}
|
|
267
|
+
const owned = ownershipMatches && (!expectedSessionId || sessionId === expectedSessionId);
|
|
268
|
+
return {
|
|
269
|
+
owned: Boolean(owned),
|
|
270
|
+
actor,
|
|
271
|
+
claimedBy: data?.claimed_by,
|
|
272
|
+
sessionId,
|
|
273
|
+
expired: typeof data?.expired === 'boolean' ? data.expired : null,
|
|
274
|
+
error: result?.error,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function selectPrBindingRow(rows, repository, number, gitCommonDir = null) {
|
|
279
|
+
if (!Array.isArray(rows)) return { error: 'Kernel PR linkage is unreadable.' };
|
|
280
|
+
const repositoryRows = repository
|
|
281
|
+
? rows.filter(row => normalizeRepository(row?.repo) === repository)
|
|
282
|
+
: rows;
|
|
283
|
+
const candidateRows = gitCommonDir
|
|
284
|
+
? repositoryRows.filter(row => row?.git_common_dir === gitCommonDir)
|
|
285
|
+
: repositoryRows;
|
|
286
|
+
if (candidateRows.some(row => !normalizeKernelPrNumber(row.number))) {
|
|
287
|
+
return { error: 'Kernel PR linkage contains a malformed PR number.' };
|
|
288
|
+
}
|
|
289
|
+
const matches = candidateRows.filter(row => normalizeKernelPrNumber(row.number) === number);
|
|
290
|
+
if (matches.length === 0) return { missing: true, error: 'Kernel PR linkage is missing.' };
|
|
291
|
+
if (matches.length > 1) return { error: 'Kernel PR linkage is ambiguous.' };
|
|
292
|
+
if (!normalizeRepository(matches[0]?.repo)) return { error: 'Kernel PR repository identity is unreadable.' };
|
|
293
|
+
return { row: matches[0] };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async function openPrBindingBroker(projectRoot, buildBroker) {
|
|
297
|
+
if (buildBroker) {
|
|
298
|
+
const built = await buildBroker({ projectRoot });
|
|
299
|
+
return { built, ownsDriver: false };
|
|
300
|
+
}
|
|
301
|
+
const { resolveGitCommonDir } = require('../kernel/broker');
|
|
302
|
+
const { buildMigratedKernelIssueDeps } = require('../kernel/cli-broker-factory');
|
|
303
|
+
const gitCommonDir = resolveGitCommonDir(projectRoot);
|
|
304
|
+
const deps = await buildMigratedKernelIssueDeps({ projectRoot, gitCommonDir });
|
|
305
|
+
return {
|
|
306
|
+
built: { gitCommonDir, broker: deps.kernelBroker, driver: deps.kernelDriver },
|
|
307
|
+
ownsDriver: true,
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async function readPrBindingRows(built, allowRetired, { issueId, number, repository }) {
|
|
312
|
+
if (allowRetired) {
|
|
313
|
+
const target = {
|
|
314
|
+
issue_id: issueId,
|
|
315
|
+
pr_number: Number(number),
|
|
316
|
+
git_common_dir: built.gitCommonDir,
|
|
317
|
+
};
|
|
318
|
+
if (repository) target.repo = repository;
|
|
319
|
+
const read = await built.broker.readTrace(target);
|
|
320
|
+
return { rows: read?.pull_requests, gaps: read?.gaps };
|
|
321
|
+
}
|
|
322
|
+
return { rows: await built.broker.listOpenPrs(built.gitCommonDir), gaps: [] };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function terminalTraceGapKind(gap) {
|
|
326
|
+
if (typeof gap !== 'string') return null;
|
|
327
|
+
if (/^(?:issue|worktree|work_folder|pull_requests|(?:plan|tasks|decisions)(?::(?:outside_project|oversized))?)$/.test(gap)) {
|
|
328
|
+
return 'benign';
|
|
329
|
+
}
|
|
330
|
+
if (gap === 'pull_requests:overflow'
|
|
331
|
+
|| /^pull_requests:[^:]+:unlinked_issue$/.test(gap)
|
|
332
|
+
|| /^iterations:[^:]+:(?:missing|incomplete|overflow)$/.test(gap)) {
|
|
333
|
+
return 'authority';
|
|
334
|
+
}
|
|
335
|
+
return null;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function terminalTraceIsIncomplete(gaps, row) {
|
|
339
|
+
if (!Array.isArray(gaps) || gaps.some(gap => terminalTraceGapKind(gap) === null)) return true;
|
|
340
|
+
const rowId = typeof row?.id === 'string' && row.id ? row.id : null;
|
|
341
|
+
return gaps.some((gap) => {
|
|
342
|
+
if (gap === 'pull_requests:overflow') return true;
|
|
343
|
+
if (rowId && gap === `pull_requests:${rowId}:unlinked_issue`) return true;
|
|
344
|
+
if (!/^iterations:[^:]+:(?:missing|incomplete|overflow)$/.test(gap)) return false;
|
|
345
|
+
return !rowId || ['missing', 'incomplete', 'overflow']
|
|
346
|
+
.some(suffix => gap === `iterations:${rowId}:${suffix}`);
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function terminalTraceAbsenceIsClean(gaps) {
|
|
351
|
+
return Array.isArray(gaps) && gaps.every(gap => terminalTraceGapKind(gap) === 'benign');
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
async function defaultVerifyPrIssueBinding({
|
|
355
|
+
issueId,
|
|
356
|
+
pr,
|
|
357
|
+
projectRoot,
|
|
358
|
+
prContext,
|
|
359
|
+
buildBroker,
|
|
360
|
+
allowRetired = false,
|
|
361
|
+
}) {
|
|
362
|
+
const number = normalizePrNumber(String(pr));
|
|
363
|
+
let repository = normalizeRepository(prContext?.repository);
|
|
364
|
+
if (!number || !prContext || prContext.number !== Number(number) || (!allowRetired && !repository)) {
|
|
365
|
+
return { bound: false, error: 'PR identity is unreadable or does not match the requested PR number.' };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
let built;
|
|
369
|
+
let ownsDriver = false;
|
|
370
|
+
try {
|
|
371
|
+
({ built, ownsDriver } = await openPrBindingBroker(projectRoot, buildBroker));
|
|
372
|
+
const linkageReader = allowRetired ? built?.broker?.readTrace : built?.broker?.listOpenPrs;
|
|
373
|
+
if (!built?.broker || typeof linkageReader !== 'function'
|
|
374
|
+
|| typeof built.gitCommonDir !== 'string' || !built.gitCommonDir) {
|
|
375
|
+
return { bound: false, error: 'Kernel PR linkage reader is unavailable.' };
|
|
376
|
+
}
|
|
377
|
+
const linkage = await readPrBindingRows(built, allowRetired, { issueId, number, repository });
|
|
378
|
+
const { rows } = linkage;
|
|
379
|
+
const selection = selectPrBindingRow(
|
|
380
|
+
rows,
|
|
381
|
+
repository,
|
|
382
|
+
number,
|
|
383
|
+
allowRetired ? built.gitCommonDir : null,
|
|
384
|
+
);
|
|
385
|
+
if (!selection.row) {
|
|
386
|
+
const cleanRetiredAbsence = allowRetired && selection.missing === true
|
|
387
|
+
&& terminalTraceAbsenceIsClean(linkage.gaps);
|
|
388
|
+
return {
|
|
389
|
+
bound: false,
|
|
390
|
+
error: cleanRetiredAbsence
|
|
391
|
+
? selection.error
|
|
392
|
+
: (selection.missing === true && allowRetired
|
|
393
|
+
? 'Kernel merged PR evidence is incomplete or ambiguous.'
|
|
394
|
+
: selection.error),
|
|
395
|
+
...(cleanRetiredAbsence ? { missing: true } : {}),
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
const { row } = selection;
|
|
399
|
+
repository = repository || normalizeRepository(row.repo);
|
|
400
|
+
const bound = (allowRetired || row.state === 'open') && row.issue_id === issueId;
|
|
401
|
+
if (allowRetired && bound && terminalTraceIsIncomplete(linkage.gaps, row)) {
|
|
402
|
+
return { bound: false, error: 'Kernel merged PR evidence is incomplete or ambiguous.' };
|
|
403
|
+
}
|
|
404
|
+
const terminalEvidence = allowRetired && bound
|
|
405
|
+
? terminalEvidenceFromTrace(row, {
|
|
406
|
+
issueId,
|
|
407
|
+
repository,
|
|
408
|
+
number: Number(number),
|
|
409
|
+
expectedHead: normalizeFullHeadSha(prContext?.headSha),
|
|
410
|
+
})
|
|
411
|
+
: null;
|
|
412
|
+
if (terminalEvidence?.invalid) {
|
|
413
|
+
return { bound: false, error: 'Kernel merged PR evidence is incomplete or ambiguous.' };
|
|
414
|
+
}
|
|
415
|
+
return {
|
|
416
|
+
bound,
|
|
417
|
+
repository,
|
|
418
|
+
number: Number(number),
|
|
419
|
+
issueId: row.issue_id || null,
|
|
420
|
+
branch: row.branch || null,
|
|
421
|
+
url: row.url || null,
|
|
422
|
+
gitCommonDir: row.git_common_dir || built.gitCommonDir,
|
|
423
|
+
error: bound ? null : (allowRetired
|
|
424
|
+
? 'Kernel PR row is linked to a different issue.'
|
|
425
|
+
: 'Kernel PR row is not open or is linked to a different issue.'),
|
|
426
|
+
...(terminalEvidence ? { terminalEvidence } : {}),
|
|
427
|
+
};
|
|
428
|
+
} catch (err) {
|
|
429
|
+
return { bound: false, error: `Kernel PR linkage verification failed: ${err.message}` };
|
|
430
|
+
} finally {
|
|
431
|
+
if (ownsDriver && typeof built?.driver?.close === 'function') {
|
|
432
|
+
try { await built.driver.close(); } catch { /* cleanup must not mask the linkage verdict */ }
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
async function defaultVerifyMergeGate({ issueId, projectRoot, now }) {
|
|
438
|
+
const result = await gateCommand.handler(
|
|
439
|
+
['check', issueId, MERGE_GATE],
|
|
440
|
+
{},
|
|
441
|
+
projectRoot,
|
|
442
|
+
{ now },
|
|
443
|
+
);
|
|
444
|
+
return result;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
async function defaultPrepareMergeDecision({
|
|
448
|
+
issueId,
|
|
449
|
+
pr,
|
|
450
|
+
expectedHead,
|
|
451
|
+
repository,
|
|
452
|
+
binding,
|
|
453
|
+
actor,
|
|
454
|
+
sessionId,
|
|
455
|
+
requireMergeGate = true,
|
|
456
|
+
config,
|
|
457
|
+
projectRoot,
|
|
458
|
+
env = process.env,
|
|
459
|
+
now = Date.now,
|
|
460
|
+
runIssue = runIssueOperation,
|
|
461
|
+
}) {
|
|
462
|
+
if (requireMergeGate !== true) {
|
|
463
|
+
throw new Error('A disabled merge gate cannot produce broker-verifiable terminal authority.');
|
|
464
|
+
}
|
|
465
|
+
const timestamp = currentTimestamp(now);
|
|
466
|
+
const frozenEnv = { ...env, FORGE_ACTOR: actor };
|
|
467
|
+
if (sessionId) frozenEnv.FORGE_SESSION_ID = sessionId;
|
|
468
|
+
else delete frozenEnv.FORGE_SESSION_ID;
|
|
469
|
+
const issue = await runIssue('show', [issueId], projectRoot, {
|
|
470
|
+
env: frozenEnv,
|
|
471
|
+
});
|
|
472
|
+
const revision = issue?.data?.revision;
|
|
473
|
+
if (issue?.ok !== true || !Number.isInteger(revision) || revision < 0) {
|
|
474
|
+
throw new Error('Live issue revision is unavailable for the merge decision.');
|
|
475
|
+
}
|
|
476
|
+
if (!binding || typeof binding.branch !== 'string' || !binding.branch
|
|
477
|
+
|| typeof binding.gitCommonDir !== 'string' || !binding.gitCommonDir) {
|
|
478
|
+
throw new Error('Authoritative PR branch/worktree linkage is unavailable for the merge decision.');
|
|
479
|
+
}
|
|
480
|
+
const capabilityDigest = computeContentHash({ capability: 'forge.merge.exact-head.v1' });
|
|
481
|
+
const workflowConfigRevision = computeContentHash({ merge: config.merge?.auto ?? null });
|
|
482
|
+
const riskManifestDigest = computeContentHash({ rules: config.merge?.auto?.rules ?? [] });
|
|
483
|
+
const decisionSeed = `${issueId}:${revision}:${repository}:${pr}:${expectedHead}`;
|
|
484
|
+
const packet = contractEnvelope(
|
|
485
|
+
'forge.memory.work-packet.v1',
|
|
486
|
+
uuidFromSeed(`merge-packet:${decisionSeed}`),
|
|
487
|
+
timestamp,
|
|
488
|
+
actor,
|
|
489
|
+
{
|
|
490
|
+
issue_id: issueId,
|
|
491
|
+
expected_issue_revision: revision,
|
|
492
|
+
packet_id: `merge:${decisionSeed}`,
|
|
493
|
+
packet_revision: 1,
|
|
494
|
+
repository_id: repository,
|
|
495
|
+
target_head: expectedHead,
|
|
496
|
+
objective: `Guarded exact-head merge of ${repository}#${pr}`,
|
|
497
|
+
authority: { kind: 'kernel-live-lease', issue_revision: revision },
|
|
498
|
+
allowed_mutations: ['pr.merged'],
|
|
499
|
+
prohibited_actions: ['pr.opened', 'review-thread.resolve'],
|
|
500
|
+
workflow_config_revision: workflowConfigRevision,
|
|
501
|
+
capability_manifest_digest: capabilityDigest,
|
|
502
|
+
risk_manifest_digest: riskManifestDigest,
|
|
503
|
+
receipt_requirements: { gate_ids: requireMergeGate ? [MERGE_GATE] : [] },
|
|
504
|
+
target: {
|
|
505
|
+
pr_number: Number(pr),
|
|
506
|
+
branch: binding.branch,
|
|
507
|
+
git_common_dir: binding.gitCommonDir,
|
|
508
|
+
...(binding.url ? { url: binding.url } : {}),
|
|
509
|
+
},
|
|
510
|
+
},
|
|
511
|
+
);
|
|
512
|
+
return {
|
|
513
|
+
decisionId: packet.object_id,
|
|
514
|
+
packet,
|
|
515
|
+
occurredAt: timestamp,
|
|
516
|
+
actor,
|
|
517
|
+
sessionId: sessionId || null,
|
|
518
|
+
binding: { ...binding },
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
async function defaultRecordMergeDecision({ decision, mergeResult, pr, expectedHead, repository, projectRoot, buildBroker }) {
|
|
523
|
+
if (!decision?.packet || !decision?.binding) throw new Error('Merge decision evidence is unavailable.');
|
|
524
|
+
const recovered = mergeResult?.recovered === true;
|
|
525
|
+
const receipt = contractEnvelope(
|
|
526
|
+
'forge.memory.run-receipt.v1',
|
|
527
|
+
uuidFromSeed(`merge-receipt:${decision.packet.content_hash}`),
|
|
528
|
+
decision.occurredAt,
|
|
529
|
+
decision.actor,
|
|
530
|
+
{
|
|
531
|
+
packet_hash: decision.packet.content_hash,
|
|
532
|
+
run_id: `merge:${repository}:${pr}:${expectedHead}`,
|
|
533
|
+
attempt_id: `merge:${expectedHead}`,
|
|
534
|
+
exact_head: expectedHead,
|
|
535
|
+
packet_revision: decision.packet.payload.packet_revision,
|
|
536
|
+
manifest_digest: decision.packet.payload.capability_manifest_digest,
|
|
537
|
+
workflow_config_revision: decision.packet.payload.workflow_config_revision,
|
|
538
|
+
status: 'PASS',
|
|
539
|
+
executor: { product_id: 'forge', mode: recovered ? 'guarded-exact-head-recovery' : 'guarded-exact-head' },
|
|
540
|
+
started_at: decision.occurredAt,
|
|
541
|
+
ended_at: decision.occurredAt,
|
|
542
|
+
evidence_refs: [{ kind: 'pr', repository, pr_number: Number(pr), head_sha: expectedHead }],
|
|
543
|
+
validation: { status: 'PASS', decision_id: decision.decisionId },
|
|
544
|
+
cleanup: { status: 'NOT_REQUIRED' },
|
|
545
|
+
mutations_attempted: ['pr.merged'],
|
|
546
|
+
mutations_authorized: ['pr.merged'],
|
|
547
|
+
},
|
|
548
|
+
);
|
|
549
|
+
|
|
550
|
+
let built;
|
|
551
|
+
const ownsDriver = !buildBroker;
|
|
552
|
+
try {
|
|
553
|
+
if (buildBroker) built = await buildBroker({ projectRoot });
|
|
554
|
+
else {
|
|
555
|
+
const { buildMigratedKernelIssueDeps } = require('../kernel/cli-broker-factory');
|
|
556
|
+
const deps = await buildMigratedKernelIssueDeps({
|
|
557
|
+
projectRoot,
|
|
558
|
+
gitCommonDir: decision.binding.gitCommonDir,
|
|
559
|
+
});
|
|
560
|
+
built = { broker: deps.kernelBroker, driver: deps.kernelDriver };
|
|
561
|
+
}
|
|
562
|
+
if (!built?.broker) throw new Error('Kernel lifecycle provider is unavailable.');
|
|
563
|
+
const { createMemoryAuthorityProvider } = require('../../packages/memory');
|
|
564
|
+
const provider = createMemoryAuthorityProvider({ broker: built.broker });
|
|
565
|
+
await provider.recordPrLinkage({
|
|
566
|
+
phase: 'merged',
|
|
567
|
+
git_common_dir: decision.binding.gitCommonDir,
|
|
568
|
+
repo: repository,
|
|
569
|
+
number: Number(pr),
|
|
570
|
+
branch: decision.binding.branch,
|
|
571
|
+
...(decision.binding.url ? { url: decision.binding.url } : {}),
|
|
572
|
+
occurred_at: decision.occurredAt,
|
|
573
|
+
work_packet: decision.packet,
|
|
574
|
+
run_receipt: receipt,
|
|
575
|
+
}, {
|
|
576
|
+
actor: decision.actor,
|
|
577
|
+
sessionId: decision.sessionId,
|
|
578
|
+
requireExactLifecycleOwnership: true,
|
|
579
|
+
});
|
|
580
|
+
return { receiptId: receipt.object_id, receiptHash: receipt.content_hash };
|
|
581
|
+
} finally {
|
|
582
|
+
if (ownsDriver && built?.driver && typeof built.driver.close === 'function') {
|
|
583
|
+
try { await built.driver.close(); } catch { /* terminal evidence outcome remains authoritative */ }
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function strictCheckSuccess(check) {
|
|
589
|
+
if (!check || typeof check !== 'object') return false;
|
|
590
|
+
const hasStatus = Object.prototype.hasOwnProperty.call(check, 'status');
|
|
591
|
+
const hasConclusion = Object.prototype.hasOwnProperty.call(check, 'conclusion');
|
|
592
|
+
const hasState = Object.prototype.hasOwnProperty.call(check, 'state');
|
|
593
|
+
if (hasState) {
|
|
594
|
+
return !hasStatus && !hasConclusion && String(check.state || '').toUpperCase() === 'SUCCESS';
|
|
595
|
+
}
|
|
596
|
+
return hasStatus && hasConclusion
|
|
597
|
+
&& String(check.status || '').toUpperCase() === 'COMPLETED'
|
|
598
|
+
&& String(check.conclusion || '').toUpperCase() === 'SUCCESS';
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* Mandatory preflight accepts only terminal check-run conclusions that are safe
|
|
603
|
+
* to classify as optional. Required checks are still evaluated with
|
|
604
|
+
* strictCheckSuccess below, and status contexts never gain NEUTRAL/SKIPPED
|
|
605
|
+
* semantics because their only successful terminal state is SUCCESS.
|
|
606
|
+
*/
|
|
607
|
+
function safeTerminalCheck(check) {
|
|
608
|
+
if (!check || typeof check !== 'object') return false;
|
|
609
|
+
if (Object.prototype.hasOwnProperty.call(check, 'state')) return strictCheckSuccess(check);
|
|
610
|
+
return Object.prototype.hasOwnProperty.call(check, 'status')
|
|
611
|
+
&& Object.prototype.hasOwnProperty.call(check, 'conclusion')
|
|
612
|
+
&& String(check.status || '').toUpperCase() === 'COMPLETED'
|
|
613
|
+
&& SAFE_TERMINAL_CONCLUSIONS.has(String(check.conclusion || '').toUpperCase());
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function malformedCheckObservation(check) {
|
|
617
|
+
if (!check || typeof check !== 'object') return true;
|
|
618
|
+
const name = check.name || check.context;
|
|
619
|
+
if (typeof name !== 'string' || !name.trim()) return true;
|
|
620
|
+
if (!Object.prototype.hasOwnProperty.call(check, 'appId')) return true;
|
|
621
|
+
const hasStatus = Object.prototype.hasOwnProperty.call(check, 'status');
|
|
622
|
+
const hasConclusion = Object.prototype.hasOwnProperty.call(check, 'conclusion');
|
|
623
|
+
const hasState = Object.prototype.hasOwnProperty.call(check, 'state');
|
|
624
|
+
const status = String(check.status || '').toUpperCase();
|
|
625
|
+
const conclusion = String(check.conclusion || '').toUpperCase();
|
|
626
|
+
const state = String(check.state || '').toUpperCase();
|
|
627
|
+
if (hasState) {
|
|
628
|
+
return hasStatus || hasConclusion || check.appId !== null
|
|
629
|
+
|| typeof check.state !== 'string' || !STATUS_CONTEXT_STATES.has(state);
|
|
630
|
+
}
|
|
631
|
+
if (!hasStatus || !hasConclusion || !Number.isInteger(check.appId) || check.appId <= 0) return true;
|
|
632
|
+
if (hasStatus && (typeof check.status !== 'string' || !CHECK_RUN_STATUSES.has(status))) return true;
|
|
633
|
+
if (hasConclusion && check.conclusion !== null && typeof check.conclusion !== 'string') return true;
|
|
634
|
+
if (hasConclusion && !CHECK_RUN_CONCLUSIONS.has(conclusion)) return true;
|
|
635
|
+
if (hasStatus && status === 'COMPLETED' && !conclusion) return true;
|
|
636
|
+
if (hasStatus && status !== 'COMPLETED' && conclusion) return true;
|
|
637
|
+
return ['status', 'conclusion', 'state']
|
|
638
|
+
.some((key) => String(check[key] || '').toUpperCase() === 'UNREADABLE');
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function normalizeRequiredEntry(entry) {
|
|
642
|
+
if (!entry || typeof entry.context !== 'string' || !entry.context.trim()) return null;
|
|
643
|
+
if (!Object.prototype.hasOwnProperty.call(entry, 'appId')) return null;
|
|
644
|
+
if (entry.appId !== null && (!Number.isInteger(entry.appId) || entry.appId <= 0)) return null;
|
|
645
|
+
return { context: entry.context, appId: entry.appId };
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function normalizeRollupObservation(entry) {
|
|
649
|
+
if (!entry || typeof entry !== 'object') return null;
|
|
650
|
+
if (entry.__typename === 'StatusContext') {
|
|
651
|
+
return { name: entry.context, state: entry.state };
|
|
652
|
+
}
|
|
653
|
+
if (entry.__typename === 'CheckRun') {
|
|
654
|
+
return { name: entry.name, status: entry.status, conclusion: entry.conclusion };
|
|
655
|
+
}
|
|
656
|
+
return null;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function malformedRollupObservation(observation) {
|
|
660
|
+
if (!observation || typeof observation.name !== 'string' || !observation.name.trim()) return true;
|
|
661
|
+
const hasState = Object.prototype.hasOwnProperty.call(observation, 'state');
|
|
662
|
+
if (hasState) {
|
|
663
|
+
return Object.prototype.hasOwnProperty.call(observation, 'status')
|
|
664
|
+
|| Object.prototype.hasOwnProperty.call(observation, 'conclusion')
|
|
665
|
+
|| typeof observation.state !== 'string'
|
|
666
|
+
|| !STATUS_CONTEXT_STATES.has(observation.state.toUpperCase());
|
|
667
|
+
}
|
|
668
|
+
const status = String(observation.status || '').toUpperCase();
|
|
669
|
+
const conclusion = String(observation.conclusion || '').toUpperCase();
|
|
670
|
+
if (typeof observation.status !== 'string' || !CHECK_RUN_STATUSES.has(status)) return true;
|
|
671
|
+
if (observation.conclusion !== null && typeof observation.conclusion !== 'string') return true;
|
|
672
|
+
if (!CHECK_RUN_CONCLUSIONS.has(conclusion)) return true;
|
|
673
|
+
if (status === 'COMPLETED' && !conclusion) return true;
|
|
674
|
+
return status !== 'COMPLETED' && Boolean(conclusion);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function evaluateProtectedRequiredChecks(context) {
|
|
678
|
+
if (!context || context.requiredCheckSource !== 'protection'
|
|
679
|
+
|| !Array.isArray(context.requiredChecks) || !Array.isArray(context.checks)) {
|
|
680
|
+
return { allowed: false, reason: 'Protected required-check policy is unreadable or non-authoritative.' };
|
|
681
|
+
}
|
|
682
|
+
const required = context.requiredChecks.map(normalizeRequiredEntry);
|
|
683
|
+
if (required.some((entry) => !entry)) {
|
|
684
|
+
return { allowed: false, reason: 'Protected required-check policy contains malformed entries.' };
|
|
685
|
+
}
|
|
686
|
+
const policyApps = new Map();
|
|
687
|
+
for (const entry of required) {
|
|
688
|
+
if (!policyApps.has(entry.context)) policyApps.set(entry.context, new Set());
|
|
689
|
+
policyApps.get(entry.context).add(entry.appId === null ? '*' : String(entry.appId));
|
|
690
|
+
}
|
|
691
|
+
if ([...policyApps.values()].some((apps) => apps.size > 1)) {
|
|
692
|
+
return { allowed: false, reason: 'Protected required-check policy contains conflicting application identities.' };
|
|
693
|
+
}
|
|
694
|
+
if (context.checks.some(malformedCheckObservation)) {
|
|
695
|
+
return { allowed: false, reason: 'Check-run observation collection contains malformed entries.' };
|
|
696
|
+
}
|
|
697
|
+
const missing = [];
|
|
698
|
+
const nonSuccess = [];
|
|
699
|
+
for (const entry of required) {
|
|
700
|
+
const matching = context.checks.filter((check) => check
|
|
701
|
+
&& (check.name || check.context) === entry.context
|
|
702
|
+
&& (entry.appId === null || check.appId === entry.appId));
|
|
703
|
+
const label = entry.appId === null ? entry.context : `${entry.context}@app:${entry.appId}`;
|
|
704
|
+
if (matching.length === 0) missing.push(label);
|
|
705
|
+
else if (matching.some((check) => !strictCheckSuccess(check))) nonSuccess.push(label);
|
|
706
|
+
}
|
|
707
|
+
if (missing.length || nonSuccess.length) {
|
|
708
|
+
const parts = [];
|
|
709
|
+
if (missing.length) parts.push(`missing: ${missing.join(', ')}`);
|
|
710
|
+
if (nonSuccess.length) parts.push(`non-success: ${nonSuccess.join(', ')}`);
|
|
711
|
+
return {
|
|
712
|
+
allowed: false,
|
|
713
|
+
reason: `Protected required checks are not successful (${parts.join('; ')}).`,
|
|
714
|
+
details: { missing, nonSuccess },
|
|
715
|
+
};
|
|
716
|
+
}
|
|
717
|
+
return { allowed: true, details: { missing: [], nonSuccess: [] } };
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function mandatoryContextError(context, expectedHead) {
|
|
721
|
+
const observedHead = normalizeFullHeadSha(context && context.headSha);
|
|
722
|
+
if (!observedHead || observedHead !== expectedHead) {
|
|
723
|
+
return 'PR head changed or could not be verified against --expect-head.';
|
|
724
|
+
}
|
|
725
|
+
if (context.state !== 'OPEN') return 'PR lifecycle state is unreadable or is not OPEN.';
|
|
726
|
+
if (context.isDraft !== false) return 'PR draft status is unreadable or the PR is still a draft.';
|
|
727
|
+
if (context.conflicting !== false) return 'PR conflict status is unreadable or conflicting.';
|
|
728
|
+
if (context.unresolvedThreads !== 0) return 'Review-thread state is unreadable or unresolved threads remain.';
|
|
729
|
+
if (context.reviewEvidenceReadable !== true || !Array.isArray(context.reviews)) {
|
|
730
|
+
return 'Review evidence is unreadable.';
|
|
731
|
+
}
|
|
732
|
+
for (const review of context.reviews) {
|
|
733
|
+
const state = review && typeof review.state === 'string' ? review.state.toUpperCase() : '';
|
|
734
|
+
const timestamps = review
|
|
735
|
+
? [review.createdAt, review.updatedAt, review.submittedAt, review.activityAt]
|
|
736
|
+
: [];
|
|
737
|
+
if (!review || typeof review.id !== 'string' || !review.id
|
|
738
|
+
|| typeof review.author !== 'string' || !review.author
|
|
739
|
+
|| !REVIEW_ACTOR_TYPENAMES.has(review.authorTypename)
|
|
740
|
+
|| !REVIEW_STATES.has(state)
|
|
741
|
+
|| typeof review.commitOid !== 'string' || !FULL_HEAD_SHA.test(review.commitOid)
|
|
742
|
+
|| typeof review.body !== 'string'
|
|
743
|
+
|| timestamps.length !== 4
|
|
744
|
+
|| timestamps.some((value) => typeof value !== 'string' || !Number.isFinite(Date.parse(value)))) {
|
|
745
|
+
return 'Review evidence contains malformed identity, state, timestamp, or commit-head data.';
|
|
746
|
+
}
|
|
747
|
+
if (state !== 'DISMISSED' && state !== 'COMMENTED' && review.commitOid.toLowerCase() !== expectedHead) {
|
|
748
|
+
return 'Latest active review evidence is stale for the expected PR head.';
|
|
749
|
+
}
|
|
750
|
+
if (state === 'CHANGES_REQUESTED' || state === 'PENDING') {
|
|
751
|
+
return `Latest review state ${state} does not authorize merging.`;
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
if (!Array.isArray(context.checks) || context.checks.some(malformedCheckObservation)
|
|
755
|
+
|| context.checks.some((check) => !safeTerminalCheck(check))) {
|
|
756
|
+
return 'Every check-run and status observation must be complete and have a safe terminal conclusion (SUCCESS, NEUTRAL, or SKIPPED).';
|
|
757
|
+
}
|
|
758
|
+
if (Object.prototype.hasOwnProperty.call(context, 'providerObservations')) {
|
|
759
|
+
if (!Array.isArray(context.providerObservations)
|
|
760
|
+
|| context.providerObservations.some(malformedRollupObservation)
|
|
761
|
+
|| context.providerObservations.some((check) => !safeTerminalCheck(check))) {
|
|
762
|
+
return 'The complete provider rollup must contain only observations with safe terminal conclusions (SUCCESS, NEUTRAL, or SKIPPED).';
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
const protectedGate = evaluateProtectedRequiredChecks(context);
|
|
766
|
+
return protectedGate.allowed ? null : protectedGate.reason;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
async function recoverMergedTerminal({
|
|
770
|
+
pr, parsed, root, config, prContext, ownershipActor, ownershipSessionId, deps, replayOnly = false,
|
|
771
|
+
}) {
|
|
772
|
+
if (normalizeFullHeadSha(prContext?.headSha) !== parsed.expectedHead) {
|
|
773
|
+
return { success: false, merged: true, error: 'Merged PR identity does not match the expected head and repository.' };
|
|
774
|
+
}
|
|
775
|
+
const verifyPrIssueBinding = deps.verifyPrIssueBinding || defaultVerifyPrIssueBinding;
|
|
776
|
+
const binding = await verifyPrIssueBinding({
|
|
777
|
+
issueId: parsed.issueId,
|
|
778
|
+
pr,
|
|
779
|
+
projectRoot: root,
|
|
780
|
+
prContext,
|
|
781
|
+
buildBroker: deps.buildPrBindingBroker,
|
|
782
|
+
allowRetired: true,
|
|
783
|
+
});
|
|
784
|
+
const repository = normalizeRepository(prContext?.repository) || normalizeRepository(binding?.repository);
|
|
785
|
+
if (binding?.bound !== true) {
|
|
786
|
+
if (replayOnly && binding?.missing === true) {
|
|
787
|
+
return {
|
|
788
|
+
success: false,
|
|
789
|
+
merged: true,
|
|
790
|
+
replayMissing: true,
|
|
791
|
+
error: 'Merged PR terminal evidence is not yet available for replay.',
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
return { success: false, merged: true, error: 'Merged PR is not authoritatively linked to the supplied Forge issue.' };
|
|
795
|
+
}
|
|
796
|
+
if (binding.terminalEvidence) {
|
|
797
|
+
if (!repository) {
|
|
798
|
+
return { success: false, merged: true, error: 'Merged PR repository identity is unavailable.' };
|
|
799
|
+
}
|
|
800
|
+
return {
|
|
801
|
+
success: true, merged: true, enabled: true, recovered: true,
|
|
802
|
+
...binding.terminalEvidence,
|
|
803
|
+
};
|
|
804
|
+
}
|
|
805
|
+
if (replayOnly) {
|
|
806
|
+
return {
|
|
807
|
+
success: false,
|
|
808
|
+
merged: true,
|
|
809
|
+
replayMissing: true,
|
|
810
|
+
error: 'Merged PR terminal evidence is not yet available for replay.',
|
|
811
|
+
};
|
|
812
|
+
}
|
|
813
|
+
if (!repository) {
|
|
814
|
+
return { success: false, merged: true, error: 'Merged PR repository identity is unavailable.' };
|
|
815
|
+
}
|
|
816
|
+
const decisionAt = currentTimestamp(deps.now || Date.now);
|
|
817
|
+
const verifyMergeGate = deps.verifyMergeGate || defaultVerifyMergeGate;
|
|
818
|
+
const gateApproval = await verifyMergeGate({
|
|
819
|
+
issueId: parsed.issueId, projectRoot: root, actor: ownershipActor, now: decisionAt,
|
|
820
|
+
});
|
|
821
|
+
if (gateApproval !== true && gateApproval?.approved !== true && gateApproval?.disabled !== true) {
|
|
822
|
+
return { success: false, merged: true, error: `${MERGE_GATE} is not approved for this issue.` };
|
|
823
|
+
}
|
|
824
|
+
if (gateApproval?.disabled === true) {
|
|
825
|
+
return {
|
|
826
|
+
success: false,
|
|
827
|
+
merged: true,
|
|
828
|
+
error: 'The disabled merge gate cannot produce broker-verifiable terminal authority.',
|
|
829
|
+
};
|
|
830
|
+
}
|
|
831
|
+
const prepareMergeDecision = deps.prepareMergeDecision || defaultPrepareMergeDecision;
|
|
832
|
+
const decision = await prepareMergeDecision({
|
|
833
|
+
issueId: parsed.issueId,
|
|
834
|
+
pr,
|
|
835
|
+
expectedHead: parsed.expectedHead,
|
|
836
|
+
repository,
|
|
837
|
+
binding,
|
|
838
|
+
actor: ownershipActor,
|
|
839
|
+
sessionId: ownershipSessionId,
|
|
840
|
+
env: deps.env || process.env,
|
|
841
|
+
requireMergeGate: gateApproval?.disabled !== true,
|
|
842
|
+
config,
|
|
843
|
+
projectRoot: root,
|
|
844
|
+
now: decisionAt,
|
|
845
|
+
});
|
|
846
|
+
if (!decision || typeof decision.decisionId !== 'string' || !decision.decisionId) {
|
|
847
|
+
return { success: false, merged: true, error: 'Merge recovery decision evidence is unavailable.' };
|
|
848
|
+
}
|
|
849
|
+
const recordMergeDecision = deps.recordMergeDecision || defaultRecordMergeDecision;
|
|
850
|
+
try {
|
|
851
|
+
const terminal = await recordMergeDecision({
|
|
852
|
+
decision,
|
|
853
|
+
mergeResult: { merged: true, recovered: true },
|
|
854
|
+
pr,
|
|
855
|
+
expectedHead: parsed.expectedHead,
|
|
856
|
+
repository,
|
|
857
|
+
projectRoot: root,
|
|
858
|
+
buildBroker: deps.buildMergeBroker,
|
|
859
|
+
});
|
|
860
|
+
if (!terminal || typeof terminal.receiptId !== 'string' || !terminal.receiptId) {
|
|
861
|
+
throw new Error('terminal receipt evidence is unavailable');
|
|
862
|
+
}
|
|
863
|
+
return {
|
|
864
|
+
success: true, merged: true, enabled: true, recovered: true,
|
|
865
|
+
decisionId: decision.decisionId, ...terminal,
|
|
866
|
+
};
|
|
867
|
+
} catch (error) {
|
|
868
|
+
return {
|
|
869
|
+
success: false, merged: true, enabled: true, decisionId: decision.decisionId,
|
|
870
|
+
error: `Merged PR terminal linkage recovery failed: ${error.message}`,
|
|
871
|
+
};
|
|
872
|
+
}
|
|
873
|
+
}
|
|
46
874
|
|
|
47
875
|
/** Default `gh` runner. Only reached by the default fetch/merge seams (never in unit tests). */
|
|
48
876
|
function defaultGh(args, options = {}) {
|
|
@@ -50,9 +878,9 @@ function defaultGh(args, options = {}) {
|
|
|
50
878
|
}
|
|
51
879
|
|
|
52
880
|
/** Parse `gh ... --json` output, returning `null` on any failure (callers fail closed). */
|
|
53
|
-
function ghJson(gh, args) {
|
|
881
|
+
function ghJson(gh, args, options) {
|
|
54
882
|
try {
|
|
55
|
-
return JSON.parse(gh(args) || '{}');
|
|
883
|
+
return JSON.parse(gh(args, options) || '{}');
|
|
56
884
|
} catch (_err) {
|
|
57
885
|
return null;
|
|
58
886
|
}
|
|
@@ -63,12 +891,9 @@ function ghJson(gh, args) {
|
|
|
63
891
|
* is not a valid `gh pr view --json` field, so this needs a dedicated query.
|
|
64
892
|
* Returns `undefined` on any failure so `threads_resolved` fails closed.
|
|
65
893
|
*/
|
|
66
|
-
function fetchUnresolvedThreadCount(gh, pr) {
|
|
894
|
+
function fetchUnresolvedThreadCount(gh, { owner, repo, pr }) {
|
|
67
895
|
try {
|
|
68
|
-
|
|
69
|
-
const owner = repo && repo.owner && repo.owner.login;
|
|
70
|
-
const name = repo && repo.name;
|
|
71
|
-
if (!owner || !name) return undefined;
|
|
896
|
+
if (!owner || !repo) return undefined;
|
|
72
897
|
// Paginate through ALL review threads — a PR can have >100, and the ones on
|
|
73
898
|
// later pages could be the unresolved/newest ones. Stopping at page 1 would
|
|
74
899
|
// both miss them and make a large PR un-mergeable. Loop on the GraphQL cursor
|
|
@@ -79,15 +904,22 @@ function fetchUnresolvedThreadCount(gh, pr) {
|
|
|
79
904
|
let count = 0;
|
|
80
905
|
for (let page = 0; page < 100; page += 1) { // 100-page cap = 10k threads backstop
|
|
81
906
|
const args = ['api', 'graphql', '-f', `query=${query}`,
|
|
82
|
-
'-F', `o=${owner}`, '-F', `n=${
|
|
907
|
+
'-F', `o=${owner}`, '-F', `n=${repo}`, '-F', `pr=${Number(pr)}`];
|
|
83
908
|
if (after) args.push('-F', `after=${after}`);
|
|
84
909
|
const data = JSON.parse(gh(args) || '{}');
|
|
910
|
+
if (Object.prototype.hasOwnProperty.call(data, 'errors')
|
|
911
|
+
&& (!Array.isArray(data.errors) || data.errors.length > 0)) return undefined;
|
|
85
912
|
const threads = (((data.data || {}).repository || {}).pullRequest || {}).reviewThreads;
|
|
86
|
-
if (!threads
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
913
|
+
if (!threads || !Array.isArray(threads.nodes) || !threads.pageInfo
|
|
914
|
+
|| typeof threads.pageInfo.hasNextPage !== 'boolean'
|
|
915
|
+
|| (threads.pageInfo.endCursor !== null && typeof threads.pageInfo.endCursor !== 'string')
|
|
916
|
+
|| threads.nodes.some((thread) => !thread || typeof thread.isResolved !== 'boolean'
|
|
917
|
+
|| typeof thread.isOutdated !== 'boolean')) return undefined;
|
|
918
|
+
count += threads.nodes.filter((thread) => thread.isResolved === false && thread.isOutdated === false).length;
|
|
919
|
+
if (!threads.pageInfo.hasNextPage) return count;
|
|
920
|
+
const nextCursor = threads.pageInfo.endCursor;
|
|
921
|
+
if (typeof nextCursor !== 'string' || !nextCursor || nextCursor === after) return undefined;
|
|
922
|
+
after = nextCursor;
|
|
91
923
|
}
|
|
92
924
|
return undefined; // exceeded the page cap → fail closed rather than undercount
|
|
93
925
|
} catch (_err) {
|
|
@@ -95,6 +927,47 @@ function fetchUnresolvedThreadCount(gh, pr) {
|
|
|
95
927
|
}
|
|
96
928
|
}
|
|
97
929
|
|
|
930
|
+
function fetchCheckRunObservations(gh, { owner, repo, head }) {
|
|
931
|
+
try {
|
|
932
|
+
const pages = JSON.parse(gh([
|
|
933
|
+
'api', '--paginate', '--slurp',
|
|
934
|
+
`repos/${owner}/${repo}/commits/${head}/check-runs?filter=latest&per_page=100`,
|
|
935
|
+
]) || 'null');
|
|
936
|
+
if (!Array.isArray(pages) || pages.length === 0
|
|
937
|
+
|| pages.some((page) => !page || !Array.isArray(page.check_runs)
|
|
938
|
+
|| !Number.isInteger(page.total_count) || page.total_count < 0)) return null;
|
|
939
|
+
const runs = pages.flatMap((page) => page.check_runs);
|
|
940
|
+
if (pages.some((page) => page.total_count !== pages[0].total_count)
|
|
941
|
+
|| pages[0].total_count !== runs.length) return null;
|
|
942
|
+
if (runs.some((run) => {
|
|
943
|
+
const name = run && typeof run.name === 'string' && run.name.trim() ? run.name : null;
|
|
944
|
+
const appId = run && run.app && Number.isInteger(run.app.id) && run.app.id > 0 ? run.app.id : null;
|
|
945
|
+
const headSha = normalizeFullHeadSha(run && run.head_sha);
|
|
946
|
+
const status = String(run && run.status || '').toUpperCase();
|
|
947
|
+
const conclusion = String(run && run.conclusion || '').toUpperCase();
|
|
948
|
+
return !run || !Number.isInteger(run.id) || run.id <= 0 || !name || !appId || headSha !== head
|
|
949
|
+
|| !CHECK_RUN_STATUSES.has(status)
|
|
950
|
+
|| !Object.prototype.hasOwnProperty.call(run, 'conclusion')
|
|
951
|
+
|| (run.conclusion !== null && typeof run.conclusion !== 'string')
|
|
952
|
+
|| !CHECK_RUN_CONCLUSIONS.has(conclusion)
|
|
953
|
+
|| (status === 'COMPLETED' ? !conclusion : Boolean(conclusion));
|
|
954
|
+
})) return null;
|
|
955
|
+
return runs.map((run) => {
|
|
956
|
+
const name = run.name;
|
|
957
|
+
const appId = run.app.id;
|
|
958
|
+
return {
|
|
959
|
+
id: run.id,
|
|
960
|
+
name,
|
|
961
|
+
appId,
|
|
962
|
+
status: String(run.status).toUpperCase(),
|
|
963
|
+
conclusion: String(run.conclusion || '').toUpperCase(),
|
|
964
|
+
};
|
|
965
|
+
});
|
|
966
|
+
} catch (_err) {
|
|
967
|
+
return null;
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
|
|
98
971
|
/**
|
|
99
972
|
* Default PR-context fetcher (the network seam). Assembles the shape consumed
|
|
100
973
|
* by `evaluateMergeRules` from `gh`. Anything it cannot read is left absent so
|
|
@@ -103,27 +976,39 @@ function fetchUnresolvedThreadCount(gh, pr) {
|
|
|
103
976
|
*
|
|
104
977
|
* @returns {object} prContext
|
|
105
978
|
*/
|
|
106
|
-
function defaultFetchPrContext({ pr, gh = defaultGh, now = Date.now() }) {
|
|
107
|
-
const
|
|
108
|
-
|
|
979
|
+
async function defaultFetchPrContext({ pr, projectRoot, gh = defaultGh, now = Date.now() }) {
|
|
980
|
+
const repoOptions = typeof projectRoot === 'string' && projectRoot ? { cwd: projectRoot } : undefined;
|
|
981
|
+
const repoIdentity = ghJson(gh, ['repo', 'view', '--json', 'owner,name,isFork,parent'], repoOptions);
|
|
982
|
+
const currentRepository = normalizeRepository(repoIdentity?.owner?.login && repoIdentity?.name
|
|
983
|
+
? `${repoIdentity.owner.login}/${repoIdentity.name}`
|
|
984
|
+
: null);
|
|
985
|
+
const repository = repoIdentity?.isFork === true
|
|
986
|
+
? normalizeRepository(repoIdentity?.parent?.nameWithOwner)
|
|
987
|
+
: (repoIdentity?.isFork === false ? currentRepository : null);
|
|
988
|
+
const [owner, repo] = repository ? repository.split('/') : [null, null];
|
|
989
|
+
const view = repository
|
|
990
|
+
? (ghJson(gh, ['pr', 'view', String(pr), '--repo', repository, '--json',
|
|
991
|
+
'number,headRefOid,baseRefName,state,isDraft,mergeable,mergeStateStatus,statusCheckRollup,comments,updatedAt']) || {})
|
|
992
|
+
: {};
|
|
109
993
|
|
|
110
994
|
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
995
|
|
|
116
996
|
const comments = Array.isArray(view.comments)
|
|
117
997
|
? view.comments.map((c) => ({
|
|
118
998
|
author: (c.author && c.author.login) || '',
|
|
119
|
-
at: c.createdAt
|
|
999
|
+
at: [c.createdAt, c.updatedAt, c.submittedAt]
|
|
1000
|
+
.filter(Boolean)
|
|
1001
|
+
.sort((left, right) => Date.parse(right) - Date.parse(left))[0] || '',
|
|
120
1002
|
}))
|
|
121
1003
|
: [];
|
|
122
1004
|
|
|
123
|
-
const
|
|
1005
|
+
const adapter = owner && repo
|
|
1006
|
+
? new PrStateAdapter({ gh: (_cmd, adapterArgs) => gh(adapterArgs) })
|
|
1007
|
+
: null;
|
|
1008
|
+
const reviews = adapter ? await adapter.readReviews({ owner, repo, pr }) : [];
|
|
124
1009
|
const approvals = reviews
|
|
125
1010
|
.filter((r) => String(r.state).toUpperCase() === 'APPROVED')
|
|
126
|
-
.map((r) => ({ author:
|
|
1011
|
+
.map((r) => ({ author: typeof r.author === 'string' ? r.author : '' }));
|
|
127
1012
|
|
|
128
1013
|
// Derive from GitHub's mergeStateStatus / mergeable. Only a known set maps to
|
|
129
1014
|
// a definite answer; anything else stays undefined so the dependent rule fails
|
|
@@ -146,29 +1031,67 @@ function defaultFetchPrContext({ pr, gh = defaultGh, now = Date.now() }) {
|
|
|
146
1031
|
|
|
147
1032
|
const stamps = [
|
|
148
1033
|
...comments.map((c) => c.at),
|
|
149
|
-
...reviews.
|
|
1034
|
+
...reviews.flatMap((r) => [r.activityAt, r.createdAt, r.updatedAt, r.submittedAt]),
|
|
150
1035
|
view.updatedAt || '',
|
|
151
1036
|
].map((s) => Date.parse(s)).filter((n) => !Number.isNaN(n));
|
|
152
1037
|
const lastActivityAt = stamps.length ? Math.max(...stamps) : undefined;
|
|
153
1038
|
|
|
1039
|
+
const base = typeof view.baseRefName === 'string' ? view.baseRefName : null;
|
|
1040
|
+
const headSha = normalizeFullHeadSha(view.headRefOid);
|
|
1041
|
+
let requiredChecks = null;
|
|
1042
|
+
let requiredCheckSource = null;
|
|
1043
|
+
let checks = null;
|
|
1044
|
+
const providerObservations = rollup ? rollup.map(normalizeRollupObservation) : null;
|
|
1045
|
+
if (adapter && base) {
|
|
1046
|
+
requiredChecks = await adapter.readRequiredCheckPolicy({ owner, repo, base });
|
|
1047
|
+
requiredCheckSource = adapter.lastRequiredSource;
|
|
1048
|
+
const checkRuns = headSha ? fetchCheckRunObservations(gh, { owner, repo, head: headSha }) : null;
|
|
1049
|
+
if (rollup && checkRuns && providerObservations.every((entry) => entry !== null)) {
|
|
1050
|
+
const statuses = rollup
|
|
1051
|
+
.filter((entry) => entry && entry.__typename === 'StatusContext')
|
|
1052
|
+
.map((entry) => ({
|
|
1053
|
+
name: typeof entry.context === 'string' ? entry.context : '',
|
|
1054
|
+
appId: null,
|
|
1055
|
+
state: String(entry.state || '').toUpperCase(),
|
|
1056
|
+
}));
|
|
1057
|
+
checks = [...checkRuns, ...statuses];
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
|
|
154
1061
|
return {
|
|
1062
|
+
number: Number.isInteger(view.number) ? view.number : null,
|
|
1063
|
+
repository,
|
|
1064
|
+
headSha: view.headRefOid || null,
|
|
155
1065
|
checks,
|
|
156
|
-
|
|
157
|
-
|
|
1066
|
+
providerObservations,
|
|
1067
|
+
requiredChecks,
|
|
1068
|
+
requiredCheckSource,
|
|
1069
|
+
requiredChecksKnown: requiredCheckSource === 'protection' && Array.isArray(requiredChecks),
|
|
1070
|
+
unresolvedThreads: fetchUnresolvedThreadCount(gh, { owner, repo, pr }),
|
|
158
1071
|
behindBase,
|
|
159
1072
|
conflicting,
|
|
160
1073
|
isDraft,
|
|
161
1074
|
state,
|
|
162
1075
|
approvals,
|
|
1076
|
+
reviews,
|
|
1077
|
+
reviewEvidenceReadable: adapter !== null,
|
|
163
1078
|
comments,
|
|
164
1079
|
lastActivityAt,
|
|
165
1080
|
now,
|
|
166
1081
|
};
|
|
167
1082
|
}
|
|
168
1083
|
|
|
169
|
-
/** Default merge action (squash)
|
|
170
|
-
function defaultMergePr({ pr, gh = defaultGh }) {
|
|
171
|
-
|
|
1084
|
+
/** Default merge action (squash), atomically bound to the reviewed remote head. */
|
|
1085
|
+
function defaultMergePr({ pr, expectedHead, repository, gh = defaultGh }) {
|
|
1086
|
+
const head = normalizeFullHeadSha(expectedHead);
|
|
1087
|
+
const repo = normalizeRepository(repository);
|
|
1088
|
+
if (!head) throw new Error('A full 40-character expected PR head SHA is required.');
|
|
1089
|
+
if (!normalizePrNumber(String(pr)) || !repo) throw new Error('A canonical PR number and repository are required.');
|
|
1090
|
+
gh(['pr', 'merge', String(pr), '--repo', repo, '--squash', '--match-head-commit', head]);
|
|
1091
|
+
const confirmed = ghJson(gh, ['pr', 'view', String(pr), '--repo', repo, '--json', 'state,headRefOid']);
|
|
1092
|
+
if (confirmed?.state !== 'MERGED' || normalizeFullHeadSha(confirmed.headRefOid) !== head) {
|
|
1093
|
+
return { merged: false, reason: 'provider merge is not confirmed for the exact reviewed head' };
|
|
1094
|
+
}
|
|
172
1095
|
return { merged: true, method: 'squash' };
|
|
173
1096
|
}
|
|
174
1097
|
|
|
@@ -183,18 +1106,81 @@ function defaultMergePr({ pr, gh = defaultGh }) {
|
|
|
183
1106
|
*/
|
|
184
1107
|
async function handler(args, _flags, projectRoot, deps = {}) {
|
|
185
1108
|
const argv = Array.isArray(args) ? args : [];
|
|
186
|
-
const
|
|
187
|
-
const
|
|
188
|
-
const pr = positional[0];
|
|
1109
|
+
const parsed = parseMergeArgs(stripGlobalFlags(argv));
|
|
1110
|
+
const pr = parsed.pr;
|
|
189
1111
|
const root = projectRoot || process.cwd();
|
|
190
1112
|
|
|
191
|
-
if (!
|
|
192
|
-
return {
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
1113
|
+
if (!parsed.auto || !pr) {
|
|
1114
|
+
return {
|
|
1115
|
+
success: false,
|
|
1116
|
+
merged: false,
|
|
1117
|
+
error: 'Usage: forge merge --auto <pr> --expect-head <40-char-sha> --issue <issue-id>',
|
|
1118
|
+
};
|
|
196
1119
|
}
|
|
197
1120
|
|
|
1121
|
+
const verifyIssueOwnership = deps.verifyIssueOwnership || defaultVerifyIssueOwnership;
|
|
1122
|
+
const ownershipEnv = deps.env || process.env;
|
|
1123
|
+
const ownershipActor = resolveOwnershipActor(ownershipEnv);
|
|
1124
|
+
const ownershipSessionId = typeof ownershipEnv.FORGE_SESSION_ID === 'string'
|
|
1125
|
+
&& ownershipEnv.FORGE_SESSION_ID.trim()
|
|
1126
|
+
? ownershipEnv.FORGE_SESSION_ID.trim()
|
|
1127
|
+
: null;
|
|
1128
|
+
const ownershipInput = {
|
|
1129
|
+
issueId: parsed.issueId,
|
|
1130
|
+
projectRoot: root,
|
|
1131
|
+
actor: ownershipActor,
|
|
1132
|
+
env: { ...ownershipEnv },
|
|
1133
|
+
};
|
|
1134
|
+
const fetchPrContext = deps.fetchPrContext || defaultFetchPrContext;
|
|
1135
|
+
const mergePr = deps.mergePr || defaultMergePr;
|
|
1136
|
+
const verifyPrIssueBinding = deps.verifyPrIssueBinding || defaultVerifyPrIssueBinding;
|
|
1137
|
+
const gh = deps.githubContext?.bound ? deps.githubContext.runGh : (deps.gh || defaultGh);
|
|
1138
|
+
const exactMergeIdentity = !parsed.error && parsed.expectedHead && parsed.issueId;
|
|
1139
|
+
let prContext; let prState;
|
|
1140
|
+
const replayBeforeProvider = async () => {
|
|
1141
|
+
const resolveLocalRepository = deps.resolveLocalRepository || defaultResolveLocalRepository;
|
|
1142
|
+
let localRepository;
|
|
1143
|
+
try {
|
|
1144
|
+
localRepository = normalizeRepository(await resolveLocalRepository({ projectRoot: root }));
|
|
1145
|
+
} catch {
|
|
1146
|
+
localRepository = null;
|
|
1147
|
+
}
|
|
1148
|
+
if (!localRepository) {
|
|
1149
|
+
return { failure: {
|
|
1150
|
+
success: false,
|
|
1151
|
+
merged: false,
|
|
1152
|
+
error: 'Local repository identity is unavailable for terminal evidence replay.',
|
|
1153
|
+
} };
|
|
1154
|
+
}
|
|
1155
|
+
try {
|
|
1156
|
+
const replay = await recoverMergedTerminal({
|
|
1157
|
+
pr,
|
|
1158
|
+
parsed,
|
|
1159
|
+
root,
|
|
1160
|
+
config: {},
|
|
1161
|
+
prContext: {
|
|
1162
|
+
number: Number(pr),
|
|
1163
|
+
headSha: parsed.expectedHead,
|
|
1164
|
+
repository: localRepository,
|
|
1165
|
+
},
|
|
1166
|
+
ownershipActor,
|
|
1167
|
+
ownershipSessionId,
|
|
1168
|
+
deps,
|
|
1169
|
+
replayOnly: true,
|
|
1170
|
+
});
|
|
1171
|
+
if (replay.replayMissing !== true) {
|
|
1172
|
+
return replay.success ? { replay } : { failure: { ...replay, merged: false } };
|
|
1173
|
+
}
|
|
1174
|
+
} catch (error) {
|
|
1175
|
+
return { failure: {
|
|
1176
|
+
success: false,
|
|
1177
|
+
merged: false,
|
|
1178
|
+
error: `Merged PR terminal evidence replay failed: ${error.message}`,
|
|
1179
|
+
} };
|
|
1180
|
+
}
|
|
1181
|
+
return {};
|
|
1182
|
+
};
|
|
1183
|
+
|
|
198
1184
|
const loadConfig = deps.loadConfig || loadRawConfig;
|
|
199
1185
|
let config;
|
|
200
1186
|
try {
|
|
@@ -217,7 +1203,6 @@ async function handler(args, _flags, projectRoot, deps = {}) {
|
|
|
217
1203
|
process.stdout.write(`${reason}\n`);
|
|
218
1204
|
return { success: true, merged: false, enabled: false, reason };
|
|
219
1205
|
}
|
|
220
|
-
|
|
221
1206
|
// Opted in but no rules → refuse (fail-closed): an empty ruleset is vacuously
|
|
222
1207
|
// "allowed", which would merge unconditionally. Treat that as misconfiguration.
|
|
223
1208
|
if (rules.length === 0) {
|
|
@@ -226,26 +1211,92 @@ async function handler(args, _flags, projectRoot, deps = {}) {
|
|
|
226
1211
|
return { success: false, merged: false, enabled: true, reason };
|
|
227
1212
|
}
|
|
228
1213
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
1214
|
+
if (!exactMergeIdentity) {
|
|
1215
|
+
const reason = parsed.error
|
|
1216
|
+
|| 'Enabled auto-merge requires --expect-head <full 40-character SHA> and --issue <Forge issue ID>.';
|
|
1217
|
+
return { success: false, merged: false, enabled: true, error: reason };
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
let ownership;
|
|
1221
|
+
try {
|
|
1222
|
+
ownership = await verifyIssueOwnership(ownershipInput);
|
|
1223
|
+
} catch (err) {
|
|
1224
|
+
return { success: false, merged: false, error: `Failed to verify issue ownership: ${err.message}` };
|
|
1225
|
+
}
|
|
1226
|
+
if (!ownership || ownership.owned !== true || ownership.expired !== false
|
|
1227
|
+
|| typeof ownership.actor !== 'string' || ownership.actor !== ownershipActor
|
|
1228
|
+
|| typeof ownership.claimedBy !== 'string' || ownership.claimedBy !== ownershipActor
|
|
1229
|
+
|| ownership.sessionId !== ownershipSessionId) {
|
|
1230
|
+
return {
|
|
1231
|
+
success: false,
|
|
1232
|
+
merged: false,
|
|
1233
|
+
error: `Active Kernel ownership claim with the exact session is required for issue ${parsed.issueId}; refusing to merge.`,
|
|
1234
|
+
};
|
|
1235
|
+
}
|
|
1236
|
+
const preProvider = await replayBeforeProvider();
|
|
1237
|
+
if (preProvider.failure) return preProvider.failure;
|
|
1238
|
+
if (preProvider.replay) return preProvider.replay;
|
|
232
1239
|
|
|
233
|
-
let prContext;
|
|
234
1240
|
try {
|
|
235
1241
|
prContext = await fetchPrContext({ pr, projectRoot: root, gh, now: deps.now || Date.now() });
|
|
236
1242
|
} catch (err) {
|
|
237
1243
|
return { success: false, merged: false, error: `Failed to fetch PR context: ${err.message}` };
|
|
238
1244
|
}
|
|
1245
|
+
prState = prContext?.state ? String(prContext.state).toUpperCase() : '';
|
|
1246
|
+
if (prState === 'MERGED') {
|
|
1247
|
+
try {
|
|
1248
|
+
const replay = await recoverMergedTerminal({
|
|
1249
|
+
pr, parsed, root, config: {}, prContext, ownershipActor, ownershipSessionId, deps, replayOnly: true,
|
|
1250
|
+
});
|
|
1251
|
+
if (replay.replayMissing !== true) return replay;
|
|
1252
|
+
} catch (error) {
|
|
1253
|
+
return { success: false, merged: true, error: `Merged PR terminal evidence replay failed: ${error.message}` };
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
239
1256
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
1257
|
+
if (prState === 'MERGED') {
|
|
1258
|
+
if (!ownershipSessionId) {
|
|
1259
|
+
return { success: false, merged: true, error: 'An exact FORGE_SESSION_ID session identity is required to recover terminal merge evidence.' };
|
|
1260
|
+
}
|
|
1261
|
+
try {
|
|
1262
|
+
return await recoverMergedTerminal({
|
|
1263
|
+
pr, parsed, root, config, prContext, ownershipActor, ownershipSessionId, deps,
|
|
1264
|
+
});
|
|
1265
|
+
} catch (error) {
|
|
1266
|
+
return { success: false, merged: true, error: `Merged PR terminal linkage recovery failed: ${error.message}` };
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
if (prState === 'CLOSED') {
|
|
245
1270
|
const reason = `PR #${pr} is ${prState} (not OPEN) — nothing to merge. No action taken.`;
|
|
246
1271
|
process.stdout.write(`${reason}\n`);
|
|
247
1272
|
return { success: true, merged: false, enabled: true, state: prState, reason };
|
|
248
1273
|
}
|
|
1274
|
+
if (!ownershipSessionId) {
|
|
1275
|
+
return { success: false, merged: false, error: 'An exact FORGE_SESSION_ID session identity is required before merge.' };
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
const mandatoryError = mandatoryContextError(prContext, parsed.expectedHead);
|
|
1279
|
+
if (mandatoryError) return { success: false, merged: false, error: mandatoryError };
|
|
1280
|
+
const leasedRepository = normalizeRepository(prContext && prContext.repository);
|
|
1281
|
+
if (!leasedRepository) {
|
|
1282
|
+
return { success: false, merged: false, error: 'PR repository identity is unreadable; refusing to merge.' };
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
let binding;
|
|
1286
|
+
try {
|
|
1287
|
+
binding = await verifyPrIssueBinding({
|
|
1288
|
+
issueId: parsed.issueId,
|
|
1289
|
+
pr,
|
|
1290
|
+
projectRoot: root,
|
|
1291
|
+
prContext,
|
|
1292
|
+
buildBroker: deps.buildPrBindingBroker,
|
|
1293
|
+
});
|
|
1294
|
+
} catch (err) {
|
|
1295
|
+
return { success: false, merged: false, error: `Failed to verify PR issue binding: ${err.message}` };
|
|
1296
|
+
}
|
|
1297
|
+
if (!binding || binding.bound !== true) {
|
|
1298
|
+
return { success: false, merged: false, error: 'PR is not authoritatively linked to the supplied Forge issue.' };
|
|
1299
|
+
}
|
|
249
1300
|
|
|
250
1301
|
const { allowed, unmet } = evaluateMergeRules(prContext, rules);
|
|
251
1302
|
|
|
@@ -258,7 +1309,7 @@ async function handler(args, _flags, projectRoot, deps = {}) {
|
|
|
258
1309
|
}
|
|
259
1310
|
|
|
260
1311
|
// 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.
|
|
1312
|
+
// new comment resets configured settle_min, a required check regresses, or a thread opens.
|
|
262
1313
|
// Re-pull LIVE data and re-evaluate immediately before merging so our custom
|
|
263
1314
|
// rules (which GitHub's server-side branch protection does NOT enforce) are
|
|
264
1315
|
// honored against the freshest possible state, never a stale snapshot.
|
|
@@ -271,11 +1322,26 @@ async function handler(args, _flags, projectRoot, deps = {}) {
|
|
|
271
1322
|
// Re-apply the terminal-state guard on the FRESH context: the PR may have been
|
|
272
1323
|
// merged or closed between the first fetch and now. Never merge a terminal PR.
|
|
273
1324
|
const freshState = freshContext && freshContext.state ? String(freshContext.state).toUpperCase() : '';
|
|
274
|
-
if (freshState
|
|
1325
|
+
if (freshState === 'MERGED') {
|
|
1326
|
+
try {
|
|
1327
|
+
return await recoverMergedTerminal({
|
|
1328
|
+
pr, parsed, root, config, prContext: freshContext, ownershipActor, ownershipSessionId, deps,
|
|
1329
|
+
});
|
|
1330
|
+
} catch (error) {
|
|
1331
|
+
return { success: false, merged: true, error: `Merged PR terminal linkage recovery failed: ${error.message}` };
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
if (freshState === 'CLOSED') {
|
|
275
1335
|
const reason = `PR #${pr} became ${freshState} (not OPEN) before merge — nothing to merge. No action taken.`;
|
|
276
1336
|
process.stdout.write(`${reason}\n`);
|
|
277
1337
|
return { success: true, merged: false, enabled: true, state: freshState, reason };
|
|
278
1338
|
}
|
|
1339
|
+
const freshMandatoryError = mandatoryContextError(freshContext, parsed.expectedHead);
|
|
1340
|
+
if (freshMandatoryError) return { success: false, merged: false, error: freshMandatoryError };
|
|
1341
|
+
const freshRepository = normalizeRepository(freshContext && freshContext.repository);
|
|
1342
|
+
if (!freshRepository || freshRepository !== leasedRepository) {
|
|
1343
|
+
return { success: false, merged: false, error: 'PR repository identity changed or became unreadable before merge.' };
|
|
1344
|
+
}
|
|
279
1345
|
const recheck = evaluateMergeRules(freshContext, rules);
|
|
280
1346
|
if (!recheck.allowed) {
|
|
281
1347
|
process.stdout.write(`Auto-merge ABORTED for PR #${pr} — state changed since first check; ${recheck.unmet.length} rule(s) now unmet:\n`);
|
|
@@ -285,28 +1351,169 @@ async function handler(args, _flags, projectRoot, deps = {}) {
|
|
|
285
1351
|
return { success: true, merged: false, enabled: true, allowed: false, unmet: recheck.unmet, reason: 'PR state changed before merge (live re-check failed)' };
|
|
286
1352
|
}
|
|
287
1353
|
|
|
1354
|
+
let freshBinding;
|
|
1355
|
+
try {
|
|
1356
|
+
freshBinding = await verifyPrIssueBinding({
|
|
1357
|
+
issueId: parsed.issueId,
|
|
1358
|
+
pr,
|
|
1359
|
+
projectRoot: root,
|
|
1360
|
+
prContext: freshContext,
|
|
1361
|
+
buildBroker: deps.buildPrBindingBroker,
|
|
1362
|
+
});
|
|
1363
|
+
} catch (err) {
|
|
1364
|
+
return { success: false, merged: false, error: `Failed to re-verify PR issue binding: ${err.message}` };
|
|
1365
|
+
}
|
|
1366
|
+
if (!freshBinding || freshBinding.bound !== true) {
|
|
1367
|
+
return { success: false, merged: false, error: 'PR issue linkage changed or is unreadable before merge.' };
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
let finalOwnership;
|
|
1371
|
+
try {
|
|
1372
|
+
finalOwnership = await verifyIssueOwnership(ownershipInput);
|
|
1373
|
+
} catch (err) {
|
|
1374
|
+
return { success: false, merged: false, error: `Failed to re-verify issue ownership before merge: ${err.message}` };
|
|
1375
|
+
}
|
|
1376
|
+
if (!finalOwnership || finalOwnership.owned !== true || finalOwnership.expired !== false
|
|
1377
|
+
|| typeof finalOwnership.actor !== 'string' || finalOwnership.actor !== ownershipActor
|
|
1378
|
+
|| typeof finalOwnership.claimedBy !== 'string' || finalOwnership.claimedBy !== ownershipActor
|
|
1379
|
+
|| finalOwnership.sessionId !== ownershipSessionId) {
|
|
1380
|
+
return { success: false, merged: false, error: 'Kernel ownership changed or expired before merge; refusing to merge.' };
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
const decisionAt = currentTimestamp(deps.now || Date.now);
|
|
1384
|
+
const verifyMergeGate = deps.verifyMergeGate || defaultVerifyMergeGate;
|
|
1385
|
+
let gateApproval;
|
|
288
1386
|
try {
|
|
289
|
-
|
|
290
|
-
|
|
1387
|
+
gateApproval = await verifyMergeGate({
|
|
1388
|
+
issueId: parsed.issueId,
|
|
1389
|
+
projectRoot: root,
|
|
1390
|
+
actor: ownershipActor,
|
|
1391
|
+
now: decisionAt,
|
|
1392
|
+
});
|
|
1393
|
+
} catch (err) {
|
|
1394
|
+
return { success: false, merged: false, error: `Failed to verify ${MERGE_GATE}: ${err.message}` };
|
|
1395
|
+
}
|
|
1396
|
+
if (gateApproval !== true && gateApproval?.approved !== true && gateApproval?.disabled !== true) {
|
|
1397
|
+
return { success: false, merged: false, error: `${MERGE_GATE} is not approved for this issue.` };
|
|
1398
|
+
}
|
|
1399
|
+
if (gateApproval?.disabled === true) {
|
|
1400
|
+
return {
|
|
1401
|
+
success: false,
|
|
1402
|
+
merged: false,
|
|
1403
|
+
error: 'The disabled merge gate cannot produce broker-verifiable terminal authority; refusing to mutate GitHub.',
|
|
1404
|
+
};
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
const prepareMergeDecision = deps.prepareMergeDecision || defaultPrepareMergeDecision;
|
|
1408
|
+
let decision;
|
|
1409
|
+
try {
|
|
1410
|
+
decision = await prepareMergeDecision({
|
|
1411
|
+
issueId: parsed.issueId,
|
|
1412
|
+
pr,
|
|
1413
|
+
expectedHead: parsed.expectedHead,
|
|
1414
|
+
repository: leasedRepository,
|
|
1415
|
+
binding: freshBinding,
|
|
1416
|
+
actor: ownershipActor,
|
|
1417
|
+
sessionId: ownershipSessionId,
|
|
1418
|
+
env: ownershipEnv,
|
|
1419
|
+
requireMergeGate: gateApproval?.disabled !== true,
|
|
1420
|
+
config,
|
|
1421
|
+
projectRoot: root,
|
|
1422
|
+
now: decisionAt,
|
|
1423
|
+
});
|
|
1424
|
+
} catch (err) {
|
|
1425
|
+
return { success: false, merged: false, error: `Failed to prepare merge decision: ${err.message}` };
|
|
1426
|
+
}
|
|
1427
|
+
if (!decision || typeof decision.decisionId !== 'string' || !decision.decisionId) {
|
|
1428
|
+
return { success: false, merged: false, error: 'Merge decision evidence is unavailable.' };
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
let mergeResult;
|
|
1432
|
+
try {
|
|
1433
|
+
mergeResult = await mergePr({
|
|
1434
|
+
pr,
|
|
1435
|
+
expectedHead: parsed.expectedHead,
|
|
1436
|
+
repository: leasedRepository,
|
|
1437
|
+
issueId: parsed.issueId,
|
|
1438
|
+
projectRoot: root,
|
|
1439
|
+
gh,
|
|
1440
|
+
});
|
|
1441
|
+
} catch (err) {
|
|
1442
|
+
return { success: false, merged: false, decisionId: decision.decisionId, error: `Merge failed: ${err.message}` };
|
|
1443
|
+
}
|
|
1444
|
+
if (mergeResult?.merged !== true) {
|
|
291
1445
|
return {
|
|
292
|
-
success:
|
|
1446
|
+
success: false,
|
|
1447
|
+
merged: false,
|
|
1448
|
+
decisionId: decision.decisionId,
|
|
1449
|
+
error: `Merge failed: ${mergeResult?.reason || 'provider did not confirm the merge'}`,
|
|
1450
|
+
};
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
const recordMergeDecision = deps.recordMergeDecision || defaultRecordMergeDecision;
|
|
1454
|
+
let terminal;
|
|
1455
|
+
try {
|
|
1456
|
+
terminal = await recordMergeDecision({
|
|
1457
|
+
decision,
|
|
1458
|
+
mergeResult,
|
|
1459
|
+
pr,
|
|
1460
|
+
expectedHead: parsed.expectedHead,
|
|
1461
|
+
repository: leasedRepository,
|
|
1462
|
+
projectRoot: root,
|
|
1463
|
+
buildBroker: deps.buildMergeBroker,
|
|
1464
|
+
});
|
|
1465
|
+
} catch (err) {
|
|
1466
|
+
return {
|
|
1467
|
+
success: false,
|
|
293
1468
|
merged: true,
|
|
294
1469
|
enabled: true,
|
|
295
1470
|
allowed: true,
|
|
296
|
-
|
|
297
|
-
|
|
1471
|
+
decisionId: decision.decisionId,
|
|
1472
|
+
error: `Merge succeeded but terminal linkage failed: ${err.message}`,
|
|
298
1473
|
};
|
|
299
|
-
} catch (err) {
|
|
300
|
-
return { success: false, merged: false, error: `Merge failed: ${err.message}` };
|
|
301
1474
|
}
|
|
1475
|
+
if (!terminal || typeof terminal.receiptId !== 'string' || !terminal.receiptId) {
|
|
1476
|
+
return {
|
|
1477
|
+
success: false,
|
|
1478
|
+
merged: true,
|
|
1479
|
+
enabled: true,
|
|
1480
|
+
allowed: true,
|
|
1481
|
+
decisionId: decision.decisionId,
|
|
1482
|
+
error: 'Merge succeeded but terminal receipt evidence is unavailable.',
|
|
1483
|
+
};
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
process.stdout.write(`All ${rules.length} merge rule(s) passed — merged PR #${pr}.\n`);
|
|
1487
|
+
const providerFields = typeof mergeResult?.method === 'string' ? { method: mergeResult.method } : {};
|
|
1488
|
+
return {
|
|
1489
|
+
...providerFields,
|
|
1490
|
+
success: true,
|
|
1491
|
+
merged: true,
|
|
1492
|
+
enabled: true,
|
|
1493
|
+
allowed: true,
|
|
1494
|
+
reason: 'all merge rules passed',
|
|
1495
|
+
decisionId: decision.decisionId,
|
|
1496
|
+
...terminal,
|
|
1497
|
+
};
|
|
302
1498
|
}
|
|
303
1499
|
|
|
304
1500
|
module.exports = {
|
|
305
1501
|
name: 'merge',
|
|
1502
|
+
githubAuth: true,
|
|
306
1503
|
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>',
|
|
1504
|
+
usage: 'Usage: forge merge --auto <pr> --expect-head <40-char-sha> --issue <issue-id>',
|
|
308
1505
|
handler,
|
|
309
1506
|
// Exported seams for testing / reuse.
|
|
310
1507
|
defaultFetchPrContext,
|
|
1508
|
+
defaultResolveLocalRepository,
|
|
311
1509
|
defaultMergePr,
|
|
1510
|
+
defaultPrepareMergeDecision,
|
|
1511
|
+
defaultRecordMergeDecision,
|
|
1512
|
+
defaultVerifyMergeGate,
|
|
1513
|
+
defaultVerifyPrIssueBinding,
|
|
1514
|
+
defaultVerifyIssueOwnership,
|
|
1515
|
+
evaluateProtectedRequiredChecks,
|
|
1516
|
+
normalizeFullHeadSha,
|
|
1517
|
+
normalizePrNumber,
|
|
1518
|
+
parseMergeArgs,
|
|
312
1519
|
};
|