@aviaratech/ai-delivery 0.3.24 → 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 +157 -16
- package/README.md +20 -0
- 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 +16 -5
- package/dist/cli.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/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.js +91 -1
- package/dist/directoryIndependent.test.js.map +1 -1
- package/dist/dispatch.js +6 -0
- package/dist/dispatch.js.map +1 -1
- package/dist/issue.d.ts +7 -0
- package/dist/issue.js +1 -1
- package/dist/issue.js.map +1 -1
- package/dist/issueJournal.test.js +88 -6
- package/dist/issueJournal.test.js.map +1 -1
- package/dist/mcp/tools.d.ts +7 -1
- package/dist/mcp/tools.js +20 -2
- package/dist/mcp/tools.js.map +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 +100 -6
- package/dist/pluginPackaging.test.js.map +1 -1
- package/dist/pr.d.ts +20 -13
- package/dist/pr.js +638 -66
- package/dist/pr.js.map +1 -1
- package/dist/pr.test.js +166 -7
- 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.js +6 -2
- package/dist/releaseReconciliation.test.js.map +1 -1
- 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/services/agentReadinessService.js +2 -1
- package/dist/services/agentReadinessService.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/package.json +9 -6
- package/plugins/ai-delivery/.claude-plugin/plugin.json +2 -2
- 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 +40 -40
- package/plugins/ai-delivery/runtime/package.json +1 -1
package/dist/pr.js
CHANGED
|
@@ -1,15 +1,319 @@
|
|
|
1
1
|
import { DeliveryError } from './errors.js';
|
|
2
2
|
import { createDeliveryGitHubClients } from './github/client.js';
|
|
3
3
|
import { getNativeBlockerRelationships } from './github/relationships.js';
|
|
4
|
-
import { issueBranch, startIssueBranch } from './issue.js';
|
|
4
|
+
import { issueBranch, linkedIssueBranches, startIssueBranch } from './issue.js';
|
|
5
5
|
import { parseGitHubReviewArtifact, parseReviewArtifact, readGitHubPrScopeHash, readRequiredReviewState, submitReview, } from './review.js';
|
|
6
6
|
const Sha = /^[a-f0-9]{40}$/u;
|
|
7
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.');
|
|
16
|
+
}
|
|
17
|
+
function referenceLine(context, issueNumber) {
|
|
18
|
+
return `References ${context.config.repository}#${issueNumber}`;
|
|
19
|
+
}
|
|
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;
|
|
26
|
+
}
|
|
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.');
|
|
33
|
+
}
|
|
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;
|
|
46
|
+
}
|
|
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
|
+
}
|
|
154
|
+
}
|
|
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.');
|
|
193
|
+
}
|
|
194
|
+
throw new DeliveryError('Introduced commit range exceeds the bounded readback window.');
|
|
195
|
+
}
|
|
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.');
|
|
290
|
+
}
|
|
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.');
|
|
299
|
+
}
|
|
300
|
+
const after = await openReferenceIssue(context, issueNumber);
|
|
301
|
+
sameReferenceIssue(issue, after);
|
|
302
|
+
sameReferencePr(pr, await getPr(context, pr.number));
|
|
303
|
+
return issue;
|
|
304
|
+
}
|
|
305
|
+
function nativeId(value) {
|
|
306
|
+
return typeof value === 'string' && value.trim().length > 0;
|
|
307
|
+
}
|
|
8
308
|
function validSha(value) {
|
|
9
309
|
return typeof value === 'string' && Sha.test(value);
|
|
10
310
|
}
|
|
11
311
|
function validBranch(value) {
|
|
12
|
-
if (
|
|
312
|
+
if (typeof value !== 'string' ||
|
|
313
|
+
!Branch.test(value) ||
|
|
314
|
+
value.startsWith('/') ||
|
|
315
|
+
value.endsWith('/') ||
|
|
316
|
+
value.endsWith('.lock'))
|
|
13
317
|
throw new DeliveryError('Invalid GitHub branch name.');
|
|
14
318
|
}
|
|
15
319
|
async function getPr(context, prNumber) {
|
|
@@ -17,40 +321,73 @@ async function getPr(context, prNumber) {
|
|
|
17
321
|
throw new DeliveryError('Configured and selected GitHub repository identities disagree.');
|
|
18
322
|
const pr = (await context.clients.rest.pulls.get({ ...context.repo, pull_number: prNumber })).data;
|
|
19
323
|
if (pr.number !== prNumber ||
|
|
324
|
+
typeof pr.base.repo?.full_name !== 'string' ||
|
|
325
|
+
typeof pr.head.repo?.full_name !== 'string' ||
|
|
20
326
|
pr.base.repo?.full_name.toLowerCase() !== context.config.repository.toLowerCase() ||
|
|
21
327
|
pr.head.repo?.full_name.toLowerCase() !== context.config.repository.toLowerCase() ||
|
|
22
|
-
!validSha(pr.head.sha)
|
|
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'))
|
|
23
334
|
throw new DeliveryError('GitHub PR identity or exact head readback is invalid.');
|
|
335
|
+
validBranch(pr.head.ref);
|
|
336
|
+
validBranch(pr.base.ref);
|
|
24
337
|
return pr;
|
|
25
338
|
}
|
|
26
|
-
async function
|
|
339
|
+
async function verifyPrIssueAssociation(context, prNumber, issueNumber, nonClosing = false, expectedPrId, onlyIntended = false, expectedIssueId, expectedRepositoryId) {
|
|
27
340
|
let cursor = null;
|
|
28
341
|
const cursors = new Set();
|
|
29
342
|
let matches = 0;
|
|
30
343
|
for (let page = 0; page < 20; page++) {
|
|
31
|
-
const result = await context.clients.graphql('query DeliveryClosingIssues($owner:String!,$repo:String!,$number:Int!,$cursor:String){repository(owner:$owner,name:$repo){pullRequest(number:$number){closingIssuesReferences(first:100,after:$cursor){nodes{number repository{nameWithOwner}} pageInfo{hasNextPage endCursor}}}}}', { ...context.repo, number: prNumber, cursor });
|
|
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 });
|
|
32
345
|
const connection = result.repository?.pullRequest?.closingIssuesReferences;
|
|
33
|
-
if (
|
|
34
|
-
|
|
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.');
|
|
35
356
|
for (const issue of connection.nodes) {
|
|
36
|
-
if (
|
|
37
|
-
|
|
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.');
|
|
38
373
|
if (issue.number === issueNumber &&
|
|
39
374
|
issue.repository.nameWithOwner.toLowerCase() === context.config.repository.toLowerCase())
|
|
40
375
|
matches++;
|
|
376
|
+
else if (onlyIntended)
|
|
377
|
+
throw new DeliveryError('Existing PR has conflicting closing associations; body transition refused.');
|
|
41
378
|
}
|
|
42
379
|
if (!connection.pageInfo.hasNextPage) {
|
|
43
|
-
if (matches !== 1)
|
|
44
|
-
throw new DeliveryError('
|
|
380
|
+
if (!nonClosing && matches !== 1)
|
|
381
|
+
throw new DeliveryError('Native GitHub PR closing-issue association does not identify the intended issue.');
|
|
45
382
|
return;
|
|
46
383
|
}
|
|
47
384
|
const next = connection.pageInfo.endCursor;
|
|
48
385
|
if (!next || cursors.has(next))
|
|
49
|
-
throw new DeliveryError('
|
|
386
|
+
throw new DeliveryError('Native PR issue pagination did not advance.');
|
|
50
387
|
cursors.add(next);
|
|
51
388
|
cursor = next;
|
|
52
389
|
}
|
|
53
|
-
throw new DeliveryError('
|
|
390
|
+
throw new DeliveryError('Native PR issue association exceeds the bounded readback window.');
|
|
54
391
|
}
|
|
55
392
|
async function matchingPrs(context, branch, base) {
|
|
56
393
|
const matches = [];
|
|
@@ -63,34 +400,55 @@ async function matchingPrs(context, branch, base) {
|
|
|
63
400
|
page,
|
|
64
401
|
per_page: 100,
|
|
65
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.');
|
|
66
405
|
matches.push(...batch);
|
|
67
406
|
if (batch.length < 100)
|
|
68
407
|
return matches;
|
|
69
408
|
}
|
|
70
409
|
throw new DeliveryError('GitHub PR match history exceeds the bounded readback window.');
|
|
71
410
|
}
|
|
72
|
-
async function verifyIssuePr(context, issueNumber, prNumber) {
|
|
411
|
+
async function verifyIssuePr(context, issueNumber, prNumber, nonClosing = false) {
|
|
73
412
|
const pr = await getPr(context, prNumber);
|
|
74
|
-
|
|
413
|
+
if (nonClosing)
|
|
414
|
+
await verifyNonClosingReference(context, issueNumber, pr);
|
|
415
|
+
else
|
|
416
|
+
await verifyPrIssueAssociation(context, prNumber, issueNumber);
|
|
75
417
|
if (!pr.merged) {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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.');
|
|
79
429
|
}
|
|
80
430
|
return pr;
|
|
81
431
|
}
|
|
82
432
|
export async function prInfo(context, input) {
|
|
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.');
|
|
83
436
|
let number = input.prNumber;
|
|
84
437
|
if (number === undefined) {
|
|
85
438
|
if (input.issueNumber === undefined)
|
|
86
439
|
throw new DeliveryError('prNumber or issueNumber is required.');
|
|
87
|
-
const
|
|
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;
|
|
88
444
|
const matches = await matchingPrs(context, branch);
|
|
89
445
|
if (matches.length !== 1)
|
|
90
446
|
throw new DeliveryError('Select an explicit PR number when the remote issue branch has zero or multiple PRs.');
|
|
91
447
|
number = matches[0].number;
|
|
92
448
|
}
|
|
93
|
-
const pr =
|
|
449
|
+
const pr = input.issueNumber === undefined
|
|
450
|
+
? await getPr(context, number)
|
|
451
|
+
: await verifyIssuePr(context, input.issueNumber, number, nonClosing);
|
|
94
452
|
return {
|
|
95
453
|
prNumber: pr.number,
|
|
96
454
|
url: pr.html_url,
|
|
@@ -272,81 +630,262 @@ export async function preflightReviewRoute(context, base, selectedReviewer, purp
|
|
|
272
630
|
};
|
|
273
631
|
}
|
|
274
632
|
export async function publishPr(context, input) {
|
|
275
|
-
const
|
|
276
|
-
if (input.
|
|
277
|
-
throw new DeliveryError('
|
|
278
|
-
|
|
279
|
-
|
|
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;
|
|
280
639
|
if ('pull_request' in issue || issue.number !== input.issueNumber || issue.state !== 'open')
|
|
281
640
|
throw new DeliveryError('PR creation requires the requested open GitHub issue.');
|
|
282
|
-
const
|
|
283
|
-
|
|
284
|
-
|
|
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)
|
|
285
649
|
throw new DeliveryError('PR delivery requires the repository default branch for durable issue association.');
|
|
286
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);
|
|
287
668
|
const ref = (await context.clients.rest.git.getRef({ ...context.repo, ref: `heads/${branch}` })).data;
|
|
288
|
-
if (!validSha(ref.object.sha))
|
|
289
|
-
throw new DeliveryError('Remote issue branch head is invalid.');
|
|
290
|
-
const
|
|
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}`);
|
|
291
677
|
if (!requestedBody.trim())
|
|
292
678
|
throw new DeliveryError('PR body must contain text.');
|
|
293
|
-
const body =
|
|
294
|
-
? requestedBody
|
|
295
|
-
:
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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.');
|
|
300
687
|
if (pr &&
|
|
301
688
|
(pr.head.sha !== ref.object.sha ||
|
|
302
689
|
pr.head.ref !== branch ||
|
|
303
690
|
pr.base.ref !== base ||
|
|
304
691
|
pr.state !== 'open' ||
|
|
305
|
-
|
|
692
|
+
pr.merged ||
|
|
693
|
+
(!transition && input.body !== undefined && pr.body !== body) ||
|
|
306
694
|
(input.title !== undefined && pr.title !== input.title)))
|
|
307
695
|
throw new DeliveryError('Existing remote PR conflicts with the requested branch, intent or state.');
|
|
308
|
-
const reviewRoute = await preflightReviewRoute(context, base);
|
|
309
|
-
if (pr && pr.user
|
|
696
|
+
const reviewRoute = nonClosing ? await nonClosingRoute(context, base) : await preflightReviewRoute(context, base);
|
|
697
|
+
if (pr && pr.user.login.toLowerCase() !== reviewRoute.author.actorLogin.toLowerCase())
|
|
310
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));
|
|
738
|
+
}
|
|
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
|
+
};
|
|
311
762
|
if (pr)
|
|
312
|
-
await
|
|
763
|
+
pr = await readExisting();
|
|
764
|
+
else
|
|
765
|
+
await readNew();
|
|
313
766
|
if (input.dryRun)
|
|
314
|
-
return {
|
|
767
|
+
return {
|
|
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,
|
|
782
|
+
};
|
|
783
|
+
let created = false;
|
|
315
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
|
+
}
|
|
316
792
|
if (input.draft === false && pr.draft) {
|
|
317
|
-
await
|
|
318
|
-
|
|
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();
|
|
319
802
|
if (pr.draft)
|
|
320
803
|
throw new DeliveryError('PR promotion readback is still draft.');
|
|
321
804
|
}
|
|
322
805
|
}
|
|
323
806
|
else {
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
+
}
|
|
831
|
+
}
|
|
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
|
+
}
|
|
333
858
|
}
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
throw new DeliveryError(
|
|
338
|
-
|
|
339
|
-
return {
|
|
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'}`);
|
|
863
|
+
}
|
|
864
|
+
return {
|
|
865
|
+
prNumber: pr.number,
|
|
866
|
+
url: pr.html_url,
|
|
867
|
+
headSha: pr.head.sha,
|
|
868
|
+
draft: pr.draft,
|
|
869
|
+
reviewRoute,
|
|
870
|
+
...(nonClosing ? { nonClosing: true } : {}),
|
|
871
|
+
};
|
|
340
872
|
}
|
|
341
873
|
export async function submitFormalReview(context, input) {
|
|
342
|
-
|
|
343
|
-
|
|
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({
|
|
344
878
|
context,
|
|
345
879
|
artifact: parseReviewArtifact(input.artifact),
|
|
346
880
|
issueNumber: input.issueNumber,
|
|
347
881
|
prNumber: input.prNumber,
|
|
348
882
|
...(input.dryRun === undefined ? {} : { dryRun: input.dryRun }),
|
|
349
883
|
});
|
|
884
|
+
if (nonClosing) {
|
|
885
|
+
sameReferencePr(pr, await verifyIssuePr(context, input.issueNumber, input.prNumber, true));
|
|
886
|
+
sameReferenceIssue(issue, await openReferenceIssue(context, input.issueNumber));
|
|
887
|
+
}
|
|
888
|
+
return result;
|
|
350
889
|
}
|
|
351
890
|
async function allReviews(context, prNumber) {
|
|
352
891
|
const reviews = [];
|
|
@@ -359,6 +898,9 @@ async function allReviews(context, prNumber) {
|
|
|
359
898
|
throw new DeliveryError('GitHub review history exceeds the bounded readback window.');
|
|
360
899
|
}
|
|
361
900
|
export async function mergePr(context, input) {
|
|
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.');
|
|
362
904
|
if (context.clients.role !== 'author' ||
|
|
363
905
|
context.clients.authSource !== (context.config.roles.author.authSource ?? 'app') ||
|
|
364
906
|
!context.clients.authenticatedAuthor)
|
|
@@ -366,7 +908,19 @@ export async function mergePr(context, input) {
|
|
|
366
908
|
const author = await context.clients.authenticatedAuthor();
|
|
367
909
|
if (!author.actorLogin || !author.credentialIdentity)
|
|
368
910
|
throw new DeliveryError('Author identity readback is incomplete.');
|
|
369
|
-
let pr = await verifyIssuePr(context, input.issueNumber, input.prNumber);
|
|
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}.`,
|
|
918
|
+
}
|
|
919
|
+
: undefined;
|
|
920
|
+
if (squash) {
|
|
921
|
+
refuseClosingDirective(squash.commit_title);
|
|
922
|
+
refuseClosingDirective(squash.commit_message);
|
|
923
|
+
}
|
|
370
924
|
if (input.reviewedHeadSha !== undefined && input.reviewedHeadSha !== pr.head.sha)
|
|
371
925
|
throw new DeliveryError('Requested reviewed head differs from the GitHub PR.');
|
|
372
926
|
if (pr.merged && pr.state === 'closed' && validSha(pr.merge_commit_sha))
|
|
@@ -433,7 +987,13 @@ export async function mergePr(context, input) {
|
|
|
433
987
|
});
|
|
434
988
|
if (blockers.blockers.some((value) => value.state === 'OPEN'))
|
|
435
989
|
throw new DeliveryError('GitHub issue has unresolved native blockers.');
|
|
436
|
-
pr = await verifyIssuePr(context, input.issueNumber, input.prNumber);
|
|
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);
|
|
996
|
+
}
|
|
437
997
|
if (pr.head.sha !== reviewedHead ||
|
|
438
998
|
pr.state !== 'open' ||
|
|
439
999
|
pr.draft ||
|
|
@@ -445,16 +1005,26 @@ export async function mergePr(context, input) {
|
|
|
445
1005
|
if (!validSha(currentBase) || currentBase !== pr.base.sha)
|
|
446
1006
|
throw new DeliveryError('GitHub base moved; refresh clean eligibility and independent review before merge.');
|
|
447
1007
|
if (input.dryRun)
|
|
448
|
-
return {
|
|
1008
|
+
return {
|
|
1009
|
+
dryRun: true,
|
|
1010
|
+
headSha: reviewedHead,
|
|
1011
|
+
prNumber: pr.number,
|
|
1012
|
+
...(squash === undefined ? {} : { nonClosing: true, ...squash }),
|
|
1013
|
+
};
|
|
449
1014
|
const result = (await context.clients.rest.pulls.merge({
|
|
450
1015
|
...context.repo,
|
|
451
1016
|
pull_number: input.prNumber,
|
|
452
1017
|
sha: reviewedHead,
|
|
453
1018
|
merge_method: input.strategy ?? 'squash',
|
|
1019
|
+
...squash,
|
|
454
1020
|
})).data;
|
|
455
1021
|
if (!result.merged || !validSha(result.sha))
|
|
456
1022
|
throw new DeliveryError('GitHub refused the reviewed-head merge.');
|
|
457
|
-
const readback = await verifyIssuePr(context, input.issueNumber, input.prNumber);
|
|
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));
|
|
1027
|
+
}
|
|
458
1028
|
if (!readback.merged ||
|
|
459
1029
|
readback.state !== 'closed' ||
|
|
460
1030
|
readback.head.sha !== reviewedHead ||
|
|
@@ -463,6 +1033,8 @@ export async function mergePr(context, input) {
|
|
|
463
1033
|
return { mergeSha: result.sha, headSha: reviewedHead, reused: false };
|
|
464
1034
|
}
|
|
465
1035
|
export async function finishIssue(context, input) {
|
|
1036
|
+
if (nonClosingMode(input.nonClosing))
|
|
1037
|
+
throw new DeliveryError('Non-closing delivery cannot finish or close its retained issue.');
|
|
466
1038
|
const result = await mergePr(context, input);
|
|
467
1039
|
if (input.dryRun)
|
|
468
1040
|
return result;
|