@aviaratech/ai-delivery 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/CONTRIBUTING.md +47 -0
  2. package/LICENSE +21 -0
  3. package/README.md +84 -0
  4. package/dist/agent.d.ts +17 -0
  5. package/dist/agent.js +16 -0
  6. package/dist/agent.js.map +1 -0
  7. package/dist/cli.d.ts +2 -0
  8. package/dist/cli.js +314 -0
  9. package/dist/cli.js.map +1 -0
  10. package/dist/cliResult.d.ts +2 -0
  11. package/dist/cliResult.js +12 -0
  12. package/dist/cliResult.js.map +1 -0
  13. package/dist/cliResult.test.d.ts +1 -0
  14. package/dist/cliResult.test.js +30 -0
  15. package/dist/cliResult.test.js.map +1 -0
  16. package/dist/config/deliveryConfig.d.ts +113 -0
  17. package/dist/config/deliveryConfig.js +261 -0
  18. package/dist/config/deliveryConfig.js.map +1 -0
  19. package/dist/config/deliveryConfig.test.d.ts +1 -0
  20. package/dist/config/deliveryConfig.test.js +355 -0
  21. package/dist/config/deliveryConfig.test.js.map +1 -0
  22. package/dist/delivery/common.d.ts +28 -0
  23. package/dist/delivery/common.js +117 -0
  24. package/dist/delivery/common.js.map +1 -0
  25. package/dist/delivery/delivery.test.d.ts +1 -0
  26. package/dist/delivery/delivery.test.js +234 -0
  27. package/dist/delivery/delivery.test.js.map +1 -0
  28. package/dist/delivery/evidence.d.ts +92 -0
  29. package/dist/delivery/evidence.js +126 -0
  30. package/dist/delivery/evidence.js.map +1 -0
  31. package/dist/delivery/index.d.ts +8 -0
  32. package/dist/delivery/index.js +5 -0
  33. package/dist/delivery/index.js.map +1 -0
  34. package/dist/delivery/policy.d.ts +237 -0
  35. package/dist/delivery/policy.js +246 -0
  36. package/dist/delivery/policy.js.map +1 -0
  37. package/dist/delivery/stage.d.ts +125 -0
  38. package/dist/delivery/stage.js +218 -0
  39. package/dist/delivery/stage.js.map +1 -0
  40. package/dist/dispatch.d.ts +15 -0
  41. package/dist/dispatch.js +397 -0
  42. package/dist/dispatch.js.map +1 -0
  43. package/dist/errors.d.ts +4 -0
  44. package/dist/errors.js +9 -0
  45. package/dist/errors.js.map +1 -0
  46. package/dist/genericCompatibility.test.d.ts +1 -0
  47. package/dist/genericCompatibility.test.js +92 -0
  48. package/dist/genericCompatibility.test.js.map +1 -0
  49. package/dist/git.d.ts +13 -0
  50. package/dist/git.js +65 -0
  51. package/dist/git.js.map +1 -0
  52. package/dist/github/client.d.ts +34 -0
  53. package/dist/github/client.js +143 -0
  54. package/dist/github/client.js.map +1 -0
  55. package/dist/github/commandIdentityPolicy.d.ts +13 -0
  56. package/dist/github/commandIdentityPolicy.js +46 -0
  57. package/dist/github/commandIdentityPolicy.js.map +1 -0
  58. package/dist/github/nativeIssueMetadata.d.ts +78 -0
  59. package/dist/github/nativeIssueMetadata.js +249 -0
  60. package/dist/github/nativeIssueMetadata.js.map +1 -0
  61. package/dist/github/projectDelivery.d.ts +68 -0
  62. package/dist/github/projectDelivery.js +540 -0
  63. package/dist/github/projectDelivery.js.map +1 -0
  64. package/dist/github/relationships.d.ts +69 -0
  65. package/dist/github/relationships.js +395 -0
  66. package/dist/github/relationships.js.map +1 -0
  67. package/dist/github/repo.d.ts +8 -0
  68. package/dist/github/repo.js +34 -0
  69. package/dist/github/repo.js.map +1 -0
  70. package/dist/index.d.ts +2 -0
  71. package/dist/index.js +3 -0
  72. package/dist/index.js.map +1 -0
  73. package/dist/issue.d.ts +86 -0
  74. package/dist/issue.js +347 -0
  75. package/dist/issue.js.map +1 -0
  76. package/dist/lifecycle.test.d.ts +1 -0
  77. package/dist/lifecycle.test.js +1118 -0
  78. package/dist/lifecycle.test.js.map +1 -0
  79. package/dist/logger.d.ts +21 -0
  80. package/dist/logger.js +86 -0
  81. package/dist/logger.js.map +1 -0
  82. package/dist/mcp/index.d.ts +5 -0
  83. package/dist/mcp/index.js +46 -0
  84. package/dist/mcp/index.js.map +1 -0
  85. package/dist/mcp/tools.d.ts +162 -0
  86. package/dist/mcp/tools.js +154 -0
  87. package/dist/mcp/tools.js.map +1 -0
  88. package/dist/pr.d.ts +146 -0
  89. package/dist/pr.js +972 -0
  90. package/dist/pr.js.map +1 -0
  91. package/dist/pr.test.d.ts +1 -0
  92. package/dist/pr.test.js +252 -0
  93. package/dist/pr.test.js.map +1 -0
  94. package/dist/review.d.ts +104 -0
  95. package/dist/review.js +228 -0
  96. package/dist/review.js.map +1 -0
  97. package/dist/review.test.d.ts +1 -0
  98. package/dist/review.test.js +327 -0
  99. package/dist/review.test.js.map +1 -0
  100. package/dist/services/agentReadinessService.d.ts +17 -0
  101. package/dist/services/agentReadinessService.js +225 -0
  102. package/dist/services/agentReadinessService.js.map +1 -0
  103. package/dist/services/deliveryAdmission.d.ts +33 -0
  104. package/dist/services/deliveryAdmission.js +122 -0
  105. package/dist/services/deliveryAdmission.js.map +1 -0
  106. package/dist/services/deliveryRecordService.d.ts +47 -0
  107. package/dist/services/deliveryRecordService.js +199 -0
  108. package/dist/services/deliveryRecordService.js.map +1 -0
  109. package/dist/services/legacyIssueMigration.d.ts +46 -0
  110. package/dist/services/legacyIssueMigration.js +313 -0
  111. package/dist/services/legacyIssueMigration.js.map +1 -0
  112. package/dist/services/worktreeRegistry.d.ts +67 -0
  113. package/dist/services/worktreeRegistry.js +545 -0
  114. package/dist/services/worktreeRegistry.js.map +1 -0
  115. package/dist/terminalMessages.d.ts +13 -0
  116. package/dist/terminalMessages.js +43 -0
  117. package/dist/terminalMessages.js.map +1 -0
  118. package/dist/utils/atomicJson.d.ts +6 -0
  119. package/dist/utils/atomicJson.js +78 -0
  120. package/dist/utils/atomicJson.js.map +1 -0
  121. package/dist/utils/filesystem.d.ts +9 -0
  122. package/dist/utils/filesystem.js +19 -0
  123. package/dist/utils/filesystem.js.map +1 -0
  124. package/dist/utils/lockfile.d.ts +12 -0
  125. package/dist/utils/lockfile.js +78 -0
  126. package/dist/utils/lockfile.js.map +1 -0
  127. package/dist/verification.d.ts +24 -0
  128. package/dist/verification.js +227 -0
  129. package/dist/verification.js.map +1 -0
  130. package/dist/worktree.d.ts +44 -0
  131. package/dist/worktree.js +255 -0
  132. package/dist/worktree.js.map +1 -0
  133. package/package.json +82 -0
  134. package/plugins/ai-delivery/.claude-plugin/plugin.json +6 -0
  135. package/plugins/ai-delivery/.mcp.json +8 -0
  136. package/plugins/ai-delivery/README.md +5 -0
  137. package/plugins/ai-delivery/dist/mcp-launcher.js +22 -0
  138. package/plugins/ai-delivery/skills/intake-create/SKILL.md +26 -0
  139. package/plugins/ai-delivery/skills/pr-handoff/SKILL.md +28 -0
  140. package/plugins/ai-delivery/skills/worktree-lifecycle/SKILL.md +24 -0
package/dist/pr.js ADDED
@@ -0,0 +1,972 @@
1
+ import { execFileSync, spawnSync } from 'node:child_process';
2
+ import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { isAbsolute, join } from 'node:path';
5
+ import { z } from 'zod';
6
+ import { loadDeliveryConfig } from './config/deliveryConfig.js';
7
+ import { assertPrivateFile } from './delivery/common.js';
8
+ import { digestValue, loadSelectedRepositoryPolicy, validateRepositoryPolicyBoundary, } from './delivery/index.js';
9
+ import { DeliveryError } from './errors.js';
10
+ import { coordinate, defaultBaseRef, git, gitCommonDir, gitExitCode, gitRoot } from './git.js';
11
+ import { withAuthorGitToken } from './github/client.js';
12
+ import { getConfiguredNativeIssueMetadata, nativeIssueSettingsFromDeliveryConfig, } from './github/nativeIssueMetadata.js';
13
+ import { getNativeBlockerRelationships } from './github/relationships.js';
14
+ import { getIssueProjectStatus, projectSettingsFromDeliveryConfig, syncIssueProjectStatus, } from './github/projectDelivery.js';
15
+ import { loadPrepublicationArtifact, loadSubmittedReview, reviewArtifactApproval, assertSubmittedReviewCurrent, parseReviewArtifact, submitReview, } from './review.js';
16
+ import { getIssueWorktreeStrict, listWorktreesStrict, updateIssueWorktreeDelivery, } from './services/worktreeRegistry.js';
17
+ import { getDeliveryRecords, recordMergedDelivery } from './services/deliveryRecordService.js';
18
+ import { createIssuePhaseEvidence, loadRemovedMergedRun, loadVerifiedRun } from './verification.js';
19
+ import { writePrivateJsonFileAtomically } from './utils/atomicJson.js';
20
+ import { cleanupMergedIssueWorktree, preparePrWorktree } from './worktree.js';
21
+ const Digest = z.string().regex(/^sha256:[a-f0-9]{64}$/u);
22
+ const Sha = z.string().regex(/^[a-f0-9]{40}$/u);
23
+ const PublicationSchema = z
24
+ .object({
25
+ baseSha: Sha,
26
+ evidenceId: Digest,
27
+ headSha: Sha,
28
+ issueNumber: z.number().int().positive(),
29
+ prNumber: z.number().int().positive(),
30
+ publicationId: Digest,
31
+ schemaVersion: z.literal('ai-delivery.publication@1'),
32
+ })
33
+ .strict()
34
+ .superRefine((value, ctx) => {
35
+ const { publicationId, ...content } = value;
36
+ if (publicationId !== digestValue(content))
37
+ ctx.addIssue({ code: 'custom', message: 'Publication identity is invalid.' });
38
+ });
39
+ const MergeStrategy = z.enum(['merge', 'squash', 'rebase']);
40
+ const MergeIntentShape = z
41
+ .object({
42
+ baseBranch: z.string().regex(/^[A-Za-z0-9._/-]+$/u),
43
+ baseSha: Sha,
44
+ evidenceId: Digest,
45
+ exactBaseHeadLease: z.boolean(),
46
+ headBranch: z.string().regex(/^[A-Za-z0-9._/-]+$/u),
47
+ headSha: Sha,
48
+ headTree: Sha,
49
+ intentId: Digest,
50
+ issueNumber: z.number().int().positive(),
51
+ prNumber: z.number().int().positive(),
52
+ reviewReceiptId: Digest,
53
+ schemaVersion: z.literal('ai-delivery.merge-intent@2'),
54
+ strategy: MergeStrategy,
55
+ })
56
+ .strict();
57
+ const MergeIntentSchema = MergeIntentShape.superRefine((value, ctx) => {
58
+ const { intentId, ...content } = value;
59
+ if (intentId !== digestValue(content))
60
+ ctx.addIssue({ code: 'custom', message: 'Merge intent identity is invalid.' });
61
+ });
62
+ const MergeAttemptSchema = z
63
+ .object({
64
+ actor: z.string().min(1),
65
+ actorLogin: z.string().min(1),
66
+ attemptId: Digest,
67
+ authSource: z.enum(['app', 'personal']),
68
+ credentialIdentity: z.string().min(1),
69
+ intentId: Digest,
70
+ operation: z.enum(['pulls.merge', 'updateRefs']),
71
+ schemaVersion: z.literal('ai-delivery.merge-attempt@1'),
72
+ })
73
+ .strict()
74
+ .superRefine((value, ctx) => {
75
+ const { attemptId, ...content } = value;
76
+ if (attemptId !== digestValue(content))
77
+ ctx.addIssue({ code: 'custom', message: 'Merge attempt identity is invalid.' });
78
+ });
79
+ const MergeResultSchema = z
80
+ .object({
81
+ attemptId: Digest,
82
+ mergeSha: Sha,
83
+ resultId: Digest,
84
+ schemaVersion: z.literal('ai-delivery.merge-result@1'),
85
+ })
86
+ .strict()
87
+ .superRefine((value, ctx) => {
88
+ const { resultId, ...content } = value;
89
+ if (resultId !== digestValue(content))
90
+ ctx.addIssue({ code: 'custom', message: 'Merge result identity is invalid.' });
91
+ });
92
+ const MergeSchema = MergeIntentShape.omit({ intentId: true, schemaVersion: true })
93
+ .extend({
94
+ intentId: Digest,
95
+ mergeId: Digest,
96
+ mergeSha: Sha,
97
+ mergeTree: Sha,
98
+ schemaVersion: z.literal('ai-delivery.merge@3'),
99
+ })
100
+ .strict()
101
+ .superRefine((value, ctx) => {
102
+ const { mergeId, ...content } = value;
103
+ if (mergeId !== digestValue(content))
104
+ ctx.addIssue({ code: 'custom', message: 'Merge identity is invalid.' });
105
+ });
106
+ function publicationPath(root, issueNumber, headSha) {
107
+ return join(gitCommonDir(root), 'ai-delivery', 'publications', String(issueNumber), `${headSha}.json`);
108
+ }
109
+ function mergePath(root, issueNumber, headSha) {
110
+ return join(gitCommonDir(root), 'ai-delivery', 'merges', String(issueNumber), `${headSha}.json`);
111
+ }
112
+ function mergeIntentPath(root, issueNumber, headSha) {
113
+ return join(gitCommonDir(root), 'ai-delivery', 'merge-intents', String(issueNumber), `${headSha}.json`);
114
+ }
115
+ function mergeAttemptPath(root, issueNumber, headSha) {
116
+ return join(gitCommonDir(root), 'ai-delivery', 'merge-attempts', String(issueNumber), `${headSha}.json`);
117
+ }
118
+ function mergeResultPath(root, issueNumber, headSha) {
119
+ return join(gitCommonDir(root), 'ai-delivery', 'merge-results', String(issueNumber), `${headSha}.json`);
120
+ }
121
+ function readOptionalPrivate(path, parse) {
122
+ let raw;
123
+ try {
124
+ raw = JSON.parse(assertPrivateFile(path).toString('utf8'));
125
+ }
126
+ catch (error) {
127
+ if (error.code === 'ENOENT')
128
+ return null;
129
+ throw new DeliveryError('Stored lifecycle receipt is unreadable.');
130
+ }
131
+ try {
132
+ return parse(raw);
133
+ }
134
+ catch {
135
+ throw new DeliveryError('Stored lifecycle receipt is corrupt.');
136
+ }
137
+ }
138
+ function readPrivate(path, parse) {
139
+ let raw;
140
+ try {
141
+ raw = JSON.parse(assertPrivateFile(path).toString('utf8'));
142
+ }
143
+ catch {
144
+ throw new DeliveryError('Required private lifecycle receipt is missing or unreadable.');
145
+ }
146
+ return parse(raw);
147
+ }
148
+ export function loadPublication(root, issueNumber, headSha) {
149
+ return readPrivate(publicationPath(root, issueNumber, headSha), (value) => PublicationSchema.parse(value));
150
+ }
151
+ function validateDeliveryImpact(body) {
152
+ if ((body.match(/^## Delivery Impact\s*$/gmu) ?? []).length !== 1 ||
153
+ (body.match(/^Roadmap impact:\s*(?:updated|none)\s*$/gmu) ?? []).length !== 1 ||
154
+ (body.match(/^Docs impact:\s*(?:updated|none)\s*$/gmu) ?? []).length !== 1) {
155
+ throw new DeliveryError('PR body requires one Delivery Impact section with Roadmap impact and Docs impact.');
156
+ }
157
+ }
158
+ function baseBranch(root) {
159
+ const ref = defaultBaseRef(root);
160
+ const value = ref.startsWith('origin/') ? ref.slice('origin/'.length) : ref;
161
+ if (!/^[A-Za-z0-9._/-]+$/u.test(value))
162
+ throw new DeliveryError('Invalid configured default branch.');
163
+ return value;
164
+ }
165
+ /** @internal Selected-App Git transport, exported for process-boundary tests. */
166
+ export async function runAuthorGit(context, path, args, operation) {
167
+ await withAuthorGitToken(context.clients, (token) => {
168
+ const gitEnv = {
169
+ PATH: process.env.PATH ?? '',
170
+ GIT_CONFIG_GLOBAL: '/dev/null',
171
+ GIT_CONFIG_NOSYSTEM: '1',
172
+ };
173
+ const unsafeConfig = spawnSync('git', ['config', '--includes', '--get-regexp', '^(url\\..*\\.(insteadof|pushinsteadof)|http(\\..*)?\\.extraheader)$'], {
174
+ cwd: path,
175
+ env: gitEnv,
176
+ encoding: 'utf8',
177
+ stdio: ['ignore', 'pipe', 'pipe'],
178
+ });
179
+ if (unsafeConfig.error || (unsafeConfig.status !== 0 && unsafeConfig.status !== 1)) {
180
+ throw new DeliveryError(`Cannot verify effective Git HTTP transport configuration (git exit ${String(unsafeConfig.status)}).`);
181
+ }
182
+ if (unsafeConfig.status === 0) {
183
+ throw new DeliveryError('Local Git transport configuration conflicts with selected App authentication.');
184
+ }
185
+ const temporary = mkdtempSync(join(tmpdir(), 'ai-delivery-git-auth-'));
186
+ const askpass = join(temporary, 'askpass.sh');
187
+ try {
188
+ writeFileSync(askpass, '#!/bin/sh\ncase "$1" in\n *Username*) printf "%s\\n" x-access-token ;;\n *Password*) printf "%s\\n" "$AI_DELIVERY_GIT_TOKEN" ;;\n *) exit 1 ;;\nesac\n', { mode: 0o700 });
189
+ execFileSync('git', ['-c', 'credential.helper=', ...args], {
190
+ cwd: path,
191
+ encoding: 'utf8',
192
+ env: { ...gitEnv, AI_DELIVERY_GIT_TOKEN: token, GIT_ASKPASS: askpass, GIT_TERMINAL_PROMPT: '0' },
193
+ maxBuffer: 4 * 1024 * 1024,
194
+ stdio: ['ignore', 'pipe', 'pipe'],
195
+ });
196
+ }
197
+ catch (cause) {
198
+ const error = cause;
199
+ const detail = String(error.stderr ?? '')
200
+ .trim()
201
+ .replaceAll(token, '[redacted]');
202
+ throw new DeliveryError(`git ${operation} failed${detail ? `: ${detail}` : ''}`);
203
+ }
204
+ finally {
205
+ rmSync(temporary, { recursive: true, force: true });
206
+ }
207
+ });
208
+ }
209
+ async function pushReviewedHead(context, path, branch, sha) {
210
+ await runAuthorGit(context, path, ['push', `https://github.com/${context.repo.owner}/${context.repo.repo}.git`, `${sha}:refs/heads/${branch}`], 'push');
211
+ }
212
+ async function fetchMergedBase(context, branch) {
213
+ const refspec = `${branch}:refs/remotes/origin/${branch}`;
214
+ const origin = git(context.root, 'remote', 'get-url', 'origin');
215
+ if (isAbsolute(origin) || origin.startsWith('file://')) {
216
+ git(context.root, 'fetch', 'origin', refspec);
217
+ return;
218
+ }
219
+ await runAuthorGit(context, context.root, ['fetch', `https://github.com/${context.repo.owner}/${context.repo.repo}.git`, refspec], 'fetch');
220
+ }
221
+ export async function prInfo(context, input) {
222
+ if (input.prNumber !== undefined) {
223
+ const pr = (await context.clients.rest.pulls.get({ ...context.repo, pull_number: input.prNumber })).data;
224
+ return {
225
+ number: pr.number,
226
+ state: pr.state,
227
+ headSha: pr.head.sha,
228
+ baseSha: pr.base.sha,
229
+ draft: pr.draft ?? null,
230
+ url: pr.html_url,
231
+ };
232
+ }
233
+ if (input.issueNumber === undefined)
234
+ throw new DeliveryError('Provide an issue or PR number.');
235
+ const row = getIssueWorktreeStrict(input.issueNumber, context.root);
236
+ const matches = (await context.clients.rest.pulls.list({
237
+ ...context.repo,
238
+ head: `${context.repo.owner}:${row.branch}`,
239
+ state: 'open',
240
+ per_page: 100,
241
+ })).data;
242
+ if (matches.length > 1)
243
+ throw new DeliveryError('Multiple open PRs match the registered branch.');
244
+ const pr = matches[0];
245
+ return pr
246
+ ? {
247
+ number: pr.number,
248
+ state: pr.state,
249
+ headSha: pr.head.sha,
250
+ baseSha: pr.base.sha,
251
+ draft: pr.draft ?? null,
252
+ url: pr.html_url,
253
+ }
254
+ : { number: null, state: null, headSha: null, baseSha: null, draft: null, url: null };
255
+ }
256
+ export async function listPrs(context, state = 'open') {
257
+ const prs = await context.clients.rest.paginate(context.clients.rest.pulls.list, {
258
+ ...context.repo,
259
+ state,
260
+ per_page: 100,
261
+ });
262
+ return {
263
+ schemaVersion: 'ai-delivery.pr-list@1',
264
+ pullRequests: prs.map((pr) => ({
265
+ number: pr.number,
266
+ state: pr.state,
267
+ draft: pr.draft ?? false,
268
+ headSha: pr.head.sha,
269
+ baseSha: pr.base.sha,
270
+ url: pr.html_url,
271
+ })),
272
+ };
273
+ }
274
+ export async function prChecks(context, prNumber) {
275
+ const pr = (await context.clients.rest.pulls.get({ ...context.repo, pull_number: prNumber })).data;
276
+ const [checks, status] = await Promise.all([
277
+ context.clients.rest.checks.listForRef({ ...context.repo, ref: pr.head.sha, per_page: 100 }),
278
+ context.clients.rest.repos.getCombinedStatusForRef({ ...context.repo, ref: pr.head.sha }),
279
+ ]);
280
+ return {
281
+ schemaVersion: 'ai-delivery.pr-checks@1',
282
+ prNumber: pr.number,
283
+ headSha: pr.head.sha,
284
+ combinedStatus: status.data.state,
285
+ checkRuns: checks.data.check_runs.map((check) => ({
286
+ name: check.name,
287
+ status: check.status,
288
+ conclusion: check.conclusion,
289
+ })),
290
+ };
291
+ }
292
+ export async function checkoutPr(context, prNumber) {
293
+ if (!Number.isSafeInteger(prNumber) || prNumber <= 0)
294
+ throw new DeliveryError('PR number must be positive.');
295
+ const pr = (await context.clients.rest.pulls.get({ ...context.repo, pull_number: prNumber })).data;
296
+ const repository = `${context.repo.owner}/${context.repo.repo}`.toLowerCase();
297
+ if (pr.state !== 'open' ||
298
+ pr.head.repo?.full_name.toLowerCase() !== repository ||
299
+ pr.base.repo?.full_name.toLowerCase() !== repository ||
300
+ gitExitCode(context.root, 'check-ref-format', '--branch', pr.head.ref) !== 0) {
301
+ throw new DeliveryError('PR checkout requires an open in-repository branch with a valid ref.');
302
+ }
303
+ const remoteHead = (await context.clients.rest.git.getRef({ ...context.repo, ref: `heads/${pr.head.ref}` })).data
304
+ .object.sha;
305
+ if (remoteHead !== pr.head.sha)
306
+ throw new DeliveryError('PR branch ref drifted from the observed head.');
307
+ const fetchedRef = `refs/remotes/origin/ai-delivery-pr-${prNumber}`;
308
+ await runAuthorGit(context, context.root, [
309
+ 'fetch',
310
+ `https://github.com/${context.repo.owner}/${context.repo.repo}.git`,
311
+ `refs/heads/${pr.head.ref}:${fetchedRef}`,
312
+ ], 'fetch');
313
+ if (git(context.root, 'rev-parse', fetchedRef) !== pr.head.sha) {
314
+ throw new DeliveryError('Fetched PR branch disagrees with the live head.');
315
+ }
316
+ const live = (await context.clients.rest.pulls.get({ ...context.repo, pull_number: prNumber })).data;
317
+ if (live.state !== 'open' || live.head.sha !== pr.head.sha || live.head.ref !== pr.head.ref) {
318
+ throw new DeliveryError('PR changed during selected-App checkout.');
319
+ }
320
+ return preparePrWorktree({
321
+ headSha: pr.head.sha,
322
+ identity: context.config.roles.author.identity,
323
+ prNumber,
324
+ repoRoot: context.root,
325
+ });
326
+ }
327
+ export async function publishPr(context, input) {
328
+ const root = gitRoot(context.root);
329
+ const row = getIssueWorktreeStrict(input.issueNumber, root);
330
+ if (row.type !== 'issue' || row.status === 'merged')
331
+ throw new DeliveryError('PR publication requires an active issue worktree.');
332
+ const run = loadVerifiedRun(row.path, input.issueNumber);
333
+ const head = coordinate(row.path);
334
+ if (input.draft === false) {
335
+ const publication = loadPublication(row.path, input.issueNumber, head.sha);
336
+ const receipt = loadSubmittedReview(row.path, input.issueNumber, head.sha);
337
+ if (!receipt || receipt.publicationEvidenceId !== publication.evidenceId) {
338
+ throw new DeliveryError('Ready promotion requires a submitted review of the exact published head.');
339
+ }
340
+ await assertSubmittedReviewCurrent(context, receipt);
341
+ const pr = (await context.clients.rest.pulls.get({ ...context.repo, pull_number: publication.prNumber })).data;
342
+ if (pr.head.sha !== head.sha)
343
+ throw new DeliveryError('PR head drifted before ready promotion.');
344
+ if (pr.draft) {
345
+ await context.clients.graphql(`mutation Ready($id: ID!) { markPullRequestReadyForReview(input: {pullRequestId: $id}) { pullRequest { id isDraft } } }`, { id: pr.node_id });
346
+ }
347
+ const readback = (await context.clients.rest.pulls.get({ ...context.repo, pull_number: publication.prNumber }))
348
+ .data;
349
+ if (readback.draft || readback.head.sha !== head.sha)
350
+ throw new DeliveryError('PR ready readback failed.');
351
+ return { prNumber: publication.prNumber, url: readback.html_url, publicationEvidenceId: publication.evidenceId };
352
+ }
353
+ const prepublication = loadPrepublicationArtifact(row.path, input.issueNumber, head.sha);
354
+ const approval = prepublication
355
+ ? reviewArtifactApproval({
356
+ artifact: prepublication,
357
+ classification: run.classification,
358
+ config: context.config,
359
+ issueNumber: input.issueNumber,
360
+ })
361
+ : undefined;
362
+ const evidence = await createIssuePhaseEvidence({
363
+ ...(approval === undefined ? {} : { approval }),
364
+ issueNumber: input.issueNumber,
365
+ phase: 'publish',
366
+ repoRoot: row.path,
367
+ });
368
+ const issue = (await context.clients.rest.issues.get({ ...context.repo, issue_number: input.issueNumber })).data;
369
+ const title = input.title ?? `${input.issueNumber}: ${issue.title}`;
370
+ const body = input.body ?? `Closes #${input.issueNumber}\n\n## Delivery Impact\n\nRoadmap impact: none\nDocs impact: none\n`;
371
+ validateDeliveryImpact(body);
372
+ const remoteHead = (await context.clients.rest.git.getRef({ ...context.repo, ref: `heads/${baseBranch(root)}` })).data
373
+ .object.sha;
374
+ if (remoteHead !== run.classification.base.sha)
375
+ throw new DeliveryError('Remote base changed after exact-head verification.');
376
+ await pushReviewedHead(context, row.path, row.branch, head.sha);
377
+ const matches = (await context.clients.rest.pulls.list({
378
+ ...context.repo,
379
+ head: `${context.repo.owner}:${row.branch}`,
380
+ state: 'open',
381
+ per_page: 100,
382
+ })).data;
383
+ if (matches.length > 1)
384
+ throw new DeliveryError('Multiple open PRs match the registered branch.');
385
+ const existing = matches[0];
386
+ const pr = existing
387
+ ? (await context.clients.rest.pulls.get({ ...context.repo, pull_number: existing.number })).data
388
+ : (await context.clients.rest.pulls.create({
389
+ ...context.repo,
390
+ base: baseBranch(root),
391
+ body,
392
+ draft: true,
393
+ head: row.branch,
394
+ title,
395
+ })).data;
396
+ if (pr.head.sha !== head.sha || pr.base.sha !== remoteHead || pr.state !== 'open' || !pr.draft) {
397
+ throw new DeliveryError('Draft PR readback does not match the verified head and base.');
398
+ }
399
+ const content = {
400
+ baseSha: remoteHead,
401
+ evidenceId: evidence.evidenceId,
402
+ headSha: head.sha,
403
+ issueNumber: input.issueNumber,
404
+ prNumber: pr.number,
405
+ schemaVersion: 'ai-delivery.publication@1',
406
+ };
407
+ const publication = PublicationSchema.parse({ ...content, publicationId: digestValue(content) });
408
+ writePrivateJsonFileAtomically(publicationPath(row.path, input.issueNumber, head.sha), publication);
409
+ await updateIssueWorktreeDelivery({
410
+ branch: row.branch,
411
+ issueNumber: input.issueNumber,
412
+ path: row.path,
413
+ prNumber: pr.number,
414
+ projectRoot: root,
415
+ status: 'pr-published',
416
+ });
417
+ return { prNumber: pr.number, url: pr.html_url, publicationEvidenceId: evidence.evidenceId };
418
+ }
419
+ export async function submitFormalReview(context, input) {
420
+ const row = getIssueWorktreeStrict(input.issueNumber, context.root);
421
+ const run = loadVerifiedRun(row.path, input.issueNumber);
422
+ const publication = loadPublication(row.path, input.issueNumber, run.classification.head.sha);
423
+ if (publication.prNumber !== input.prNumber)
424
+ throw new DeliveryError('Review PR does not match exact publication.');
425
+ const receipt = await submitReview({
426
+ artifact: parseReviewArtifact(input.artifact),
427
+ classification: run.classification,
428
+ context,
429
+ issueNumber: input.issueNumber,
430
+ prNumber: input.prNumber,
431
+ publicationEvidenceId: publication.evidenceId,
432
+ });
433
+ return { receiptId: receipt.receiptId, githubReviewId: receipt.githubReviewId };
434
+ }
435
+ async function checkPrMergeability(context, prNumber, expectedHead) {
436
+ const pr = (await context.clients.rest.pulls.get({ ...context.repo, pull_number: prNumber })).data;
437
+ if (pr.state !== 'open' ||
438
+ pr.draft ||
439
+ pr.head.sha !== expectedHead ||
440
+ pr.mergeable !== true ||
441
+ pr.mergeable_state !== 'clean')
442
+ throw new DeliveryError('PR is not clean, ready and mergeable at the exact reviewed head.');
443
+ const issueNumber = Number(/^issue\/(\d+)$/u.exec(pr.head.ref)?.[1]);
444
+ if (!Number.isSafeInteger(issueNumber))
445
+ throw new DeliveryError('PR branch is not a registered issue branch.');
446
+ const relationships = await getNativeBlockerRelationships({
447
+ graphql: context.clients.graphql,
448
+ issueNumber,
449
+ repo: context.repo,
450
+ });
451
+ const blockedBy = relationships.blockers.filter((value) => value.state === 'OPEN').map((value) => value.number);
452
+ const [checks, statuses] = await Promise.all([
453
+ context.clients.rest.checks.listForRef({ ...context.repo, ref: expectedHead, per_page: 100 }),
454
+ context.clients.rest.repos.getCombinedStatusForRef({ ...context.repo, ref: expectedHead }),
455
+ ]);
456
+ const checksPassed = checks.data.total_count === checks.data.check_runs.length &&
457
+ checks.data.check_runs.every((check) => check.status === 'completed' && ['success', 'neutral', 'skipped'].includes(check.conclusion ?? '')) &&
458
+ (statuses.data.statuses.length === 0 || statuses.data.state === 'success');
459
+ if (!checksPassed || blockedBy.length > 0)
460
+ throw new DeliveryError('PR has failed or pending checks or unresolved native blockers.');
461
+ return {
462
+ baseSha: pr.base.sha,
463
+ baseBranch: pr.base.ref,
464
+ headBranch: pr.head.ref,
465
+ title: pr.title,
466
+ blockedBy,
467
+ checksPassed,
468
+ };
469
+ }
470
+ /** @internal Lifecycle primitive; exported for local interruption tests. */
471
+ export async function mergeWithExactLease(context, input) {
472
+ if (gitExitCode(input.worktreePath, 'merge-base', '--is-ancestor', input.baseSha, input.headSha) !== 0) {
473
+ throw new DeliveryError('Exact-lease merge requires the verified base to be an ancestor of the reviewed head.');
474
+ }
475
+ const latest = (await context.clients.rest.pulls.get({ ...context.repo, pull_number: input.prNumber })).data;
476
+ if (latest.base.sha !== input.baseSha ||
477
+ latest.head.sha !== input.headSha ||
478
+ latest.base.ref !== input.baseBranch ||
479
+ latest.head.ref !== input.headBranch)
480
+ throw new DeliveryError('Exact base or head drifted before atomic merge.');
481
+ const repo = await context.clients.graphql('query RepositoryId($owner: String!, $name: String!) { repository(owner: $owner, name: $name) { id } }', { owner: context.repo.owner, name: context.repo.repo });
482
+ if (!repo.repository?.id)
483
+ throw new DeliveryError('GitHub omitted repository node identity.');
484
+ input.beforeMutation?.();
485
+ const created = (await context.clients.rest.git.createCommit({
486
+ ...context.repo,
487
+ message: input.title,
488
+ parents: [input.baseSha, input.headSha],
489
+ tree: input.headTree,
490
+ })).data;
491
+ const mergeSha = Sha.parse(created.sha);
492
+ input.onPreparedMergeCommit?.(mergeSha);
493
+ const clientMutationId = `ai-delivery-exact-${mergeSha}`;
494
+ const response = await context.clients.graphql('mutation UpdateExactRefs($input: UpdateRefsInput!) { updateRefs(input: $input) { clientMutationId } }', {
495
+ input: {
496
+ clientMutationId,
497
+ repositoryId: repo.repository.id,
498
+ refUpdates: [
499
+ { name: `refs/heads/${input.baseBranch}`, beforeOid: input.baseSha, afterOid: mergeSha, force: false },
500
+ { name: `refs/heads/${input.headBranch}`, beforeOid: input.headSha, afterOid: input.headSha, force: false },
501
+ ],
502
+ },
503
+ });
504
+ if (response.updateRefs.clientMutationId !== clientMutationId)
505
+ throw new DeliveryError('Atomic merge mutation identity readback failed.');
506
+ const [base, head, commit] = await Promise.all([
507
+ context.clients.rest.git.getRef({ ...context.repo, ref: `heads/${input.baseBranch}` }),
508
+ context.clients.rest.git.getRef({ ...context.repo, ref: `heads/${input.headBranch}` }),
509
+ context.clients.rest.git.getCommit({ ...context.repo, commit_sha: mergeSha }),
510
+ ]);
511
+ if (base.data.object.sha !== mergeSha ||
512
+ head.data.object.sha !== input.headSha ||
513
+ commit.data.tree.sha !== input.headTree ||
514
+ commit.data.parents[0]?.sha !== input.baseSha ||
515
+ commit.data.parents[1]?.sha !== input.headSha)
516
+ throw new DeliveryError('Atomic merge ref or commit readback failed.');
517
+ const pr = (await context.clients.rest.pulls.get({ ...context.repo, pull_number: input.prNumber })).data;
518
+ if (pr.state !== 'closed' || !pr.merged_at || pr.merge_commit_sha !== mergeSha) {
519
+ throw new DeliveryError('GitHub did not report the exact merge commit as the completed PR.');
520
+ }
521
+ return mergeSha;
522
+ }
523
+ async function readMergedResult(context, intent, expectedSha) {
524
+ const pr = (await context.clients.rest.pulls.get({ ...context.repo, pull_number: intent.prNumber })).data;
525
+ if (pr.state !== 'closed' ||
526
+ !pr.merged_at ||
527
+ pr.head.sha !== intent.headSha ||
528
+ pr.head.ref !== intent.headBranch ||
529
+ pr.base.ref !== intent.baseBranch) {
530
+ throw new DeliveryError('Merged PR does not match the exact prepared merge intent.');
531
+ }
532
+ const mergeSha = Sha.parse(pr.merge_commit_sha);
533
+ if (expectedSha !== undefined && mergeSha !== expectedSha) {
534
+ throw new DeliveryError('Merged PR does not match the exact prepared merge intent.');
535
+ }
536
+ const [base, head, commit] = await Promise.all([
537
+ context.clients.rest.git.getRef({ ...context.repo, ref: `heads/${intent.baseBranch}` }),
538
+ context.clients.rest.git.getRef({ ...context.repo, ref: `heads/${intent.headBranch}` }).catch((error) => {
539
+ if (error.status === 404)
540
+ return null;
541
+ throw error;
542
+ }),
543
+ context.clients.rest.git.getCommit({ ...context.repo, commit_sha: mergeSha }),
544
+ ]);
545
+ const expectedMergeTree = !intent.exactBaseHeadLease && (intent.strategy === 'merge' || intent.strategy === 'squash')
546
+ ? git(context.root, 'merge-tree', '--write-tree', intent.baseSha, intent.headSha)
547
+ : intent.headTree;
548
+ if (!base.data.object.sha ||
549
+ (head !== null && head.data.object.sha !== intent.headSha) ||
550
+ commit.data.sha !== mergeSha ||
551
+ (intent.strategy === 'merge' &&
552
+ (commit.data.parents[0]?.sha !== intent.baseSha ||
553
+ commit.data.parents[1]?.sha !== intent.headSha ||
554
+ commit.data.tree.sha !== expectedMergeTree)) ||
555
+ (intent.strategy === 'squash' &&
556
+ (commit.data.parents[0]?.sha !== intent.baseSha || commit.data.tree.sha !== expectedMergeTree))) {
557
+ throw new DeliveryError('Remote merge ref, branch or commit readback disagrees with the prepared intent.');
558
+ }
559
+ if (expectedSha === undefined && intent.strategy === 'rebase') {
560
+ throw new DeliveryError('Rebase recovery lacks the returned exact merge SHA.');
561
+ }
562
+ return { mergeSha, mergeTree: Sha.parse(commit.data.tree.sha) };
563
+ }
564
+ async function expectedMergeAttempt(context, intent) {
565
+ if (context.clients.role !== 'author' || !context.clients.authenticatedAuthor) {
566
+ throw new DeliveryError('Merge recovery requires authenticated author identity readback.');
567
+ }
568
+ const verified = await context.clients.authenticatedAuthor();
569
+ if (!verified.actorLogin || !verified.credentialIdentity) {
570
+ throw new DeliveryError('Authenticated author identity readback is incomplete.');
571
+ }
572
+ const content = {
573
+ actor: context.config.roles.author.identity,
574
+ actorLogin: verified.actorLogin,
575
+ authSource: context.clients.authSource,
576
+ credentialIdentity: verified.credentialIdentity,
577
+ intentId: intent.intentId,
578
+ operation: intent.exactBaseHeadLease ? 'updateRefs' : 'pulls.merge',
579
+ schemaVersion: 'ai-delivery.merge-attempt@1',
580
+ };
581
+ return MergeAttemptSchema.parse({ ...content, attemptId: digestValue(content) });
582
+ }
583
+ function persistMergeResult(root, intent, attempt, mergeSha) {
584
+ const content = {
585
+ attemptId: attempt.attemptId,
586
+ mergeSha: Sha.parse(mergeSha),
587
+ schemaVersion: 'ai-delivery.merge-result@1',
588
+ };
589
+ writePrivateJsonFileAtomically(mergeResultPath(root, intent.issueNumber, intent.headSha), MergeResultSchema.parse({ ...content, resultId: digestValue(content) }));
590
+ }
591
+ /** @internal Lifecycle primitive; exported for local interruption tests. */
592
+ export async function persistMergedResult(context, input) {
593
+ const { mergeSha, mergeTree } = await readMergedResult(context, input.intent, input.expectedSha);
594
+ const { schemaVersion: _version, ...intentContent } = input.intent;
595
+ const content = { ...intentContent, mergeSha, mergeTree, schemaVersion: 'ai-delivery.merge@3' };
596
+ const merged = MergeSchema.parse({ ...content, mergeId: digestValue(content) });
597
+ writePrivateJsonFileAtomically(input.path, merged);
598
+ await updateIssueWorktreeDelivery({
599
+ branch: input.branch,
600
+ issueNumber: input.intent.issueNumber,
601
+ path: input.worktreePath,
602
+ projectRoot: context.root,
603
+ status: 'merged',
604
+ });
605
+ return merged;
606
+ }
607
+ export async function mergePr(context, input) {
608
+ const row = getIssueWorktreeStrict(input.issueNumber, context.root);
609
+ const receiptRoot = existsSync(row.path) ? row.path : context.root;
610
+ const run = receiptRoot === row.path
611
+ ? loadVerifiedRun(row.path, input.issueNumber)
612
+ : loadRemovedMergedRun(context.root, input.issueNumber);
613
+ const head = run.classification.head;
614
+ const publication = loadPublication(receiptRoot, input.issueNumber, head.sha);
615
+ if (publication.prNumber !== input.prNumber)
616
+ throw new DeliveryError('Merge PR does not match publication receipt.');
617
+ const existingPath = mergePath(receiptRoot, input.issueNumber, head.sha);
618
+ const existing = readOptionalPrivate(existingPath, (value) => MergeSchema.parse(value));
619
+ if (existing) {
620
+ const pr = (await context.clients.rest.pulls.get({ ...context.repo, pull_number: input.prNumber })).data;
621
+ if (existing.prNumber !== input.prNumber ||
622
+ existing.issueNumber !== input.issueNumber ||
623
+ existing.headSha !== head.sha ||
624
+ pr.state !== 'closed' ||
625
+ !pr.merged_at ||
626
+ pr.head.sha !== head.sha ||
627
+ pr.head.ref !== row.branch ||
628
+ pr.merge_commit_sha !== existing.mergeSha) {
629
+ throw new DeliveryError('Merged PR readback disagrees with terminal receipt.');
630
+ }
631
+ const intent = readOptionalPrivate(mergeIntentPath(receiptRoot, input.issueNumber, head.sha), (value) => MergeIntentSchema.parse(value));
632
+ if (!intent || intent.intentId !== existing.intentId)
633
+ throw new DeliveryError('Terminal merge lacks its exact intent.');
634
+ const result = await readMergedResult(context, intent, existing.mergeSha);
635
+ if (result.mergeTree !== existing.mergeTree)
636
+ throw new DeliveryError('Terminal merge tree changed.');
637
+ const { schemaVersion: _version, ...intentContent } = intent;
638
+ const expected = { ...intentContent, ...result, schemaVersion: 'ai-delivery.merge@3' };
639
+ if (existing.mergeId !== digestValue(expected))
640
+ throw new DeliveryError('Terminal merge receipt disagrees with its intent.');
641
+ await updateIssueWorktreeDelivery({
642
+ branch: row.branch,
643
+ issueNumber: input.issueNumber,
644
+ path: row.path,
645
+ projectRoot: context.root,
646
+ status: 'merged',
647
+ });
648
+ return existing;
649
+ }
650
+ if (receiptRoot !== row.path) {
651
+ throw new DeliveryError('Removed merged worktree lacks its terminal merge receipt.');
652
+ }
653
+ const intentPath = mergeIntentPath(row.path, input.issueNumber, head.sha);
654
+ const prepared = readOptionalPrivate(intentPath, (value) => MergeIntentSchema.parse(value));
655
+ const attemptPath = mergeAttemptPath(row.path, input.issueNumber, head.sha);
656
+ const priorAttempt = readOptionalPrivate(attemptPath, (value) => MergeAttemptSchema.parse(value));
657
+ const priorResult = readOptionalPrivate(mergeResultPath(row.path, input.issueNumber, head.sha), (value) => MergeResultSchema.parse(value));
658
+ if ((priorAttempt !== null || priorResult !== null) && prepared === null) {
659
+ throw new DeliveryError('Merge operation record lacks its prepared intent.');
660
+ }
661
+ const review = loadSubmittedReview(row.path, input.issueNumber, head.sha);
662
+ if (!review || review.prNumber !== input.prNumber || review.publicationEvidenceId !== publication.evidenceId) {
663
+ throw new DeliveryError('Exact publication lacks its bound submitted independent review.');
664
+ }
665
+ await assertSubmittedReviewCurrent(context, review);
666
+ if (prepared) {
667
+ if (prepared.issueNumber !== input.issueNumber ||
668
+ prepared.prNumber !== input.prNumber ||
669
+ prepared.headSha !== head.sha ||
670
+ prepared.headTree !== head.tree ||
671
+ prepared.reviewReceiptId !== review.receiptId ||
672
+ prepared.headBranch !== row.branch ||
673
+ prepared.baseSha !== run.classification.base.sha ||
674
+ (input.strategy !== undefined && input.strategy !== prepared.strategy)) {
675
+ throw new DeliveryError('Prepared merge intent disagrees with this retry.');
676
+ }
677
+ if (priorAttempt !== null) {
678
+ const expected = await expectedMergeAttempt(context, prepared);
679
+ if (priorAttempt.attemptId !== expected.attemptId ||
680
+ (priorResult !== null && priorResult.attemptId !== priorAttempt.attemptId)) {
681
+ throw new DeliveryError('Merge attempt disagrees with its exact source, operation or author.');
682
+ }
683
+ const remote = (await context.clients.rest.pulls.get({ ...context.repo, pull_number: input.prNumber })).data;
684
+ if (remote.state !== 'closed' || !remote.merged_at) {
685
+ throw new DeliveryError('Merge mutation was attempted; remote readback is unresolved. Retry readback without another mutation.');
686
+ }
687
+ return persistMergedResult(context, {
688
+ branch: row.branch,
689
+ intent: prepared,
690
+ path: existingPath,
691
+ worktreePath: row.path,
692
+ ...(priorResult === null ? {} : { expectedSha: priorResult.mergeSha }),
693
+ });
694
+ }
695
+ if (priorResult !== null)
696
+ throw new DeliveryError('Merge result lacks its bound attempt.');
697
+ const remote = (await context.clients.rest.pulls.get({ ...context.repo, pull_number: input.prNumber })).data;
698
+ if (remote.state === 'closed' && remote.merged_at) {
699
+ return persistMergedResult(context, {
700
+ branch: row.branch,
701
+ intent: prepared,
702
+ path: existingPath,
703
+ worktreePath: row.path,
704
+ });
705
+ }
706
+ if (remote.state !== 'open' ||
707
+ remote.head.sha !== head.sha ||
708
+ remote.head.ref !== row.branch ||
709
+ remote.base.ref !== prepared.baseBranch ||
710
+ remote.base.sha !== prepared.baseSha) {
711
+ throw new DeliveryError('Prepared merge retry disagrees with the remote PR.');
712
+ }
713
+ }
714
+ const currentPublication = await createIssuePhaseEvidence({
715
+ ...(run.classification.risk === 'high'
716
+ ? {
717
+ approval: reviewArtifactApproval({
718
+ artifact: review.artifact,
719
+ classification: run.classification,
720
+ config: context.config,
721
+ issueNumber: input.issueNumber,
722
+ prNumber: input.prNumber,
723
+ }),
724
+ }
725
+ : {}),
726
+ issueNumber: input.issueNumber,
727
+ phase: 'publish',
728
+ repoRoot: row.path,
729
+ });
730
+ if (currentPublication.evidenceId !== publication.evidenceId) {
731
+ throw new DeliveryError('Publication evidence changed after formal review.');
732
+ }
733
+ const readback = await checkPrMergeability(context, input.prNumber, head.sha);
734
+ if (readback.baseSha !== run.classification.base.sha || readback.headBranch !== row.branch) {
735
+ throw new DeliveryError('PR base or registered head drifted after verification.');
736
+ }
737
+ const approval = {
738
+ authorIdentity: context.config.roles.author.identity,
739
+ reviewerIdentity: context.config.roles.reviewer.identity,
740
+ diffScopeHash: review.artifact.diffScopeHash,
741
+ head,
742
+ result: 'approved',
743
+ reviewReceiptId: review.receiptId,
744
+ };
745
+ const evidence = await createIssuePhaseEvidence({
746
+ approval,
747
+ issueNumber: input.issueNumber,
748
+ mergeReadback: {
749
+ blockedBy: readback.blockedBy,
750
+ checksPassed: readback.checksPassed,
751
+ head,
752
+ mergeable: true,
753
+ observedAt: new Date().toISOString(),
754
+ },
755
+ phase: 'merge',
756
+ repoRoot: row.path,
757
+ });
758
+ const loaded = loadDeliveryConfig(row.path);
759
+ const selected = await loadSelectedRepositoryPolicy({
760
+ repoRoot: row.path,
761
+ policySourcePath: loaded.config.policy.module,
762
+ });
763
+ const boundary = validateRepositoryPolicyBoundary({
764
+ classification: run.classification,
765
+ configDigest: loaded.configDigest,
766
+ currentBase: run.classification.base,
767
+ currentHead: head,
768
+ phase: 'merge',
769
+ policy: selected.policy,
770
+ policySourcePath: loaded.config.policy.module,
771
+ repoRoot: row.path,
772
+ stageReceiptIds: run.aggregate.stages.map((stage) => ({ stageId: stage.stageId, receiptId: stage.receiptId })),
773
+ });
774
+ if (boundary.additionalConstraints.exactBaseHeadLease && input.strategy && input.strategy !== 'merge') {
775
+ throw new DeliveryError('Exact base/head lease requires merge-commit strategy.');
776
+ }
777
+ const strategy = boundary.additionalConstraints.exactBaseHeadLease
778
+ ? 'merge'
779
+ : (input.strategy ?? prepared?.strategy ?? 'squash');
780
+ const intentContent = {
781
+ baseBranch: readback.baseBranch,
782
+ baseSha: readback.baseSha,
783
+ exactBaseHeadLease: boundary.additionalConstraints.exactBaseHeadLease,
784
+ evidenceId: prepared?.evidenceId ?? evidence.evidenceId,
785
+ headBranch: readback.headBranch,
786
+ headSha: head.sha,
787
+ headTree: head.tree,
788
+ issueNumber: input.issueNumber,
789
+ prNumber: input.prNumber,
790
+ reviewReceiptId: review.receiptId,
791
+ schemaVersion: 'ai-delivery.merge-intent@2',
792
+ strategy,
793
+ };
794
+ const intent = MergeIntentSchema.parse({ ...intentContent, intentId: digestValue(intentContent) });
795
+ if (prepared && prepared.intentId !== intent.intentId) {
796
+ throw new DeliveryError('Prepared merge intent no longer matches reviewed inputs.');
797
+ }
798
+ if (!prepared)
799
+ writePrivateJsonFileAtomically(intentPath, intent);
800
+ const attempt = await expectedMergeAttempt(context, intent);
801
+ let mergeSha;
802
+ if (boundary.additionalConstraints.exactBaseHeadLease) {
803
+ mergeSha = await mergeWithExactLease(context, {
804
+ baseBranch: readback.baseBranch,
805
+ baseSha: readback.baseSha,
806
+ headBranch: readback.headBranch,
807
+ headSha: head.sha,
808
+ headTree: head.tree,
809
+ title: readback.title,
810
+ prNumber: input.prNumber,
811
+ worktreePath: row.path,
812
+ beforeMutation: () => writePrivateJsonFileAtomically(attemptPath, attempt),
813
+ onPreparedMergeCommit: (sha) => persistMergeResult(row.path, intent, attempt, sha),
814
+ });
815
+ }
816
+ else {
817
+ writePrivateJsonFileAtomically(attemptPath, attempt);
818
+ mergeSha = (await context.clients.rest.pulls.merge({
819
+ ...context.repo,
820
+ pull_number: input.prNumber,
821
+ sha: head.sha,
822
+ merge_method: strategy,
823
+ })).data.sha;
824
+ }
825
+ persistMergeResult(row.path, intent, attempt, mergeSha);
826
+ return persistMergedResult(context, {
827
+ branch: row.branch,
828
+ intent,
829
+ path: existingPath,
830
+ worktreePath: row.path,
831
+ expectedSha: Sha.parse(mergeSha),
832
+ });
833
+ }
834
+ export async function finishIssue(context, input) {
835
+ const matches = listWorktreesStrict(context.root).filter((entry) => entry.type === 'issue' && entry.issueNumber === input.issueNumber);
836
+ if (matches.length === 0)
837
+ return readCompletedIssue(context, input);
838
+ if (matches.length !== 1)
839
+ throw new DeliveryError('Issue has duplicate worktree registry rows.');
840
+ const row = getIssueWorktreeStrict(input.issueNumber, context.root);
841
+ const metadata = await getConfiguredNativeIssueMetadata({
842
+ issueNumber: input.issueNumber,
843
+ owner: context.repo.owner,
844
+ repo: context.repo.repo,
845
+ rest: context.clients.rest,
846
+ settings: nativeIssueSettingsFromDeliveryConfig(context.config),
847
+ });
848
+ if (metadata.points === undefined || !context.config.native.points.values.includes(String(metadata.points))) {
849
+ throw new DeliveryError('Executable issue requires configured native Points before finish.');
850
+ }
851
+ const points = metadata.points;
852
+ const merge = await mergePr(context, input);
853
+ const review = loadSubmittedReview(existsSync(row.path) ? row.path : context.root, input.issueNumber, merge.headSha);
854
+ if (!review)
855
+ throw new DeliveryError('Terminal delivery requires the submitted review receipt.');
856
+ const issue = (await context.clients.rest.issues.get({ ...context.repo, issue_number: input.issueNumber })).data;
857
+ if (issue.state !== 'closed')
858
+ await context.clients.rest.issues.update({
859
+ ...context.repo,
860
+ issue_number: input.issueNumber,
861
+ state: 'closed',
862
+ });
863
+ const closed = (await context.clients.rest.issues.get({ ...context.repo, issue_number: input.issueNumber })).data;
864
+ if (closed.state !== 'closed')
865
+ throw new DeliveryError('Issue close readback failed.');
866
+ await syncIssueProjectStatus({
867
+ graphql: context.clients.graphql,
868
+ issueNodeId: closed.node_id,
869
+ org: context.config.native.organization,
870
+ settings: projectSettingsFromDeliveryConfig(context.config),
871
+ status: 'Done',
872
+ });
873
+ const branch = baseBranch(context.root);
874
+ await fetchMergedBase(context, branch);
875
+ const pr = (await context.clients.rest.pulls.get({ ...context.repo, pull_number: input.prNumber })).data;
876
+ const [remoteBase, remoteHead] = await Promise.all([
877
+ context.clients.rest.git.getRef({ ...context.repo, ref: `heads/${branch}` }),
878
+ context.clients.rest.git.getRef({ ...context.repo, ref: `heads/issue/${input.issueNumber}` }).catch((error) => {
879
+ if (error.status === 404)
880
+ return null;
881
+ throw error;
882
+ }),
883
+ ]);
884
+ if (pr.state !== 'closed' ||
885
+ !pr.merged_at ||
886
+ pr.merge_commit_sha !== merge.mergeSha ||
887
+ pr.head.sha !== merge.headSha ||
888
+ pr.head.ref !== `issue/${input.issueNumber}` ||
889
+ pr.base.ref !== branch)
890
+ throw new DeliveryError('Merged PR drifted before cleanup.');
891
+ const attestation = {
892
+ baseBranch: merge.baseBranch,
893
+ baseSha: merge.baseSha,
894
+ headBranch: merge.headBranch,
895
+ headSha: merge.headSha,
896
+ mergeSha: merge.mergeSha,
897
+ mergeTree: merge.mergeTree,
898
+ remoteBaseSha: remoteBase.data.object.sha,
899
+ remoteHeadSha: remoteHead?.data.object.sha ?? null,
900
+ strategy: merge.strategy,
901
+ };
902
+ const mergedAt = pr.merged_at;
903
+ if (typeof mergedAt !== 'string' || Date.parse(mergedAt) < Date.parse(row.createdAt)) {
904
+ throw new DeliveryError('Terminal delivery timeline is invalid.');
905
+ }
906
+ await cleanupMergedIssueWorktree({
907
+ issueNumber: input.issueNumber,
908
+ repoRoot: context.root,
909
+ merge: attestation,
910
+ afterRemoval: async () => {
911
+ const terminalCleanupAt = new Date().toISOString();
912
+ const metrics = review.artifact.deliveryMetrics;
913
+ await recordMergedDelivery({
914
+ blockerTimeMs: metrics?.blockerTimeMs ?? null,
915
+ cycleTimeMs: Date.parse(mergedAt) - Date.parse(row.createdAt),
916
+ firstPassApproved: metrics === undefined ? null : metrics.reviewRound === 1,
917
+ issueNumber: input.issueNumber,
918
+ mergedAt,
919
+ mergeSha: merge.mergeSha,
920
+ points,
921
+ recordedAt: terminalCleanupAt,
922
+ repository: `${context.repo.owner}/${context.repo.repo}`,
923
+ reviewRounds: metrics?.reviewRound ?? null,
924
+ schemaVersion: 'ai-delivery.delivery-record@1',
925
+ terminalCleanupAt,
926
+ }, context.root);
927
+ },
928
+ });
929
+ return { mergeSha: merge.mergeSha, issueClosed: true, cleaned: true };
930
+ }
931
+ async function readCompletedIssue(context, input) {
932
+ const pr = (await context.clients.rest.pulls.get({ ...context.repo, pull_number: input.prNumber })).data;
933
+ if (pr.state !== 'closed' || !pr.merged_at || !pr.merge_commit_sha || pr.head.ref !== `issue/${input.issueNumber}`) {
934
+ throw new DeliveryError('Absent issue worktree lacks an exact completed PR.');
935
+ }
936
+ const receipt = readOptionalPrivate(mergePath(context.root, input.issueNumber, pr.head.sha), (value) => MergeSchema.parse(value));
937
+ const intent = readOptionalPrivate(mergeIntentPath(context.root, input.issueNumber, pr.head.sha), (value) => MergeIntentSchema.parse(value));
938
+ if (!receipt ||
939
+ !intent ||
940
+ receipt.issueNumber !== input.issueNumber ||
941
+ receipt.prNumber !== input.prNumber ||
942
+ receipt.headSha !== pr.head.sha ||
943
+ receipt.mergeSha !== pr.merge_commit_sha ||
944
+ receipt.intentId !== intent.intentId) {
945
+ throw new DeliveryError('Absent issue worktree lacks exact terminal merge receipts.');
946
+ }
947
+ const result = await readMergedResult(context, intent, receipt.mergeSha);
948
+ const { schemaVersion: _version, ...intentContent } = intent;
949
+ if (result.mergeTree !== receipt.mergeTree ||
950
+ receipt.mergeId !== digestValue({ ...intentContent, ...result, schemaVersion: 'ai-delivery.merge@3' })) {
951
+ throw new DeliveryError('Completed merge receipt disagrees with its intent or remote result.');
952
+ }
953
+ const records = getDeliveryRecords(context.root).filter((record) => record.repository === `${context.repo.owner}/${context.repo.repo}` &&
954
+ record.issueNumber === input.issueNumber &&
955
+ record.mergeSha === receipt.mergeSha);
956
+ if (records.length !== 1)
957
+ throw new DeliveryError('Completed issue lacks one exact terminal delivery record.');
958
+ const issue = (await context.clients.rest.issues.get({ ...context.repo, issue_number: input.issueNumber })).data;
959
+ if (issue.state !== 'closed' || 'pull_request' in issue) {
960
+ throw new DeliveryError('Completed issue is not closed.');
961
+ }
962
+ const project = await getIssueProjectStatus({
963
+ graphql: context.clients.graphql,
964
+ issueNodeId: issue.node_id,
965
+ org: context.config.native.organization,
966
+ settings: projectSettingsFromDeliveryConfig(context.config),
967
+ });
968
+ if (project?.status !== 'Done')
969
+ throw new DeliveryError('Completed issue Project status is not Done.');
970
+ return { mergeSha: receipt.mergeSha, issueClosed: true, cleaned: true };
971
+ }
972
+ //# sourceMappingURL=pr.js.map