@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
|
@@ -0,0 +1,1336 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join, resolve } from 'node:path';
|
|
6
|
+
import { afterEach, describe, it, vi } from 'vitest';
|
|
7
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
8
|
+
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
|
|
9
|
+
import * as settings from './config/deliveryConfig.js';
|
|
10
|
+
import * as github from './github/client.js';
|
|
11
|
+
import { digestValue } from './delivery/common.js';
|
|
12
|
+
import { executeTool } from './dispatch.js';
|
|
13
|
+
import * as issues from './issue.js';
|
|
14
|
+
import { createAiDeliveryMcpServer } from './mcp/index.js';
|
|
15
|
+
import { prInfo, publishPr, startIssueBranch, submitFormalReview } from './pr.js';
|
|
16
|
+
const repo = 'example/widget';
|
|
17
|
+
const head = 'a'.repeat(40);
|
|
18
|
+
const base = 'b'.repeat(40);
|
|
19
|
+
const tree = 'c'.repeat(40);
|
|
20
|
+
const pageInfo = { hasNextPage: false, endCursor: null };
|
|
21
|
+
function fixture(published = true, nonClosing = false) {
|
|
22
|
+
const state = {
|
|
23
|
+
published,
|
|
24
|
+
nonClosing,
|
|
25
|
+
body: nonClosing ? `References ${repo}#17\n\nImprove widget.` : 'Closes #17',
|
|
26
|
+
title: 'Improve widget',
|
|
27
|
+
issueBody: 'Four retained criteria',
|
|
28
|
+
issueState: 'open',
|
|
29
|
+
referenceReadback: undefined,
|
|
30
|
+
referencePages: [],
|
|
31
|
+
referencePageIndex: 0,
|
|
32
|
+
commitReadback: undefined,
|
|
33
|
+
comparisonReadback: undefined,
|
|
34
|
+
commitMessage: 'Improve widget',
|
|
35
|
+
referenceActor: 'author',
|
|
36
|
+
referenceCloses: false,
|
|
37
|
+
updates: 0,
|
|
38
|
+
failCreateAck: false,
|
|
39
|
+
failUpdateAck: false,
|
|
40
|
+
linked: false,
|
|
41
|
+
remoteHead: published ? head : base,
|
|
42
|
+
headSha: head,
|
|
43
|
+
headRef: 'issue/17',
|
|
44
|
+
headRepo: repo,
|
|
45
|
+
baseRepo: repo,
|
|
46
|
+
baseSha: base,
|
|
47
|
+
baseRef: 'main',
|
|
48
|
+
nodeId: 'PR23',
|
|
49
|
+
prAuthor: 'author',
|
|
50
|
+
prState: 'open',
|
|
51
|
+
merged: false,
|
|
52
|
+
draft: true,
|
|
53
|
+
remoteRef: 'refs/heads/issue/17',
|
|
54
|
+
remoteType: 'commit',
|
|
55
|
+
issueNumber: 17,
|
|
56
|
+
associationRepo: repo,
|
|
57
|
+
associationPr: 23,
|
|
58
|
+
closingReadback: undefined,
|
|
59
|
+
closingPages: [],
|
|
60
|
+
closingPageIndex: 0,
|
|
61
|
+
branchReadback: undefined,
|
|
62
|
+
reviewerActor: 'reviewer[bot]',
|
|
63
|
+
movedOnRead: 0,
|
|
64
|
+
reads: 0,
|
|
65
|
+
readHook: undefined,
|
|
66
|
+
matchReadback: undefined,
|
|
67
|
+
promotionReadback: undefined,
|
|
68
|
+
keepDraftOnPromotion: false,
|
|
69
|
+
promotions: 0,
|
|
70
|
+
creations: 0,
|
|
71
|
+
expectedCreationDraft: true,
|
|
72
|
+
events: [],
|
|
73
|
+
reviews: [],
|
|
74
|
+
submissions: 0,
|
|
75
|
+
};
|
|
76
|
+
const pr = () => ({
|
|
77
|
+
number: 23,
|
|
78
|
+
node_id: state.nodeId,
|
|
79
|
+
changed_files: 1,
|
|
80
|
+
title: state.title,
|
|
81
|
+
body: state.body,
|
|
82
|
+
html_url: `https://github.com/${repo}/pull/23`,
|
|
83
|
+
state: state.prState,
|
|
84
|
+
draft: state.draft,
|
|
85
|
+
merged: state.merged,
|
|
86
|
+
head: { sha: state.headSha, ref: state.headRef, repo: { full_name: state.headRepo } },
|
|
87
|
+
base: { sha: state.baseSha, ref: state.baseRef, repo: { full_name: state.baseRepo } },
|
|
88
|
+
user: { login: state.prAuthor },
|
|
89
|
+
});
|
|
90
|
+
const rest = {
|
|
91
|
+
request: async () => ({ data: [] }),
|
|
92
|
+
repos: {
|
|
93
|
+
get: async () => ({ data: { node_id: 'REPO', full_name: repo, default_branch: 'main' } }),
|
|
94
|
+
compareCommitsWithBasehead: async () => ({
|
|
95
|
+
data: state.comparisonReadback ?? {
|
|
96
|
+
base_commit: { sha: base },
|
|
97
|
+
merge_base_commit: { sha: base },
|
|
98
|
+
status: 'ahead',
|
|
99
|
+
total_commits: 1,
|
|
100
|
+
commits: [{ sha: head, commit: { message: state.commitMessage }, parents: [{ sha: base }] }],
|
|
101
|
+
},
|
|
102
|
+
}),
|
|
103
|
+
},
|
|
104
|
+
issues: {
|
|
105
|
+
get: async () => ({
|
|
106
|
+
data: {
|
|
107
|
+
node_id: 'ISSUE17',
|
|
108
|
+
number: 17,
|
|
109
|
+
state: state.issueState,
|
|
110
|
+
title: 'Improve widget',
|
|
111
|
+
body: state.issueBody,
|
|
112
|
+
html_url: `https://github.com/${repo}/issues/17`,
|
|
113
|
+
},
|
|
114
|
+
}),
|
|
115
|
+
},
|
|
116
|
+
git: {
|
|
117
|
+
getRef: async (input) => {
|
|
118
|
+
assert.equal(`${input.owner}/${input.repo}`, repo);
|
|
119
|
+
return {
|
|
120
|
+
data: {
|
|
121
|
+
ref: input.ref === 'heads/main' ? 'refs/heads/main' : state.remoteRef,
|
|
122
|
+
object: { type: state.remoteType, sha: input.ref === 'heads/main' ? base : state.remoteHead },
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
},
|
|
126
|
+
getCommit: async () => ({ data: { sha: head, tree: { sha: tree } } }),
|
|
127
|
+
},
|
|
128
|
+
pulls: {
|
|
129
|
+
get: async () => {
|
|
130
|
+
state.reads++;
|
|
131
|
+
state.readHook?.(state.reads);
|
|
132
|
+
if (state.movedOnRead && state.reads >= state.movedOnRead)
|
|
133
|
+
state.headSha = 'd'.repeat(40);
|
|
134
|
+
return { data: pr() };
|
|
135
|
+
},
|
|
136
|
+
list: async () => ({ data: state.matchReadback ?? (state.published ? [{ number: 23 }] : []) }),
|
|
137
|
+
create: async (input) => {
|
|
138
|
+
assert.equal(input.draft, state.expectedCreationDraft);
|
|
139
|
+
assert.equal(input.head, 'issue/17');
|
|
140
|
+
state.creations++;
|
|
141
|
+
state.body = input.body;
|
|
142
|
+
if (state.failCreateAck) {
|
|
143
|
+
state.published = true;
|
|
144
|
+
throw new Error('Transport lost after creation');
|
|
145
|
+
}
|
|
146
|
+
state.draft = input.draft;
|
|
147
|
+
state.published = true;
|
|
148
|
+
state.linked = false;
|
|
149
|
+
state.events.push('draft-publication-replaces-branch-link');
|
|
150
|
+
return { data: pr() };
|
|
151
|
+
},
|
|
152
|
+
update: async (input) => {
|
|
153
|
+
state.updates++;
|
|
154
|
+
state.body = input.body;
|
|
155
|
+
state.nonClosing = true;
|
|
156
|
+
if (state.failUpdateAck)
|
|
157
|
+
throw new Error('Transport lost after body update');
|
|
158
|
+
return { data: pr() };
|
|
159
|
+
},
|
|
160
|
+
listFiles: async () => ({ data: [{ filename: 'change.ts' }] }),
|
|
161
|
+
listReviews: async () => ({ data: state.reviews }),
|
|
162
|
+
getReview: async () => ({ data: state.reviews[0] }),
|
|
163
|
+
createReview: async (input) => {
|
|
164
|
+
assert.equal(input.commit_id, head);
|
|
165
|
+
assert.equal(input.event, 'APPROVE');
|
|
166
|
+
state.submissions++;
|
|
167
|
+
const review = {
|
|
168
|
+
id: 99,
|
|
169
|
+
body: input.body,
|
|
170
|
+
user: { login: state.reviewerActor },
|
|
171
|
+
state: 'APPROVED',
|
|
172
|
+
commit_id: head,
|
|
173
|
+
html_url: `https://github.com/${repo}/pull/23#pullrequestreview-99`,
|
|
174
|
+
};
|
|
175
|
+
state.reviews.push(review);
|
|
176
|
+
return { data: review };
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
const graphql = async (query) => {
|
|
181
|
+
if (query.includes('DeliveryNonClosingReferences')) {
|
|
182
|
+
if (state.referencePages.length)
|
|
183
|
+
return state.referencePages[state.referencePageIndex++];
|
|
184
|
+
return (state.referenceReadback ?? {
|
|
185
|
+
repository: {
|
|
186
|
+
id: 'REPO',
|
|
187
|
+
nameWithOwner: repo,
|
|
188
|
+
issue: {
|
|
189
|
+
id: 'ISSUE17',
|
|
190
|
+
number: 17,
|
|
191
|
+
state: 'OPEN',
|
|
192
|
+
timelineItems: {
|
|
193
|
+
nodes: [
|
|
194
|
+
{
|
|
195
|
+
id: 'REFERENCE',
|
|
196
|
+
actor: { login: state.referenceActor },
|
|
197
|
+
isCrossRepository: false,
|
|
198
|
+
willCloseTarget: state.referenceCloses,
|
|
199
|
+
source: {
|
|
200
|
+
__typename: 'PullRequest',
|
|
201
|
+
id: 'PR23',
|
|
202
|
+
number: 23,
|
|
203
|
+
repository: { id: 'REPO', nameWithOwner: repo },
|
|
204
|
+
},
|
|
205
|
+
target: {
|
|
206
|
+
__typename: 'Issue',
|
|
207
|
+
id: 'ISSUE17',
|
|
208
|
+
number: 17,
|
|
209
|
+
repository: { id: 'REPO', nameWithOwner: repo },
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
],
|
|
213
|
+
pageInfo,
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
if (query.includes('DeliveryNonClosingCommits'))
|
|
220
|
+
return (state.commitReadback ?? {
|
|
221
|
+
repository: {
|
|
222
|
+
nameWithOwner: repo,
|
|
223
|
+
pullRequest: {
|
|
224
|
+
id: 'PR23',
|
|
225
|
+
number: 23,
|
|
226
|
+
headRefOid: head,
|
|
227
|
+
baseRefOid: base,
|
|
228
|
+
baseRefName: 'main',
|
|
229
|
+
commits: {
|
|
230
|
+
totalCount: 1,
|
|
231
|
+
nodes: [
|
|
232
|
+
{
|
|
233
|
+
commit: {
|
|
234
|
+
oid: head,
|
|
235
|
+
message: state.commitMessage,
|
|
236
|
+
parents: { totalCount: 1, nodes: [{ oid: base }], pageInfo },
|
|
237
|
+
},
|
|
238
|
+
},
|
|
239
|
+
],
|
|
240
|
+
pageInfo,
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
});
|
|
245
|
+
if (query.includes('DeliveryStartBranch')) {
|
|
246
|
+
state.linked = true;
|
|
247
|
+
state.events.push('native-start');
|
|
248
|
+
return { createLinkedBranch: { linkedBranch: { ref: { name: 'issue/17', target: { oid: base } } } } };
|
|
249
|
+
}
|
|
250
|
+
if (query.includes('DeliveryIssueBranches'))
|
|
251
|
+
return (state.branchReadback ?? {
|
|
252
|
+
repository: {
|
|
253
|
+
issue: {
|
|
254
|
+
linkedBranches: {
|
|
255
|
+
nodes: state.linked
|
|
256
|
+
? [
|
|
257
|
+
{
|
|
258
|
+
ref: {
|
|
259
|
+
name: 'issue/17',
|
|
260
|
+
target: { oid: state.remoteHead },
|
|
261
|
+
repository: { nameWithOwner: repo },
|
|
262
|
+
},
|
|
263
|
+
},
|
|
264
|
+
]
|
|
265
|
+
: [],
|
|
266
|
+
pageInfo,
|
|
267
|
+
},
|
|
268
|
+
},
|
|
269
|
+
},
|
|
270
|
+
});
|
|
271
|
+
if (query.includes('DeliveryClosingIssues') && state.closingPages.length > 0)
|
|
272
|
+
return state.closingPages[state.closingPageIndex++];
|
|
273
|
+
if (query.includes('DeliveryClosingIssues'))
|
|
274
|
+
return (state.closingReadback ?? {
|
|
275
|
+
repository: {
|
|
276
|
+
nameWithOwner: state.associationRepo,
|
|
277
|
+
pullRequest: {
|
|
278
|
+
id: state.nodeId,
|
|
279
|
+
number: state.associationPr,
|
|
280
|
+
closingIssuesReferences: {
|
|
281
|
+
nodes: state.nonClosing
|
|
282
|
+
? []
|
|
283
|
+
: [
|
|
284
|
+
{
|
|
285
|
+
__typename: 'Issue',
|
|
286
|
+
id: 'ISSUE17',
|
|
287
|
+
number: state.issueNumber,
|
|
288
|
+
repository: { id: 'REPO', nameWithOwner: repo },
|
|
289
|
+
},
|
|
290
|
+
],
|
|
291
|
+
pageInfo,
|
|
292
|
+
},
|
|
293
|
+
},
|
|
294
|
+
},
|
|
295
|
+
});
|
|
296
|
+
if (query.includes('DeliveryReadyPr')) {
|
|
297
|
+
state.promotions++;
|
|
298
|
+
if (!state.keepDraftOnPromotion)
|
|
299
|
+
state.draft = false;
|
|
300
|
+
return (state.promotionReadback ?? { markPullRequestReadyForReview: { pullRequest: { id: 'PR23', isDraft: false } } });
|
|
301
|
+
}
|
|
302
|
+
throw new Error(`Unexpected synthetic query: ${query}`);
|
|
303
|
+
};
|
|
304
|
+
const reviewer = {
|
|
305
|
+
role: 'reviewer',
|
|
306
|
+
authSource: 'app',
|
|
307
|
+
effectiveContentsPermission: 'write',
|
|
308
|
+
appActorLogin: async () => state.reviewerActor,
|
|
309
|
+
rest,
|
|
310
|
+
graphql,
|
|
311
|
+
};
|
|
312
|
+
const context = {
|
|
313
|
+
root: '/nonexistent-launch-directory',
|
|
314
|
+
repo: { owner: 'example', repo: 'widget' },
|
|
315
|
+
config: {
|
|
316
|
+
schemaVersion: 'ai-delivery.github@1',
|
|
317
|
+
repository: repo,
|
|
318
|
+
roles: { author: { authSource: 'personal', identity: 'author' }, reviewer: { identity: 'reviewer' } },
|
|
319
|
+
},
|
|
320
|
+
clients: {
|
|
321
|
+
role: 'author',
|
|
322
|
+
authSource: 'personal',
|
|
323
|
+
authenticatedAuthor: async () => ({ actorLogin: 'author', credentialIdentity: 'synthetic-author' }),
|
|
324
|
+
rest,
|
|
325
|
+
graphql,
|
|
326
|
+
},
|
|
327
|
+
reviewerClients: reviewer,
|
|
328
|
+
};
|
|
329
|
+
const content = {
|
|
330
|
+
schemaVersion: 'ai-delivery.review-artifact@1',
|
|
331
|
+
authorIdentity: 'author',
|
|
332
|
+
reviewerIdentity: 'reviewer',
|
|
333
|
+
checks: ['Synthetic exact-head checks'],
|
|
334
|
+
diffScopeHash: digestValue(['change.ts']),
|
|
335
|
+
elapsedMs: 1,
|
|
336
|
+
findings: [],
|
|
337
|
+
head: { sha: head, tree },
|
|
338
|
+
issueNumber: 17,
|
|
339
|
+
prNumber: 23,
|
|
340
|
+
readOnly: true,
|
|
341
|
+
requestedEffort: 'xhigh',
|
|
342
|
+
requestedModel: 'gpt-6-astra',
|
|
343
|
+
effectiveEffort: 'unknown',
|
|
344
|
+
effectiveModel: 'unknown',
|
|
345
|
+
summary: 'Synthetic independent review.',
|
|
346
|
+
verdict: 'approve',
|
|
347
|
+
};
|
|
348
|
+
const artifact = JSON.stringify({ ...content, artifactId: digestValue(content) });
|
|
349
|
+
return { state, context, reviewerContext: { ...context, clients: reviewer }, artifact };
|
|
350
|
+
}
|
|
351
|
+
afterEach(() => vi.restoreAllMocks());
|
|
352
|
+
describe('native exact PR association after publication', () => {
|
|
353
|
+
it('replays native start, push, draft publication, empty branch links, author inspection and distinct App review', async () => {
|
|
354
|
+
const { context, reviewerContext, state, artifact } = fixture(false);
|
|
355
|
+
const started = await startIssueBranch(context, 17);
|
|
356
|
+
assert.equal(started.reused, false);
|
|
357
|
+
assert.equal(started.headSha, base);
|
|
358
|
+
state.remoteHead = head;
|
|
359
|
+
state.events.push('push');
|
|
360
|
+
await publishPr(context, { issueNumber: 17, body: 'Improve widget', draft: true });
|
|
361
|
+
assert.deepEqual(state.events, ['native-start', 'push', 'draft-publication-replaces-branch-link']);
|
|
362
|
+
assert.equal(state.linked, false);
|
|
363
|
+
assert.equal((await prInfo(context, { issueNumber: 17, prNumber: 23 })).headSha, head);
|
|
364
|
+
assert.equal((await submitFormalReview(reviewerContext, { issueNumber: 17, prNumber: 23, artifact, dryRun: true })).headSha, head);
|
|
365
|
+
assert.equal(state.submissions, 0);
|
|
366
|
+
const submitted = await submitFormalReview(reviewerContext, { issueNumber: 17, prNumber: 23, artifact });
|
|
367
|
+
const reused = await submitFormalReview(reviewerContext, { issueNumber: 17, prNumber: 23, artifact });
|
|
368
|
+
assert.deepEqual(reused, submitted);
|
|
369
|
+
assert.equal(state.submissions, 1);
|
|
370
|
+
});
|
|
371
|
+
it('gives an exact-PR next action for issue-only lookup with empty branch links', async () => {
|
|
372
|
+
const { context } = fixture();
|
|
373
|
+
await assert.rejects(prInfo(context, { issueNumber: 17 }), (error) => {
|
|
374
|
+
assert.ok(error instanceof Error);
|
|
375
|
+
assert.match(error.message, /exact issue and PR numbers.*--issue and --pr/u);
|
|
376
|
+
assert.doesNotMatch(error.message, /zero or multiple PRs|start again|repeat.*start/u);
|
|
377
|
+
return true;
|
|
378
|
+
});
|
|
379
|
+
assert.equal((await prInfo(context, { prNumber: 23 })).prNumber, 23);
|
|
380
|
+
});
|
|
381
|
+
it('verifies native association when issue-only lookup still has a linked branch', async () => {
|
|
382
|
+
const { context, state } = fixture();
|
|
383
|
+
state.linked = true;
|
|
384
|
+
assert.equal((await prInfo(context, { issueNumber: 17 })).prNumber, 23);
|
|
385
|
+
state.issueNumber = 18;
|
|
386
|
+
await assert.rejects(prInfo(context, { issueNumber: 17 }), /association/u);
|
|
387
|
+
});
|
|
388
|
+
for (const [name, change] of [
|
|
389
|
+
['wrong closing issue', { issueNumber: 18 }],
|
|
390
|
+
['wrong PR repository', { headRepo: 'other/widget' }],
|
|
391
|
+
['wrong base repository', { baseRepo: 'other/widget' }],
|
|
392
|
+
['wrong native repository', { associationRepo: 'other/widget' }],
|
|
393
|
+
['wrong native PR', { associationPr: 24 }],
|
|
394
|
+
['remote head drift', { remoteHead: 'd'.repeat(40) }],
|
|
395
|
+
['remote ref mismatch', { remoteRef: 'refs/heads/other' }],
|
|
396
|
+
['incomplete remote ref', { remoteType: '' }],
|
|
397
|
+
['invalid head branch', { headRef: '../other' }],
|
|
398
|
+
['invalid PR head', { headSha: 'missing' }],
|
|
399
|
+
['incomplete PR state', { prState: '' }],
|
|
400
|
+
['incomplete merged state', { merged: undefined }],
|
|
401
|
+
['incomplete draft state', { draft: undefined }],
|
|
402
|
+
['conflicting merged state', { merged: true }],
|
|
403
|
+
]) {
|
|
404
|
+
it(`refuses ${name} before inspection or review mutation`, async () => {
|
|
405
|
+
const { context, reviewerContext, state, artifact } = fixture();
|
|
406
|
+
Object.assign(state, change);
|
|
407
|
+
await assert.rejects(prInfo(context, { issueNumber: 17, prNumber: 23 }));
|
|
408
|
+
await assert.rejects(submitFormalReview(reviewerContext, { issueNumber: 17, prNumber: 23, artifact }));
|
|
409
|
+
assert.equal(state.submissions, 0);
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
for (const [name, response] of [
|
|
413
|
+
['missing connection', { repository: { nameWithOwner: repo, pullRequest: { number: 23 } } }],
|
|
414
|
+
[
|
|
415
|
+
'empty closing issues',
|
|
416
|
+
{
|
|
417
|
+
repository: {
|
|
418
|
+
nameWithOwner: repo,
|
|
419
|
+
pullRequest: { number: 23, closingIssuesReferences: { nodes: [], pageInfo } },
|
|
420
|
+
},
|
|
421
|
+
},
|
|
422
|
+
],
|
|
423
|
+
[
|
|
424
|
+
'foreign closing issue',
|
|
425
|
+
{
|
|
426
|
+
repository: {
|
|
427
|
+
nameWithOwner: repo,
|
|
428
|
+
pullRequest: {
|
|
429
|
+
number: 23,
|
|
430
|
+
closingIssuesReferences: {
|
|
431
|
+
nodes: [{ number: 17, repository: { nameWithOwner: 'other/widget' } }],
|
|
432
|
+
pageInfo,
|
|
433
|
+
},
|
|
434
|
+
},
|
|
435
|
+
},
|
|
436
|
+
},
|
|
437
|
+
],
|
|
438
|
+
[
|
|
439
|
+
'incomplete closing issue',
|
|
440
|
+
{
|
|
441
|
+
repository: {
|
|
442
|
+
nameWithOwner: repo,
|
|
443
|
+
pullRequest: { number: 23, closingIssuesReferences: { nodes: [null], pageInfo } },
|
|
444
|
+
},
|
|
445
|
+
},
|
|
446
|
+
],
|
|
447
|
+
[
|
|
448
|
+
'duplicate closing issue',
|
|
449
|
+
{
|
|
450
|
+
repository: {
|
|
451
|
+
nameWithOwner: repo,
|
|
452
|
+
pullRequest: {
|
|
453
|
+
number: 23,
|
|
454
|
+
closingIssuesReferences: {
|
|
455
|
+
nodes: Array.from({ length: 2 }, () => ({ number: 17, repository: { nameWithOwner: repo } })),
|
|
456
|
+
pageInfo,
|
|
457
|
+
},
|
|
458
|
+
},
|
|
459
|
+
},
|
|
460
|
+
},
|
|
461
|
+
],
|
|
462
|
+
[
|
|
463
|
+
'missing pagination',
|
|
464
|
+
{ repository: { nameWithOwner: repo, pullRequest: { number: 23, closingIssuesReferences: { nodes: [] } } } },
|
|
465
|
+
],
|
|
466
|
+
[
|
|
467
|
+
'nonadvancing pagination',
|
|
468
|
+
{
|
|
469
|
+
repository: {
|
|
470
|
+
nameWithOwner: repo,
|
|
471
|
+
pullRequest: {
|
|
472
|
+
number: 23,
|
|
473
|
+
closingIssuesReferences: { nodes: [], pageInfo: { hasNextPage: true, endCursor: null } },
|
|
474
|
+
},
|
|
475
|
+
},
|
|
476
|
+
},
|
|
477
|
+
],
|
|
478
|
+
]) {
|
|
479
|
+
it(`refuses ${name} before App submission`, async () => {
|
|
480
|
+
const { reviewerContext, state, artifact } = fixture();
|
|
481
|
+
state.closingReadback = response;
|
|
482
|
+
await assert.rejects(submitFormalReview(reviewerContext, { issueNumber: 17, prNumber: 23, artifact }), /association|identity|pagination/u);
|
|
483
|
+
assert.equal(state.submissions, 0);
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
for (const [name, response] of [
|
|
487
|
+
['incomplete linked branches', { repository: { issue: { linkedBranches: { nodes: [], pageInfo: {} } } } }],
|
|
488
|
+
[
|
|
489
|
+
'conflicting linked branch',
|
|
490
|
+
{
|
|
491
|
+
repository: {
|
|
492
|
+
issue: {
|
|
493
|
+
linkedBranches: {
|
|
494
|
+
nodes: [{ ref: { name: 'other', target: { oid: head }, repository: { nameWithOwner: repo } } }],
|
|
495
|
+
pageInfo,
|
|
496
|
+
},
|
|
497
|
+
},
|
|
498
|
+
},
|
|
499
|
+
},
|
|
500
|
+
],
|
|
501
|
+
[
|
|
502
|
+
'stale linked head',
|
|
503
|
+
{
|
|
504
|
+
repository: {
|
|
505
|
+
issue: {
|
|
506
|
+
linkedBranches: {
|
|
507
|
+
nodes: [{ ref: { name: 'issue/17', target: { oid: base }, repository: { nameWithOwner: repo } } }],
|
|
508
|
+
pageInfo,
|
|
509
|
+
},
|
|
510
|
+
},
|
|
511
|
+
},
|
|
512
|
+
},
|
|
513
|
+
],
|
|
514
|
+
]) {
|
|
515
|
+
it(`refuses ${name} despite a valid closing-issue association`, async () => {
|
|
516
|
+
const { reviewerContext, state, artifact } = fixture();
|
|
517
|
+
state.branchReadback = response;
|
|
518
|
+
await assert.rejects(submitFormalReview(reviewerContext, { issueNumber: 17, prNumber: 23, artifact }));
|
|
519
|
+
assert.equal(state.submissions, 0);
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
it('refuses configured/selected repository disagreement', async () => {
|
|
523
|
+
const { context } = fixture();
|
|
524
|
+
context.repo = { owner: 'other', repo: 'widget' };
|
|
525
|
+
await assert.rejects(prInfo(context, { issueNumber: 17, prNumber: 23 }), /repository identities/u);
|
|
526
|
+
});
|
|
527
|
+
it('reads the complete closing-issue connection before accepting a later-page association', async () => {
|
|
528
|
+
const { context, state } = fixture();
|
|
529
|
+
state.closingPages = [
|
|
530
|
+
{
|
|
531
|
+
repository: {
|
|
532
|
+
nameWithOwner: repo,
|
|
533
|
+
pullRequest: {
|
|
534
|
+
number: 23,
|
|
535
|
+
closingIssuesReferences: {
|
|
536
|
+
nodes: [{ number: 18, repository: { nameWithOwner: repo } }],
|
|
537
|
+
pageInfo: { hasNextPage: true, endCursor: 'NEXT' },
|
|
538
|
+
},
|
|
539
|
+
},
|
|
540
|
+
},
|
|
541
|
+
},
|
|
542
|
+
{
|
|
543
|
+
repository: {
|
|
544
|
+
nameWithOwner: repo,
|
|
545
|
+
pullRequest: {
|
|
546
|
+
number: 23,
|
|
547
|
+
closingIssuesReferences: {
|
|
548
|
+
nodes: [{ number: 17, repository: { nameWithOwner: repo } }],
|
|
549
|
+
pageInfo,
|
|
550
|
+
},
|
|
551
|
+
},
|
|
552
|
+
},
|
|
553
|
+
},
|
|
554
|
+
];
|
|
555
|
+
assert.equal((await prInfo(context, { issueNumber: 17, prNumber: 23 })).headSha, head);
|
|
556
|
+
assert.equal(state.closingPageIndex, 2);
|
|
557
|
+
});
|
|
558
|
+
it('refuses an incomplete later page even after finding the intended closing issue', async () => {
|
|
559
|
+
const { reviewerContext, state, artifact } = fixture();
|
|
560
|
+
state.closingPages = [
|
|
561
|
+
{
|
|
562
|
+
repository: {
|
|
563
|
+
nameWithOwner: repo,
|
|
564
|
+
pullRequest: {
|
|
565
|
+
number: 23,
|
|
566
|
+
closingIssuesReferences: {
|
|
567
|
+
nodes: [{ number: 17, repository: { nameWithOwner: repo } }],
|
|
568
|
+
pageInfo: { hasNextPage: true, endCursor: 'NEXT' },
|
|
569
|
+
},
|
|
570
|
+
},
|
|
571
|
+
},
|
|
572
|
+
},
|
|
573
|
+
{ repository: null },
|
|
574
|
+
];
|
|
575
|
+
await assert.rejects(submitFormalReview(reviewerContext, { issueNumber: 17, prNumber: 23, artifact }), /association/u);
|
|
576
|
+
assert.equal(state.closingPageIndex, 2);
|
|
577
|
+
assert.equal(state.submissions, 0);
|
|
578
|
+
});
|
|
579
|
+
it('preserves configured reviewer separation and stale artifact/head refusals', async () => {
|
|
580
|
+
const { context, reviewerContext, state, artifact } = fixture();
|
|
581
|
+
await assert.rejects(submitFormalReview(context, { issueNumber: 17, prNumber: 23, artifact }), /reviewer.*role/u);
|
|
582
|
+
state.reviewerActor = 'author';
|
|
583
|
+
await assert.rejects(submitFormalReview(reviewerContext, { issueNumber: 17, prNumber: 23, artifact }), /differ/u);
|
|
584
|
+
state.reviewerActor = 'reviewer[bot]';
|
|
585
|
+
state.movedOnRead = state.reads + 2;
|
|
586
|
+
await assert.rejects(submitFormalReview(reviewerContext, { issueNumber: 17, prNumber: 23, artifact }), /head|moved/u);
|
|
587
|
+
assert.equal(state.submissions, 0);
|
|
588
|
+
});
|
|
589
|
+
it('rejects a conflicting existing App review instead of resubmitting', async () => {
|
|
590
|
+
const { reviewerContext, state, artifact } = fixture();
|
|
591
|
+
await submitFormalReview(reviewerContext, { issueNumber: 17, prNumber: 23, artifact });
|
|
592
|
+
state.reviews[0].body = `<!-- ai-delivery-review-artifact: sha256:${'f'.repeat(64)} -->`;
|
|
593
|
+
await assert.rejects(submitFormalReview(reviewerContext, { issueNumber: 17, prNumber: 23, artifact }), /conflict/u);
|
|
594
|
+
assert.equal(state.submissions, 1);
|
|
595
|
+
});
|
|
596
|
+
});
|
|
597
|
+
describe('authoritative existing PR promotion', () => {
|
|
598
|
+
const input = { issueNumber: 17, headBranch: 'issue/17', draft: false };
|
|
599
|
+
it('promotes after publication and exact-head review, then reuses authoritative ready readback', async () => {
|
|
600
|
+
const { context, reviewerContext, state, artifact } = fixture();
|
|
601
|
+
await submitFormalReview(reviewerContext, { issueNumber: 17, prNumber: 23, artifact });
|
|
602
|
+
const preview = await publishPr(context, { ...input, dryRun: true });
|
|
603
|
+
assert.equal(preview.prNumber, 23);
|
|
604
|
+
assert.equal(preview.draft, true);
|
|
605
|
+
assert.equal(state.promotions, 0);
|
|
606
|
+
const promoted = await publishPr(context, input);
|
|
607
|
+
const reused = await publishPr(context, input);
|
|
608
|
+
assert.equal(promoted.prNumber, 23);
|
|
609
|
+
assert.equal(promoted.draft, false);
|
|
610
|
+
assert.deepEqual(reused, promoted);
|
|
611
|
+
assert.equal(state.promotions, 1);
|
|
612
|
+
assert.equal(state.creations, 0);
|
|
613
|
+
assert.equal(state.submissions, 1);
|
|
614
|
+
assert.equal(state.linked, false);
|
|
615
|
+
assert.deepEqual(state.events, []);
|
|
616
|
+
});
|
|
617
|
+
for (const [name, change] of [
|
|
618
|
+
['wrong issue', { issueNumber: 18 }],
|
|
619
|
+
['foreign PR head repository', { headRepo: 'other/widget' }],
|
|
620
|
+
['foreign PR base repository', { baseRepo: 'other/widget' }],
|
|
621
|
+
['foreign native repository', { associationRepo: 'other/widget' }],
|
|
622
|
+
['wrong native PR', { associationPr: 24 }],
|
|
623
|
+
['wrong author', { prAuthor: 'collaborator' }],
|
|
624
|
+
['wrong head', { headSha: 'd'.repeat(40) }],
|
|
625
|
+
['moved remote head', { remoteHead: 'd'.repeat(40) }],
|
|
626
|
+
['wrong ref', { headRef: 'other' }],
|
|
627
|
+
['wrong base', { baseRef: 'release' }],
|
|
628
|
+
['closed PR', { prState: 'closed' }],
|
|
629
|
+
['missing node identity', { nodeId: '' }],
|
|
630
|
+
['incomplete draft state', { draft: undefined }],
|
|
631
|
+
['ambiguous matches', { matchReadback: [{ number: 23 }, { number: 24 }] }],
|
|
632
|
+
['incomplete match identity', { matchReadback: [{}] }],
|
|
633
|
+
['incomplete match connection', { matchReadback: {} }],
|
|
634
|
+
['incomplete association', { closingReadback: { repository: null } }],
|
|
635
|
+
['incomplete branch connection', { branchReadback: { repository: null } }],
|
|
636
|
+
]) {
|
|
637
|
+
it(`refuses ${name} before promotion, including already-ready reuse`, async () => {
|
|
638
|
+
for (const draft of [true, false]) {
|
|
639
|
+
const { context, state } = fixture();
|
|
640
|
+
state.draft = draft;
|
|
641
|
+
Object.assign(state, change);
|
|
642
|
+
await assert.rejects(publishPr(context, input));
|
|
643
|
+
assert.equal(state.promotions, 0);
|
|
644
|
+
assert.equal(state.creations, 0);
|
|
645
|
+
assert.deepEqual(state.events, []);
|
|
646
|
+
}
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
for (const read of [2, 3]) {
|
|
650
|
+
it(`refuses head drift on authoritative PR read ${read} before mutation`, async () => {
|
|
651
|
+
const { context, state } = fixture();
|
|
652
|
+
state.readHook = (count) => {
|
|
653
|
+
if (count === read)
|
|
654
|
+
state.headSha = state.remoteHead = 'd'.repeat(40);
|
|
655
|
+
};
|
|
656
|
+
await assert.rejects(publishPr(context, input), /drift/u);
|
|
657
|
+
assert.equal(state.promotions, 0);
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
it('refuses author and native node drift immediately before mutation', async () => {
|
|
661
|
+
for (const field of ['prAuthor', 'nodeId']) {
|
|
662
|
+
const { context, state } = fixture();
|
|
663
|
+
state.readHook = (count) => {
|
|
664
|
+
if (count === 3)
|
|
665
|
+
state[field] = 'changed';
|
|
666
|
+
};
|
|
667
|
+
await assert.rejects(publishPr(context, input), /drift/u);
|
|
668
|
+
assert.equal(state.promotions, 0);
|
|
669
|
+
}
|
|
670
|
+
});
|
|
671
|
+
for (const response of [
|
|
672
|
+
{},
|
|
673
|
+
{ markPullRequestReadyForReview: null },
|
|
674
|
+
{ markPullRequestReadyForReview: { pullRequest: { id: 'PR24', isDraft: false } } },
|
|
675
|
+
{ markPullRequestReadyForReview: { pullRequest: { id: 'PR23' } } },
|
|
676
|
+
]) {
|
|
677
|
+
it('refuses incomplete or conflicting mutation acknowledgement without retrying it', async () => {
|
|
678
|
+
const { context, state } = fixture();
|
|
679
|
+
state.promotionReadback = response;
|
|
680
|
+
await assert.rejects(publishPr(context, input), /mutation readback/u);
|
|
681
|
+
assert.equal(state.promotions, 1);
|
|
682
|
+
assert.equal(state.creations, 0);
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
it('refuses a still-draft authoritative readback after a ready acknowledgement', async () => {
|
|
686
|
+
const { context, state } = fixture();
|
|
687
|
+
state.keepDraftOnPromotion = true;
|
|
688
|
+
await assert.rejects(publishPr(context, input), /still draft/u);
|
|
689
|
+
assert.equal(state.promotions, 1);
|
|
690
|
+
});
|
|
691
|
+
it('refuses post-mutation head or association drift without another mutation', async () => {
|
|
692
|
+
for (const kind of ['head', 'association']) {
|
|
693
|
+
const { context, state } = fixture();
|
|
694
|
+
state.readHook = (count) => {
|
|
695
|
+
if (count === 4) {
|
|
696
|
+
if (kind === 'head')
|
|
697
|
+
state.headSha = state.remoteHead = 'd'.repeat(40);
|
|
698
|
+
else
|
|
699
|
+
state.closingReadback = { repository: null };
|
|
700
|
+
}
|
|
701
|
+
};
|
|
702
|
+
await assert.rejects(publishPr(context, input), /drift|association/u);
|
|
703
|
+
assert.equal(state.promotions, 1);
|
|
704
|
+
}
|
|
705
|
+
});
|
|
706
|
+
it('requires explicit head and ready intent rather than guessing a post-publication branch', async () => {
|
|
707
|
+
const { context, state } = fixture();
|
|
708
|
+
await assert.rejects(publishPr(context, { issueNumber: 17, draft: false }), /linked/u);
|
|
709
|
+
await assert.rejects(publishPr(context, { ...input, draft: true }), /linked/u);
|
|
710
|
+
assert.equal(state.promotions, 0);
|
|
711
|
+
});
|
|
712
|
+
it('keeps new ready-PR creation behind native linked-branch authority', async () => {
|
|
713
|
+
const { context, state } = fixture(false);
|
|
714
|
+
await assert.rejects(publishPr(context, input), /linked/u);
|
|
715
|
+
assert.equal(state.creations, 0);
|
|
716
|
+
const started = await startIssueBranch(context, 17);
|
|
717
|
+
assert.equal(started.headSha, base);
|
|
718
|
+
state.remoteHead = head;
|
|
719
|
+
state.expectedCreationDraft = false;
|
|
720
|
+
const created = await publishPr(context, input);
|
|
721
|
+
assert.equal(created.draft, false);
|
|
722
|
+
assert.equal(state.creations, 1);
|
|
723
|
+
assert.equal(state.promotions, 0);
|
|
724
|
+
});
|
|
725
|
+
it('keeps incomplete native branch readback behind the unchanged start gate', async () => {
|
|
726
|
+
const { context, state } = fixture(false);
|
|
727
|
+
state.branchReadback = { repository: null };
|
|
728
|
+
await assert.rejects(startIssueBranch(context, 17), /Incomplete/u);
|
|
729
|
+
assert.deepEqual(state.events, []);
|
|
730
|
+
});
|
|
731
|
+
it('retains configured author role and distinct App preflight before promotion', async () => {
|
|
732
|
+
const { context, reviewerContext, state } = fixture();
|
|
733
|
+
await assert.rejects(publishPr(reviewerContext, input), /author/u);
|
|
734
|
+
state.reviewerActor = 'author';
|
|
735
|
+
await assert.rejects(publishPr(context, input), /distinct/u);
|
|
736
|
+
assert.equal(state.promotions, 0);
|
|
737
|
+
});
|
|
738
|
+
});
|
|
739
|
+
describe('directory-independent exact-PR contract', () => {
|
|
740
|
+
it('uses the existing MCP selector for author dry-run, promotion and ready reuse outside a checkout', async () => {
|
|
741
|
+
const root = mkdtempSync(join(tmpdir(), 'delivery-pr-promotion-'));
|
|
742
|
+
const { context, state } = fixture();
|
|
743
|
+
vi.spyOn(settings, 'loadDeliverySettings').mockResolvedValue(context.config);
|
|
744
|
+
vi.spyOn(issues, 'loadDeliveryContext').mockImplementation(async (input) => {
|
|
745
|
+
assert.equal(input.repository, repo);
|
|
746
|
+
assert.equal(input.repoRoot, root);
|
|
747
|
+
assert.equal(input.role, 'author');
|
|
748
|
+
return context;
|
|
749
|
+
});
|
|
750
|
+
const server = createAiDeliveryMcpServer({ repoRoot: root });
|
|
751
|
+
const client = new Client({ name: 'promotion-fixture', version: '1' });
|
|
752
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
753
|
+
const input = { repo, issueNumber: 17, headBranch: 'issue/17', draft: false };
|
|
754
|
+
try {
|
|
755
|
+
await server.connect(serverTransport);
|
|
756
|
+
await client.connect(clientTransport);
|
|
757
|
+
const preview = await client.callTool({ name: 'issue_pr_create', arguments: { ...input, dryRun: true } });
|
|
758
|
+
assert.equal(preview.isError, undefined);
|
|
759
|
+
assert.equal(state.promotions, 0);
|
|
760
|
+
for (let call = 0; call < 2; call++) {
|
|
761
|
+
const result = await client.callTool({ name: 'issue_pr_create', arguments: input });
|
|
762
|
+
assert.equal(result.isError, undefined);
|
|
763
|
+
}
|
|
764
|
+
assert.equal(state.promotions, 1);
|
|
765
|
+
assert.equal(state.creations, 0);
|
|
766
|
+
assert.equal(state.draft, false);
|
|
767
|
+
assert.equal(state.linked, false);
|
|
768
|
+
}
|
|
769
|
+
finally {
|
|
770
|
+
await client.close();
|
|
771
|
+
await server.close();
|
|
772
|
+
rmSync(root, { recursive: true, force: true });
|
|
773
|
+
}
|
|
774
|
+
});
|
|
775
|
+
it('dispatches native inspection and App dry-run through MCP from a non-Git directory', async () => {
|
|
776
|
+
const root = mkdtempSync(join(tmpdir(), 'delivery-pr-association-'));
|
|
777
|
+
const { context, reviewerContext, state, artifact } = fixture();
|
|
778
|
+
vi.spyOn(settings, 'loadDeliverySettings').mockResolvedValue(context.config);
|
|
779
|
+
const loader = vi.spyOn(issues, 'loadDeliveryContext').mockImplementation(async (input) => {
|
|
780
|
+
assert.equal(input.repository, repo);
|
|
781
|
+
assert.equal(input.repoRoot, root);
|
|
782
|
+
return input.role === 'reviewer' ? reviewerContext : context;
|
|
783
|
+
});
|
|
784
|
+
const server = createAiDeliveryMcpServer({ repoRoot: root });
|
|
785
|
+
const client = new Client({ name: 'exact-pr-fixture', version: '1' });
|
|
786
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
787
|
+
try {
|
|
788
|
+
await server.connect(serverTransport);
|
|
789
|
+
await client.connect(clientTransport);
|
|
790
|
+
const info = await client.callTool({ name: 'issue_pr_info', arguments: { repo, issueNumber: 17, prNumber: 23 } });
|
|
791
|
+
assert.equal(info.isError, undefined);
|
|
792
|
+
const review = await client.callTool({
|
|
793
|
+
name: 'issue_pr_review',
|
|
794
|
+
arguments: { repo, issueNumber: 17, prNumber: 23, artifact, dryRun: true },
|
|
795
|
+
});
|
|
796
|
+
assert.equal(review.isError, undefined);
|
|
797
|
+
state.issueNumber = 18;
|
|
798
|
+
await assert.rejects(executeTool('issue_pr_info', { repo, issueNumber: 17, prNumber: 23 }, { repoRoot: root }), /association/u);
|
|
799
|
+
assert.deepEqual(loader.mock.calls.map(([input]) => input.role), ['author', 'reviewer', 'author']);
|
|
800
|
+
assert.equal(state.submissions, 0);
|
|
801
|
+
}
|
|
802
|
+
finally {
|
|
803
|
+
await client.close();
|
|
804
|
+
await server.close();
|
|
805
|
+
rmSync(root, { recursive: true, force: true });
|
|
806
|
+
}
|
|
807
|
+
});
|
|
808
|
+
it('documents explicit issue/PR selection in actual CLI help without credentials or a checkout', () => {
|
|
809
|
+
const root = mkdtempSync(join(tmpdir(), 'delivery-pr-help-'));
|
|
810
|
+
try {
|
|
811
|
+
const help = execFileSync(process.execPath, [resolve('dist/cli.js'), 'pr:info', '--help'], {
|
|
812
|
+
cwd: root,
|
|
813
|
+
env: { HOME: root, PATH: process.env.PATH },
|
|
814
|
+
encoding: 'utf8',
|
|
815
|
+
});
|
|
816
|
+
assert.match(help, /combine --issue and --pr/u);
|
|
817
|
+
assert.match(help, /issue-only lookup requires a GitHub-linked\s+branch/u);
|
|
818
|
+
const creationHelp = execFileSync(process.execPath, [resolve('dist/cli.js'), 'pr:create', '--help'], {
|
|
819
|
+
cwd: root,
|
|
820
|
+
env: { HOME: root, PATH: process.env.PATH },
|
|
821
|
+
encoding: 'utf8',
|
|
822
|
+
}).replace(/\s+/gu, ' ');
|
|
823
|
+
assert.match(creationHelp, /promote an existing associated PR with --head and --ready/u);
|
|
824
|
+
assert.match(creationHelp, /Explicit remote head/u);
|
|
825
|
+
assert.match(creationHelp, /--pr <number>/u);
|
|
826
|
+
for (const command of ['pr:create', 'pr:info', 'pr:review', 'pr:merge', 'finish']) {
|
|
827
|
+
const modeHelp = execFileSync(process.execPath, [resolve('dist/cli.js'), command, '--help'], {
|
|
828
|
+
cwd: root,
|
|
829
|
+
env: { HOME: root, PATH: process.env.PATH },
|
|
830
|
+
encoding: 'utf8',
|
|
831
|
+
});
|
|
832
|
+
assert.match(modeHelp, /--non-closing/u);
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
finally {
|
|
836
|
+
rmSync(root, { recursive: true, force: true });
|
|
837
|
+
}
|
|
838
|
+
});
|
|
839
|
+
});
|
|
840
|
+
describe('explicit native non-closing reference delivery', () => {
|
|
841
|
+
const input = { issueNumber: 17, prNumber: 23, nonClosing: true };
|
|
842
|
+
it('accepts the exact native historical reference with empty branch/closing connections', async () => {
|
|
843
|
+
const { context, state } = fixture(true, true);
|
|
844
|
+
assert.equal((await prInfo(context, input)).headSha, head);
|
|
845
|
+
const preview = await publishPr(context, { ...input, draft: false, dryRun: true });
|
|
846
|
+
assert.equal(preview.prNumber, 23);
|
|
847
|
+
assert.equal(preview.nonClosing, true);
|
|
848
|
+
await publishPr(context, { ...input, draft: false });
|
|
849
|
+
await publishPr(context, { ...input, draft: false });
|
|
850
|
+
assert.equal(state.promotions, 1);
|
|
851
|
+
assert.equal(state.updates, 0);
|
|
852
|
+
assert.equal(state.creations, 0);
|
|
853
|
+
assert.equal(state.issueState, 'open');
|
|
854
|
+
assert.equal(state.issueBody, 'Four retained criteria');
|
|
855
|
+
});
|
|
856
|
+
it('transitions only an exact selected author PR, then reuses the verified body without another update', async () => {
|
|
857
|
+
const { context, state } = fixture();
|
|
858
|
+
const transition = { ...input, body: 'Deliver widget while retaining its issue.' };
|
|
859
|
+
const preview = await publishPr(context, { ...transition, dryRun: true });
|
|
860
|
+
assert.ok('bodyTransition' in preview);
|
|
861
|
+
assert.equal(preview.bodyTransition, true);
|
|
862
|
+
assert.equal(state.updates, 0);
|
|
863
|
+
await publishPr(context, transition);
|
|
864
|
+
await publishPr(context, transition);
|
|
865
|
+
assert.equal(state.body, `References ${repo}#17\n\nDeliver widget while retaining its issue.`);
|
|
866
|
+
assert.equal(state.updates, 1);
|
|
867
|
+
assert.equal(state.creations, 0);
|
|
868
|
+
});
|
|
869
|
+
it('keeps new PR publication behind native start/branch linkage and checks introduced messages first', async () => {
|
|
870
|
+
const { context, state } = fixture(false, true);
|
|
871
|
+
await assert.rejects(publishPr(context, { issueNumber: 17, nonClosing: true }), /branch/u);
|
|
872
|
+
state.linked = true;
|
|
873
|
+
state.remoteHead = head;
|
|
874
|
+
state.commitMessage = 'Fixes: example/widget#99';
|
|
875
|
+
await assert.rejects(publishPr(context, { issueNumber: 17, nonClosing: true }), /closing directives/u);
|
|
876
|
+
assert.equal(state.creations, 0);
|
|
877
|
+
state.commitMessage = 'Improve widget';
|
|
878
|
+
await publishPr(context, { issueNumber: 17, nonClosing: true });
|
|
879
|
+
assert.equal(state.creations, 1);
|
|
880
|
+
assert.equal(state.linked, false);
|
|
881
|
+
assert.equal(state.issueState, 'open');
|
|
882
|
+
});
|
|
883
|
+
it('uses the independent reviewer route for dry-run, submission and authoritative idempotence', async () => {
|
|
884
|
+
const { context, reviewerContext, state, artifact } = fixture(true, true);
|
|
885
|
+
vi.spyOn(github, 'createDeliveryGitHubClients').mockResolvedValue(context.clients);
|
|
886
|
+
await submitFormalReview(reviewerContext, { ...input, artifact, dryRun: true });
|
|
887
|
+
assert.equal(state.submissions, 0);
|
|
888
|
+
await submitFormalReview(reviewerContext, { ...input, artifact });
|
|
889
|
+
await submitFormalReview(reviewerContext, { ...input, artifact });
|
|
890
|
+
assert.equal(state.submissions, 1);
|
|
891
|
+
assert.equal(state.issueState, 'open');
|
|
892
|
+
});
|
|
893
|
+
for (const directive of [
|
|
894
|
+
'Closes #17',
|
|
895
|
+
'CLOSE: #17, #18',
|
|
896
|
+
'closed example/widget#17',
|
|
897
|
+
'Fixes: other_owner/other_repo#9',
|
|
898
|
+
'fixed https://github.com/example/widget/issues/17',
|
|
899
|
+
'Resolves: https://github.com/example/widget/pull/23',
|
|
900
|
+
'Resolved **#17**',
|
|
901
|
+
'Fixes [the issue](https://github.com/example/widget/issues/17)',
|
|
902
|
+
'Closes \\#17',
|
|
903
|
+
]) {
|
|
904
|
+
it(`refuses closure syntax in introduced messages: ${directive}`, async () => {
|
|
905
|
+
const { context, state } = fixture(true, true);
|
|
906
|
+
state.commitMessage = directive;
|
|
907
|
+
await assert.rejects(publishPr(context, { ...input, draft: false }), /closing directives/u);
|
|
908
|
+
assert.equal(state.promotions, 0);
|
|
909
|
+
assert.equal(state.updates, 0);
|
|
910
|
+
});
|
|
911
|
+
}
|
|
912
|
+
for (const [name, change] of [
|
|
913
|
+
[
|
|
914
|
+
'removed current intent',
|
|
915
|
+
(state) => {
|
|
916
|
+
state.body = 'Improve widget';
|
|
917
|
+
},
|
|
918
|
+
],
|
|
919
|
+
[
|
|
920
|
+
'changed intended issue',
|
|
921
|
+
(state) => {
|
|
922
|
+
state.body = `References ${repo}#18`;
|
|
923
|
+
},
|
|
924
|
+
],
|
|
925
|
+
[
|
|
926
|
+
'conflicting intent declarations',
|
|
927
|
+
(state) => {
|
|
928
|
+
state.body += `\nReferences ${repo}#18`;
|
|
929
|
+
},
|
|
930
|
+
],
|
|
931
|
+
[
|
|
932
|
+
'body closure',
|
|
933
|
+
(state) => {
|
|
934
|
+
state.body += '\nFixes #18';
|
|
935
|
+
},
|
|
936
|
+
],
|
|
937
|
+
[
|
|
938
|
+
'title closure',
|
|
939
|
+
(state) => {
|
|
940
|
+
state.title = 'Fixes #17';
|
|
941
|
+
},
|
|
942
|
+
],
|
|
943
|
+
[
|
|
944
|
+
'closing native event',
|
|
945
|
+
(state) => {
|
|
946
|
+
state.referenceCloses = true;
|
|
947
|
+
},
|
|
948
|
+
],
|
|
949
|
+
[
|
|
950
|
+
'unknown native event intent',
|
|
951
|
+
(state) => {
|
|
952
|
+
state.referenceCloses = null;
|
|
953
|
+
},
|
|
954
|
+
],
|
|
955
|
+
[
|
|
956
|
+
'unauthorized reference actor',
|
|
957
|
+
(state) => {
|
|
958
|
+
state.referenceActor = 'collaborator';
|
|
959
|
+
},
|
|
960
|
+
],
|
|
961
|
+
[
|
|
962
|
+
'wrong PR author',
|
|
963
|
+
(state) => {
|
|
964
|
+
state.prAuthor = 'collaborator';
|
|
965
|
+
},
|
|
966
|
+
],
|
|
967
|
+
[
|
|
968
|
+
'closed issue',
|
|
969
|
+
(state) => {
|
|
970
|
+
state.issueState = 'closed';
|
|
971
|
+
},
|
|
972
|
+
],
|
|
973
|
+
[
|
|
974
|
+
'foreign PR repository',
|
|
975
|
+
(state) => {
|
|
976
|
+
state.headRepo = 'other/widget';
|
|
977
|
+
},
|
|
978
|
+
],
|
|
979
|
+
[
|
|
980
|
+
'moved default base',
|
|
981
|
+
(state) => {
|
|
982
|
+
state.baseSha = 'd'.repeat(40);
|
|
983
|
+
},
|
|
984
|
+
],
|
|
985
|
+
[
|
|
986
|
+
'incomplete commits',
|
|
987
|
+
(state) => {
|
|
988
|
+
state.commitReadback = { repository: null };
|
|
989
|
+
},
|
|
990
|
+
],
|
|
991
|
+
[
|
|
992
|
+
'missing native reference',
|
|
993
|
+
(state) => {
|
|
994
|
+
state.referenceReadback = {
|
|
995
|
+
repository: {
|
|
996
|
+
id: 'REPO',
|
|
997
|
+
nameWithOwner: repo,
|
|
998
|
+
issue: { id: 'ISSUE17', number: 17, state: 'OPEN', timelineItems: { nodes: [], pageInfo } },
|
|
999
|
+
},
|
|
1000
|
+
};
|
|
1001
|
+
},
|
|
1002
|
+
],
|
|
1003
|
+
]) {
|
|
1004
|
+
it(`refuses ${name} without mutation`, async () => {
|
|
1005
|
+
const { context, state } = fixture(true, true);
|
|
1006
|
+
change(state);
|
|
1007
|
+
await assert.rejects(publishPr(context, { ...input, draft: false }));
|
|
1008
|
+
assert.equal(state.promotions + state.creations + state.updates, 0);
|
|
1009
|
+
});
|
|
1010
|
+
}
|
|
1011
|
+
it('requires complete empty closing associations including later pages', async () => {
|
|
1012
|
+
const { context, state } = fixture(true, true);
|
|
1013
|
+
state.closingPages = [
|
|
1014
|
+
{
|
|
1015
|
+
repository: {
|
|
1016
|
+
nameWithOwner: repo,
|
|
1017
|
+
pullRequest: {
|
|
1018
|
+
id: 'PR23',
|
|
1019
|
+
number: 23,
|
|
1020
|
+
closingIssuesReferences: { nodes: [], pageInfo: { hasNextPage: true, endCursor: 'NEXT' } },
|
|
1021
|
+
},
|
|
1022
|
+
},
|
|
1023
|
+
},
|
|
1024
|
+
{
|
|
1025
|
+
repository: {
|
|
1026
|
+
nameWithOwner: repo,
|
|
1027
|
+
pullRequest: {
|
|
1028
|
+
id: 'PR23',
|
|
1029
|
+
number: 23,
|
|
1030
|
+
closingIssuesReferences: { nodes: [{ number: 99, repository: { nameWithOwner: repo } }], pageInfo },
|
|
1031
|
+
},
|
|
1032
|
+
},
|
|
1033
|
+
},
|
|
1034
|
+
];
|
|
1035
|
+
await assert.rejects(publishPr(context, { ...input, draft: false }), /closing association/u);
|
|
1036
|
+
assert.equal(state.promotions, 0);
|
|
1037
|
+
});
|
|
1038
|
+
it('accepts equivalent duplicate native events without duplicating authority, but refuses conflicts', async () => {
|
|
1039
|
+
const { context, state } = fixture(true, true);
|
|
1040
|
+
const native = (await context.clients.graphql('DeliveryNonClosingReferences', {}));
|
|
1041
|
+
const event = native.repository.issue.timelineItems.nodes[0];
|
|
1042
|
+
native.repository.issue.timelineItems.nodes.push({ ...event });
|
|
1043
|
+
state.referenceReadback = native;
|
|
1044
|
+
await prInfo(context, input);
|
|
1045
|
+
native.repository.issue.timelineItems.nodes[1].willCloseTarget = true;
|
|
1046
|
+
await assert.rejects(prInfo(context, input), /Conflicting/u);
|
|
1047
|
+
});
|
|
1048
|
+
it('refuses incomplete reference pagination after a matching observation', async () => {
|
|
1049
|
+
const { context, state } = fixture(true, true);
|
|
1050
|
+
const native = (await context.clients.graphql('DeliveryNonClosingReferences', {}));
|
|
1051
|
+
native.repository.issue.timelineItems.pageInfo = { hasNextPage: true, endCursor: 'NEXT' };
|
|
1052
|
+
state.referencePages = [native, { repository: null }];
|
|
1053
|
+
await assert.rejects(publishPr(context, { ...input, draft: false }), /Incomplete/u);
|
|
1054
|
+
assert.equal(state.promotions, 0);
|
|
1055
|
+
});
|
|
1056
|
+
it('does not create again after a server-created draft fails association verification', async () => {
|
|
1057
|
+
const { context, state } = fixture(false);
|
|
1058
|
+
state.linked = true;
|
|
1059
|
+
state.remoteHead = head;
|
|
1060
|
+
state.closingReadback = { repository: null };
|
|
1061
|
+
await assert.rejects(publishPr(context, { issueNumber: 17 }), /created PR #23.*post-publication association\/identity verification is unresolved/u);
|
|
1062
|
+
assert.equal((await prInfo(context, { prNumber: 23 })).prNumber, 23);
|
|
1063
|
+
await assert.rejects(publishPr(context, { issueNumber: 17 }));
|
|
1064
|
+
assert.equal(state.creations, 1);
|
|
1065
|
+
assert.equal(state.published, true);
|
|
1066
|
+
});
|
|
1067
|
+
it('preserves uncertain create/update acknowledgements and reconciles without another mutation', async () => {
|
|
1068
|
+
const { context, state } = fixture(false);
|
|
1069
|
+
state.linked = true;
|
|
1070
|
+
state.remoteHead = head;
|
|
1071
|
+
state.failCreateAck = true;
|
|
1072
|
+
await assert.rejects(publishPr(context, { issueNumber: 17 }), /acknowledgement is uncertain/u);
|
|
1073
|
+
assert.equal((await prInfo(context, { prNumber: 23 })).prNumber, 23);
|
|
1074
|
+
state.failCreateAck = false;
|
|
1075
|
+
await publishPr(context, { issueNumber: 17, prNumber: 23 });
|
|
1076
|
+
assert.equal(state.creations, 1);
|
|
1077
|
+
state.failUpdateAck = true;
|
|
1078
|
+
const transition = { ...input, body: 'Keep the issue open.' };
|
|
1079
|
+
await assert.rejects(publishPr(context, transition), /Transport lost after body update/u);
|
|
1080
|
+
state.failUpdateAck = false;
|
|
1081
|
+
await publishPr(context, transition);
|
|
1082
|
+
assert.equal(state.updates, 1);
|
|
1083
|
+
});
|
|
1084
|
+
it('refuses explicit ambiguity and requires exact numbers for non-closing inspection', async () => {
|
|
1085
|
+
const { context, state } = fixture(true, true);
|
|
1086
|
+
await assert.rejects(prInfo(context, { issueNumber: 17, nonClosing: true }), /explicit/u);
|
|
1087
|
+
state.matchReadback = [{ number: 23 }, { number: 24 }];
|
|
1088
|
+
await assert.rejects(publishPr(context, { ...input, draft: false }), /conflicting/u);
|
|
1089
|
+
assert.equal(state.promotions, 0);
|
|
1090
|
+
});
|
|
1091
|
+
});
|
|
1092
|
+
describe('non-closing complete native graph and mutation drift', () => {
|
|
1093
|
+
const input = { issueNumber: 17, prNumber: 23, nonClosing: true };
|
|
1094
|
+
it('checks earlier introduced commits rather than only the safe head message', async () => {
|
|
1095
|
+
const { context, state } = fixture(true, true);
|
|
1096
|
+
const graph = (await context.clients.graphql('DeliveryNonClosingCommits', {}));
|
|
1097
|
+
graph.repository.pullRequest.commits.totalCount = 2;
|
|
1098
|
+
graph.repository.pullRequest.commits.nodes.unshift({
|
|
1099
|
+
commit: {
|
|
1100
|
+
oid: 'e'.repeat(40),
|
|
1101
|
+
message: 'Closes: #999',
|
|
1102
|
+
parents: { totalCount: 1, nodes: [{ oid: base }], pageInfo },
|
|
1103
|
+
},
|
|
1104
|
+
});
|
|
1105
|
+
state.commitReadback = graph;
|
|
1106
|
+
await assert.rejects(publishPr(context, { ...input, draft: false }), /closing directives/u);
|
|
1107
|
+
assert.equal(state.promotions, 0);
|
|
1108
|
+
graph.repository.pullRequest.commits.nodes[0].commit.message = 'Earlier improvement';
|
|
1109
|
+
await prInfo(context, input);
|
|
1110
|
+
graph.repository.pullRequest.commits.totalCount = 3;
|
|
1111
|
+
await assert.rejects(prInfo(context, input), /count\/head is incomplete/u);
|
|
1112
|
+
});
|
|
1113
|
+
for (const kind of [
|
|
1114
|
+
'source-node',
|
|
1115
|
+
'source-number',
|
|
1116
|
+
'source-repository',
|
|
1117
|
+
'source-type',
|
|
1118
|
+
'target-node',
|
|
1119
|
+
'target-number',
|
|
1120
|
+
'target-repository',
|
|
1121
|
+
'cross-repository',
|
|
1122
|
+
'missing-actor',
|
|
1123
|
+
'wrong-pr-node',
|
|
1124
|
+
]) {
|
|
1125
|
+
it(`refuses conflicting native identity ${kind}`, async () => {
|
|
1126
|
+
const { context, state } = fixture(true, true);
|
|
1127
|
+
const native = (await context.clients.graphql('DeliveryNonClosingReferences', {}));
|
|
1128
|
+
const event = native.repository.issue.timelineItems.nodes[0];
|
|
1129
|
+
if (kind === 'source-node')
|
|
1130
|
+
event.source.id = 'PROTHER';
|
|
1131
|
+
if (kind === 'source-number')
|
|
1132
|
+
event.source.number = 24;
|
|
1133
|
+
if (kind === 'source-repository')
|
|
1134
|
+
event.source.repository.nameWithOwner = 'other/widget';
|
|
1135
|
+
if (kind === 'source-type')
|
|
1136
|
+
event.source.__typename = 'Issue';
|
|
1137
|
+
if (kind === 'target-node')
|
|
1138
|
+
event.target.id = 'ISSUEOTHER';
|
|
1139
|
+
if (kind === 'target-number')
|
|
1140
|
+
event.target.number = 18;
|
|
1141
|
+
if (kind === 'target-repository')
|
|
1142
|
+
event.target.repository.id = 'OTHER';
|
|
1143
|
+
if (kind === 'cross-repository')
|
|
1144
|
+
event.isCrossRepository = true;
|
|
1145
|
+
if (kind === 'missing-actor')
|
|
1146
|
+
event.actor = null;
|
|
1147
|
+
if (kind === 'wrong-pr-node')
|
|
1148
|
+
state.nodeId = 'OTHER';
|
|
1149
|
+
state.referenceReadback = native;
|
|
1150
|
+
await assert.rejects(publishPr(context, { ...input, draft: false }));
|
|
1151
|
+
assert.equal(state.promotions + state.creations + state.updates, 0);
|
|
1152
|
+
});
|
|
1153
|
+
}
|
|
1154
|
+
it('refuses changed issue criteria on promotion readback without another ready mutation', async () => {
|
|
1155
|
+
const { context, state } = fixture(true, true);
|
|
1156
|
+
state.readHook = () => {
|
|
1157
|
+
if (state.promotions)
|
|
1158
|
+
state.issueBody = 'External changed criteria';
|
|
1159
|
+
};
|
|
1160
|
+
await assert.rejects(publishPr(context, { ...input, draft: false }), /criteria drifted/u);
|
|
1161
|
+
assert.equal(state.promotions, 1);
|
|
1162
|
+
assert.equal(state.issueState, 'open');
|
|
1163
|
+
});
|
|
1164
|
+
it('refuses changed body during formal review readback without a duplicate review', async () => {
|
|
1165
|
+
const { context, reviewerContext, state, artifact } = fixture(true, true);
|
|
1166
|
+
vi.spyOn(github, 'createDeliveryGitHubClients').mockResolvedValue(context.clients);
|
|
1167
|
+
state.readHook = () => {
|
|
1168
|
+
if (state.submissions)
|
|
1169
|
+
state.body = 'External removed reference';
|
|
1170
|
+
};
|
|
1171
|
+
await assert.rejects(submitFormalReview(reviewerContext, { ...input, artifact }), /References line/u);
|
|
1172
|
+
assert.equal(state.submissions, 1);
|
|
1173
|
+
assert.equal(state.issueState, 'open');
|
|
1174
|
+
});
|
|
1175
|
+
});
|
|
1176
|
+
describe('exact created intent and native reader bounds', () => {
|
|
1177
|
+
for (const drift of ['body', 'title', 'draft', 'closed', 'base']) {
|
|
1178
|
+
it(`reports created identity but refuses post-publication ${drift} drift without a replacement`, async () => {
|
|
1179
|
+
const { context, state } = fixture(false, true);
|
|
1180
|
+
state.linked = true;
|
|
1181
|
+
state.remoteHead = head;
|
|
1182
|
+
state.readHook = () => {
|
|
1183
|
+
if (!state.creations)
|
|
1184
|
+
return;
|
|
1185
|
+
if (drift === 'body')
|
|
1186
|
+
state.body = `References ${repo}#17\n\nOther safe intent`;
|
|
1187
|
+
if (drift === 'title')
|
|
1188
|
+
state.title = 'Other safe title';
|
|
1189
|
+
if (drift === 'draft')
|
|
1190
|
+
state.draft = false;
|
|
1191
|
+
if (drift === 'closed')
|
|
1192
|
+
state.prState = 'closed';
|
|
1193
|
+
if (drift === 'base')
|
|
1194
|
+
state.baseSha = 'd'.repeat(40);
|
|
1195
|
+
};
|
|
1196
|
+
await assert.rejects(publishPr(context, { issueNumber: 17, nonClosing: true }), /created PR #23.*differs from publication intent/u);
|
|
1197
|
+
assert.equal(state.creations, 1);
|
|
1198
|
+
await assert.rejects(publishPr(context, { issueNumber: 17, nonClosing: true }));
|
|
1199
|
+
assert.equal(state.creations, 1);
|
|
1200
|
+
});
|
|
1201
|
+
}
|
|
1202
|
+
for (const id of [1, true, {}, null, '', ' ']) {
|
|
1203
|
+
it(`refuses malformed native event ID ${JSON.stringify(id)} before ready`, async () => {
|
|
1204
|
+
const { context, state } = fixture(true, true);
|
|
1205
|
+
const native = (await context.clients.graphql('DeliveryNonClosingReferences', {}));
|
|
1206
|
+
native.repository.issue.timelineItems.nodes[0].id = id;
|
|
1207
|
+
state.referenceReadback = native;
|
|
1208
|
+
await assert.rejects(publishPr(context, { issueNumber: 17, prNumber: 23, nonClosing: true, draft: false }), /event identity/u);
|
|
1209
|
+
assert.equal(state.promotions + state.updates + state.creations, 0);
|
|
1210
|
+
});
|
|
1211
|
+
}
|
|
1212
|
+
it('refuses an oversized reference page despite equivalent observations', async () => {
|
|
1213
|
+
const { context, state } = fixture(true, true);
|
|
1214
|
+
const native = (await context.clients.graphql('DeliveryNonClosingReferences', {}));
|
|
1215
|
+
native.repository.issue.timelineItems.nodes = Array.from({ length: 101 }, () => native.repository.issue.timelineItems.nodes[0]);
|
|
1216
|
+
state.referenceReadback = native;
|
|
1217
|
+
await assert.rejects(publishPr(context, { issueNumber: 17, prNumber: 23, nonClosing: true, draft: false }), /Incomplete/u);
|
|
1218
|
+
assert.equal(state.promotions, 0);
|
|
1219
|
+
});
|
|
1220
|
+
it('refuses oversized introduced commit and parent pages', async () => {
|
|
1221
|
+
for (const kind of ['commits', 'parents']) {
|
|
1222
|
+
const { context, state } = fixture(true, true);
|
|
1223
|
+
const native = (await context.clients.graphql('DeliveryNonClosingCommits', {}));
|
|
1224
|
+
const commits = native.repository.pullRequest.commits;
|
|
1225
|
+
if (kind === 'commits') {
|
|
1226
|
+
commits.nodes = Array.from({ length: 101 }, () => commits.nodes[0]);
|
|
1227
|
+
commits.totalCount = 101;
|
|
1228
|
+
}
|
|
1229
|
+
else {
|
|
1230
|
+
commits.nodes[0].commit.parents.nodes = Array.from({ length: 101 }, () => ({ oid: base }));
|
|
1231
|
+
commits.nodes[0].commit.parents.totalCount = 101;
|
|
1232
|
+
}
|
|
1233
|
+
state.commitReadback = native;
|
|
1234
|
+
await assert.rejects(publishPr(context, { issueNumber: 17, prNumber: 23, nonClosing: true, draft: false }), /Incomplete/u);
|
|
1235
|
+
assert.equal(state.promotions, 0);
|
|
1236
|
+
}
|
|
1237
|
+
});
|
|
1238
|
+
it('refuses body-transition draft drift before promotion, with no second body mutation', async () => {
|
|
1239
|
+
const { context, state } = fixture();
|
|
1240
|
+
state.readHook = () => {
|
|
1241
|
+
if (state.updates)
|
|
1242
|
+
state.draft = false;
|
|
1243
|
+
};
|
|
1244
|
+
await assert.rejects(publishPr(context, {
|
|
1245
|
+
issueNumber: 17,
|
|
1246
|
+
prNumber: 23,
|
|
1247
|
+
nonClosing: true,
|
|
1248
|
+
body: 'Safe partial intent',
|
|
1249
|
+
draft: false,
|
|
1250
|
+
}), /drifted/u);
|
|
1251
|
+
assert.equal(state.updates, 1);
|
|
1252
|
+
assert.equal(state.promotions, 0);
|
|
1253
|
+
assert.equal(state.creations, 0);
|
|
1254
|
+
});
|
|
1255
|
+
it('does not use the closing-transition exception for stale body with empty closing associations', async () => {
|
|
1256
|
+
const { context, state } = fixture(true, true);
|
|
1257
|
+
state.body = 'Stale intent with removed References line';
|
|
1258
|
+
await assert.rejects(publishPr(context, { issueNumber: 17, prNumber: 23, nonClosing: true, body: 'New safe intent' }), /closing-issue association/u);
|
|
1259
|
+
assert.equal(state.updates + state.creations + state.promotions, 0);
|
|
1260
|
+
});
|
|
1261
|
+
});
|
|
1262
|
+
describe('explicit metadata transition typed authority', () => {
|
|
1263
|
+
for (const closing of [
|
|
1264
|
+
{ number: 17, repository: { nameWithOwner: repo } },
|
|
1265
|
+
{ __typename: 'PullRequest', id: 'ISSUE17', number: 17, repository: { id: 'REPO', nameWithOwner: repo } },
|
|
1266
|
+
{ __typename: 'Issue', id: 'OTHER', number: 17, repository: { id: 'REPO', nameWithOwner: repo } },
|
|
1267
|
+
{ __typename: 'Issue', id: 'ISSUE17', number: 17, repository: { id: 'OTHER', nameWithOwner: repo } },
|
|
1268
|
+
]) {
|
|
1269
|
+
it(`refuses incomplete/foreign typed closing identity ${JSON.stringify(closing)}`, async () => {
|
|
1270
|
+
const { context, state } = fixture();
|
|
1271
|
+
state.closingReadback = {
|
|
1272
|
+
repository: {
|
|
1273
|
+
nameWithOwner: repo,
|
|
1274
|
+
pullRequest: { id: 'PR23', number: 23, closingIssuesReferences: { nodes: [closing], pageInfo } },
|
|
1275
|
+
},
|
|
1276
|
+
};
|
|
1277
|
+
await assert.rejects(publishPr(context, { issueNumber: 17, prNumber: 23, nonClosing: true, body: 'Safe partial intent' }), /typed closing/u);
|
|
1278
|
+
assert.equal(state.updates + state.creations + state.promotions, 0);
|
|
1279
|
+
});
|
|
1280
|
+
}
|
|
1281
|
+
});
|
|
1282
|
+
describe('later-read publication and pre-creation parent bound', () => {
|
|
1283
|
+
for (const drift of ['body', 'title', 'draft', 'closed', 'merged', 'base']) {
|
|
1284
|
+
it(`refuses later ${drift} drift after the first correct created PR read`, async () => {
|
|
1285
|
+
const { context, state } = fixture(false, true);
|
|
1286
|
+
state.linked = true;
|
|
1287
|
+
state.remoteHead = head;
|
|
1288
|
+
state.readHook = (count) => {
|
|
1289
|
+
if (!state.creations || count < 2)
|
|
1290
|
+
return;
|
|
1291
|
+
if (drift === 'body')
|
|
1292
|
+
state.body = `References ${repo}#17\n\nOther safe intent`;
|
|
1293
|
+
if (drift === 'title')
|
|
1294
|
+
state.title = 'Other safe title';
|
|
1295
|
+
if (drift === 'draft')
|
|
1296
|
+
state.draft = false;
|
|
1297
|
+
if (drift === 'closed')
|
|
1298
|
+
state.prState = 'closed';
|
|
1299
|
+
if (drift === 'merged') {
|
|
1300
|
+
state.prState = 'closed';
|
|
1301
|
+
state.merged = true;
|
|
1302
|
+
}
|
|
1303
|
+
if (drift === 'base')
|
|
1304
|
+
state.baseSha = 'd'.repeat(40);
|
|
1305
|
+
};
|
|
1306
|
+
await assert.rejects(publishPr(context, { issueNumber: 17, nonClosing: true }), /created PR #23.*unresolved/u);
|
|
1307
|
+
assert.equal(state.creations, 1);
|
|
1308
|
+
});
|
|
1309
|
+
}
|
|
1310
|
+
it('rejects state drift observed only by the verifier final internal PR read', async () => {
|
|
1311
|
+
const { context, state } = fixture(true, true);
|
|
1312
|
+
state.readHook = (count) => {
|
|
1313
|
+
if (count === 3)
|
|
1314
|
+
state.draft = false;
|
|
1315
|
+
};
|
|
1316
|
+
await assert.rejects(publishPr(context, { issueNumber: 17, prNumber: 23, nonClosing: true }), /state\/draft\/merged/u);
|
|
1317
|
+
assert.equal(state.updates + state.promotions + state.creations, 0);
|
|
1318
|
+
});
|
|
1319
|
+
it('refuses oversized REST parent arrays before creating any PR', async () => {
|
|
1320
|
+
const { context, state } = fixture(false, true);
|
|
1321
|
+
state.linked = true;
|
|
1322
|
+
state.remoteHead = head;
|
|
1323
|
+
state.comparisonReadback = {
|
|
1324
|
+
base_commit: { sha: base },
|
|
1325
|
+
merge_base_commit: { sha: base },
|
|
1326
|
+
status: 'ahead',
|
|
1327
|
+
total_commits: 1,
|
|
1328
|
+
commits: [
|
|
1329
|
+
{ sha: head, commit: { message: 'Safe message' }, parents: Array.from({ length: 101 }, () => ({ sha: base })) },
|
|
1330
|
+
],
|
|
1331
|
+
};
|
|
1332
|
+
await assert.rejects(publishPr(context, { issueNumber: 17, nonClosing: true }), /Incomplete/u);
|
|
1333
|
+
assert.equal(state.creations, 0);
|
|
1334
|
+
});
|
|
1335
|
+
});
|
|
1336
|
+
//# sourceMappingURL=prAssociation.test.js.map
|