@aviaratech/ai-delivery 0.3.23 → 0.3.25
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 +166 -16
- package/README.md +75 -446
- package/dist/agent.d.ts +5 -11
- package/dist/agent.js +4 -8
- package/dist/agent.js.map +1 -1
- package/dist/agentReadiness.test.d.ts +1 -0
- package/dist/agentReadiness.test.js +139 -0
- package/dist/agentReadiness.test.js.map +1 -0
- package/dist/cli.js +27 -111
- 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/contributorChecks.test.d.ts +1 -0
- package/dist/contributorChecks.test.js +1010 -0
- package/dist/contributorChecks.test.js.map +1 -0
- package/dist/currentConsumer.test.d.ts +1 -0
- package/dist/currentConsumer.test.js +832 -0
- package/dist/currentConsumer.test.js.map +1 -0
- package/dist/delivery/common.js +7 -2
- 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/dependencyCompatibility.test.d.ts +1 -0
- package/dist/dependencyCompatibility.test.js +175 -0
- package/dist/dependencyCompatibility.test.js.map +1 -0
- package/dist/directoryIndependent.test.d.ts +1 -0
- package/dist/directoryIndependent.test.js +352 -0
- package/dist/directoryIndependent.test.js.map +1 -0
- package/dist/dispatch.d.ts +29 -3
- package/dist/dispatch.js +166 -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 +18 -6
- package/dist/issue.js +136 -319
- package/dist/issue.js.map +1 -1
- package/dist/issueJournal.d.ts +1 -1
- package/dist/issueJournal.test.js +88 -6
- package/dist/issueJournal.test.js.map +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/tools.d.ts +32 -42
- package/dist/mcp/tools.js +51 -42
- package/dist/mcp/tools.js.map +1 -1
- package/dist/pluginInstaller.d.ts +1 -1
- package/dist/pluginInstaller.js +8 -2
- package/dist/pluginInstaller.js.map +1 -1
- package/dist/pluginInstaller.test.js +213 -2
- package/dist/pluginInstaller.test.js.map +1 -1
- package/dist/pluginPackaging.test.js +225 -2
- package/dist/pluginPackaging.test.js.map +1 -1
- package/dist/pr.d.ts +73 -114
- package/dist/pr.js +837 -1085
- package/dist/pr.js.map +1 -1
- package/dist/pr.test.js +453 -236
- package/dist/pr.test.js.map +1 -1
- package/dist/prAssociation.test.d.ts +1 -0
- package/dist/prAssociation.test.js +1336 -0
- package/dist/prAssociation.test.js.map +1 -0
- package/dist/prDispatch.test.d.ts +1 -0
- package/dist/prDispatch.test.js +96 -0
- package/dist/prDispatch.test.js.map +1 -0
- package/dist/releaseReconciliation.test.d.ts +1 -0
- package/dist/releaseReconciliation.test.js +318 -0
- package/dist/releaseReconciliation.test.js.map +1 -0
- package/dist/releaseWorkflow.test.d.ts +1 -0
- package/dist/releaseWorkflow.test.js +447 -0
- package/dist/releaseWorkflow.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 +4 -1
- 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.test.js +10 -5
- package/dist/setup.test.js.map +1 -1
- package/dist/stagedChecks.test.d.ts +1 -0
- package/dist/stagedChecks.test.js +228 -0
- package/dist/stagedChecks.test.js.map +1 -0
- 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 +1 -27
- package/dist/verification.js +5 -1332
- 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 +9 -6
- package/plugins/ai-delivery/.claude-plugin/plugin.json +2 -2
- package/plugins/ai-delivery/README.md +3 -3
- package/plugins/ai-delivery/plugin.json +1 -1
- package/plugins/ai-delivery/runtime/dist/THIRD-PARTY-NOTICES.md +1 -1
- package/plugins/ai-delivery/runtime/dist/cli.js +50 -59
- package/plugins/ai-delivery/runtime/package.json +1 -1
- 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,464 @@
|
|
|
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
|
-
const PublicationSchema = z
|
|
28
|
-
.object({
|
|
29
|
-
baseSha: Sha,
|
|
30
|
-
evidenceId: Digest,
|
|
31
|
-
headSha: Sha,
|
|
32
|
-
issueNumber: z.number().int().positive(),
|
|
33
|
-
prNumber: z.number().int().positive(),
|
|
34
|
-
publicationId: Digest,
|
|
35
|
-
schemaVersion: z.literal('ai-delivery.publication@1'),
|
|
36
|
-
})
|
|
37
|
-
.strict()
|
|
38
|
-
.superRefine((value, ctx) => {
|
|
39
|
-
const { publicationId, ...content } = value;
|
|
40
|
-
if (publicationId !== digestValue(content))
|
|
41
|
-
ctx.addIssue({ code: 'custom', message: 'Publication identity is invalid.' });
|
|
42
|
-
});
|
|
43
|
-
const MergeStrategy = z.enum(['merge', 'squash', 'rebase']);
|
|
44
|
-
const MergeIntentShape = z
|
|
45
|
-
.object({
|
|
46
|
-
baseBranch: z.string().regex(/^[A-Za-z0-9._/-]+$/u),
|
|
47
|
-
baseSha: Sha,
|
|
48
|
-
evidenceId: Digest,
|
|
49
|
-
exactBaseHeadLease: z.boolean(),
|
|
50
|
-
headBranch: z.string().regex(/^[A-Za-z0-9._/-]+$/u),
|
|
51
|
-
headSha: Sha,
|
|
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`);
|
|
4
|
+
import { issueBranch, linkedIssueBranches, 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
|
+
// Conservative refusals also cover Markdown decoration/escaped references.
|
|
9
|
+
// This is a closure safety check, never authority for an issue association.
|
|
10
|
+
function refuseClosingDirective(text) {
|
|
11
|
+
if (typeof text !== 'string')
|
|
12
|
+
throw new DeliveryError('Incomplete non-closing message readback.');
|
|
13
|
+
const normalized = text.replace(/[*_~`\\<>[\]]/gu, '');
|
|
14
|
+
if (/\b(?:close[sd]?|fix(?:es|ed)?|resolve[sd]?)\b(?:\s*:\s*|\s+)[^\n]*?(?:#[0-9]+|[A-Za-z0-9.-]+\/[A-Za-z0-9.-]+#[0-9]+|https?:\/\/(?:www\.)?github\.com\/[A-Za-z0-9.-]+\/[A-Za-z0-9.-]+\/(?:issues|pull)\/[0-9]+)/iu.test(normalized))
|
|
15
|
+
throw new DeliveryError('Non-closing delivery refuses closing directives in body, title or commit messages.');
|
|
112
16
|
}
|
|
113
|
-
function
|
|
114
|
-
return
|
|
17
|
+
function referenceLine(context, issueNumber) {
|
|
18
|
+
return `References ${context.config.repository}#${issueNumber}`;
|
|
115
19
|
}
|
|
116
|
-
function
|
|
117
|
-
|
|
20
|
+
function nonClosingBody(context, issueNumber, requested) {
|
|
21
|
+
refuseClosingDirective(requested);
|
|
22
|
+
const line = referenceLine(context, issueNumber);
|
|
23
|
+
const body = requested.startsWith(`${line}\n`) || requested === line ? requested : `${line}\n\n${requested}`;
|
|
24
|
+
verifyReferenceBody(context, issueNumber, body);
|
|
25
|
+
return body;
|
|
118
26
|
}
|
|
119
|
-
function
|
|
120
|
-
|
|
27
|
+
function verifyReferenceBody(context, issueNumber, body) {
|
|
28
|
+
refuseClosingDirective(body);
|
|
29
|
+
const lines = body.split(/\r?\n/u);
|
|
30
|
+
if (lines[0] !== referenceLine(context, issueNumber) ||
|
|
31
|
+
lines.filter((line) => /^References\s/iu.test(line)).length !== 1)
|
|
32
|
+
throw new DeliveryError('Non-closing PR body must retain one leading canonical References line.');
|
|
121
33
|
}
|
|
122
|
-
function
|
|
123
|
-
|
|
34
|
+
function pageCursor(connection, seen) {
|
|
35
|
+
const info = connection.pageInfo;
|
|
36
|
+
if (!info ||
|
|
37
|
+
typeof info.hasNextPage !== 'boolean' ||
|
|
38
|
+
!(info.endCursor === null || typeof info.endCursor === 'string'))
|
|
39
|
+
throw new DeliveryError('Incomplete non-closing native pagination.');
|
|
40
|
+
if (!info.hasNextPage)
|
|
41
|
+
return null;
|
|
42
|
+
if (!info.endCursor || seen.has(info.endCursor))
|
|
43
|
+
throw new DeliveryError('Non-closing native pagination did not advance.');
|
|
44
|
+
seen.add(info.endCursor);
|
|
45
|
+
return info.endCursor;
|
|
124
46
|
}
|
|
125
|
-
function
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
47
|
+
async function openReferenceIssue(context, issueNumber) {
|
|
48
|
+
const issue = (await context.clients.rest.issues.get({ ...context.repo, issue_number: issueNumber })).data;
|
|
49
|
+
if (issue.number !== issueNumber ||
|
|
50
|
+
'pull_request' in issue ||
|
|
51
|
+
issue.state !== 'open' ||
|
|
52
|
+
typeof issue.node_id !== 'string' ||
|
|
53
|
+
!issue.node_id ||
|
|
54
|
+
typeof issue.title !== 'string' ||
|
|
55
|
+
typeof issue.body !== 'string' ||
|
|
56
|
+
issue.html_url?.toLowerCase() !==
|
|
57
|
+
`https://github.com/${context.config.repository}/issues/${issueNumber}`.toLowerCase())
|
|
58
|
+
throw new DeliveryError('Non-closing delivery requires the exact canonical OPEN issue and complete title/body.');
|
|
59
|
+
return issue;
|
|
60
|
+
}
|
|
61
|
+
function sameReferencePr(before, after, expectedMerge = false) {
|
|
62
|
+
if (expectedMerge
|
|
63
|
+
? before.state !== 'open' ||
|
|
64
|
+
before.draft ||
|
|
65
|
+
before.merged ||
|
|
66
|
+
after.state !== 'closed' ||
|
|
67
|
+
after.draft ||
|
|
68
|
+
!after.merged
|
|
69
|
+
: before.state !== after.state || before.draft !== after.draft || before.merged !== after.merged)
|
|
70
|
+
throw new DeliveryError('Non-closing PR state/draft/merged readback drifted; reconcile read-only.');
|
|
71
|
+
if (before.node_id !== after.node_id ||
|
|
72
|
+
before.head.sha !== after.head.sha ||
|
|
73
|
+
before.head.ref !== after.head.ref ||
|
|
74
|
+
before.base.sha !== after.base.sha ||
|
|
75
|
+
before.base.ref !== after.base.ref ||
|
|
76
|
+
before.body !== after.body ||
|
|
77
|
+
before.title !== after.title ||
|
|
78
|
+
before.user.login.toLowerCase() !== after.user.login.toLowerCase())
|
|
79
|
+
throw new DeliveryError('Non-closing PR identity, reference, message or head/base drifted; reconcile read-only.');
|
|
80
|
+
}
|
|
81
|
+
function sameReferenceIssue(before, after) {
|
|
82
|
+
if (before.node_id !== after.node_id || before.title !== after.title || before.body !== after.body)
|
|
83
|
+
throw new DeliveryError('Non-closing issue identity/title/criteria drifted; reconcile read-only.');
|
|
84
|
+
}
|
|
85
|
+
function nonClosingMode(value) {
|
|
86
|
+
if (value !== undefined && typeof value !== 'boolean')
|
|
87
|
+
throw new DeliveryError('nonClosing must be a boolean.');
|
|
88
|
+
return value === true;
|
|
89
|
+
}
|
|
90
|
+
async function nonClosingRoute(context, base) {
|
|
91
|
+
const authorContext = context.clients.role === 'author'
|
|
92
|
+
? context
|
|
93
|
+
: {
|
|
94
|
+
...context,
|
|
95
|
+
clients: await createDeliveryGitHubClients({
|
|
96
|
+
config: context.config,
|
|
97
|
+
identity: context.config.roles.author.identity,
|
|
98
|
+
role: 'author',
|
|
99
|
+
}),
|
|
100
|
+
reviewerClients: context.clients,
|
|
101
|
+
};
|
|
102
|
+
const route = await preflightReviewRoute(authorContext, base);
|
|
103
|
+
if (route.rules.visibility !== 'complete' || route.approvalEligibility === 'insufficient-permission')
|
|
104
|
+
throw new DeliveryError('Non-closing delivery requires complete native rule visibility and an independent reviewer route.');
|
|
105
|
+
return route;
|
|
106
|
+
}
|
|
107
|
+
async function verifyNonClosingCommits(context, pr) {
|
|
108
|
+
let cursor = null;
|
|
109
|
+
const cursors = new Set();
|
|
110
|
+
const commits = new Map();
|
|
111
|
+
let total;
|
|
112
|
+
for (let page = 0; page < 20; page++) {
|
|
113
|
+
const data = await context.clients.graphql('query DeliveryNonClosingCommits($owner:String!,$repo:String!,$number:Int!,$cursor:String){repository(owner:$owner,name:$repo){nameWithOwner pullRequest(number:$number){id number headRefOid baseRefOid baseRefName commits(first:100,after:$cursor){totalCount nodes{commit{oid message parents(first:100){totalCount nodes{oid} pageInfo{hasNextPage endCursor}}}} pageInfo{hasNextPage endCursor}}}}}', { ...context.repo, number: pr.number, cursor });
|
|
114
|
+
const live = data.repository?.pullRequest;
|
|
115
|
+
const connection = live?.commits;
|
|
116
|
+
if (data.repository?.nameWithOwner?.toLowerCase() !== context.config.repository.toLowerCase() ||
|
|
117
|
+
live?.id !== pr.node_id ||
|
|
118
|
+
live.number !== pr.number ||
|
|
119
|
+
live.headRefOid !== pr.head.sha ||
|
|
120
|
+
live.baseRefOid !== pr.base.sha ||
|
|
121
|
+
live.baseRefName !== pr.base.ref ||
|
|
122
|
+
!connection ||
|
|
123
|
+
!Array.isArray(connection.nodes) ||
|
|
124
|
+
connection.nodes.length > 100 ||
|
|
125
|
+
!Number.isSafeInteger(connection.totalCount) ||
|
|
126
|
+
connection.totalCount < 1 ||
|
|
127
|
+
connection.totalCount > 2000 ||
|
|
128
|
+
(total !== undefined && total !== connection.totalCount))
|
|
129
|
+
throw new DeliveryError('Incomplete or moved introduced commit graph at the non-closing head/base.');
|
|
130
|
+
total = connection.totalCount;
|
|
131
|
+
for (const node of connection.nodes) {
|
|
132
|
+
const commit = node?.commit;
|
|
133
|
+
if (!commit ||
|
|
134
|
+
!validSha(commit.oid) ||
|
|
135
|
+
commits.has(commit.oid) ||
|
|
136
|
+
!commit.parents ||
|
|
137
|
+
!Array.isArray(commit.parents.nodes) ||
|
|
138
|
+
commit.parents.nodes.length > 100 ||
|
|
139
|
+
!Number.isSafeInteger(commit.parents.totalCount) ||
|
|
140
|
+
commit.parents.totalCount < 1 ||
|
|
141
|
+
commit.parents.totalCount !== commit.parents.nodes.length ||
|
|
142
|
+
commit.parents.pageInfo?.hasNextPage !== false ||
|
|
143
|
+
commit.parents.nodes.some((parent) => !validSha(parent?.oid)))
|
|
144
|
+
throw new DeliveryError('Incomplete, duplicate or truncated introduced commit identity/parents.');
|
|
145
|
+
refuseClosingDirective(commit.message);
|
|
146
|
+
commits.set(commit.oid, commit.message);
|
|
147
|
+
}
|
|
148
|
+
cursor = pageCursor(connection, cursors);
|
|
149
|
+
if (cursor === null) {
|
|
150
|
+
if (commits.size !== total || !commits.has(pr.head.sha))
|
|
151
|
+
throw new DeliveryError('Introduced commit count/head is incomplete.');
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
137
154
|
}
|
|
138
|
-
|
|
139
|
-
|
|
155
|
+
throw new DeliveryError('Non-closing commit graph exceeds the bounded readback window.');
|
|
156
|
+
}
|
|
157
|
+
async function verifyUnpublishedCommits(context, baseSha, headSha) {
|
|
158
|
+
const commits = new Set();
|
|
159
|
+
let total;
|
|
160
|
+
for (let page = 1; page <= 20; page++) {
|
|
161
|
+
const comparison = (await context.clients.rest.repos.compareCommitsWithBasehead({
|
|
162
|
+
...context.repo,
|
|
163
|
+
basehead: `${baseSha}...${headSha}`,
|
|
164
|
+
per_page: 100,
|
|
165
|
+
page,
|
|
166
|
+
})).data;
|
|
167
|
+
if (comparison.base_commit?.sha !== baseSha ||
|
|
168
|
+
!validSha(comparison.merge_base_commit?.sha) ||
|
|
169
|
+
!['ahead', 'diverged'].includes(comparison.status) ||
|
|
170
|
+
!Number.isSafeInteger(comparison.total_commits) ||
|
|
171
|
+
comparison.total_commits < 1 ||
|
|
172
|
+
comparison.total_commits > 2000 ||
|
|
173
|
+
(total !== undefined && total !== comparison.total_commits) ||
|
|
174
|
+
!Array.isArray(comparison.commits) ||
|
|
175
|
+
comparison.commits.length > 100)
|
|
176
|
+
throw new DeliveryError('Incomplete introduced commit range before non-closing publication.');
|
|
177
|
+
total = comparison.total_commits;
|
|
178
|
+
for (const commit of comparison.commits) {
|
|
179
|
+
if (!validSha(commit.sha) ||
|
|
180
|
+
commits.has(commit.sha) ||
|
|
181
|
+
!Array.isArray(commit.parents) ||
|
|
182
|
+
commit.parents.length < 1 ||
|
|
183
|
+
commit.parents.length > 100 ||
|
|
184
|
+
commit.parents.some((parent) => !validSha(parent.sha)))
|
|
185
|
+
throw new DeliveryError('Incomplete or duplicate introduced commit identity.');
|
|
186
|
+
refuseClosingDirective(commit.commit?.message);
|
|
187
|
+
commits.add(commit.sha);
|
|
188
|
+
}
|
|
189
|
+
if (commits.size === total && commits.has(headSha))
|
|
190
|
+
return;
|
|
191
|
+
if (comparison.commits.length < 100)
|
|
192
|
+
throw new DeliveryError('Truncated introduced commit range before publication.');
|
|
140
193
|
}
|
|
194
|
+
throw new DeliveryError('Introduced commit range exceeds the bounded readback window.');
|
|
141
195
|
}
|
|
142
|
-
function
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
196
|
+
async function verifyNonClosingReference(context, issueNumber, pr) {
|
|
197
|
+
if (typeof pr.node_id !== 'string' ||
|
|
198
|
+
!pr.node_id ||
|
|
199
|
+
pr.html_url?.toLowerCase() !== `https://github.com/${context.config.repository}/pull/${pr.number}`.toLowerCase())
|
|
200
|
+
throw new DeliveryError('Incomplete non-closing PR node identity.');
|
|
201
|
+
verifyReferenceBody(context, issueNumber, pr.body);
|
|
202
|
+
refuseClosingDirective(pr.title);
|
|
203
|
+
const issue = await openReferenceIssue(context, issueNumber);
|
|
204
|
+
const repository = (await context.clients.rest.repos.get({ ...context.repo })).data;
|
|
205
|
+
if (repository.full_name?.toLowerCase() !== context.config.repository.toLowerCase() ||
|
|
206
|
+
pr.base.ref !== repository.default_branch ||
|
|
207
|
+
typeof repository.node_id !== 'string' ||
|
|
208
|
+
!repository.node_id)
|
|
209
|
+
throw new DeliveryError('Non-closing PR must target the canonical default repository/base.');
|
|
210
|
+
const route = await nonClosingRoute(context, pr.base.ref);
|
|
211
|
+
if (pr.user.login.toLowerCase() !== route.author.actorLogin.toLowerCase())
|
|
212
|
+
throw new DeliveryError('Non-closing PR author differs from the configured author.');
|
|
213
|
+
let cursor = null;
|
|
214
|
+
const cursors = new Set();
|
|
215
|
+
const events = new Map();
|
|
216
|
+
let matched = false;
|
|
217
|
+
for (let page = 0; page < 20; page++) {
|
|
218
|
+
const data = await context.clients.graphql('query DeliveryNonClosingReferences($owner:String!,$repo:String!,$number:Int!,$cursor:String){repository(owner:$owner,name:$repo){id nameWithOwner issue(number:$number){id number state timelineItems(first:100,after:$cursor,itemTypes:[CROSS_REFERENCED_EVENT]){nodes{... on CrossReferencedEvent{id actor{login} isCrossRepository willCloseTarget source{__typename ... on Issue{id number repository{id nameWithOwner}} ... on PullRequest{id number repository{id nameWithOwner}}} target{__typename ... on Issue{id number repository{id nameWithOwner}} ... on PullRequest{id number repository{id nameWithOwner}}}}} pageInfo{hasNextPage endCursor}}}}}', { ...context.repo, number: issueNumber, cursor });
|
|
219
|
+
const target = data.repository?.issue;
|
|
220
|
+
const connection = target?.timelineItems;
|
|
221
|
+
if (data.repository?.id !== repository.node_id ||
|
|
222
|
+
data.repository.nameWithOwner?.toLowerCase() !== context.config.repository.toLowerCase() ||
|
|
223
|
+
target?.id !== issue.node_id ||
|
|
224
|
+
target.number !== issueNumber ||
|
|
225
|
+
target.state !== 'OPEN' ||
|
|
226
|
+
!connection ||
|
|
227
|
+
!Array.isArray(connection.nodes) ||
|
|
228
|
+
connection.nodes.length > 100)
|
|
229
|
+
throw new DeliveryError('Incomplete or conflicting native non-closing issue identity.');
|
|
230
|
+
for (const event of connection.nodes) {
|
|
231
|
+
const source = event?.source;
|
|
232
|
+
const referenced = event?.target;
|
|
233
|
+
if (!event ||
|
|
234
|
+
!nativeId(event.id) ||
|
|
235
|
+
!source ||
|
|
236
|
+
!referenced ||
|
|
237
|
+
!['Issue', 'PullRequest'].includes(source.__typename) ||
|
|
238
|
+
!nativeId(source.id) ||
|
|
239
|
+
!Number.isSafeInteger(source.number) ||
|
|
240
|
+
source.number <= 0 ||
|
|
241
|
+
!nativeId(source.repository?.id) ||
|
|
242
|
+
typeof source.repository.nameWithOwner !== 'string' ||
|
|
243
|
+
referenced.__typename !== 'Issue' ||
|
|
244
|
+
referenced.id !== issue.node_id ||
|
|
245
|
+
referenced.number !== issueNumber ||
|
|
246
|
+
referenced.repository?.id !== repository.node_id ||
|
|
247
|
+
referenced.repository.nameWithOwner?.toLowerCase() !== context.config.repository.toLowerCase() ||
|
|
248
|
+
typeof event.isCrossRepository !== 'boolean' ||
|
|
249
|
+
typeof event.willCloseTarget !== 'boolean' ||
|
|
250
|
+
!nativeId(event.actor?.login))
|
|
251
|
+
throw new DeliveryError('Incomplete native cross-reference event identity/actor.');
|
|
252
|
+
const signature = JSON.stringify([
|
|
253
|
+
source.__typename,
|
|
254
|
+
source.id,
|
|
255
|
+
source.number,
|
|
256
|
+
source.repository.id,
|
|
257
|
+
source.repository.nameWithOwner.toLowerCase(),
|
|
258
|
+
referenced.id,
|
|
259
|
+
referenced.number,
|
|
260
|
+
referenced.repository.id,
|
|
261
|
+
referenced.repository.nameWithOwner.toLowerCase(),
|
|
262
|
+
event.actor.login.toLowerCase(),
|
|
263
|
+
event.isCrossRepository,
|
|
264
|
+
event.willCloseTarget,
|
|
265
|
+
]);
|
|
266
|
+
if (events.has(event.id) && events.get(event.id) !== signature)
|
|
267
|
+
throw new DeliveryError('Conflicting duplicate native reference event.');
|
|
268
|
+
events.set(event.id, signature);
|
|
269
|
+
if (source.id === pr.node_id ||
|
|
270
|
+
(source.__typename === 'PullRequest' &&
|
|
271
|
+
source.number === pr.number &&
|
|
272
|
+
source.repository.nameWithOwner.toLowerCase() === context.config.repository.toLowerCase())) {
|
|
273
|
+
if (source.__typename !== 'PullRequest' ||
|
|
274
|
+
source.id !== pr.node_id ||
|
|
275
|
+
source.number !== pr.number ||
|
|
276
|
+
source.repository.id !== repository.node_id ||
|
|
277
|
+
source.repository.nameWithOwner.toLowerCase() !== context.config.repository.toLowerCase() ||
|
|
278
|
+
event.isCrossRepository ||
|
|
279
|
+
event.willCloseTarget !== false ||
|
|
280
|
+
event.actor.login.toLowerCase() !== route.author.actorLogin.toLowerCase())
|
|
281
|
+
throw new DeliveryError('Conflicting, closing or unauthorized native PR reference.');
|
|
282
|
+
matched = true;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
cursor = pageCursor(connection, cursors);
|
|
286
|
+
if (cursor === null)
|
|
287
|
+
break;
|
|
288
|
+
if (page === 19)
|
|
289
|
+
throw new DeliveryError('Native non-closing references exceed the bounded readback window.');
|
|
146
290
|
}
|
|
147
|
-
|
|
148
|
-
throw new DeliveryError('
|
|
291
|
+
if (!matched)
|
|
292
|
+
throw new DeliveryError('Missing authorized native non-closing PR-to-issue reference.');
|
|
293
|
+
await verifyPrIssueAssociation(context, pr.number, issueNumber, true, pr.node_id);
|
|
294
|
+
await verifyNonClosingCommits(context, pr);
|
|
295
|
+
if (!pr.merged) {
|
|
296
|
+
const ref = (await context.clients.rest.git.getRef({ ...context.repo, ref: `heads/${pr.base.ref}` })).data;
|
|
297
|
+
if (ref.ref !== `refs/heads/${pr.base.ref}` || ref.object.type !== 'commit' || ref.object.sha !== pr.base.sha)
|
|
298
|
+
throw new DeliveryError('Non-closing default base has moved or is incomplete.');
|
|
149
299
|
}
|
|
150
|
-
|
|
300
|
+
const after = await openReferenceIssue(context, issueNumber);
|
|
301
|
+
sameReferenceIssue(issue, after);
|
|
302
|
+
sameReferencePr(pr, await getPr(context, pr.number));
|
|
303
|
+
return issue;
|
|
151
304
|
}
|
|
152
|
-
|
|
153
|
-
return
|
|
305
|
+
function nativeId(value) {
|
|
306
|
+
return typeof value === 'string' && value.trim().length > 0;
|
|
154
307
|
}
|
|
155
|
-
function
|
|
156
|
-
|
|
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
|
-
}
|
|
308
|
+
function validSha(value) {
|
|
309
|
+
return typeof value === 'string' && Sha.test(value);
|
|
161
310
|
}
|
|
162
|
-
function
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
311
|
+
function validBranch(value) {
|
|
312
|
+
if (typeof value !== 'string' ||
|
|
313
|
+
!Branch.test(value) ||
|
|
314
|
+
value.startsWith('/') ||
|
|
315
|
+
value.endsWith('/') ||
|
|
316
|
+
value.endsWith('.lock'))
|
|
317
|
+
throw new DeliveryError('Invalid GitHub branch name.');
|
|
169
318
|
}
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
319
|
+
async function getPr(context, prNumber) {
|
|
320
|
+
if (context.config.repository.toLowerCase() !== `${context.repo.owner}/${context.repo.repo}`.toLowerCase())
|
|
321
|
+
throw new DeliveryError('Configured and selected GitHub repository identities disagree.');
|
|
322
|
+
const pr = (await context.clients.rest.pulls.get({ ...context.repo, pull_number: prNumber })).data;
|
|
323
|
+
if (pr.number !== prNumber ||
|
|
324
|
+
typeof pr.base.repo?.full_name !== 'string' ||
|
|
325
|
+
typeof pr.head.repo?.full_name !== 'string' ||
|
|
326
|
+
pr.base.repo?.full_name.toLowerCase() !== context.config.repository.toLowerCase() ||
|
|
327
|
+
pr.head.repo?.full_name.toLowerCase() !== context.config.repository.toLowerCase() ||
|
|
328
|
+
!validSha(pr.head.sha) ||
|
|
329
|
+
!validSha(pr.base.sha) ||
|
|
330
|
+
(pr.state !== 'open' && pr.state !== 'closed') ||
|
|
331
|
+
typeof pr.merged !== 'boolean' ||
|
|
332
|
+
typeof pr.draft !== 'boolean' ||
|
|
333
|
+
(pr.merged && pr.state !== 'closed'))
|
|
334
|
+
throw new DeliveryError('GitHub PR identity or exact head readback is invalid.');
|
|
335
|
+
validBranch(pr.head.ref);
|
|
336
|
+
validBranch(pr.base.ref);
|
|
337
|
+
return pr;
|
|
338
|
+
}
|
|
339
|
+
async function verifyPrIssueAssociation(context, prNumber, issueNumber, nonClosing = false, expectedPrId, onlyIntended = false, expectedIssueId, expectedRepositoryId) {
|
|
340
|
+
let cursor = null;
|
|
341
|
+
const cursors = new Set();
|
|
342
|
+
let matches = 0;
|
|
343
|
+
for (let page = 0; page < 20; page++) {
|
|
344
|
+
const result = await context.clients.graphql('query DeliveryClosingIssues($owner:String!,$repo:String!,$number:Int!,$cursor:String){repository(owner:$owner,name:$repo){nameWithOwner pullRequest(number:$number){id number closingIssuesReferences(first:100,after:$cursor){nodes{__typename id number repository{id nameWithOwner}} pageInfo{hasNextPage endCursor}}}}}', { ...context.repo, number: prNumber, cursor });
|
|
345
|
+
const connection = result.repository?.pullRequest?.closingIssuesReferences;
|
|
346
|
+
if (typeof result.repository?.nameWithOwner !== 'string' ||
|
|
347
|
+
result.repository.nameWithOwner.toLowerCase() !== context.config.repository.toLowerCase() ||
|
|
348
|
+
result.repository?.pullRequest?.number !== prNumber ||
|
|
349
|
+
(expectedPrId !== undefined && result.repository?.pullRequest?.id !== expectedPrId) ||
|
|
350
|
+
!connection ||
|
|
351
|
+
!Array.isArray(connection.nodes) ||
|
|
352
|
+
connection.nodes.length > 100 ||
|
|
353
|
+
typeof connection.pageInfo?.hasNextPage !== 'boolean' ||
|
|
354
|
+
!(connection.pageInfo.endCursor === null || typeof connection.pageInfo.endCursor === 'string'))
|
|
355
|
+
throw new DeliveryError('Incomplete or conflicting native GitHub PR issue association readback.');
|
|
356
|
+
for (const issue of connection.nodes) {
|
|
357
|
+
if (onlyIntended &&
|
|
358
|
+
(!issue ||
|
|
359
|
+
issue.__typename !== 'Issue' ||
|
|
360
|
+
!nativeId(expectedIssueId) ||
|
|
361
|
+
issue.id !== expectedIssueId ||
|
|
362
|
+
!nativeId(expectedRepositoryId) ||
|
|
363
|
+
issue.repository?.id !== expectedRepositoryId))
|
|
364
|
+
throw new DeliveryError('Incomplete or conflicting typed closing issue/repository identity before body transition.');
|
|
365
|
+
if (nonClosing)
|
|
366
|
+
throw new DeliveryError('Non-closing PR has an unintended native closing association.');
|
|
367
|
+
if (!issue ||
|
|
368
|
+
!Number.isSafeInteger(issue.number) ||
|
|
369
|
+
issue.number <= 0 ||
|
|
370
|
+
typeof issue.repository?.nameWithOwner !== 'string' ||
|
|
371
|
+
!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(issue.repository.nameWithOwner))
|
|
372
|
+
throw new DeliveryError('Incomplete native PR closing-issue identity.');
|
|
373
|
+
if (issue.number === issueNumber &&
|
|
374
|
+
issue.repository.nameWithOwner.toLowerCase() === context.config.repository.toLowerCase())
|
|
375
|
+
matches++;
|
|
376
|
+
else if (onlyIntended)
|
|
377
|
+
throw new DeliveryError('Existing PR has conflicting closing associations; body transition refused.');
|
|
208
378
|
}
|
|
209
|
-
|
|
210
|
-
|
|
379
|
+
if (!connection.pageInfo.hasNextPage) {
|
|
380
|
+
if (!nonClosing && matches !== 1)
|
|
381
|
+
throw new DeliveryError('Native GitHub PR closing-issue association does not identify the intended issue.');
|
|
382
|
+
return;
|
|
211
383
|
}
|
|
212
|
-
|
|
384
|
+
const next = connection.pageInfo.endCursor;
|
|
385
|
+
if (!next || cursors.has(next))
|
|
386
|
+
throw new DeliveryError('Native PR issue pagination did not advance.');
|
|
387
|
+
cursors.add(next);
|
|
388
|
+
cursor = next;
|
|
389
|
+
}
|
|
390
|
+
throw new DeliveryError('Native PR issue association exceeds the bounded readback window.');
|
|
213
391
|
}
|
|
214
|
-
async function
|
|
215
|
-
|
|
392
|
+
async function matchingPrs(context, branch, base) {
|
|
393
|
+
const matches = [];
|
|
394
|
+
for (let page = 1; page <= 20; page++) {
|
|
395
|
+
const batch = (await context.clients.rest.pulls.list({
|
|
396
|
+
...context.repo,
|
|
397
|
+
head: `${context.repo.owner}:${branch}`,
|
|
398
|
+
...(base === undefined ? {} : { base }),
|
|
399
|
+
state: 'all',
|
|
400
|
+
page,
|
|
401
|
+
per_page: 100,
|
|
402
|
+
})).data;
|
|
403
|
+
if (!Array.isArray(batch) || batch.some((pr) => !pr || !Number.isSafeInteger(pr.number) || pr.number <= 0))
|
|
404
|
+
throw new DeliveryError('Incomplete GitHub PR match readback.');
|
|
405
|
+
matches.push(...batch);
|
|
406
|
+
if (batch.length < 100)
|
|
407
|
+
return matches;
|
|
408
|
+
}
|
|
409
|
+
throw new DeliveryError('GitHub PR match history exceeds the bounded readback window.');
|
|
216
410
|
}
|
|
217
|
-
async function
|
|
218
|
-
const
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
411
|
+
async function verifyIssuePr(context, issueNumber, prNumber, nonClosing = false) {
|
|
412
|
+
const pr = await getPr(context, prNumber);
|
|
413
|
+
if (nonClosing)
|
|
414
|
+
await verifyNonClosingReference(context, issueNumber, pr);
|
|
415
|
+
else
|
|
416
|
+
await verifyPrIssueAssociation(context, prNumber, issueNumber);
|
|
417
|
+
if (!pr.merged) {
|
|
418
|
+
// GitHub replaces the issue's branch connection with its PR on publication.
|
|
419
|
+
// Existing connections remain consistency evidence, never a fallback authority.
|
|
420
|
+
const branches = await linkedIssueBranches(context, issueNumber);
|
|
421
|
+
if (branches.length > 0) {
|
|
422
|
+
const matches = branches.filter((branch) => branch.name === pr.head.ref);
|
|
423
|
+
if (matches.length !== 1 || matches[0].sha !== pr.head.sha)
|
|
424
|
+
throw new DeliveryError('Native linked issue branches conflict with the exact PR head/ref.');
|
|
425
|
+
}
|
|
426
|
+
const ref = (await context.clients.rest.git.getRef({ ...context.repo, ref: `heads/${pr.head.ref}` })).data;
|
|
427
|
+
if (ref.ref !== `refs/heads/${pr.head.ref}` || ref.object.type !== 'commit' || ref.object.sha !== pr.head.sha)
|
|
428
|
+
throw new DeliveryError('Current remote PR head/ref readback is incomplete or has moved.');
|
|
224
429
|
}
|
|
225
|
-
|
|
430
|
+
return pr;
|
|
226
431
|
}
|
|
227
432
|
export async function prInfo(context, input) {
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
433
|
+
const nonClosing = nonClosingMode(input.nonClosing);
|
|
434
|
+
if (nonClosing && (input.issueNumber === undefined || input.prNumber === undefined))
|
|
435
|
+
throw new DeliveryError('Non-closing inspection requires explicit canonical issue and PR numbers.');
|
|
436
|
+
let number = input.prNumber;
|
|
437
|
+
if (number === undefined) {
|
|
438
|
+
if (input.issueNumber === undefined)
|
|
439
|
+
throw new DeliveryError('prNumber or issueNumber is required.');
|
|
440
|
+
const branches = await linkedIssueBranches(context, input.issueNumber);
|
|
441
|
+
if (branches.length !== 1)
|
|
442
|
+
throw new DeliveryError('Issue-only PR inspection requires one GitHub-linked issue branch. After publication, supply the exact issue and PR numbers (CLI --issue and --pr) to verify the native PR closing-issue association.');
|
|
443
|
+
const branch = branches[0].name;
|
|
444
|
+
const matches = await matchingPrs(context, branch);
|
|
445
|
+
if (matches.length !== 1)
|
|
446
|
+
throw new DeliveryError('Select an explicit PR number when the remote issue branch has zero or multiple PRs.');
|
|
447
|
+
number = matches[0].number;
|
|
239
448
|
}
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
state:
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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 };
|
|
449
|
+
const pr = input.issueNumber === undefined
|
|
450
|
+
? await getPr(context, number)
|
|
451
|
+
: await verifyIssuePr(context, input.issueNumber, number, nonClosing);
|
|
452
|
+
return {
|
|
453
|
+
prNumber: pr.number,
|
|
454
|
+
url: pr.html_url,
|
|
455
|
+
state: pr.state,
|
|
456
|
+
draft: pr.draft,
|
|
457
|
+
headSha: pr.head.sha,
|
|
458
|
+
headBranch: pr.head.ref,
|
|
459
|
+
baseBranch: pr.base.ref,
|
|
460
|
+
merged: pr.merged,
|
|
461
|
+
};
|
|
263
462
|
}
|
|
264
463
|
export async function listPrs(context, state = 'open') {
|
|
265
464
|
const prs = await context.clients.rest.paginate(context.clients.rest.pulls.list, {
|
|
@@ -299,42 +498,6 @@ export async function prChecks(context, prNumber) {
|
|
|
299
498
|
})),
|
|
300
499
|
};
|
|
301
500
|
}
|
|
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
501
|
function requiredApprovalCount(value) {
|
|
339
502
|
if (typeof value !== 'object' || value === null)
|
|
340
503
|
return null;
|
|
@@ -342,7 +505,15 @@ function requiredApprovalCount(value) {
|
|
|
342
505
|
return typeof count === 'number' && Number.isSafeInteger(count) && count >= 0 ? count : null;
|
|
343
506
|
}
|
|
344
507
|
/** Check selected actors and rule evidence; publication also requires the configured author source. */
|
|
345
|
-
export async function preflightReviewRoute(context, base
|
|
508
|
+
export async function preflightReviewRoute(context, base, selectedReviewer, purpose = 'publication', control) {
|
|
509
|
+
if (base === undefined) {
|
|
510
|
+
try {
|
|
511
|
+
base = (await context.clients.rest.repos.get({ ...context.repo })).data.default_branch;
|
|
512
|
+
}
|
|
513
|
+
catch {
|
|
514
|
+
throw new DeliveryError('Author cannot read the selected GitHub repository; verify configured access.');
|
|
515
|
+
}
|
|
516
|
+
}
|
|
346
517
|
const check = async () => {
|
|
347
518
|
await control?.checkResources();
|
|
348
519
|
control?.signal.throwIfAborted();
|
|
@@ -364,6 +535,7 @@ export async function preflightReviewRoute(context, base = baseBranch(context.ro
|
|
|
364
535
|
throw new DeliveryError('Author GitHub identity readback is incomplete.');
|
|
365
536
|
}
|
|
366
537
|
const reviewer = selectedReviewer ??
|
|
538
|
+
context.reviewerClients ??
|
|
367
539
|
(await createDeliveryGitHubClients({
|
|
368
540
|
config: context.config,
|
|
369
541
|
identity: context.config.roles.reviewer.identity,
|
|
@@ -458,851 +630,431 @@ export async function preflightReviewRoute(context, base = baseBranch(context.ro
|
|
|
458
630
|
};
|
|
459
631
|
}
|
|
460
632
|
export async function publishPr(context, input) {
|
|
461
|
-
const
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
if (input.
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
633
|
+
const nonClosing = nonClosingMode(input.nonClosing);
|
|
634
|
+
if (input.prNumber !== undefined && (!Number.isSafeInteger(input.prNumber) || input.prNumber <= 0))
|
|
635
|
+
throw new DeliveryError('Existing PR selector must be a positive safe integer.');
|
|
636
|
+
const issue = nonClosing
|
|
637
|
+
? await openReferenceIssue(context, input.issueNumber)
|
|
638
|
+
: (await context.clients.rest.issues.get({ ...context.repo, issue_number: input.issueNumber })).data;
|
|
639
|
+
if ('pull_request' in issue || issue.number !== input.issueNumber || issue.state !== 'open')
|
|
640
|
+
throw new DeliveryError('PR creation requires the requested open GitHub issue.');
|
|
641
|
+
const repository = (await context.clients.rest.repos.get({ ...context.repo })).data;
|
|
642
|
+
if (repository.full_name?.toLowerCase() !== context.config.repository.toLowerCase() ||
|
|
643
|
+
context.config.repository.toLowerCase() !== `${context.repo.owner}/${context.repo.repo}`.toLowerCase())
|
|
644
|
+
throw new DeliveryError('Configured and selected GitHub repository identities disagree.');
|
|
645
|
+
if (nonClosing && !nativeId(repository.node_id))
|
|
646
|
+
throw new DeliveryError('Incomplete canonical non-closing repository node identity.');
|
|
647
|
+
const base = input.baseBranch ?? repository.default_branch;
|
|
648
|
+
if (base !== repository.default_branch)
|
|
649
|
+
throw new DeliveryError('PR delivery requires the repository default branch for durable issue association.');
|
|
650
|
+
validBranch(base);
|
|
651
|
+
if (input.headBranch !== undefined)
|
|
652
|
+
validBranch(input.headBranch);
|
|
653
|
+
const explicit = input.prNumber === undefined ? undefined : await getPr(context, input.prNumber);
|
|
654
|
+
const explicitPromotion = input.draft === false && input.headBranch !== undefined;
|
|
655
|
+
const selectedMatches = explicit
|
|
656
|
+
? await matchingPrs(context, explicit.head.ref, base)
|
|
657
|
+
: explicitPromotion
|
|
658
|
+
? await matchingPrs(context, input.headBranch, base)
|
|
659
|
+
: undefined;
|
|
660
|
+
const branch = explicit
|
|
661
|
+
? explicit.head.ref
|
|
662
|
+
: explicitPromotion && selectedMatches.length > 0
|
|
663
|
+
? input.headBranch
|
|
664
|
+
: await issueBranch(context, input.issueNumber, input.headBranch);
|
|
665
|
+
if (input.headBranch !== undefined && branch !== input.headBranch)
|
|
666
|
+
throw new DeliveryError('Explicit PR and head selectors disagree.');
|
|
667
|
+
validBranch(branch);
|
|
668
|
+
const ref = (await context.clients.rest.git.getRef({ ...context.repo, ref: `heads/${branch}` })).data;
|
|
669
|
+
if (ref.ref !== `refs/heads/${branch}` || ref.object.type !== 'commit' || !validSha(ref.object.sha))
|
|
670
|
+
throw new DeliveryError('Remote issue branch head/ref readback is invalid.');
|
|
671
|
+
const matches = selectedMatches ?? (await matchingPrs(context, branch, base));
|
|
672
|
+
if (matches.length > 1 || (explicit && (matches.length !== 1 || matches[0].number !== explicit.number)))
|
|
673
|
+
throw new DeliveryError('Remote issue branch has conflicting PR matches.');
|
|
674
|
+
let pr = explicit ?? (matches[0] === undefined ? undefined : await getPr(context, matches[0].number));
|
|
675
|
+
const requestedBody = input.body ??
|
|
676
|
+
(nonClosing ? (pr?.body ?? referenceLine(context, input.issueNumber)) : `Closes #${input.issueNumber}`);
|
|
677
|
+
if (!requestedBody.trim())
|
|
678
|
+
throw new DeliveryError('PR body must contain text.');
|
|
679
|
+
const body = nonClosing
|
|
680
|
+
? nonClosingBody(context, input.issueNumber, requestedBody)
|
|
681
|
+
: new RegExp(`(?:close[sd]?|fix(?:es|ed)?|resolve[sd]?)\\s+#${input.issueNumber}(?![0-9])`, 'iu').test(requestedBody)
|
|
682
|
+
? requestedBody
|
|
683
|
+
: `${requestedBody}\n\nCloses #${input.issueNumber}`;
|
|
684
|
+
const transition = nonClosing && pr !== undefined && pr.body !== body;
|
|
685
|
+
if (transition && (input.prNumber === undefined || input.body === undefined))
|
|
686
|
+
throw new DeliveryError('Non-closing body transition requires an explicit existing PR number and body.');
|
|
687
|
+
if (pr &&
|
|
688
|
+
(pr.head.sha !== ref.object.sha ||
|
|
689
|
+
pr.head.ref !== branch ||
|
|
690
|
+
pr.base.ref !== base ||
|
|
691
|
+
pr.state !== 'open' ||
|
|
692
|
+
pr.merged ||
|
|
693
|
+
(!transition && input.body !== undefined && pr.body !== body) ||
|
|
694
|
+
(input.title !== undefined && pr.title !== input.title)))
|
|
695
|
+
throw new DeliveryError('Existing remote PR conflicts with the requested branch, intent or state.');
|
|
696
|
+
const reviewRoute = nonClosing ? await nonClosingRoute(context, base) : await preflightReviewRoute(context, base);
|
|
697
|
+
if (pr && pr.user.login.toLowerCase() !== reviewRoute.author.actorLogin.toLowerCase())
|
|
698
|
+
throw new DeliveryError('Existing PR author differs from the configured authenticated author.');
|
|
699
|
+
if (pr && (typeof pr.node_id !== 'string' || !pr.node_id))
|
|
700
|
+
throw new DeliveryError('Existing PR publication identity is incomplete.');
|
|
701
|
+
if (nonClosing)
|
|
702
|
+
refuseClosingDirective(input.title ?? pr?.title ?? issue.title);
|
|
703
|
+
const creationTitle = input.title ?? issue.title;
|
|
704
|
+
let publicationBaseSha;
|
|
705
|
+
const existing = pr;
|
|
706
|
+
let pendingTransition = transition;
|
|
707
|
+
let expectedBody = pr?.body;
|
|
708
|
+
const transitionDraft = pr?.draft;
|
|
709
|
+
let promotionApplied = false;
|
|
710
|
+
const preserveIssue = async () => {
|
|
711
|
+
if (nonClosing)
|
|
712
|
+
sameReferenceIssue(issue, await openReferenceIssue(context, input.issueNumber));
|
|
713
|
+
};
|
|
714
|
+
const readExisting = async () => {
|
|
715
|
+
const current = await verifyIssuePr(context, input.issueNumber, existing.number, nonClosing && !pendingTransition);
|
|
716
|
+
if (current.state !== 'open' ||
|
|
717
|
+
current.merged ||
|
|
718
|
+
current.node_id !== existing.node_id ||
|
|
719
|
+
current.head.sha !== ref.object.sha ||
|
|
720
|
+
current.head.ref !== branch ||
|
|
721
|
+
current.base.ref !== base ||
|
|
722
|
+
current.base.sha !== existing.base.sha ||
|
|
723
|
+
current.user.login.toLowerCase() !== reviewRoute.author.actorLogin.toLowerCase() ||
|
|
724
|
+
(transition && !promotionApplied && current.draft !== transitionDraft) ||
|
|
725
|
+
current.body !== expectedBody ||
|
|
726
|
+
current.title !== existing.title)
|
|
727
|
+
throw new DeliveryError('Existing PR publication head, identity, intent or author readback drifted.');
|
|
728
|
+
if (pendingTransition) {
|
|
729
|
+
await verifyPrIssueAssociation(context, current.number, input.issueNumber, false, current.node_id, true, issue.node_id, repository.node_id);
|
|
730
|
+
refuseClosingDirective(current.title);
|
|
731
|
+
await verifyNonClosingCommits(context, current);
|
|
732
|
+
const baseRef = (await context.clients.rest.git.getRef({ ...context.repo, ref: `heads/${base}` })).data;
|
|
733
|
+
if (baseRef.ref !== `refs/heads/${base}` ||
|
|
734
|
+
baseRef.object.type !== 'commit' ||
|
|
735
|
+
baseRef.object.sha !== current.base.sha)
|
|
736
|
+
throw new DeliveryError('Non-closing transition default base drifted.');
|
|
737
|
+
sameReferencePr(current, await getPr(context, current.number));
|
|
479
738
|
}
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
739
|
+
await preserveIssue();
|
|
740
|
+
return current;
|
|
741
|
+
};
|
|
742
|
+
const readNew = async () => {
|
|
743
|
+
const baseRef = (await context.clients.rest.git.getRef({ ...context.repo, ref: `heads/${base}` })).data;
|
|
744
|
+
if (baseRef.ref !== `refs/heads/${base}` || baseRef.object.type !== 'commit' || !validSha(baseRef.object.sha))
|
|
745
|
+
throw new DeliveryError('Incomplete publication default base.');
|
|
746
|
+
if (publicationBaseSha !== undefined && publicationBaseSha !== baseRef.object.sha)
|
|
747
|
+
throw new DeliveryError('Publication default base drifted before creation.');
|
|
748
|
+
publicationBaseSha = baseRef.object.sha;
|
|
749
|
+
if (nonClosing)
|
|
750
|
+
await verifyUnpublishedCommits(context, baseRef.object.sha, ref.object.sha);
|
|
751
|
+
const afterHead = (await context.clients.rest.git.getRef({ ...context.repo, ref: `heads/${branch}` })).data;
|
|
752
|
+
const afterBase = (await context.clients.rest.git.getRef({ ...context.repo, ref: `heads/${base}` })).data;
|
|
753
|
+
if (afterHead.ref !== ref.ref ||
|
|
754
|
+
afterHead.object.type !== 'commit' ||
|
|
755
|
+
afterHead.object.sha !== ref.object.sha ||
|
|
756
|
+
afterBase.ref !== baseRef.ref ||
|
|
757
|
+
afterBase.object.type !== 'commit' ||
|
|
758
|
+
afterBase.object.sha !== baseRef.object.sha)
|
|
759
|
+
throw new DeliveryError('Non-closing publication head/base drifted.');
|
|
760
|
+
await preserveIssue();
|
|
761
|
+
};
|
|
762
|
+
if (pr)
|
|
763
|
+
pr = await readExisting();
|
|
764
|
+
else
|
|
765
|
+
await readNew();
|
|
766
|
+
if (input.dryRun)
|
|
484
767
|
return {
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
768
|
+
dryRun: true,
|
|
769
|
+
...(pr === undefined ? {} : { prNumber: pr.number, draft: pr.draft }),
|
|
770
|
+
...(nonClosing
|
|
771
|
+
? {
|
|
772
|
+
nonClosing: true,
|
|
773
|
+
nonClosingVerified: pr !== undefined && !pendingTransition,
|
|
774
|
+
body,
|
|
775
|
+
bodyTransition: pendingTransition,
|
|
776
|
+
}
|
|
777
|
+
: {}),
|
|
778
|
+
headSha: ref.object.sha,
|
|
779
|
+
headBranch: branch,
|
|
780
|
+
baseBranch: base,
|
|
781
|
+
reviewRoute,
|
|
489
782
|
};
|
|
783
|
+
let created = false;
|
|
784
|
+
if (pr) {
|
|
785
|
+
if (pendingTransition) {
|
|
786
|
+
pr = await readExisting();
|
|
787
|
+
await context.clients.rest.pulls.update({ ...context.repo, pull_number: pr.number, body });
|
|
788
|
+
pendingTransition = false;
|
|
789
|
+
expectedBody = body;
|
|
790
|
+
pr = await readExisting();
|
|
791
|
+
}
|
|
792
|
+
if (input.draft === false && pr.draft) {
|
|
793
|
+
pr = await readExisting();
|
|
794
|
+
if (pr.draft) {
|
|
795
|
+
const promoted = await context.clients.graphql('mutation DeliveryReadyPr($id:ID!){markPullRequestReadyForReview(input:{pullRequestId:$id}){pullRequest{id isDraft}}}', { id: pr.node_id });
|
|
796
|
+
if (promoted?.markPullRequestReadyForReview?.pullRequest?.id !== pr.node_id ||
|
|
797
|
+
promoted.markPullRequestReadyForReview.pullRequest.isDraft !== false)
|
|
798
|
+
throw new DeliveryError('PR promotion mutation readback is incomplete or conflicting; reconcile this PR.');
|
|
799
|
+
promotionApplied = true;
|
|
800
|
+
}
|
|
801
|
+
pr = await readExisting();
|
|
802
|
+
if (pr.draft)
|
|
803
|
+
throw new DeliveryError('PR promotion readback is still draft.');
|
|
804
|
+
}
|
|
490
805
|
}
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
throw new DeliveryError('Remote base changed after exact-head verification.');
|
|
517
|
-
const reviewRoute = await preflightReviewRoute(context, baseBranch(root, context.configuration?.remote));
|
|
518
|
-
const matches = (await context.clients.rest.pulls.list({
|
|
519
|
-
...context.repo,
|
|
520
|
-
head: `${context.repo.owner}:${row.branch}`,
|
|
521
|
-
state: 'open',
|
|
522
|
-
per_page: 100,
|
|
523
|
-
})).data;
|
|
524
|
-
if (matches.length > 1)
|
|
525
|
-
throw new DeliveryError('Multiple open PRs match the registered branch.');
|
|
526
|
-
const existing = matches[0];
|
|
527
|
-
if (existing?.user?.login && existing.user.login.toLowerCase() !== reviewRoute.author.actorLogin.toLowerCase()) {
|
|
528
|
-
throw new DeliveryError('Existing PR author differs from the configured author actor; inspect the PR and resolve the actor mismatch explicitly.');
|
|
806
|
+
else {
|
|
807
|
+
await readNew();
|
|
808
|
+
let response;
|
|
809
|
+
try {
|
|
810
|
+
response = await context.clients.rest.pulls.create({
|
|
811
|
+
...context.repo,
|
|
812
|
+
head: branch,
|
|
813
|
+
base,
|
|
814
|
+
title: creationTitle,
|
|
815
|
+
body,
|
|
816
|
+
draft: input.draft !== false,
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
catch (error) {
|
|
820
|
+
throw new DeliveryError(`PR creation acknowledgement is uncertain; reconcile the remote head read-only before any new mutation. ${error instanceof Error ? error.message : 'Unknown failure'}`);
|
|
821
|
+
}
|
|
822
|
+
if (!Number.isSafeInteger(response.data.number) || response.data.number <= 0)
|
|
823
|
+
throw new DeliveryError('PR creation acknowledgement lacks a valid PR identity; reconcile the remote head read-only, do not create again.');
|
|
824
|
+
created = true;
|
|
825
|
+
try {
|
|
826
|
+
pr = await getPr(context, response.data.number);
|
|
827
|
+
}
|
|
828
|
+
catch (error) {
|
|
829
|
+
throw new DeliveryError(`GitHub returned created PR #${response.data.number}; identity readback is unresolved. Reconcile read-only; do not create again. ${error instanceof Error ? error.message : 'Unknown failure'}`);
|
|
830
|
+
}
|
|
529
831
|
}
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
832
|
+
try {
|
|
833
|
+
if (created &&
|
|
834
|
+
(pr.body !== body ||
|
|
835
|
+
pr.title !== creationTitle ||
|
|
836
|
+
pr.draft !== (input.draft !== false) ||
|
|
837
|
+
pr.state !== 'open' ||
|
|
838
|
+
pr.merged ||
|
|
839
|
+
pr.base.sha !== publicationBaseSha))
|
|
840
|
+
throw new DeliveryError('Created PR body, title, draft/open state or default base differs from publication intent.');
|
|
841
|
+
if (pr.head.sha !== ref.object.sha || pr.head.ref !== branch || pr.base.ref !== base)
|
|
842
|
+
throw new DeliveryError('GitHub PR creation readback drifted.');
|
|
843
|
+
if (pr.user.login.toLowerCase() !== reviewRoute.author.actorLogin.toLowerCase())
|
|
844
|
+
throw new DeliveryError('Published PR author differs from the configured authenticated author.');
|
|
845
|
+
if (nonClosing) {
|
|
846
|
+
const verified = await verifyIssuePr(context, input.issueNumber, pr.number, true);
|
|
847
|
+
sameReferencePr(pr, verified);
|
|
848
|
+
pr = verified;
|
|
849
|
+
await preserveIssue();
|
|
850
|
+
}
|
|
851
|
+
else
|
|
852
|
+
await verifyPrIssueAssociation(context, pr.number, input.issueNumber);
|
|
853
|
+
if (created) {
|
|
854
|
+
const final = await getPr(context, pr.number);
|
|
855
|
+
sameReferencePr(pr, final);
|
|
856
|
+
pr = final;
|
|
857
|
+
}
|
|
543
858
|
}
|
|
544
|
-
|
|
545
|
-
|
|
859
|
+
catch (error) {
|
|
860
|
+
if (!created)
|
|
861
|
+
throw error;
|
|
862
|
+
throw new DeliveryError(`GitHub created PR #${pr.number} (${pr.html_url}) at ${pr.head.sha}; post-publication association/identity verification is unresolved. Reconcile this PR read-only; do not create again. ${error instanceof Error ? error.message : 'Unknown failure'}`);
|
|
546
863
|
}
|
|
547
|
-
|
|
548
|
-
baseSha: remoteHead,
|
|
549
|
-
evidenceId: evidence.evidenceId,
|
|
550
|
-
headSha: head.sha,
|
|
551
|
-
issueNumber: input.issueNumber,
|
|
864
|
+
return {
|
|
552
865
|
prNumber: pr.number,
|
|
553
|
-
|
|
866
|
+
url: pr.html_url,
|
|
867
|
+
headSha: pr.head.sha,
|
|
868
|
+
draft: pr.draft,
|
|
869
|
+
reviewRoute,
|
|
870
|
+
...(nonClosing ? { nonClosing: true } : {}),
|
|
554
871
|
};
|
|
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 };
|
|
566
872
|
}
|
|
567
873
|
export async function submitFormalReview(context, input) {
|
|
568
|
-
const
|
|
569
|
-
const
|
|
570
|
-
const
|
|
571
|
-
|
|
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,
|
|
874
|
+
const nonClosing = nonClosingMode(input.nonClosing);
|
|
875
|
+
const pr = await verifyIssuePr(context, input.issueNumber, input.prNumber, nonClosing);
|
|
876
|
+
const issue = nonClosing ? await openReferenceIssue(context, input.issueNumber) : undefined;
|
|
877
|
+
const result = await submitReview({
|
|
576
878
|
context,
|
|
879
|
+
artifact: parseReviewArtifact(input.artifact),
|
|
577
880
|
issueNumber: input.issueNumber,
|
|
578
881
|
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
|
-
},
|
|
882
|
+
...(input.dryRun === undefined ? {} : { dryRun: input.dryRun }),
|
|
663
883
|
});
|
|
664
|
-
if (
|
|
665
|
-
|
|
666
|
-
|
|
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.');
|
|
884
|
+
if (nonClosing) {
|
|
885
|
+
sameReferencePr(pr, await verifyIssuePr(context, input.issueNumber, input.prNumber, true));
|
|
886
|
+
sameReferenceIssue(issue, await openReferenceIssue(context, input.issueNumber));
|
|
680
887
|
}
|
|
681
|
-
return
|
|
888
|
+
return result;
|
|
682
889
|
}
|
|
683
|
-
async function
|
|
684
|
-
const
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
throw new DeliveryError('Merged PR does not match the exact prepared merge intent.');
|
|
890
|
+
async function allReviews(context, prNumber) {
|
|
891
|
+
const reviews = [];
|
|
892
|
+
for (let page = 1; page <= 20; page++) {
|
|
893
|
+
const batch = (await context.clients.rest.pulls.listReviews({ ...context.repo, pull_number: prNumber, page, per_page: 100 })).data;
|
|
894
|
+
reviews.push(...batch);
|
|
895
|
+
if (batch.length < 100)
|
|
896
|
+
return reviews;
|
|
691
897
|
}
|
|
692
|
-
|
|
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
|
-
}
|
|
724
|
-
async function expectedMergeAttempt(context, intent) {
|
|
725
|
-
if (context.clients.role !== 'author' || !context.clients.authenticatedAuthor) {
|
|
726
|
-
throw new DeliveryError('Merge recovery requires authenticated author identity readback.');
|
|
727
|
-
}
|
|
728
|
-
const verified = await context.clients.authenticatedAuthor();
|
|
729
|
-
if (!verified.actorLogin || !verified.credentialIdentity) {
|
|
730
|
-
throw new DeliveryError('Authenticated author identity readback is incomplete.');
|
|
731
|
-
}
|
|
732
|
-
const content = {
|
|
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
|
-
};
|
|
898
|
+
throw new DeliveryError('GitHub review history exceeds the bounded readback window.');
|
|
931
899
|
}
|
|
932
900
|
export async function mergePr(context, input) {
|
|
933
|
-
const
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
const
|
|
946
|
-
const
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
branch: row.branch,
|
|
951
|
-
issueNumber: input.issueNumber,
|
|
952
|
-
path: row.path,
|
|
953
|
-
projectRoot: context.root,
|
|
954
|
-
status: 'merged',
|
|
955
|
-
});
|
|
956
|
-
return existing;
|
|
957
|
-
}
|
|
958
|
-
if (receiptRoot !== row.path) {
|
|
959
|
-
throw new DeliveryError('Removed merged worktree lacks its terminal merge receipt.');
|
|
960
|
-
}
|
|
961
|
-
const intentPath = mergeIntentPath(row.path, input.issueNumber, head.sha);
|
|
962
|
-
const prepared = readOptionalPrivate(intentPath, (value) => MergeIntentSchema.parse(value));
|
|
963
|
-
const attemptPath = mergeAttemptPath(row.path, input.issueNumber, head.sha);
|
|
964
|
-
const priorAttempt = readOptionalPrivate(attemptPath, (value) => MergeAttemptSchema.parse(value));
|
|
965
|
-
const priorResult = readOptionalPrivate(mergeResultPath(row.path, input.issueNumber, head.sha), (value) => MergeResultSchema.parse(value));
|
|
966
|
-
if ((priorAttempt !== null || priorResult !== null) && prepared === null) {
|
|
967
|
-
throw new DeliveryError('Merge operation record lacks its prepared intent.');
|
|
968
|
-
}
|
|
969
|
-
const review = loadSubmittedReview(row.path, input.issueNumber, head.sha);
|
|
970
|
-
if (!review || review.prNumber !== input.prNumber || review.publicationEvidenceId !== publication.evidenceId) {
|
|
971
|
-
throw new DeliveryError('Exact publication lacks its bound submitted independent review.');
|
|
972
|
-
}
|
|
973
|
-
await assertSubmittedReviewCurrent(context, review);
|
|
974
|
-
if (prepared) {
|
|
975
|
-
if (prepared.issueNumber !== input.issueNumber ||
|
|
976
|
-
prepared.prNumber !== input.prNumber ||
|
|
977
|
-
prepared.headSha !== head.sha ||
|
|
978
|
-
prepared.headTree !== head.tree ||
|
|
979
|
-
prepared.reviewReceiptId !== review.receiptId ||
|
|
980
|
-
prepared.headBranch !== row.branch ||
|
|
981
|
-
prepared.baseSha !== run.classification.base.sha ||
|
|
982
|
-
(input.strategy !== undefined && input.strategy !== prepared.strategy)) {
|
|
983
|
-
throw new DeliveryError('Prepared merge intent disagrees with this retry.');
|
|
984
|
-
}
|
|
985
|
-
if (priorAttempt !== null) {
|
|
986
|
-
const expected = await expectedMergeAttempt(context, prepared);
|
|
987
|
-
if (priorAttempt.attemptId !== expected.attemptId ||
|
|
988
|
-
(priorResult !== null && priorResult.attemptId !== priorAttempt.attemptId)) {
|
|
989
|
-
throw new DeliveryError('Merge attempt disagrees with its exact source, operation or author.');
|
|
990
|
-
}
|
|
991
|
-
const remote = (await context.clients.rest.pulls.get({ ...context.repo, pull_number: input.prNumber })).data;
|
|
992
|
-
if (remote.state !== 'closed' || !remote.merged_at) {
|
|
993
|
-
throw new DeliveryError('Merge mutation was attempted; remote readback is unresolved. Retry readback without another mutation.');
|
|
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.');
|
|
901
|
+
const nonClosing = nonClosingMode(input.nonClosing);
|
|
902
|
+
if (nonClosing && input.strategy !== undefined && input.strategy !== 'squash')
|
|
903
|
+
throw new DeliveryError('Non-closing merge requires squash with explicit safe messages.');
|
|
904
|
+
if (context.clients.role !== 'author' ||
|
|
905
|
+
context.clients.authSource !== (context.config.roles.author.authSource ?? 'app') ||
|
|
906
|
+
!context.clients.authenticatedAuthor)
|
|
907
|
+
throw new DeliveryError('Merge and finish require the configured authenticated author role.');
|
|
908
|
+
const author = await context.clients.authenticatedAuthor();
|
|
909
|
+
if (!author.actorLogin || !author.credentialIdentity)
|
|
910
|
+
throw new DeliveryError('Author identity readback is incomplete.');
|
|
911
|
+
let pr = await verifyIssuePr(context, input.issueNumber, input.prNumber, nonClosing);
|
|
912
|
+
const initialPr = pr;
|
|
913
|
+
const issue = nonClosing ? await openReferenceIssue(context, input.issueNumber) : undefined;
|
|
914
|
+
const squash = nonClosing
|
|
915
|
+
? {
|
|
916
|
+
commit_title: pr.title,
|
|
917
|
+
commit_message: `${referenceLine(context, input.issueNumber)}\n\nDelivered by PR #${pr.number}.`,
|
|
1020
918
|
}
|
|
919
|
+
: undefined;
|
|
920
|
+
if (squash) {
|
|
921
|
+
refuseClosingDirective(squash.commit_title);
|
|
922
|
+
refuseClosingDirective(squash.commit_message);
|
|
1021
923
|
}
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
:
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
924
|
+
if (input.reviewedHeadSha !== undefined && input.reviewedHeadSha !== pr.head.sha)
|
|
925
|
+
throw new DeliveryError('Requested reviewed head differs from the GitHub PR.');
|
|
926
|
+
if (pr.merged && pr.state === 'closed' && validSha(pr.merge_commit_sha))
|
|
927
|
+
return { mergeSha: pr.merge_commit_sha, headSha: pr.head.sha, reused: true };
|
|
928
|
+
const route = await preflightReviewRoute(context, pr.base.ref);
|
|
929
|
+
if (route.approvalEligibility === 'insufficient-permission')
|
|
930
|
+
throw new DeliveryError('Configured reviewer App is ineligible for the required approval.');
|
|
931
|
+
if (pr.state !== 'open' || pr.draft)
|
|
932
|
+
throw new DeliveryError('GitHub PR must be open and ready for merge.');
|
|
933
|
+
const reviewer = context.reviewerClients ??
|
|
934
|
+
(await createDeliveryGitHubClients({
|
|
935
|
+
config: context.config,
|
|
936
|
+
identity: context.config.roles.reviewer.identity,
|
|
937
|
+
role: 'reviewer',
|
|
938
|
+
selectedAuthor: context.clients,
|
|
939
|
+
}));
|
|
940
|
+
if (reviewer.role !== 'reviewer' || reviewer.authSource !== 'app' || !reviewer.appActorLogin)
|
|
941
|
+
throw new DeliveryError('Independent reviewer App route is unavailable.');
|
|
942
|
+
const actor = await reviewer.appActorLogin();
|
|
943
|
+
if (!pr.user?.login || actor.toLowerCase() === pr.user.login.toLowerCase())
|
|
944
|
+
throw new DeliveryError('Reviewer App must differ from the PR author.');
|
|
945
|
+
const reviews = (await allReviews(context, input.prNumber)).filter((review) => review.user?.login.toLowerCase() === actor.toLowerCase() &&
|
|
946
|
+
review.commit_id === pr.head.sha &&
|
|
947
|
+
review.state === 'APPROVED' &&
|
|
948
|
+
/<!-- ai-delivery-review-artifact: sha256:[a-f0-9]{64} -->/u.test(review.body ?? ''));
|
|
949
|
+
if (reviews.length !== 1)
|
|
950
|
+
throw new DeliveryError('GitHub requires one unambiguous exact-head independent App review.');
|
|
951
|
+
const approved = reviews[0];
|
|
952
|
+
const readReview = (await context.clients.rest.pulls.getReview({ ...context.repo, pull_number: input.prNumber, review_id: approved.id })).data;
|
|
953
|
+
if (readReview.id !== approved.id ||
|
|
954
|
+
readReview.state !== 'APPROVED' ||
|
|
955
|
+
readReview.commit_id !== pr.head.sha ||
|
|
956
|
+
readReview.user?.login.toLowerCase() !== actor.toLowerCase() ||
|
|
957
|
+
readReview.body !== approved.body)
|
|
958
|
+
throw new DeliveryError('Independent GitHub review readback changed or was dismissed.');
|
|
959
|
+
const artifact = parseGitHubReviewArtifact(readReview.body);
|
|
960
|
+
if (artifact.verdict !== 'approve' ||
|
|
961
|
+
artifact.issueNumber !== input.issueNumber ||
|
|
962
|
+
(artifact.prNumber !== null && artifact.prNumber !== input.prNumber) ||
|
|
963
|
+
artifact.authorIdentity !== context.config.roles.author.identity ||
|
|
964
|
+
artifact.reviewerIdentity !== context.config.roles.reviewer.identity ||
|
|
965
|
+
artifact.head.sha !== pr.head.sha)
|
|
966
|
+
throw new DeliveryError('Independent review artifact binding is stale, foreign or rejected.');
|
|
967
|
+
const commit = (await context.clients.rest.git.getCommit({ ...context.repo, commit_sha: pr.head.sha })).data;
|
|
968
|
+
if (commit.sha !== artifact.head.sha || commit.tree.sha !== artifact.head.tree)
|
|
969
|
+
throw new DeliveryError('Independent review tree differs from the remote commit.');
|
|
970
|
+
if (artifact.diffScopeHash !== (await readGitHubPrScopeHash(context, input.prNumber, pr.changed_files)))
|
|
971
|
+
throw new DeliveryError('Independent review scope differs from the remote PR diff.');
|
|
972
|
+
const reviewedHead = artifact.head.sha;
|
|
973
|
+
if (input.reviewedHeadSha !== undefined && input.reviewedHeadSha !== reviewedHead)
|
|
974
|
+
throw new DeliveryError('Requested reviewed head differs from the independent GitHub review.');
|
|
975
|
+
const decision = await readRequiredReviewState(context, input.prNumber, reviewedHead, {
|
|
976
|
+
id: approved.id,
|
|
977
|
+
login: actor,
|
|
1038
978
|
});
|
|
1039
|
-
if (
|
|
1040
|
-
throw new DeliveryError('
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
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,
|
|
979
|
+
if (decision.submittedReviewAuthorCanPushToRepository !== true)
|
|
980
|
+
throw new DeliveryError('Specific reviewer App approval is not verifiably qualifying for required review.');
|
|
981
|
+
if (decision.status !== 'satisfied')
|
|
982
|
+
throw new DeliveryError(`GitHub review eligibility is ${decision.status}: ${decision.nextAction}`);
|
|
983
|
+
const blockers = await getNativeBlockerRelationships({
|
|
984
|
+
graphql: context.clients.graphql,
|
|
1057
985
|
issueNumber: input.issueNumber,
|
|
1058
|
-
|
|
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 })),
|
|
986
|
+
repo: context.repo,
|
|
1085
987
|
});
|
|
1086
|
-
if (
|
|
1087
|
-
throw new DeliveryError('
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
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,
|
|
1100
|
-
issueNumber: input.issueNumber,
|
|
1101
|
-
prNumber: input.prNumber,
|
|
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
|
-
});
|
|
988
|
+
if (blockers.blockers.some((value) => value.state === 'OPEN'))
|
|
989
|
+
throw new DeliveryError('GitHub issue has unresolved native blockers.');
|
|
990
|
+
pr = await verifyIssuePr(context, input.issueNumber, input.prNumber, nonClosing);
|
|
991
|
+
if (nonClosing) {
|
|
992
|
+
sameReferencePr(initialPr, pr);
|
|
993
|
+
sameReferenceIssue(issue, await openReferenceIssue(context, input.issueNumber));
|
|
994
|
+
refuseClosingDirective(squash.commit_title);
|
|
995
|
+
refuseClosingDirective(squash.commit_message);
|
|
1127
996
|
}
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
997
|
+
if (pr.head.sha !== reviewedHead ||
|
|
998
|
+
pr.state !== 'open' ||
|
|
999
|
+
pr.draft ||
|
|
1000
|
+
pr.mergeable !== true ||
|
|
1001
|
+
pr.mergeable_state !== 'clean')
|
|
1002
|
+
throw new DeliveryError(`GitHub merge eligibility is ${pr.mergeable_state ?? 'unknown'}; CLEAN at the reviewed head is required.`);
|
|
1003
|
+
const currentBase = (await context.clients.rest.git.getRef({ ...context.repo, ref: `heads/${pr.base.ref}` })).data
|
|
1004
|
+
.object.sha;
|
|
1005
|
+
if (!validSha(currentBase) || currentBase !== pr.base.sha)
|
|
1006
|
+
throw new DeliveryError('GitHub base moved; refresh clean eligibility and independent review before merge.');
|
|
1007
|
+
if (input.dryRun)
|
|
1008
|
+
return {
|
|
1009
|
+
dryRun: true,
|
|
1010
|
+
headSha: reviewedHead,
|
|
1011
|
+
prNumber: pr.number,
|
|
1012
|
+
...(squash === undefined ? {} : { nonClosing: true, ...squash }),
|
|
1013
|
+
};
|
|
1014
|
+
const result = (await context.clients.rest.pulls.merge({
|
|
1015
|
+
...context.repo,
|
|
1016
|
+
pull_number: input.prNumber,
|
|
1017
|
+
sha: reviewedHead,
|
|
1018
|
+
merge_method: input.strategy ?? 'squash',
|
|
1019
|
+
...squash,
|
|
1020
|
+
})).data;
|
|
1021
|
+
if (!result.merged || !validSha(result.sha))
|
|
1022
|
+
throw new DeliveryError('GitHub refused the reviewed-head merge.');
|
|
1023
|
+
const readback = await verifyIssuePr(context, input.issueNumber, input.prNumber, nonClosing);
|
|
1024
|
+
if (nonClosing) {
|
|
1025
|
+
sameReferencePr(initialPr, readback, true);
|
|
1026
|
+
sameReferenceIssue(issue, await openReferenceIssue(context, input.issueNumber));
|
|
1136
1027
|
}
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
expectedSha: Sha.parse(mergeSha),
|
|
1144
|
-
});
|
|
1028
|
+
if (!readback.merged ||
|
|
1029
|
+
readback.state !== 'closed' ||
|
|
1030
|
+
readback.head.sha !== reviewedHead ||
|
|
1031
|
+
readback.merge_commit_sha !== result.sha)
|
|
1032
|
+
throw new DeliveryError('GitHub merged PR readback disagrees with the reviewed-head mutation.');
|
|
1033
|
+
return { mergeSha: result.sha, headSha: reviewedHead, reused: false };
|
|
1145
1034
|
}
|
|
1146
1035
|
export async function finishIssue(context, input) {
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
if (
|
|
1151
|
-
|
|
1152
|
-
const
|
|
1153
|
-
|
|
1154
|
-
|
|
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.');
|
|
1036
|
+
if (nonClosingMode(input.nonClosing))
|
|
1037
|
+
throw new DeliveryError('Non-closing delivery cannot finish or close its retained issue.');
|
|
1038
|
+
const result = await mergePr(context, input);
|
|
1039
|
+
if (input.dryRun)
|
|
1040
|
+
return result;
|
|
1041
|
+
const pr = await verifyIssuePr(context, input.issueNumber, input.prNumber);
|
|
1042
|
+
if (!pr.merged || pr.state !== 'closed' || pr.merge_commit_sha !== result.mergeSha)
|
|
1043
|
+
throw new DeliveryError('Intended GitHub PR is not verifiably merged; issue remains open.');
|
|
1168
1044
|
const issue = (await context.clients.rest.issues.get({ ...context.repo, issue_number: input.issueNumber })).data;
|
|
1045
|
+
if (issue.number !== input.issueNumber || 'pull_request' in issue)
|
|
1046
|
+
throw new DeliveryError('Finish target is not the intended GitHub issue.');
|
|
1169
1047
|
if (issue.state !== 'closed')
|
|
1170
1048
|
await context.clients.rest.issues.update({
|
|
1171
1049
|
...context.repo,
|
|
1172
1050
|
issue_number: input.issueNumber,
|
|
1173
1051
|
state: 'closed',
|
|
1052
|
+
state_reason: 'completed',
|
|
1174
1053
|
});
|
|
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 };
|
|
1054
|
+
const readback = (await context.clients.rest.issues.get({ ...context.repo, issue_number: input.issueNumber })).data;
|
|
1055
|
+
if (readback.number !== input.issueNumber || readback.state !== 'closed')
|
|
1056
|
+
throw new DeliveryError('GitHub issue closure readback disagrees.');
|
|
1057
|
+
return { ...result, issueClosed: true };
|
|
1307
1058
|
}
|
|
1059
|
+
export { startIssueBranch };
|
|
1308
1060
|
//# sourceMappingURL=pr.js.map
|