@aviaratech/ai-delivery 0.3.22 → 0.3.24
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/CONTRIBUTING.md +9 -0
- package/README.md +77 -442
- package/dist/agent.d.ts +5 -11
- package/dist/agent.js +4 -8
- package/dist/agent.js.map +1 -1
- package/dist/cli.js +59 -107
- package/dist/cli.js.map +1 -1
- package/dist/config/deliveryConfig.d.ts +44 -55
- package/dist/config/deliveryConfig.js +85 -88
- package/dist/config/deliveryConfig.js.map +1 -1
- package/dist/config/deliveryConfig.test.js +13 -5
- package/dist/config/deliveryConfig.test.js.map +1 -1
- package/dist/delivery/common.d.ts +4 -1
- package/dist/delivery/common.js +56 -8
- package/dist/delivery/common.js.map +1 -1
- package/dist/delivery/delivery.test.js +2 -3
- package/dist/delivery/delivery.test.js.map +1 -1
- package/dist/delivery/index.d.ts +0 -6
- package/dist/delivery/index.js +0 -3
- package/dist/delivery/index.js.map +1 -1
- package/dist/delivery/legacy.d.ts +8 -0
- package/dist/delivery/legacy.js +5 -0
- package/dist/delivery/legacy.js.map +1 -0
- package/dist/delivery/policy.js +2 -13
- package/dist/delivery/policy.js.map +1 -1
- package/dist/delivery/stage.d.ts +6 -0
- package/dist/delivery/stage.js +11 -2
- package/dist/delivery/stage.js.map +1 -1
- package/dist/directoryIndependent.test.d.ts +1 -0
- package/dist/directoryIndependent.test.js +262 -0
- package/dist/directoryIndependent.test.js.map +1 -0
- package/dist/dispatch.d.ts +29 -3
- package/dist/dispatch.js +160 -457
- package/dist/dispatch.js.map +1 -1
- package/dist/genericCompatibility.test.js +0 -35
- package/dist/genericCompatibility.test.js.map +1 -1
- package/dist/git.js +10 -3
- package/dist/git.js.map +1 -1
- package/dist/gitProcess.d.ts +6 -0
- package/dist/gitProcess.js +56 -0
- package/dist/gitProcess.js.map +1 -0
- package/dist/github/discovery.d.ts +1 -0
- package/dist/github/discovery.js +1 -1
- package/dist/github/discovery.js.map +1 -1
- package/dist/github/discovery.test.js +25 -20
- package/dist/github/discovery.test.js.map +1 -1
- package/dist/github/nativeIssueMetadata.d.ts +2 -2
- package/dist/github/nativeIssueMetadata.js.map +1 -1
- package/dist/github/projectDelivery.d.ts +1 -1
- package/dist/github/projectDelivery.js.map +1 -1
- package/dist/github/repo.d.ts +7 -1
- package/dist/github/repo.js +58 -2
- package/dist/github/repo.js.map +1 -1
- package/dist/issue.d.ts +11 -6
- package/dist/issue.js +136 -319
- package/dist/issue.js.map +1 -1
- package/dist/issueJournal.d.ts +1 -1
- package/dist/lifecycle.test.js +40 -7371
- package/dist/lifecycle.test.js.map +1 -1
- package/dist/logger.js +4 -4
- package/dist/logger.js.map +1 -1
- package/dist/mcp/index.js +2 -1
- package/dist/mcp/index.js.map +1 -1
- package/dist/mcp/tools.d.ts +27 -42
- package/dist/mcp/tools.js +34 -42
- package/dist/mcp/tools.js.map +1 -1
- package/dist/pluginInstaller.d.ts +51 -0
- package/dist/pluginInstaller.js +774 -0
- package/dist/pluginInstaller.js.map +1 -0
- package/dist/pluginInstaller.test.d.ts +1 -0
- package/dist/pluginInstaller.test.js +543 -0
- package/dist/pluginInstaller.test.js.map +1 -0
- package/dist/pluginPackaging.test.d.ts +1 -0
- package/dist/pluginPackaging.test.js +245 -0
- package/dist/pluginPackaging.test.js.map +1 -0
- package/dist/pr.d.ts +66 -114
- package/dist/pr.js +288 -1108
- package/dist/pr.js.map +1 -1
- package/dist/pr.test.js +295 -237
- package/dist/pr.test.js.map +1 -1
- package/dist/releaseReconciliation.test.d.ts +1 -0
- package/dist/releaseReconciliation.test.js +314 -0
- package/dist/releaseReconciliation.test.js.map +1 -0
- package/dist/review.d.ts +37 -7
- package/dist/review.js +106 -45
- package/dist/review.js.map +1 -1
- package/dist/review.test.js +30 -71
- package/dist/review.test.js.map +1 -1
- package/dist/services/agentReadinessService.d.ts +1 -1
- package/dist/services/agentReadinessService.js +2 -0
- package/dist/services/agentReadinessService.js.map +1 -1
- package/dist/services/deliveryAdmission.d.ts +4 -4
- package/dist/services/deliveryAdmission.js.map +1 -1
- package/dist/setup.d.ts +1 -0
- package/dist/setup.js +71 -10
- package/dist/setup.js.map +1 -1
- package/dist/setup.test.js +594 -204
- package/dist/setup.test.js.map +1 -1
- package/dist/start.test.d.ts +1 -0
- package/dist/start.test.js +72 -0
- package/dist/start.test.js.map +1 -0
- package/dist/stdoutRegression.test.d.ts +1 -0
- package/dist/stdoutRegression.test.js +326 -0
- package/dist/stdoutRegression.test.js.map +1 -0
- package/dist/verification.d.ts +62 -27
- package/dist/verification.js +172 -1337
- package/dist/verification.js.map +1 -1
- package/dist/worktree.d.ts +1 -31
- package/dist/worktree.js +5 -200
- package/dist/worktree.js.map +1 -1
- package/dist/worktreeTransition.d.ts +1 -1
- package/dist/worktreeTransition.js +10 -5
- package/dist/worktreeTransition.js.map +1 -1
- package/dist/worktreeTransition.test.js +2 -3
- package/dist/worktreeTransition.test.js.map +1 -1
- package/package.json +4 -4
- package/plugins/ai-delivery/.claude-plugin/plugin.json +6 -1
- package/plugins/ai-delivery/LICENSE +21 -0
- package/plugins/ai-delivery/README.md +4 -2
- package/plugins/ai-delivery/dist/mcp-launcher.js +19 -8
- package/plugins/ai-delivery/mcp.json +10 -0
- package/plugins/ai-delivery/plugin.json +10 -0
- package/plugins/ai-delivery/runtime/dist/THIRD-PARTY-NOTICES.md +1010 -0
- package/plugins/ai-delivery/runtime/dist/cli.js +276 -0
- package/plugins/ai-delivery/runtime/package.json +13 -0
- package/plugins/ai-delivery/skills/intake-create/SKILL.md +1 -1
- package/plugins/ai-delivery/skills/pr-handoff/SKILL.md +14 -21
- package/plugins/ai-delivery/skills/worktree-lifecycle/SKILL.md +12 -23
- package/dist/services/deliveryRecordService.d.ts +0 -47
- package/dist/services/deliveryRecordService.js +0 -199
- package/dist/services/deliveryRecordService.js.map +0 -1
package/dist/pr.js
CHANGED
|
@@ -1,265 +1,106 @@
|
|
|
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, digestBytes } from './delivery/common.js';
|
|
8
|
-
import { digestValue, loadSelectedRepositoryPolicy, validateRepositoryPolicyBoundary, } from './delivery/index.js';
|
|
9
1
|
import { DeliveryError } from './errors.js';
|
|
10
|
-
import {
|
|
11
|
-
import { resolveGitRemoteName } from './github/repo.js';
|
|
12
|
-
import { createDeliveryGitHubClients, withAuthorGitToken } from './github/client.js';
|
|
13
|
-
import { getConfiguredNativeIssueMetadata, nativeIssueSettingsFromDeliveryConfig, } from './github/nativeIssueMetadata.js';
|
|
2
|
+
import { createDeliveryGitHubClients } from './github/client.js';
|
|
14
3
|
import { getNativeBlockerRelationships } from './github/relationships.js';
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
});
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
headTree: Sha,
|
|
53
|
-
intentId: Digest,
|
|
54
|
-
issueNumber: z.number().int().positive(),
|
|
55
|
-
prNumber: z.number().int().positive(),
|
|
56
|
-
reviewReceiptId: Digest,
|
|
57
|
-
schemaVersion: z.literal('ai-delivery.merge-intent@2'),
|
|
58
|
-
strategy: MergeStrategy,
|
|
59
|
-
})
|
|
60
|
-
.strict();
|
|
61
|
-
const MergeIntentSchema = MergeIntentShape.superRefine((value, ctx) => {
|
|
62
|
-
const { intentId, ...content } = value;
|
|
63
|
-
if (intentId !== digestValue(content))
|
|
64
|
-
ctx.addIssue({ code: 'custom', message: 'Merge intent identity is invalid.' });
|
|
65
|
-
});
|
|
66
|
-
const MergeAttemptSchema = z
|
|
67
|
-
.object({
|
|
68
|
-
actor: z.string().min(1),
|
|
69
|
-
actorLogin: z.string().min(1),
|
|
70
|
-
attemptId: Digest,
|
|
71
|
-
authSource: z.enum(['app', 'personal']),
|
|
72
|
-
credentialIdentity: z.string().min(1),
|
|
73
|
-
intentId: Digest,
|
|
74
|
-
operation: z.enum(['pulls.merge', 'updateRefs']),
|
|
75
|
-
schemaVersion: z.literal('ai-delivery.merge-attempt@1'),
|
|
76
|
-
})
|
|
77
|
-
.strict()
|
|
78
|
-
.superRefine((value, ctx) => {
|
|
79
|
-
const { attemptId, ...content } = value;
|
|
80
|
-
if (attemptId !== digestValue(content))
|
|
81
|
-
ctx.addIssue({ code: 'custom', message: 'Merge attempt identity is invalid.' });
|
|
82
|
-
});
|
|
83
|
-
const MergeResultSchema = z
|
|
84
|
-
.object({
|
|
85
|
-
attemptId: Digest,
|
|
86
|
-
mergeSha: Sha,
|
|
87
|
-
resultId: Digest,
|
|
88
|
-
schemaVersion: z.literal('ai-delivery.merge-result@1'),
|
|
89
|
-
})
|
|
90
|
-
.strict()
|
|
91
|
-
.superRefine((value, ctx) => {
|
|
92
|
-
const { resultId, ...content } = value;
|
|
93
|
-
if (resultId !== digestValue(content))
|
|
94
|
-
ctx.addIssue({ code: 'custom', message: 'Merge result identity is invalid.' });
|
|
95
|
-
});
|
|
96
|
-
const MergeSchema = MergeIntentShape.omit({ intentId: true, schemaVersion: true })
|
|
97
|
-
.extend({
|
|
98
|
-
intentId: Digest,
|
|
99
|
-
mergeId: Digest,
|
|
100
|
-
mergeSha: Sha,
|
|
101
|
-
mergeTree: Sha,
|
|
102
|
-
schemaVersion: z.literal('ai-delivery.merge@3'),
|
|
103
|
-
})
|
|
104
|
-
.strict()
|
|
105
|
-
.superRefine((value, ctx) => {
|
|
106
|
-
const { mergeId, ...content } = value;
|
|
107
|
-
if (mergeId !== digestValue(content))
|
|
108
|
-
ctx.addIssue({ code: 'custom', message: 'Merge identity is invalid.' });
|
|
109
|
-
});
|
|
110
|
-
function publicationPath(root, issueNumber, headSha) {
|
|
111
|
-
return join(gitCommonDir(root), 'ai-delivery', 'publications', String(issueNumber), `${headSha}.json`);
|
|
112
|
-
}
|
|
113
|
-
function mergePath(root, issueNumber, headSha) {
|
|
114
|
-
return join(gitCommonDir(root), 'ai-delivery', 'merges', String(issueNumber), `${headSha}.json`);
|
|
115
|
-
}
|
|
116
|
-
function mergeIntentPath(root, issueNumber, headSha) {
|
|
117
|
-
return join(gitCommonDir(root), 'ai-delivery', 'merge-intents', String(issueNumber), `${headSha}.json`);
|
|
118
|
-
}
|
|
119
|
-
function mergeAttemptPath(root, issueNumber, headSha) {
|
|
120
|
-
return join(gitCommonDir(root), 'ai-delivery', 'merge-attempts', String(issueNumber), `${headSha}.json`);
|
|
121
|
-
}
|
|
122
|
-
function mergeResultPath(root, issueNumber, headSha) {
|
|
123
|
-
return join(gitCommonDir(root), 'ai-delivery', 'merge-results', String(issueNumber), `${headSha}.json`);
|
|
124
|
-
}
|
|
125
|
-
function readOptionalPrivate(path, parse) {
|
|
126
|
-
let raw;
|
|
127
|
-
try {
|
|
128
|
-
raw = JSON.parse(assertPrivateFile(path).toString('utf8'));
|
|
129
|
-
}
|
|
130
|
-
catch (error) {
|
|
131
|
-
if (error.code === 'ENOENT')
|
|
132
|
-
return null;
|
|
133
|
-
throw new DeliveryError('Stored lifecycle receipt is unreadable.');
|
|
134
|
-
}
|
|
135
|
-
try {
|
|
136
|
-
return parse(raw);
|
|
137
|
-
}
|
|
138
|
-
catch {
|
|
139
|
-
throw new DeliveryError('Stored lifecycle receipt is corrupt.');
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
function readPrivate(path, parse) {
|
|
143
|
-
let raw;
|
|
144
|
-
try {
|
|
145
|
-
raw = JSON.parse(assertPrivateFile(path).toString('utf8'));
|
|
146
|
-
}
|
|
147
|
-
catch {
|
|
148
|
-
throw new DeliveryError('Required private lifecycle receipt is missing or unreadable.');
|
|
149
|
-
}
|
|
150
|
-
return parse(raw);
|
|
151
|
-
}
|
|
152
|
-
export function loadPublication(root, issueNumber, headSha) {
|
|
153
|
-
return readPrivate(publicationPath(root, issueNumber, headSha), (value) => PublicationSchema.parse(value));
|
|
154
|
-
}
|
|
155
|
-
function validateDeliveryImpact(body) {
|
|
156
|
-
if ((body.match(/^## Delivery Impact\s*$/gmu) ?? []).length !== 1 ||
|
|
157
|
-
(body.match(/^Roadmap impact:\s*(?:updated|none)\s*$/gmu) ?? []).length !== 1 ||
|
|
158
|
-
(body.match(/^Docs impact:\s*(?:updated|none)\s*$/gmu) ?? []).length !== 1) {
|
|
159
|
-
throw new DeliveryError('PR body requires one Delivery Impact section with Roadmap impact and Docs impact.');
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
function baseBranch(root, selectedRemote) {
|
|
163
|
-
const remote = resolveGitRemoteName(root, selectedRemote);
|
|
164
|
-
const ref = defaultBaseRef(root, remote);
|
|
165
|
-
const value = ref.slice(`refs/remotes/${remote}/`.length);
|
|
166
|
-
if (!/^[A-Za-z0-9._/-]+$/u.test(value))
|
|
167
|
-
throw new DeliveryError('Invalid configured default branch.');
|
|
168
|
-
return value;
|
|
169
|
-
}
|
|
170
|
-
/** @internal Selected-author Git transport, exported for process-boundary tests. */
|
|
171
|
-
export async function runAuthorGit(context, path, args, operation) {
|
|
172
|
-
await withAuthorGitToken(context.clients, (token) => {
|
|
173
|
-
const gitEnv = {
|
|
174
|
-
PATH: process.env.PATH ?? '',
|
|
175
|
-
GIT_CONFIG_GLOBAL: '/dev/null',
|
|
176
|
-
GIT_CONFIG_NOSYSTEM: '1',
|
|
177
|
-
};
|
|
178
|
-
const unsafeConfig = spawnSync('git', ['config', '--includes', '--get-regexp', '^(url\\..*\\.(insteadof|pushinsteadof)|http(\\..*)?\\.extraheader)$'], {
|
|
179
|
-
cwd: path,
|
|
180
|
-
env: gitEnv,
|
|
181
|
-
encoding: 'utf8',
|
|
182
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
183
|
-
});
|
|
184
|
-
if (unsafeConfig.error || (unsafeConfig.status !== 0 && unsafeConfig.status !== 1)) {
|
|
185
|
-
throw new DeliveryError(`Cannot verify effective Git HTTP transport configuration (git exit ${String(unsafeConfig.status)}).`);
|
|
186
|
-
}
|
|
187
|
-
if (unsafeConfig.status === 0) {
|
|
188
|
-
throw new DeliveryError('Local Git transport configuration conflicts with selected author authentication.');
|
|
189
|
-
}
|
|
190
|
-
const temporary = mkdtempSync(join(tmpdir(), 'ai-delivery-git-auth-'));
|
|
191
|
-
const askpass = join(temporary, 'askpass.sh');
|
|
192
|
-
try {
|
|
193
|
-
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 });
|
|
194
|
-
execFileSync('git', ['-c', 'credential.helper=', ...args], {
|
|
195
|
-
cwd: path,
|
|
196
|
-
encoding: 'utf8',
|
|
197
|
-
env: { ...gitEnv, AI_DELIVERY_GIT_TOKEN: token, GIT_ASKPASS: askpass, GIT_TERMINAL_PROMPT: '0' },
|
|
198
|
-
maxBuffer: 4 * 1024 * 1024,
|
|
199
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
200
|
-
});
|
|
201
|
-
}
|
|
202
|
-
catch (cause) {
|
|
203
|
-
const error = cause;
|
|
204
|
-
const detail = String(error.stderr ?? '')
|
|
205
|
-
.trim()
|
|
206
|
-
.replaceAll(token, '[redacted]');
|
|
207
|
-
throw new DeliveryError(`git ${operation} failed${detail ? `: ${detail}` : ''}`);
|
|
4
|
+
import { issueBranch, startIssueBranch } from './issue.js';
|
|
5
|
+
import { parseGitHubReviewArtifact, parseReviewArtifact, readGitHubPrScopeHash, readRequiredReviewState, submitReview, } from './review.js';
|
|
6
|
+
const Sha = /^[a-f0-9]{40}$/u;
|
|
7
|
+
const Branch = /^(?!.*(?:\.\.|@\{|\/\/))[A-Za-z0-9._/-]+$/u;
|
|
8
|
+
function validSha(value) {
|
|
9
|
+
return typeof value === 'string' && Sha.test(value);
|
|
10
|
+
}
|
|
11
|
+
function validBranch(value) {
|
|
12
|
+
if (!Branch.test(value) || value.startsWith('/') || value.endsWith('/') || value.endsWith('.lock'))
|
|
13
|
+
throw new DeliveryError('Invalid GitHub branch name.');
|
|
14
|
+
}
|
|
15
|
+
async function getPr(context, prNumber) {
|
|
16
|
+
if (context.config.repository.toLowerCase() !== `${context.repo.owner}/${context.repo.repo}`.toLowerCase())
|
|
17
|
+
throw new DeliveryError('Configured and selected GitHub repository identities disagree.');
|
|
18
|
+
const pr = (await context.clients.rest.pulls.get({ ...context.repo, pull_number: prNumber })).data;
|
|
19
|
+
if (pr.number !== prNumber ||
|
|
20
|
+
pr.base.repo?.full_name.toLowerCase() !== context.config.repository.toLowerCase() ||
|
|
21
|
+
pr.head.repo?.full_name.toLowerCase() !== context.config.repository.toLowerCase() ||
|
|
22
|
+
!validSha(pr.head.sha))
|
|
23
|
+
throw new DeliveryError('GitHub PR identity or exact head readback is invalid.');
|
|
24
|
+
return pr;
|
|
25
|
+
}
|
|
26
|
+
async function mergedIssueAssociation(context, prNumber, issueNumber) {
|
|
27
|
+
let cursor = null;
|
|
28
|
+
const cursors = new Set();
|
|
29
|
+
let matches = 0;
|
|
30
|
+
for (let page = 0; page < 20; page++) {
|
|
31
|
+
const result = await context.clients.graphql('query DeliveryClosingIssues($owner:String!,$repo:String!,$number:Int!,$cursor:String){repository(owner:$owner,name:$repo){pullRequest(number:$number){closingIssuesReferences(first:100,after:$cursor){nodes{number repository{nameWithOwner}} pageInfo{hasNextPage endCursor}}}}}', { ...context.repo, number: prNumber, cursor });
|
|
32
|
+
const connection = result.repository?.pullRequest?.closingIssuesReferences;
|
|
33
|
+
if (!connection || !Array.isArray(connection.nodes) || typeof connection.pageInfo?.hasNextPage !== 'boolean')
|
|
34
|
+
throw new DeliveryError('Incomplete merged GitHub PR issue association readback.');
|
|
35
|
+
for (const issue of connection.nodes) {
|
|
36
|
+
if (!issue || !Number.isSafeInteger(issue.number) || issue.number <= 0 || !issue.repository?.nameWithOwner)
|
|
37
|
+
throw new DeliveryError('Incomplete merged PR issue identity.');
|
|
38
|
+
if (issue.number === issueNumber &&
|
|
39
|
+
issue.repository.nameWithOwner.toLowerCase() === context.config.repository.toLowerCase())
|
|
40
|
+
matches++;
|
|
208
41
|
}
|
|
209
|
-
|
|
210
|
-
|
|
42
|
+
if (!connection.pageInfo.hasNextPage) {
|
|
43
|
+
if (matches !== 1)
|
|
44
|
+
throw new DeliveryError('Merged GitHub PR does not identify the intended issue; issue remains open.');
|
|
45
|
+
return;
|
|
211
46
|
}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
47
|
+
const next = connection.pageInfo.endCursor;
|
|
48
|
+
if (!next || cursors.has(next))
|
|
49
|
+
throw new DeliveryError('Merged PR issue pagination did not advance.');
|
|
50
|
+
cursors.add(next);
|
|
51
|
+
cursor = next;
|
|
52
|
+
}
|
|
53
|
+
throw new DeliveryError('Merged PR issue association exceeds the bounded readback window.');
|
|
54
|
+
}
|
|
55
|
+
async function matchingPrs(context, branch, base) {
|
|
56
|
+
const matches = [];
|
|
57
|
+
for (let page = 1; page <= 20; page++) {
|
|
58
|
+
const batch = (await context.clients.rest.pulls.list({
|
|
59
|
+
...context.repo,
|
|
60
|
+
head: `${context.repo.owner}:${branch}`,
|
|
61
|
+
...(base === undefined ? {} : { base }),
|
|
62
|
+
state: 'all',
|
|
63
|
+
page,
|
|
64
|
+
per_page: 100,
|
|
65
|
+
})).data;
|
|
66
|
+
matches.push(...batch);
|
|
67
|
+
if (batch.length < 100)
|
|
68
|
+
return matches;
|
|
69
|
+
}
|
|
70
|
+
throw new DeliveryError('GitHub PR match history exceeds the bounded readback window.');
|
|
216
71
|
}
|
|
217
|
-
async function
|
|
218
|
-
const
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
72
|
+
async function verifyIssuePr(context, issueNumber, prNumber) {
|
|
73
|
+
const pr = await getPr(context, prNumber);
|
|
74
|
+
await mergedIssueAssociation(context, prNumber, issueNumber);
|
|
75
|
+
if (!pr.merged) {
|
|
76
|
+
const branch = await issueBranch(context, issueNumber, pr.head.ref);
|
|
77
|
+
if (pr.head.ref !== branch)
|
|
78
|
+
throw new DeliveryError('GitHub PR is not linked to the requested issue branch.');
|
|
224
79
|
}
|
|
225
|
-
|
|
80
|
+
return pr;
|
|
226
81
|
}
|
|
227
82
|
export async function prInfo(context, input) {
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
}
|
|
249
|
-
if (matches.length > 1)
|
|
250
|
-
throw new DeliveryError('Multiple open PRs match the registered branch.');
|
|
251
|
-
const pr = matches[0];
|
|
252
|
-
return pr
|
|
253
|
-
? {
|
|
254
|
-
number: pr.number,
|
|
255
|
-
state: pr.state,
|
|
256
|
-
headSha: pr.head.sha,
|
|
257
|
-
baseSha: pr.base.sha,
|
|
258
|
-
draft: pr.draft ?? null,
|
|
259
|
-
authorLogin: pr.user?.login ?? null,
|
|
260
|
-
url: pr.html_url,
|
|
261
|
-
}
|
|
262
|
-
: { number: null, state: null, headSha: null, baseSha: null, draft: null, authorLogin: null, url: null };
|
|
83
|
+
let number = input.prNumber;
|
|
84
|
+
if (number === undefined) {
|
|
85
|
+
if (input.issueNumber === undefined)
|
|
86
|
+
throw new DeliveryError('prNumber or issueNumber is required.');
|
|
87
|
+
const branch = await issueBranch(context, input.issueNumber);
|
|
88
|
+
const matches = await matchingPrs(context, branch);
|
|
89
|
+
if (matches.length !== 1)
|
|
90
|
+
throw new DeliveryError('Select an explicit PR number when the remote issue branch has zero or multiple PRs.');
|
|
91
|
+
number = matches[0].number;
|
|
92
|
+
}
|
|
93
|
+
const pr = await getPr(context, number);
|
|
94
|
+
return {
|
|
95
|
+
prNumber: pr.number,
|
|
96
|
+
url: pr.html_url,
|
|
97
|
+
state: pr.state,
|
|
98
|
+
draft: pr.draft,
|
|
99
|
+
headSha: pr.head.sha,
|
|
100
|
+
headBranch: pr.head.ref,
|
|
101
|
+
baseBranch: pr.base.ref,
|
|
102
|
+
merged: pr.merged,
|
|
103
|
+
};
|
|
263
104
|
}
|
|
264
105
|
export async function listPrs(context, state = 'open') {
|
|
265
106
|
const prs = await context.clients.rest.paginate(context.clients.rest.pulls.list, {
|
|
@@ -299,42 +140,6 @@ export async function prChecks(context, prNumber) {
|
|
|
299
140
|
})),
|
|
300
141
|
};
|
|
301
142
|
}
|
|
302
|
-
export async function checkoutPr(context, prNumber) {
|
|
303
|
-
if (!Number.isSafeInteger(prNumber) || prNumber <= 0)
|
|
304
|
-
throw new DeliveryError('PR number must be positive.');
|
|
305
|
-
const pr = (await context.clients.rest.pulls.get({ ...context.repo, pull_number: prNumber })).data;
|
|
306
|
-
const repository = `${context.repo.owner}/${context.repo.repo}`.toLowerCase();
|
|
307
|
-
if (pr.state !== 'open' ||
|
|
308
|
-
pr.head.repo?.full_name.toLowerCase() !== repository ||
|
|
309
|
-
pr.base.repo?.full_name.toLowerCase() !== repository ||
|
|
310
|
-
gitExitCode(context.root, 'check-ref-format', '--branch', pr.head.ref) !== 0) {
|
|
311
|
-
throw new DeliveryError('PR checkout requires an open in-repository branch with a valid ref.');
|
|
312
|
-
}
|
|
313
|
-
const remoteHead = (await context.clients.rest.git.getRef({ ...context.repo, ref: `heads/${pr.head.ref}` })).data
|
|
314
|
-
.object.sha;
|
|
315
|
-
if (remoteHead !== pr.head.sha)
|
|
316
|
-
throw new DeliveryError('PR branch ref drifted from the observed head.');
|
|
317
|
-
const remote = resolveGitRemoteName(context.root, context.configuration?.remote);
|
|
318
|
-
const fetchedRef = `refs/remotes/${remote}/ai-delivery-pr-${prNumber}`;
|
|
319
|
-
await runAuthorGit(context, context.root, [
|
|
320
|
-
'fetch',
|
|
321
|
-
`https://github.com/${context.repo.owner}/${context.repo.repo}.git`,
|
|
322
|
-
`refs/heads/${pr.head.ref}:${fetchedRef}`,
|
|
323
|
-
], 'fetch');
|
|
324
|
-
if (git(context.root, 'rev-parse', fetchedRef) !== pr.head.sha) {
|
|
325
|
-
throw new DeliveryError('Fetched PR branch disagrees with the live head.');
|
|
326
|
-
}
|
|
327
|
-
const live = (await context.clients.rest.pulls.get({ ...context.repo, pull_number: prNumber })).data;
|
|
328
|
-
if (live.state !== 'open' || live.head.sha !== pr.head.sha || live.head.ref !== pr.head.ref) {
|
|
329
|
-
throw new DeliveryError('PR changed during selected-App checkout.');
|
|
330
|
-
}
|
|
331
|
-
return preparePrWorktree({
|
|
332
|
-
headSha: pr.head.sha,
|
|
333
|
-
identity: context.config.roles.author.identity,
|
|
334
|
-
prNumber,
|
|
335
|
-
repoRoot: context.root,
|
|
336
|
-
});
|
|
337
|
-
}
|
|
338
143
|
function requiredApprovalCount(value) {
|
|
339
144
|
if (typeof value !== 'object' || value === null)
|
|
340
145
|
return null;
|
|
@@ -342,7 +147,15 @@ function requiredApprovalCount(value) {
|
|
|
342
147
|
return typeof count === 'number' && Number.isSafeInteger(count) && count >= 0 ? count : null;
|
|
343
148
|
}
|
|
344
149
|
/** Check selected actors and rule evidence; publication also requires the configured author source. */
|
|
345
|
-
export async function preflightReviewRoute(context, base
|
|
150
|
+
export async function preflightReviewRoute(context, base, selectedReviewer, purpose = 'publication', control) {
|
|
151
|
+
if (base === undefined) {
|
|
152
|
+
try {
|
|
153
|
+
base = (await context.clients.rest.repos.get({ ...context.repo })).data.default_branch;
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
throw new DeliveryError('Author cannot read the selected GitHub repository; verify configured access.');
|
|
157
|
+
}
|
|
158
|
+
}
|
|
346
159
|
const check = async () => {
|
|
347
160
|
await control?.checkResources();
|
|
348
161
|
control?.signal.throwIfAborted();
|
|
@@ -364,6 +177,7 @@ export async function preflightReviewRoute(context, base = baseBranch(context.ro
|
|
|
364
177
|
throw new DeliveryError('Author GitHub identity readback is incomplete.');
|
|
365
178
|
}
|
|
366
179
|
const reviewer = selectedReviewer ??
|
|
180
|
+
context.reviewerClients ??
|
|
367
181
|
(await createDeliveryGitHubClients({
|
|
368
182
|
config: context.config,
|
|
369
183
|
identity: context.config.roles.reviewer.identity,
|
|
@@ -458,851 +272,217 @@ export async function preflightReviewRoute(context, base = baseBranch(context.ro
|
|
|
458
272
|
};
|
|
459
273
|
}
|
|
460
274
|
export async function publishPr(context, input) {
|
|
461
|
-
const
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
const run = loadVerifiedRun(row.path, input.issueNumber);
|
|
466
|
-
const head = coordinate(row.path);
|
|
467
|
-
if (input.draft === false) {
|
|
468
|
-
const publication = loadPublication(row.path, input.issueNumber, head.sha);
|
|
469
|
-
const receipt = loadSubmittedReview(row.path, input.issueNumber, head.sha);
|
|
470
|
-
if (!receipt || receipt.publicationEvidenceId !== publication.evidenceId) {
|
|
471
|
-
throw new DeliveryError('Ready promotion requires a submitted review of the exact published head.');
|
|
472
|
-
}
|
|
473
|
-
await assertSubmittedReviewCurrent(context, receipt);
|
|
474
|
-
const pr = (await context.clients.rest.pulls.get({ ...context.repo, pull_number: publication.prNumber })).data;
|
|
475
|
-
if (pr.head.sha !== head.sha)
|
|
476
|
-
throw new DeliveryError('PR head drifted before ready promotion.');
|
|
477
|
-
if (pr.draft) {
|
|
478
|
-
await context.clients.graphql(`mutation Ready($id: ID!) { markPullRequestReadyForReview(input: {pullRequestId: $id}) { pullRequest { id isDraft } } }`, { id: pr.node_id });
|
|
479
|
-
}
|
|
480
|
-
const readback = (await context.clients.rest.pulls.get({ ...context.repo, pull_number: publication.prNumber }))
|
|
481
|
-
.data;
|
|
482
|
-
if (readback.draft || readback.head.sha !== head.sha)
|
|
483
|
-
throw new DeliveryError('PR ready readback failed.');
|
|
484
|
-
return {
|
|
485
|
-
prNumber: publication.prNumber,
|
|
486
|
-
url: readback.html_url,
|
|
487
|
-
publicationEvidenceId: publication.evidenceId,
|
|
488
|
-
reviewState: await readRequiredReviewState(context, publication.prNumber, head.sha),
|
|
489
|
-
};
|
|
490
|
-
}
|
|
491
|
-
const prepublication = loadPrepublicationArtifact(row.path, input.issueNumber, head.sha);
|
|
492
|
-
const approval = prepublication
|
|
493
|
-
? reviewArtifactApproval({
|
|
494
|
-
artifact: prepublication,
|
|
495
|
-
classification: run.classification,
|
|
496
|
-
config: context.config,
|
|
497
|
-
issueNumber: input.issueNumber,
|
|
498
|
-
})
|
|
499
|
-
: undefined;
|
|
500
|
-
const evidence = await createIssuePhaseEvidence({
|
|
501
|
-
personalAuth: context.clients.authSource === 'personal' && context.config.roles.author.authSource !== 'personal',
|
|
502
|
-
...(approval === undefined ? {} : { approval }),
|
|
503
|
-
issueNumber: input.issueNumber,
|
|
504
|
-
phase: 'publish',
|
|
505
|
-
repoRoot: row.path,
|
|
506
|
-
});
|
|
275
|
+
const branch = await issueBranch(context, input.issueNumber, input.headBranch);
|
|
276
|
+
if (input.headBranch !== undefined && input.headBranch !== branch)
|
|
277
|
+
throw new DeliveryError('Requested PR branch differs from the GitHub-linked issue branch.');
|
|
278
|
+
validBranch(branch);
|
|
507
279
|
const issue = (await context.clients.rest.issues.get({ ...context.repo, issue_number: input.issueNumber })).data;
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
const
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
const
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
280
|
+
if ('pull_request' in issue || issue.number !== input.issueNumber || issue.state !== 'open')
|
|
281
|
+
throw new DeliveryError('PR creation requires the requested open GitHub issue.');
|
|
282
|
+
const defaultBranch = (await context.clients.rest.repos.get({ ...context.repo })).data.default_branch;
|
|
283
|
+
const base = input.baseBranch ?? defaultBranch;
|
|
284
|
+
if (base !== defaultBranch)
|
|
285
|
+
throw new DeliveryError('PR delivery requires the repository default branch for durable issue association.');
|
|
286
|
+
validBranch(base);
|
|
287
|
+
const ref = (await context.clients.rest.git.getRef({ ...context.repo, ref: `heads/${branch}` })).data;
|
|
288
|
+
if (!validSha(ref.object.sha))
|
|
289
|
+
throw new DeliveryError('Remote issue branch head is invalid.');
|
|
290
|
+
const requestedBody = input.body ?? `Closes #${input.issueNumber}`;
|
|
291
|
+
if (!requestedBody.trim())
|
|
292
|
+
throw new DeliveryError('PR body must contain text.');
|
|
293
|
+
const body = new RegExp(`(?:close[sd]?|fix(?:es|ed)?|resolve[sd]?)\\s+#${input.issueNumber}(?![0-9])`, 'iu').test(requestedBody)
|
|
294
|
+
? requestedBody
|
|
295
|
+
: `${requestedBody}\n\nCloses #${input.issueNumber}`;
|
|
296
|
+
const matches = await matchingPrs(context, branch, base);
|
|
524
297
|
if (matches.length > 1)
|
|
525
|
-
throw new DeliveryError('
|
|
526
|
-
|
|
527
|
-
if (
|
|
528
|
-
|
|
298
|
+
throw new DeliveryError('Remote issue branch has conflicting PR matches.');
|
|
299
|
+
let pr = matches[0] === undefined ? undefined : await getPr(context, matches[0].number);
|
|
300
|
+
if (pr &&
|
|
301
|
+
(pr.head.sha !== ref.object.sha ||
|
|
302
|
+
pr.head.ref !== branch ||
|
|
303
|
+
pr.base.ref !== base ||
|
|
304
|
+
pr.state !== 'open' ||
|
|
305
|
+
(input.body !== undefined && pr.body !== body) ||
|
|
306
|
+
(input.title !== undefined && pr.title !== input.title)))
|
|
307
|
+
throw new DeliveryError('Existing remote PR conflicts with the requested branch, intent or state.');
|
|
308
|
+
const reviewRoute = await preflightReviewRoute(context, base);
|
|
309
|
+
if (pr && pr.user?.login.toLowerCase() !== reviewRoute.author.actorLogin.toLowerCase())
|
|
310
|
+
throw new DeliveryError('Existing PR author differs from the configured authenticated author.');
|
|
311
|
+
if (pr)
|
|
312
|
+
await mergedIssueAssociation(context, pr.number, input.issueNumber);
|
|
313
|
+
if (input.dryRun)
|
|
314
|
+
return { dryRun: true, headSha: ref.object.sha, headBranch: branch, baseBranch: base, reviewRoute };
|
|
315
|
+
if (pr) {
|
|
316
|
+
if (input.draft === false && pr.draft) {
|
|
317
|
+
await context.clients.graphql('mutation DeliveryReadyPr($id:ID!){markPullRequestReadyForReview(input:{pullRequestId:$id}){pullRequest{id isDraft}}}', { id: pr.node_id });
|
|
318
|
+
pr = await getPr(context, pr.number);
|
|
319
|
+
if (pr.draft)
|
|
320
|
+
throw new DeliveryError('PR promotion readback is still draft.');
|
|
321
|
+
}
|
|
529
322
|
}
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
? (await context.clients.rest.pulls.get({ ...context.repo, pull_number: existing.number })).data
|
|
533
|
-
: (await context.clients.rest.pulls.create({
|
|
323
|
+
else {
|
|
324
|
+
pr = (await context.clients.rest.pulls.create({
|
|
534
325
|
...context.repo,
|
|
535
|
-
|
|
326
|
+
head: branch,
|
|
327
|
+
base,
|
|
328
|
+
title: input.title ?? issue.title,
|
|
536
329
|
body,
|
|
537
|
-
draft:
|
|
538
|
-
head: row.branch,
|
|
539
|
-
title,
|
|
330
|
+
draft: input.draft !== false,
|
|
540
331
|
})).data;
|
|
541
|
-
|
|
542
|
-
throw new DeliveryError('Draft PR readback does not match the verified head and base.');
|
|
332
|
+
pr = await getPr(context, pr.number);
|
|
543
333
|
}
|
|
544
|
-
if (
|
|
545
|
-
throw new DeliveryError('
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
headSha: head.sha,
|
|
551
|
-
issueNumber: input.issueNumber,
|
|
552
|
-
prNumber: pr.number,
|
|
553
|
-
schemaVersion: 'ai-delivery.publication@1',
|
|
554
|
-
};
|
|
555
|
-
const publication = PublicationSchema.parse({ ...content, publicationId: digestValue(content) });
|
|
556
|
-
writePrivateJsonFileAtomically(publicationPath(row.path, input.issueNumber, head.sha), publication);
|
|
557
|
-
await updateIssueWorktreeDelivery({
|
|
558
|
-
branch: row.branch,
|
|
559
|
-
issueNumber: input.issueNumber,
|
|
560
|
-
path: row.path,
|
|
561
|
-
prNumber: pr.number,
|
|
562
|
-
projectRoot: root,
|
|
563
|
-
status: 'pr-published',
|
|
564
|
-
});
|
|
565
|
-
return { prNumber: pr.number, url: pr.html_url, publicationEvidenceId: evidence.evidenceId, reviewRoute };
|
|
334
|
+
if (pr.head.sha !== ref.object.sha || pr.head.ref !== branch || pr.base.ref !== base)
|
|
335
|
+
throw new DeliveryError('GitHub PR creation readback drifted.');
|
|
336
|
+
if (pr.user?.login.toLowerCase() !== reviewRoute.author.actorLogin.toLowerCase())
|
|
337
|
+
throw new DeliveryError('Published PR author differs from the configured authenticated author.');
|
|
338
|
+
await mergedIssueAssociation(context, pr.number, input.issueNumber);
|
|
339
|
+
return { prNumber: pr.number, url: pr.html_url, headSha: pr.head.sha, draft: pr.draft, reviewRoute };
|
|
566
340
|
}
|
|
567
341
|
export async function submitFormalReview(context, input) {
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
const publication = loadPublication(row.path, input.issueNumber, run.classification.head.sha);
|
|
571
|
-
if (publication.prNumber !== input.prNumber)
|
|
572
|
-
throw new DeliveryError('Review PR does not match exact publication.');
|
|
573
|
-
const receipt = await submitReview({
|
|
574
|
-
artifact: parseReviewArtifact(input.artifact),
|
|
575
|
-
classification: run.classification,
|
|
342
|
+
await verifyIssuePr(context, input.issueNumber, input.prNumber);
|
|
343
|
+
return submitReview({
|
|
576
344
|
context,
|
|
345
|
+
artifact: parseReviewArtifact(input.artifact),
|
|
577
346
|
issueNumber: input.issueNumber,
|
|
578
347
|
prNumber: input.prNumber,
|
|
579
|
-
|
|
580
|
-
});
|
|
581
|
-
return {
|
|
582
|
-
receiptId: receipt.receiptId,
|
|
583
|
-
githubReviewId: receipt.githubReviewId,
|
|
584
|
-
reviewState: await readRequiredReviewState(context, input.prNumber, run.classification.head.sha, {
|
|
585
|
-
id: receipt.githubReviewId,
|
|
586
|
-
login: receipt.login,
|
|
587
|
-
}),
|
|
588
|
-
};
|
|
589
|
-
}
|
|
590
|
-
async function checkPrMergeability(context, prNumber, expectedHead) {
|
|
591
|
-
const pr = (await context.clients.rest.pulls.get({ ...context.repo, pull_number: prNumber })).data;
|
|
592
|
-
if (pr.state !== 'open' || pr.draft || pr.head.sha !== expectedHead)
|
|
593
|
-
throw new DeliveryError('PR is not clean, ready and mergeable at the exact reviewed head.');
|
|
594
|
-
const reviewState = await readRequiredReviewState(context, prNumber, expectedHead);
|
|
595
|
-
if (reviewState.status === 'still-required') {
|
|
596
|
-
throw new DeliveryError(`The submitted APPROVED review is stored, but GitHub still requires a qualifying approval. ${reviewState.nextAction}`);
|
|
597
|
-
}
|
|
598
|
-
if (reviewState.status === 'changes-requested') {
|
|
599
|
-
throw new DeliveryError(`GitHub reports changes requested. ${reviewState.nextAction}`);
|
|
600
|
-
}
|
|
601
|
-
if (pr.mergeable !== true || pr.mergeable_state !== 'clean')
|
|
602
|
-
throw new DeliveryError('PR is not clean, ready and mergeable at the exact reviewed head.');
|
|
603
|
-
const issueNumber = Number(/^issue\/(\d+)$/u.exec(pr.head.ref)?.[1]);
|
|
604
|
-
if (!Number.isSafeInteger(issueNumber))
|
|
605
|
-
throw new DeliveryError('PR branch is not a registered issue branch.');
|
|
606
|
-
const relationships = await getNativeBlockerRelationships({
|
|
607
|
-
graphql: context.clients.graphql,
|
|
608
|
-
issueNumber,
|
|
609
|
-
repo: context.repo,
|
|
610
|
-
});
|
|
611
|
-
const blockedBy = relationships.blockers.filter((value) => value.state === 'OPEN').map((value) => value.number);
|
|
612
|
-
const [checks, statuses] = await Promise.all([
|
|
613
|
-
context.clients.rest.checks.listForRef({ ...context.repo, ref: expectedHead, per_page: 100 }),
|
|
614
|
-
context.clients.rest.repos.getCombinedStatusForRef({ ...context.repo, ref: expectedHead }),
|
|
615
|
-
]);
|
|
616
|
-
const checksPassed = checks.data.total_count === checks.data.check_runs.length &&
|
|
617
|
-
checks.data.check_runs.every((check) => check.status === 'completed' && ['success', 'neutral', 'skipped'].includes(check.conclusion ?? '')) &&
|
|
618
|
-
(statuses.data.statuses.length === 0 || statuses.data.state === 'success');
|
|
619
|
-
if (!checksPassed || blockedBy.length > 0)
|
|
620
|
-
throw new DeliveryError('PR has failed or pending checks or unresolved native blockers.');
|
|
621
|
-
return {
|
|
622
|
-
baseSha: pr.base.sha,
|
|
623
|
-
baseBranch: pr.base.ref,
|
|
624
|
-
headBranch: pr.head.ref,
|
|
625
|
-
title: pr.title,
|
|
626
|
-
blockedBy,
|
|
627
|
-
checksPassed,
|
|
628
|
-
};
|
|
629
|
-
}
|
|
630
|
-
/** @internal Lifecycle primitive; exported for local interruption tests. */
|
|
631
|
-
export async function mergeWithExactLease(context, input) {
|
|
632
|
-
if (gitExitCode(input.worktreePath, 'merge-base', '--is-ancestor', input.baseSha, input.headSha) !== 0) {
|
|
633
|
-
throw new DeliveryError('Exact-lease merge requires the verified base to be an ancestor of the reviewed head.');
|
|
634
|
-
}
|
|
635
|
-
const latest = (await context.clients.rest.pulls.get({ ...context.repo, pull_number: input.prNumber })).data;
|
|
636
|
-
if (latest.base.sha !== input.baseSha ||
|
|
637
|
-
latest.head.sha !== input.headSha ||
|
|
638
|
-
latest.base.ref !== input.baseBranch ||
|
|
639
|
-
latest.head.ref !== input.headBranch)
|
|
640
|
-
throw new DeliveryError('Exact base or head drifted before atomic merge.');
|
|
641
|
-
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 });
|
|
642
|
-
if (!repo.repository?.id)
|
|
643
|
-
throw new DeliveryError('GitHub omitted repository node identity.');
|
|
644
|
-
input.beforeMutation?.();
|
|
645
|
-
const created = (await context.clients.rest.git.createCommit({
|
|
646
|
-
...context.repo,
|
|
647
|
-
message: input.title,
|
|
648
|
-
parents: [input.baseSha, input.headSha],
|
|
649
|
-
tree: input.headTree,
|
|
650
|
-
})).data;
|
|
651
|
-
const mergeSha = Sha.parse(created.sha);
|
|
652
|
-
input.onPreparedMergeCommit?.(mergeSha);
|
|
653
|
-
const clientMutationId = `ai-delivery-exact-${mergeSha}`;
|
|
654
|
-
const response = await context.clients.graphql('mutation UpdateExactRefs($input: UpdateRefsInput!) { updateRefs(input: $input) { clientMutationId } }', {
|
|
655
|
-
input: {
|
|
656
|
-
clientMutationId,
|
|
657
|
-
repositoryId: repo.repository.id,
|
|
658
|
-
refUpdates: [
|
|
659
|
-
{ name: `refs/heads/${input.baseBranch}`, beforeOid: input.baseSha, afterOid: mergeSha, force: false },
|
|
660
|
-
{ name: `refs/heads/${input.headBranch}`, beforeOid: input.headSha, afterOid: input.headSha, force: false },
|
|
661
|
-
],
|
|
662
|
-
},
|
|
348
|
+
...(input.dryRun === undefined ? {} : { dryRun: input.dryRun }),
|
|
663
349
|
});
|
|
664
|
-
if (response.updateRefs.clientMutationId !== clientMutationId)
|
|
665
|
-
throw new DeliveryError('Atomic merge mutation identity readback failed.');
|
|
666
|
-
const [base, head, commit] = await Promise.all([
|
|
667
|
-
context.clients.rest.git.getRef({ ...context.repo, ref: `heads/${input.baseBranch}` }),
|
|
668
|
-
context.clients.rest.git.getRef({ ...context.repo, ref: `heads/${input.headBranch}` }),
|
|
669
|
-
context.clients.rest.git.getCommit({ ...context.repo, commit_sha: mergeSha }),
|
|
670
|
-
]);
|
|
671
|
-
if (base.data.object.sha !== mergeSha ||
|
|
672
|
-
head.data.object.sha !== input.headSha ||
|
|
673
|
-
commit.data.tree.sha !== input.headTree ||
|
|
674
|
-
commit.data.parents[0]?.sha !== input.baseSha ||
|
|
675
|
-
commit.data.parents[1]?.sha !== input.headSha)
|
|
676
|
-
throw new DeliveryError('Atomic merge ref or commit readback failed.');
|
|
677
|
-
const pr = (await context.clients.rest.pulls.get({ ...context.repo, pull_number: input.prNumber })).data;
|
|
678
|
-
if (pr.state !== 'closed' || !pr.merged_at || pr.merge_commit_sha !== mergeSha) {
|
|
679
|
-
throw new DeliveryError('GitHub did not report the exact merge commit as the completed PR.');
|
|
680
|
-
}
|
|
681
|
-
return mergeSha;
|
|
682
|
-
}
|
|
683
|
-
async function readMergedResult(context, intent, expectedSha) {
|
|
684
|
-
const pr = (await context.clients.rest.pulls.get({ ...context.repo, pull_number: intent.prNumber })).data;
|
|
685
|
-
if (pr.state !== 'closed' ||
|
|
686
|
-
!pr.merged_at ||
|
|
687
|
-
pr.head.sha !== intent.headSha ||
|
|
688
|
-
pr.head.ref !== intent.headBranch ||
|
|
689
|
-
pr.base.ref !== intent.baseBranch) {
|
|
690
|
-
throw new DeliveryError('Merged PR does not match the exact prepared merge intent.');
|
|
691
|
-
}
|
|
692
|
-
const mergeSha = Sha.parse(pr.merge_commit_sha);
|
|
693
|
-
if (expectedSha !== undefined && mergeSha !== expectedSha) {
|
|
694
|
-
throw new DeliveryError('Merged PR does not match the exact prepared merge intent.');
|
|
695
|
-
}
|
|
696
|
-
const [base, head, commit] = await Promise.all([
|
|
697
|
-
context.clients.rest.git.getRef({ ...context.repo, ref: `heads/${intent.baseBranch}` }),
|
|
698
|
-
context.clients.rest.git.getRef({ ...context.repo, ref: `heads/${intent.headBranch}` }).catch((error) => {
|
|
699
|
-
if (error.status === 404)
|
|
700
|
-
return null;
|
|
701
|
-
throw error;
|
|
702
|
-
}),
|
|
703
|
-
context.clients.rest.git.getCommit({ ...context.repo, commit_sha: mergeSha }),
|
|
704
|
-
]);
|
|
705
|
-
const expectedMergeTree = !intent.exactBaseHeadLease && (intent.strategy === 'merge' || intent.strategy === 'squash')
|
|
706
|
-
? git(context.root, 'merge-tree', '--write-tree', intent.baseSha, intent.headSha)
|
|
707
|
-
: intent.headTree;
|
|
708
|
-
if (!base.data.object.sha ||
|
|
709
|
-
(head !== null && head.data.object.sha !== intent.headSha) ||
|
|
710
|
-
commit.data.sha !== mergeSha ||
|
|
711
|
-
(intent.strategy === 'merge' &&
|
|
712
|
-
(commit.data.parents[0]?.sha !== intent.baseSha ||
|
|
713
|
-
commit.data.parents[1]?.sha !== intent.headSha ||
|
|
714
|
-
commit.data.tree.sha !== expectedMergeTree)) ||
|
|
715
|
-
(intent.strategy === 'squash' &&
|
|
716
|
-
(commit.data.parents[0]?.sha !== intent.baseSha || commit.data.tree.sha !== expectedMergeTree))) {
|
|
717
|
-
throw new DeliveryError('Remote merge ref, branch or commit readback disagrees with the prepared intent.');
|
|
718
|
-
}
|
|
719
|
-
if (expectedSha === undefined && intent.strategy === 'rebase') {
|
|
720
|
-
throw new DeliveryError('Rebase recovery lacks the returned exact merge SHA.');
|
|
721
|
-
}
|
|
722
|
-
return { mergeSha, mergeTree: Sha.parse(commit.data.tree.sha) };
|
|
723
350
|
}
|
|
724
|
-
async function
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
351
|
+
async function allReviews(context, prNumber) {
|
|
352
|
+
const reviews = [];
|
|
353
|
+
for (let page = 1; page <= 20; page++) {
|
|
354
|
+
const batch = (await context.clients.rest.pulls.listReviews({ ...context.repo, pull_number: prNumber, page, per_page: 100 })).data;
|
|
355
|
+
reviews.push(...batch);
|
|
356
|
+
if (batch.length < 100)
|
|
357
|
+
return reviews;
|
|
731
358
|
}
|
|
732
|
-
|
|
733
|
-
actor: context.config.roles.author.identity,
|
|
734
|
-
actorLogin: verified.actorLogin,
|
|
735
|
-
authSource: context.clients.authSource,
|
|
736
|
-
credentialIdentity: verified.credentialIdentity,
|
|
737
|
-
intentId: intent.intentId,
|
|
738
|
-
operation: intent.exactBaseHeadLease ? 'updateRefs' : 'pulls.merge',
|
|
739
|
-
schemaVersion: 'ai-delivery.merge-attempt@1',
|
|
740
|
-
};
|
|
741
|
-
return MergeAttemptSchema.parse({ ...content, attemptId: digestValue(content) });
|
|
742
|
-
}
|
|
743
|
-
function persistMergeResult(root, intent, attempt, mergeSha) {
|
|
744
|
-
const content = {
|
|
745
|
-
attemptId: attempt.attemptId,
|
|
746
|
-
mergeSha: Sha.parse(mergeSha),
|
|
747
|
-
schemaVersion: 'ai-delivery.merge-result@1',
|
|
748
|
-
};
|
|
749
|
-
writePrivateJsonFileAtomically(mergeResultPath(root, intent.issueNumber, intent.headSha), MergeResultSchema.parse({ ...content, resultId: digestValue(content) }));
|
|
750
|
-
}
|
|
751
|
-
/** @internal Lifecycle primitive; exported for local interruption tests. */
|
|
752
|
-
export async function persistMergedResult(context, input) {
|
|
753
|
-
const { mergeSha, mergeTree } = await readMergedResult(context, input.intent, input.expectedSha);
|
|
754
|
-
const { schemaVersion: _version, ...intentContent } = input.intent;
|
|
755
|
-
const content = { ...intentContent, mergeSha, mergeTree, schemaVersion: 'ai-delivery.merge@3' };
|
|
756
|
-
const merged = MergeSchema.parse({ ...content, mergeId: digestValue(content) });
|
|
757
|
-
writePrivateJsonFileAtomically(input.path, merged);
|
|
758
|
-
await updateIssueWorktreeDelivery({
|
|
759
|
-
branch: input.branch,
|
|
760
|
-
issueNumber: input.intent.issueNumber,
|
|
761
|
-
path: input.worktreePath,
|
|
762
|
-
projectRoot: context.root,
|
|
763
|
-
status: 'merged',
|
|
764
|
-
});
|
|
765
|
-
return merged;
|
|
766
|
-
}
|
|
767
|
-
async function validateTerminalMerge(context, row, run, publication, existing) {
|
|
768
|
-
const head = run.classification.head;
|
|
769
|
-
const pr = (await context.clients.rest.pulls.get({ ...context.repo, pull_number: existing.prNumber })).data;
|
|
770
|
-
if (existing.prNumber !== row.prNumber ||
|
|
771
|
-
existing.issueNumber !== row.issueNumber ||
|
|
772
|
-
existing.headSha !== head.sha ||
|
|
773
|
-
publication.prNumber !== existing.prNumber ||
|
|
774
|
-
publication.issueNumber !== existing.issueNumber ||
|
|
775
|
-
publication.headSha !== head.sha ||
|
|
776
|
-
publication.baseSha !== run.classification.base.sha ||
|
|
777
|
-
pr.state !== 'closed' ||
|
|
778
|
-
!pr.merged_at ||
|
|
779
|
-
pr.head.sha !== head.sha ||
|
|
780
|
-
pr.head.ref !== row.branch ||
|
|
781
|
-
pr.merge_commit_sha !== existing.mergeSha)
|
|
782
|
-
throw new DeliveryError('Merged PR readback or publication disagrees with terminal receipt.');
|
|
783
|
-
const intent = readOptionalPrivate(mergeIntentPath(row.path, existing.issueNumber, head.sha), (value) => MergeIntentSchema.parse(value));
|
|
784
|
-
if (!intent ||
|
|
785
|
-
intent.intentId !== existing.intentId ||
|
|
786
|
-
intent.baseSha !== run.classification.base.sha ||
|
|
787
|
-
intent.headSha !== head.sha ||
|
|
788
|
-
intent.headTree !== head.tree)
|
|
789
|
-
throw new DeliveryError('Terminal merge lacks its exact verified intent.');
|
|
790
|
-
const historicalReview = loadSubmittedReview(row.path, existing.issueNumber, head.sha);
|
|
791
|
-
if (!historicalReview ||
|
|
792
|
-
historicalReview.prNumber !== existing.prNumber ||
|
|
793
|
-
historicalReview.headSha !== head.sha ||
|
|
794
|
-
historicalReview.artifact.issueNumber !== existing.issueNumber ||
|
|
795
|
-
historicalReview.artifact.head.tree !== head.tree ||
|
|
796
|
-
historicalReview.artifact.diffScopeHash !== digestValue(run.classification.changedPaths) ||
|
|
797
|
-
historicalReview.artifact.verdict !== 'approve' ||
|
|
798
|
-
historicalReview.publicationEvidenceId !== publication.evidenceId ||
|
|
799
|
-
historicalReview.receiptId !== intent.reviewReceiptId)
|
|
800
|
-
throw new DeliveryError('Terminal merge publication lacks its exact historical approved review.');
|
|
801
|
-
const result = await readMergedResult(context, intent, existing.mergeSha);
|
|
802
|
-
if (result.mergeTree !== existing.mergeTree)
|
|
803
|
-
throw new DeliveryError('Terminal merge tree changed.');
|
|
804
|
-
const { schemaVersion: _version, ...intentContent } = intent;
|
|
805
|
-
const expected = { ...intentContent, ...result, schemaVersion: 'ai-delivery.merge@3' };
|
|
806
|
-
if (existing.mergeId !== digestValue(expected))
|
|
807
|
-
throw new DeliveryError('Terminal merge receipt disagrees with its intent.');
|
|
808
|
-
}
|
|
809
|
-
/** @internal Read-only ancestor validation for the locked committed-descendant continuation. */
|
|
810
|
-
export async function readMergedContinuationTerminal(context, row, operator) {
|
|
811
|
-
if (row.status !== 'merged' || row.prNumber === undefined || row.issueNumber === undefined)
|
|
812
|
-
throw new DeliveryError('Committed continuation requires exact merged issue custody.');
|
|
813
|
-
const pr = (await context.clients.rest.pulls.get({ ...context.repo, pull_number: row.prNumber })).data;
|
|
814
|
-
const historical = loadHistoricalMergedRun(row.path, Sha.parse(pr.head.sha));
|
|
815
|
-
const head = historical.run.classification.head.sha;
|
|
816
|
-
const publication = loadPublication(row.path, row.issueNumber, head);
|
|
817
|
-
const path = mergePath(row.path, row.issueNumber, head);
|
|
818
|
-
const terminal = readOptionalPrivate(path, (value) => MergeSchema.parse(value));
|
|
819
|
-
if (!terminal || !historical.run.writer || historical.run.classification.repository !== context.config.repository)
|
|
820
|
-
throw new DeliveryError('Committed continuation lacks supported exact historical terminal provenance.');
|
|
821
|
-
await validateTerminalMerge(context, row, historical.run, publication, terminal);
|
|
822
|
-
const lineage = await context.clients.graphql(`query CommittedContinuationLineage($owner: String!, $repo: String!, $number: Int!) {
|
|
823
|
-
repository(owner: $owner, name: $repo) { pullRequest(number: $number) {
|
|
824
|
-
closingIssuesReferences(first: 100) { nodes { number repository { nameWithOwner } } pageInfo { hasNextPage } }
|
|
825
|
-
} }
|
|
826
|
-
}`, { ...context.repo, number: row.prNumber });
|
|
827
|
-
const closing = z
|
|
828
|
-
.object({
|
|
829
|
-
nodes: z.array(z.object({ number: z.number().int().positive(), repository: z.object({ nameWithOwner: z.string().min(1) }) })),
|
|
830
|
-
pageInfo: z.object({ hasNextPage: z.literal(false) }),
|
|
831
|
-
})
|
|
832
|
-
.safeParse(lineage.repository?.pullRequest?.closingIssuesReferences);
|
|
833
|
-
if (!closing.success ||
|
|
834
|
-
pr.head.repo?.full_name.toLowerCase() !== context.config.repository.toLowerCase() ||
|
|
835
|
-
pr.base.repo.full_name.toLowerCase() !== context.config.repository.toLowerCase())
|
|
836
|
-
throw new DeliveryError('Native PR lineage does not establish the exact repository and complete closing connection.');
|
|
837
|
-
if (closing.data.nodes.some((issue) => issue.number === row.issueNumber &&
|
|
838
|
-
issue.repository.nameWithOwner.toLowerCase() === context.config.repository.toLowerCase())) {
|
|
839
|
-
await readNativeWorktreePrLineage(context, row.issueNumber, row.prNumber);
|
|
840
|
-
}
|
|
841
|
-
else {
|
|
842
|
-
const mergedAt = Date.parse(pr.merged_at ?? '');
|
|
843
|
-
if (closing.data.nodes.length !== 0 ||
|
|
844
|
-
!new RegExp(`^Refs[ \\t]+#${String(row.issueNumber)}(?=$|[ \\t.,;:!?])`, 'imu').test(pr.body ?? '') ||
|
|
845
|
-
!Number.isFinite(mergedAt) ||
|
|
846
|
-
!pr.merged ||
|
|
847
|
-
pr.head.ref !== row.branch ||
|
|
848
|
-
!pr.base.ref ||
|
|
849
|
-
!Sha.safeParse(pr.base.sha).success ||
|
|
850
|
-
pr.merge_commit_sha !== terminal.mergeSha)
|
|
851
|
-
throw new DeliveryError('Native PR lineage lacks the exact unfinished Refs relationship.');
|
|
852
|
-
const reference = z.object({
|
|
853
|
-
event: z.literal('cross-referenced'),
|
|
854
|
-
actor: z.object({ login: z.string(), id: z.number().int().positive(), type: z.literal('User') }),
|
|
855
|
-
created_at: z.string(),
|
|
856
|
-
source: z.object({
|
|
857
|
-
type: z.literal('issue'),
|
|
858
|
-
issue: z.object({
|
|
859
|
-
number: z.number().int().positive(),
|
|
860
|
-
user: z.object({ login: z.string(), id: z.number().int().positive(), type: z.literal('User') }),
|
|
861
|
-
repository_url: z.string(),
|
|
862
|
-
html_url: z.string(),
|
|
863
|
-
pull_request: z.object({ url: z.string() }),
|
|
864
|
-
}),
|
|
865
|
-
}),
|
|
866
|
-
});
|
|
867
|
-
let matched = false;
|
|
868
|
-
// Read every page before accepting even a matching first-page reference.
|
|
869
|
-
for (let page = 1;; page += 1) {
|
|
870
|
-
const events = await context.clients.rest.issues.listEventsForTimeline({
|
|
871
|
-
...context.repo,
|
|
872
|
-
issue_number: row.issueNumber,
|
|
873
|
-
per_page: 100,
|
|
874
|
-
page,
|
|
875
|
-
});
|
|
876
|
-
if (!Array.isArray(events.data))
|
|
877
|
-
throw new DeliveryError('Native PR lineage timeline is incomplete.');
|
|
878
|
-
for (const event of events.data) {
|
|
879
|
-
const parsed = reference.safeParse(event);
|
|
880
|
-
if (!parsed.success)
|
|
881
|
-
continue;
|
|
882
|
-
const native = parsed.data;
|
|
883
|
-
const at = Date.parse(native.created_at);
|
|
884
|
-
matched ||=
|
|
885
|
-
Number.isFinite(at) &&
|
|
886
|
-
at < mergedAt &&
|
|
887
|
-
native.actor.login.toLowerCase() === operator.actorLogin.toLowerCase() &&
|
|
888
|
-
`user:${String(native.actor.id)}` === operator.credentialIdentity &&
|
|
889
|
-
native.source.issue.number === row.prNumber &&
|
|
890
|
-
native.source.issue.user.login.toLowerCase() === operator.actorLogin.toLowerCase() &&
|
|
891
|
-
`user:${String(native.source.issue.user.id)}` === operator.credentialIdentity &&
|
|
892
|
-
native.source.issue.repository_url === pr.base.repo.url &&
|
|
893
|
-
native.source.issue.html_url === pr.html_url &&
|
|
894
|
-
native.source.issue.pull_request.url === pr.url;
|
|
895
|
-
}
|
|
896
|
-
if (events.data.length < 100 && !/;\s*rel="next"/u.test(events.headers.link ?? ''))
|
|
897
|
-
break;
|
|
898
|
-
}
|
|
899
|
-
if (!matched)
|
|
900
|
-
throw new DeliveryError('Native PR lineage lacks complete authenticated before-merge cross-reference evidence.');
|
|
901
|
-
}
|
|
902
|
-
const attemptPath = mergeAttemptPath(row.path, row.issueNumber, head);
|
|
903
|
-
const resultPath = mergeResultPath(row.path, row.issueNumber, head);
|
|
904
|
-
const attempt = readOptionalPrivate(attemptPath, (value) => MergeAttemptSchema.parse(value));
|
|
905
|
-
const result = readOptionalPrivate(resultPath, (value) => MergeResultSchema.parse(value));
|
|
906
|
-
if (pr.number !== row.prNumber ||
|
|
907
|
-
pr.user?.type !== 'User' ||
|
|
908
|
-
pr.user.login.toLowerCase() !== operator.actorLogin.toLowerCase() ||
|
|
909
|
-
`user:${String(pr.user.id)}` !== operator.credentialIdentity ||
|
|
910
|
-
!attempt ||
|
|
911
|
-
attempt.authSource !== 'personal' ||
|
|
912
|
-
attempt.actorLogin.toLowerCase() !== operator.actorLogin.toLowerCase() ||
|
|
913
|
-
attempt.credentialIdentity !== operator.credentialIdentity ||
|
|
914
|
-
attempt.intentId !== terminal.intentId ||
|
|
915
|
-
!result ||
|
|
916
|
-
result.attemptId !== attempt.attemptId ||
|
|
917
|
-
result.mergeSha !== terminal.mergeSha)
|
|
918
|
-
throw new DeliveryError('Committed continuation authenticated host author disagrees with native historical custody.');
|
|
919
|
-
return {
|
|
920
|
-
terminal,
|
|
921
|
-
preserved: [
|
|
922
|
-
historical.path,
|
|
923
|
-
publicationPath(row.path, row.issueNumber, head),
|
|
924
|
-
join(gitCommonDir(row.path), 'ai-delivery', 'reviews', String(row.issueNumber), `${head}.json`),
|
|
925
|
-
mergeIntentPath(row.path, row.issueNumber, head),
|
|
926
|
-
attemptPath,
|
|
927
|
-
resultPath,
|
|
928
|
-
path,
|
|
929
|
-
].map((file) => ({ path: file, digest: digestBytes(assertPrivateFile(file)) })),
|
|
930
|
-
};
|
|
359
|
+
throw new DeliveryError('GitHub review history exceeds the bounded readback window.');
|
|
931
360
|
}
|
|
932
361
|
export async function mergePr(context, input) {
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
const
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
const
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
if (
|
|
967
|
-
throw new DeliveryError('
|
|
968
|
-
|
|
969
|
-
const
|
|
970
|
-
if (
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
return persistMergedResult(context, {
|
|
996
|
-
branch: row.branch,
|
|
997
|
-
intent: prepared,
|
|
998
|
-
path: existingPath,
|
|
999
|
-
worktreePath: row.path,
|
|
1000
|
-
...(priorResult === null ? {} : { expectedSha: priorResult.mergeSha }),
|
|
1001
|
-
});
|
|
1002
|
-
}
|
|
1003
|
-
if (priorResult !== null)
|
|
1004
|
-
throw new DeliveryError('Merge result lacks its bound attempt.');
|
|
1005
|
-
const remote = (await context.clients.rest.pulls.get({ ...context.repo, pull_number: input.prNumber })).data;
|
|
1006
|
-
if (remote.state === 'closed' && remote.merged_at) {
|
|
1007
|
-
return persistMergedResult(context, {
|
|
1008
|
-
branch: row.branch,
|
|
1009
|
-
intent: prepared,
|
|
1010
|
-
path: existingPath,
|
|
1011
|
-
worktreePath: row.path,
|
|
1012
|
-
});
|
|
1013
|
-
}
|
|
1014
|
-
if (remote.state !== 'open' ||
|
|
1015
|
-
remote.head.sha !== head.sha ||
|
|
1016
|
-
remote.head.ref !== row.branch ||
|
|
1017
|
-
remote.base.ref !== prepared.baseBranch ||
|
|
1018
|
-
remote.base.sha !== prepared.baseSha) {
|
|
1019
|
-
throw new DeliveryError('Prepared merge retry disagrees with the remote PR.');
|
|
1020
|
-
}
|
|
1021
|
-
}
|
|
1022
|
-
const currentPublication = await createIssuePhaseEvidence({
|
|
1023
|
-
personalAuth: context.clients.authSource === 'personal' && context.config.roles.author.authSource !== 'personal',
|
|
1024
|
-
...(run.classification.risk === 'high'
|
|
1025
|
-
? {
|
|
1026
|
-
approval: reviewArtifactApproval({
|
|
1027
|
-
artifact: review.artifact,
|
|
1028
|
-
classification: run.classification,
|
|
1029
|
-
config: context.config,
|
|
1030
|
-
issueNumber: input.issueNumber,
|
|
1031
|
-
prNumber: input.prNumber,
|
|
1032
|
-
}),
|
|
1033
|
-
}
|
|
1034
|
-
: {}),
|
|
1035
|
-
issueNumber: input.issueNumber,
|
|
1036
|
-
phase: 'publish',
|
|
1037
|
-
repoRoot: row.path,
|
|
1038
|
-
});
|
|
1039
|
-
if (currentPublication.evidenceId !== publication.evidenceId) {
|
|
1040
|
-
throw new DeliveryError('Publication evidence changed after formal review.');
|
|
1041
|
-
}
|
|
1042
|
-
const readback = await checkPrMergeability(context, input.prNumber, head.sha);
|
|
1043
|
-
if (readback.baseSha !== run.classification.base.sha || readback.headBranch !== row.branch) {
|
|
1044
|
-
throw new DeliveryError('PR base or registered head drifted after verification.');
|
|
1045
|
-
}
|
|
1046
|
-
const approval = {
|
|
1047
|
-
authorIdentity: context.config.roles.author.identity,
|
|
1048
|
-
reviewerIdentity: context.config.roles.reviewer.identity,
|
|
1049
|
-
diffScopeHash: review.artifact.diffScopeHash,
|
|
1050
|
-
head,
|
|
1051
|
-
result: 'approved',
|
|
1052
|
-
reviewReceiptId: review.receiptId,
|
|
1053
|
-
};
|
|
1054
|
-
const evidence = await createIssuePhaseEvidence({
|
|
1055
|
-
personalAuth: context.clients.authSource === 'personal' && context.config.roles.author.authSource !== 'personal',
|
|
1056
|
-
approval,
|
|
1057
|
-
issueNumber: input.issueNumber,
|
|
1058
|
-
mergeReadback: {
|
|
1059
|
-
blockedBy: readback.blockedBy,
|
|
1060
|
-
checksPassed: readback.checksPassed,
|
|
1061
|
-
head,
|
|
1062
|
-
mergeable: true,
|
|
1063
|
-
observedAt: new Date().toISOString(),
|
|
1064
|
-
},
|
|
1065
|
-
phase: 'merge',
|
|
1066
|
-
repoRoot: row.path,
|
|
1067
|
-
});
|
|
1068
|
-
const loaded = await loadDeliveryConfig(row.path, {
|
|
1069
|
-
personalAuth: context.clients.authSource === 'personal' && context.config.roles.author.authSource !== 'personal',
|
|
1070
|
-
});
|
|
1071
|
-
const selected = await loadSelectedRepositoryPolicy({
|
|
1072
|
-
repoRoot: row.path,
|
|
1073
|
-
policySourcePath: loaded.config.policy.module,
|
|
1074
|
-
});
|
|
1075
|
-
const boundary = validateRepositoryPolicyBoundary({
|
|
1076
|
-
classification: run.classification,
|
|
1077
|
-
configDigest: loaded.configDigest,
|
|
1078
|
-
currentBase: run.classification.base,
|
|
1079
|
-
currentHead: head,
|
|
1080
|
-
phase: 'merge',
|
|
1081
|
-
policy: selected.policy,
|
|
1082
|
-
policySourcePath: loaded.config.policy.module,
|
|
1083
|
-
repoRoot: row.path,
|
|
1084
|
-
stageReceiptIds: run.aggregate.stages.map((stage) => ({ stageId: stage.stageId, receiptId: stage.receiptId })),
|
|
362
|
+
if (context.clients.role !== 'author' ||
|
|
363
|
+
context.clients.authSource !== (context.config.roles.author.authSource ?? 'app') ||
|
|
364
|
+
!context.clients.authenticatedAuthor)
|
|
365
|
+
throw new DeliveryError('Merge and finish require the configured authenticated author role.');
|
|
366
|
+
const author = await context.clients.authenticatedAuthor();
|
|
367
|
+
if (!author.actorLogin || !author.credentialIdentity)
|
|
368
|
+
throw new DeliveryError('Author identity readback is incomplete.');
|
|
369
|
+
let pr = await verifyIssuePr(context, input.issueNumber, input.prNumber);
|
|
370
|
+
if (input.reviewedHeadSha !== undefined && input.reviewedHeadSha !== pr.head.sha)
|
|
371
|
+
throw new DeliveryError('Requested reviewed head differs from the GitHub PR.');
|
|
372
|
+
if (pr.merged && pr.state === 'closed' && validSha(pr.merge_commit_sha))
|
|
373
|
+
return { mergeSha: pr.merge_commit_sha, headSha: pr.head.sha, reused: true };
|
|
374
|
+
const route = await preflightReviewRoute(context, pr.base.ref);
|
|
375
|
+
if (route.approvalEligibility === 'insufficient-permission')
|
|
376
|
+
throw new DeliveryError('Configured reviewer App is ineligible for the required approval.');
|
|
377
|
+
if (pr.state !== 'open' || pr.draft)
|
|
378
|
+
throw new DeliveryError('GitHub PR must be open and ready for merge.');
|
|
379
|
+
const reviewer = context.reviewerClients ??
|
|
380
|
+
(await createDeliveryGitHubClients({
|
|
381
|
+
config: context.config,
|
|
382
|
+
identity: context.config.roles.reviewer.identity,
|
|
383
|
+
role: 'reviewer',
|
|
384
|
+
selectedAuthor: context.clients,
|
|
385
|
+
}));
|
|
386
|
+
if (reviewer.role !== 'reviewer' || reviewer.authSource !== 'app' || !reviewer.appActorLogin)
|
|
387
|
+
throw new DeliveryError('Independent reviewer App route is unavailable.');
|
|
388
|
+
const actor = await reviewer.appActorLogin();
|
|
389
|
+
if (!pr.user?.login || actor.toLowerCase() === pr.user.login.toLowerCase())
|
|
390
|
+
throw new DeliveryError('Reviewer App must differ from the PR author.');
|
|
391
|
+
const reviews = (await allReviews(context, input.prNumber)).filter((review) => review.user?.login.toLowerCase() === actor.toLowerCase() &&
|
|
392
|
+
review.commit_id === pr.head.sha &&
|
|
393
|
+
review.state === 'APPROVED' &&
|
|
394
|
+
/<!-- ai-delivery-review-artifact: sha256:[a-f0-9]{64} -->/u.test(review.body ?? ''));
|
|
395
|
+
if (reviews.length !== 1)
|
|
396
|
+
throw new DeliveryError('GitHub requires one unambiguous exact-head independent App review.');
|
|
397
|
+
const approved = reviews[0];
|
|
398
|
+
const readReview = (await context.clients.rest.pulls.getReview({ ...context.repo, pull_number: input.prNumber, review_id: approved.id })).data;
|
|
399
|
+
if (readReview.id !== approved.id ||
|
|
400
|
+
readReview.state !== 'APPROVED' ||
|
|
401
|
+
readReview.commit_id !== pr.head.sha ||
|
|
402
|
+
readReview.user?.login.toLowerCase() !== actor.toLowerCase() ||
|
|
403
|
+
readReview.body !== approved.body)
|
|
404
|
+
throw new DeliveryError('Independent GitHub review readback changed or was dismissed.');
|
|
405
|
+
const artifact = parseGitHubReviewArtifact(readReview.body);
|
|
406
|
+
if (artifact.verdict !== 'approve' ||
|
|
407
|
+
artifact.issueNumber !== input.issueNumber ||
|
|
408
|
+
(artifact.prNumber !== null && artifact.prNumber !== input.prNumber) ||
|
|
409
|
+
artifact.authorIdentity !== context.config.roles.author.identity ||
|
|
410
|
+
artifact.reviewerIdentity !== context.config.roles.reviewer.identity ||
|
|
411
|
+
artifact.head.sha !== pr.head.sha)
|
|
412
|
+
throw new DeliveryError('Independent review artifact binding is stale, foreign or rejected.');
|
|
413
|
+
const commit = (await context.clients.rest.git.getCommit({ ...context.repo, commit_sha: pr.head.sha })).data;
|
|
414
|
+
if (commit.sha !== artifact.head.sha || commit.tree.sha !== artifact.head.tree)
|
|
415
|
+
throw new DeliveryError('Independent review tree differs from the remote commit.');
|
|
416
|
+
if (artifact.diffScopeHash !== (await readGitHubPrScopeHash(context, input.prNumber, pr.changed_files)))
|
|
417
|
+
throw new DeliveryError('Independent review scope differs from the remote PR diff.');
|
|
418
|
+
const reviewedHead = artifact.head.sha;
|
|
419
|
+
if (input.reviewedHeadSha !== undefined && input.reviewedHeadSha !== reviewedHead)
|
|
420
|
+
throw new DeliveryError('Requested reviewed head differs from the independent GitHub review.');
|
|
421
|
+
const decision = await readRequiredReviewState(context, input.prNumber, reviewedHead, {
|
|
422
|
+
id: approved.id,
|
|
423
|
+
login: actor,
|
|
1085
424
|
});
|
|
1086
|
-
if (
|
|
1087
|
-
throw new DeliveryError('
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
:
|
|
1092
|
-
const intentContent = {
|
|
1093
|
-
baseBranch: readback.baseBranch,
|
|
1094
|
-
baseSha: readback.baseSha,
|
|
1095
|
-
exactBaseHeadLease: boundary.additionalConstraints.exactBaseHeadLease,
|
|
1096
|
-
evidenceId: prepared?.evidenceId ?? evidence.evidenceId,
|
|
1097
|
-
headBranch: readback.headBranch,
|
|
1098
|
-
headSha: head.sha,
|
|
1099
|
-
headTree: head.tree,
|
|
425
|
+
if (decision.submittedReviewAuthorCanPushToRepository !== true)
|
|
426
|
+
throw new DeliveryError('Specific reviewer App approval is not verifiably qualifying for required review.');
|
|
427
|
+
if (decision.status !== 'satisfied')
|
|
428
|
+
throw new DeliveryError(`GitHub review eligibility is ${decision.status}: ${decision.nextAction}`);
|
|
429
|
+
const blockers = await getNativeBlockerRelationships({
|
|
430
|
+
graphql: context.clients.graphql,
|
|
1100
431
|
issueNumber: input.issueNumber,
|
|
1101
|
-
|
|
1102
|
-
reviewReceiptId: review.receiptId,
|
|
1103
|
-
schemaVersion: 'ai-delivery.merge-intent@2',
|
|
1104
|
-
strategy,
|
|
1105
|
-
};
|
|
1106
|
-
const intent = MergeIntentSchema.parse({ ...intentContent, intentId: digestValue(intentContent) });
|
|
1107
|
-
if (prepared && prepared.intentId !== intent.intentId) {
|
|
1108
|
-
throw new DeliveryError('Prepared merge intent no longer matches reviewed inputs.');
|
|
1109
|
-
}
|
|
1110
|
-
if (!prepared)
|
|
1111
|
-
writePrivateJsonFileAtomically(intentPath, intent);
|
|
1112
|
-
const attempt = await expectedMergeAttempt(context, intent);
|
|
1113
|
-
let mergeSha;
|
|
1114
|
-
if (boundary.additionalConstraints.exactBaseHeadLease) {
|
|
1115
|
-
mergeSha = await mergeWithExactLease(context, {
|
|
1116
|
-
baseBranch: readback.baseBranch,
|
|
1117
|
-
baseSha: readback.baseSha,
|
|
1118
|
-
headBranch: readback.headBranch,
|
|
1119
|
-
headSha: head.sha,
|
|
1120
|
-
headTree: head.tree,
|
|
1121
|
-
title: readback.title,
|
|
1122
|
-
prNumber: input.prNumber,
|
|
1123
|
-
worktreePath: row.path,
|
|
1124
|
-
beforeMutation: () => writePrivateJsonFileAtomically(attemptPath, attempt),
|
|
1125
|
-
onPreparedMergeCommit: (sha) => persistMergeResult(row.path, intent, attempt, sha),
|
|
1126
|
-
});
|
|
1127
|
-
}
|
|
1128
|
-
else {
|
|
1129
|
-
writePrivateJsonFileAtomically(attemptPath, attempt);
|
|
1130
|
-
mergeSha = (await context.clients.rest.pulls.merge({
|
|
1131
|
-
...context.repo,
|
|
1132
|
-
pull_number: input.prNumber,
|
|
1133
|
-
sha: head.sha,
|
|
1134
|
-
merge_method: strategy,
|
|
1135
|
-
})).data.sha;
|
|
1136
|
-
}
|
|
1137
|
-
persistMergeResult(row.path, intent, attempt, mergeSha);
|
|
1138
|
-
return persistMergedResult(context, {
|
|
1139
|
-
branch: row.branch,
|
|
1140
|
-
intent,
|
|
1141
|
-
path: existingPath,
|
|
1142
|
-
worktreePath: row.path,
|
|
1143
|
-
expectedSha: Sha.parse(mergeSha),
|
|
432
|
+
repo: context.repo,
|
|
1144
433
|
});
|
|
434
|
+
if (blockers.blockers.some((value) => value.state === 'OPEN'))
|
|
435
|
+
throw new DeliveryError('GitHub issue has unresolved native blockers.');
|
|
436
|
+
pr = await verifyIssuePr(context, input.issueNumber, input.prNumber);
|
|
437
|
+
if (pr.head.sha !== reviewedHead ||
|
|
438
|
+
pr.state !== 'open' ||
|
|
439
|
+
pr.draft ||
|
|
440
|
+
pr.mergeable !== true ||
|
|
441
|
+
pr.mergeable_state !== 'clean')
|
|
442
|
+
throw new DeliveryError(`GitHub merge eligibility is ${pr.mergeable_state ?? 'unknown'}; CLEAN at the reviewed head is required.`);
|
|
443
|
+
const currentBase = (await context.clients.rest.git.getRef({ ...context.repo, ref: `heads/${pr.base.ref}` })).data
|
|
444
|
+
.object.sha;
|
|
445
|
+
if (!validSha(currentBase) || currentBase !== pr.base.sha)
|
|
446
|
+
throw new DeliveryError('GitHub base moved; refresh clean eligibility and independent review before merge.');
|
|
447
|
+
if (input.dryRun)
|
|
448
|
+
return { dryRun: true, headSha: reviewedHead, prNumber: pr.number };
|
|
449
|
+
const result = (await context.clients.rest.pulls.merge({
|
|
450
|
+
...context.repo,
|
|
451
|
+
pull_number: input.prNumber,
|
|
452
|
+
sha: reviewedHead,
|
|
453
|
+
merge_method: input.strategy ?? 'squash',
|
|
454
|
+
})).data;
|
|
455
|
+
if (!result.merged || !validSha(result.sha))
|
|
456
|
+
throw new DeliveryError('GitHub refused the reviewed-head merge.');
|
|
457
|
+
const readback = await verifyIssuePr(context, input.issueNumber, input.prNumber);
|
|
458
|
+
if (!readback.merged ||
|
|
459
|
+
readback.state !== 'closed' ||
|
|
460
|
+
readback.head.sha !== reviewedHead ||
|
|
461
|
+
readback.merge_commit_sha !== result.sha)
|
|
462
|
+
throw new DeliveryError('GitHub merged PR readback disagrees with the reviewed-head mutation.');
|
|
463
|
+
return { mergeSha: result.sha, headSha: reviewedHead, reused: false };
|
|
1145
464
|
}
|
|
1146
465
|
export async function finishIssue(context, input) {
|
|
1147
|
-
const
|
|
1148
|
-
if (
|
|
1149
|
-
return
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
const metadata = await getConfiguredNativeIssueMetadata({
|
|
1154
|
-
issueNumber: input.issueNumber,
|
|
1155
|
-
owner: context.repo.owner,
|
|
1156
|
-
repo: context.repo.repo,
|
|
1157
|
-
rest: context.clients.rest,
|
|
1158
|
-
settings: nativeIssueSettingsFromDeliveryConfig(context.config),
|
|
1159
|
-
});
|
|
1160
|
-
if (metadata.points === undefined || !context.config.native.points.values.includes(String(metadata.points))) {
|
|
1161
|
-
throw new DeliveryError('Executable issue requires configured native Points before finish.');
|
|
1162
|
-
}
|
|
1163
|
-
const points = metadata.points;
|
|
1164
|
-
const merge = await mergePr(context, input);
|
|
1165
|
-
const review = loadSubmittedReview(existsSync(row.path) ? row.path : context.root, input.issueNumber, merge.headSha);
|
|
1166
|
-
if (!review)
|
|
1167
|
-
throw new DeliveryError('Terminal delivery requires the submitted review receipt.');
|
|
466
|
+
const result = await mergePr(context, input);
|
|
467
|
+
if (input.dryRun)
|
|
468
|
+
return result;
|
|
469
|
+
const pr = await verifyIssuePr(context, input.issueNumber, input.prNumber);
|
|
470
|
+
if (!pr.merged || pr.state !== 'closed' || pr.merge_commit_sha !== result.mergeSha)
|
|
471
|
+
throw new DeliveryError('Intended GitHub PR is not verifiably merged; issue remains open.');
|
|
1168
472
|
const issue = (await context.clients.rest.issues.get({ ...context.repo, issue_number: input.issueNumber })).data;
|
|
473
|
+
if (issue.number !== input.issueNumber || 'pull_request' in issue)
|
|
474
|
+
throw new DeliveryError('Finish target is not the intended GitHub issue.');
|
|
1169
475
|
if (issue.state !== 'closed')
|
|
1170
476
|
await context.clients.rest.issues.update({
|
|
1171
477
|
...context.repo,
|
|
1172
478
|
issue_number: input.issueNumber,
|
|
1173
479
|
state: 'closed',
|
|
480
|
+
state_reason: 'completed',
|
|
1174
481
|
});
|
|
1175
|
-
const
|
|
1176
|
-
if (
|
|
1177
|
-
throw new DeliveryError('
|
|
1178
|
-
|
|
1179
|
-
graphql: context.clients.graphql,
|
|
1180
|
-
issueNodeId: closed.node_id,
|
|
1181
|
-
org: context.config.native.organization,
|
|
1182
|
-
...(context.projectConfiguration ? { configuration: context.projectConfiguration } : {}),
|
|
1183
|
-
settings: projectSettingsFromDeliveryConfig(context.config),
|
|
1184
|
-
status: 'Done',
|
|
1185
|
-
});
|
|
1186
|
-
const branch = baseBranch(context.root, context.configuration?.remote);
|
|
1187
|
-
await fetchMergedBase(context, branch);
|
|
1188
|
-
const pr = (await context.clients.rest.pulls.get({ ...context.repo, pull_number: input.prNumber })).data;
|
|
1189
|
-
const [remoteBase, remoteHead] = await Promise.all([
|
|
1190
|
-
context.clients.rest.git.getRef({ ...context.repo, ref: `heads/${branch}` }),
|
|
1191
|
-
context.clients.rest.git.getRef({ ...context.repo, ref: `heads/issue/${input.issueNumber}` }).catch((error) => {
|
|
1192
|
-
if (error.status === 404)
|
|
1193
|
-
return null;
|
|
1194
|
-
throw error;
|
|
1195
|
-
}),
|
|
1196
|
-
]);
|
|
1197
|
-
if (pr.state !== 'closed' ||
|
|
1198
|
-
!pr.merged_at ||
|
|
1199
|
-
pr.merge_commit_sha !== merge.mergeSha ||
|
|
1200
|
-
pr.head.sha !== merge.headSha ||
|
|
1201
|
-
pr.head.ref !== `issue/${input.issueNumber}` ||
|
|
1202
|
-
pr.base.ref !== branch)
|
|
1203
|
-
throw new DeliveryError('Merged PR drifted before cleanup.');
|
|
1204
|
-
const attestation = {
|
|
1205
|
-
baseBranch: merge.baseBranch,
|
|
1206
|
-
baseSha: merge.baseSha,
|
|
1207
|
-
headBranch: merge.headBranch,
|
|
1208
|
-
headSha: merge.headSha,
|
|
1209
|
-
mergeSha: merge.mergeSha,
|
|
1210
|
-
mergeTree: merge.mergeTree,
|
|
1211
|
-
remoteBaseSha: remoteBase.data.object.sha,
|
|
1212
|
-
remoteHeadSha: remoteHead?.data.object.sha ?? null,
|
|
1213
|
-
strategy: merge.strategy,
|
|
1214
|
-
};
|
|
1215
|
-
const mergedAt = pr.merged_at;
|
|
1216
|
-
if (typeof mergedAt !== 'string' || Date.parse(mergedAt) < Date.parse(row.createdAt)) {
|
|
1217
|
-
throw new DeliveryError('Terminal delivery timeline is invalid.');
|
|
1218
|
-
}
|
|
1219
|
-
await cleanupMergedIssueWorktree({
|
|
1220
|
-
...(context.configuration?.remote ? { remote: context.configuration.remote } : {}),
|
|
1221
|
-
issueNumber: input.issueNumber,
|
|
1222
|
-
repoRoot: context.root,
|
|
1223
|
-
merge: attestation,
|
|
1224
|
-
afterRemoval: async () => {
|
|
1225
|
-
const terminalCleanupAt = new Date().toISOString();
|
|
1226
|
-
const metrics = review.artifact.deliveryMetrics;
|
|
1227
|
-
await recordMergedDelivery({
|
|
1228
|
-
blockerTimeMs: metrics?.blockerTimeMs ?? null,
|
|
1229
|
-
cycleTimeMs: Date.parse(mergedAt) - Date.parse(row.createdAt),
|
|
1230
|
-
firstPassApproved: metrics === undefined ? null : metrics.reviewRound === 1,
|
|
1231
|
-
issueNumber: input.issueNumber,
|
|
1232
|
-
mergedAt,
|
|
1233
|
-
mergeSha: merge.mergeSha,
|
|
1234
|
-
points,
|
|
1235
|
-
recordedAt: terminalCleanupAt,
|
|
1236
|
-
repository: `${context.repo.owner}/${context.repo.repo}`,
|
|
1237
|
-
reviewRounds: metrics?.reviewRound ?? null,
|
|
1238
|
-
schemaVersion: 'ai-delivery.delivery-record@1',
|
|
1239
|
-
terminalCleanupAt,
|
|
1240
|
-
}, context.root);
|
|
1241
|
-
},
|
|
1242
|
-
});
|
|
1243
|
-
await journalIssueCloseout(context, input, merge.mergeSha);
|
|
1244
|
-
return { mergeSha: merge.mergeSha, issueClosed: true, cleaned: true };
|
|
1245
|
-
}
|
|
1246
|
-
async function journalIssueCloseout(context, input, mergeSha) {
|
|
1247
|
-
const issue = (await context.clients.rest.issues.get({ ...context.repo, issue_number: input.issueNumber })).data;
|
|
1248
|
-
const pr = (await context.clients.rest.pulls.get({ ...context.repo, pull_number: input.prNumber })).data;
|
|
1249
|
-
const criteria = acceptanceCriteria(issue.body ?? '');
|
|
1250
|
-
if (!criteria.length)
|
|
1251
|
-
throw new DeliveryError('Closeout requires acceptance criteria with public delivery evidence.');
|
|
1252
|
-
await commentIssue(context, {
|
|
1253
|
-
issueNumber: input.issueNumber,
|
|
1254
|
-
kind: 'closeout',
|
|
1255
|
-
summary: `Merged delivery completed for ${issue.title}.`,
|
|
1256
|
-
status: 'Closed; merged and cleaned',
|
|
1257
|
-
acceptance: criteria.map((criterion) => ({ criterion, evidence: pr.html_url })),
|
|
1258
|
-
followUps: issueFollowUps(issue.body ?? ''),
|
|
1259
|
-
keyNumbers: [`${String(criteria.length)} acceptance criteria`, `PR #${String(input.prNumber)}`],
|
|
1260
|
-
evidence: [pr.html_url],
|
|
1261
|
-
nextStep: 'No further delivery action; see the merged PR for verification and follow-ups',
|
|
1262
|
-
nextDate: null,
|
|
1263
|
-
}, `closeout:${mergeSha}`);
|
|
1264
|
-
}
|
|
1265
|
-
async function readCompletedIssue(context, input) {
|
|
1266
|
-
const pr = (await context.clients.rest.pulls.get({ ...context.repo, pull_number: input.prNumber })).data;
|
|
1267
|
-
if (pr.state !== 'closed' || !pr.merged_at || !pr.merge_commit_sha || pr.head.ref !== `issue/${input.issueNumber}`) {
|
|
1268
|
-
throw new DeliveryError('Absent issue worktree lacks an exact completed PR.');
|
|
1269
|
-
}
|
|
1270
|
-
const receipt = readOptionalPrivate(mergePath(context.root, input.issueNumber, pr.head.sha), (value) => MergeSchema.parse(value));
|
|
1271
|
-
const intent = readOptionalPrivate(mergeIntentPath(context.root, input.issueNumber, pr.head.sha), (value) => MergeIntentSchema.parse(value));
|
|
1272
|
-
if (!receipt ||
|
|
1273
|
-
!intent ||
|
|
1274
|
-
receipt.issueNumber !== input.issueNumber ||
|
|
1275
|
-
receipt.prNumber !== input.prNumber ||
|
|
1276
|
-
receipt.headSha !== pr.head.sha ||
|
|
1277
|
-
receipt.mergeSha !== pr.merge_commit_sha ||
|
|
1278
|
-
receipt.intentId !== intent.intentId) {
|
|
1279
|
-
throw new DeliveryError('Absent issue worktree lacks exact terminal merge receipts.');
|
|
1280
|
-
}
|
|
1281
|
-
const result = await readMergedResult(context, intent, receipt.mergeSha);
|
|
1282
|
-
const { schemaVersion: _version, ...intentContent } = intent;
|
|
1283
|
-
if (result.mergeTree !== receipt.mergeTree ||
|
|
1284
|
-
receipt.mergeId !== digestValue({ ...intentContent, ...result, schemaVersion: 'ai-delivery.merge@3' })) {
|
|
1285
|
-
throw new DeliveryError('Completed merge receipt disagrees with its intent or remote result.');
|
|
1286
|
-
}
|
|
1287
|
-
const records = getDeliveryRecords(context.root).filter((record) => record.repository === `${context.repo.owner}/${context.repo.repo}` &&
|
|
1288
|
-
record.issueNumber === input.issueNumber &&
|
|
1289
|
-
record.mergeSha === receipt.mergeSha);
|
|
1290
|
-
if (records.length !== 1)
|
|
1291
|
-
throw new DeliveryError('Completed issue lacks one exact terminal delivery record.');
|
|
1292
|
-
const issue = (await context.clients.rest.issues.get({ ...context.repo, issue_number: input.issueNumber })).data;
|
|
1293
|
-
if (issue.state !== 'closed' || 'pull_request' in issue) {
|
|
1294
|
-
throw new DeliveryError('Completed issue is not closed.');
|
|
1295
|
-
}
|
|
1296
|
-
const project = await getIssueProjectStatus({
|
|
1297
|
-
graphql: context.clients.graphql,
|
|
1298
|
-
issueNodeId: issue.node_id,
|
|
1299
|
-
org: context.config.native.organization,
|
|
1300
|
-
...(context.projectConfiguration ? { configuration: context.projectConfiguration } : {}),
|
|
1301
|
-
settings: projectSettingsFromDeliveryConfig(context.config),
|
|
1302
|
-
});
|
|
1303
|
-
if (project?.status !== 'Done')
|
|
1304
|
-
throw new DeliveryError('Completed issue Project status is not Done.');
|
|
1305
|
-
await journalIssueCloseout(context, input, receipt.mergeSha);
|
|
1306
|
-
return { mergeSha: receipt.mergeSha, issueClosed: true, cleaned: true };
|
|
482
|
+
const readback = (await context.clients.rest.issues.get({ ...context.repo, issue_number: input.issueNumber })).data;
|
|
483
|
+
if (readback.number !== input.issueNumber || readback.state !== 'closed')
|
|
484
|
+
throw new DeliveryError('GitHub issue closure readback disagrees.');
|
|
485
|
+
return { ...result, issueClosed: true };
|
|
1307
486
|
}
|
|
487
|
+
export { startIssueBranch };
|
|
1308
488
|
//# sourceMappingURL=pr.js.map
|