@aviaratech/ai-delivery 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +51 -11
- package/dist/cli.js +9 -5
- package/dist/cli.js.map +1 -1
- package/dist/config/deliveryConfig.d.ts +20 -4
- package/dist/config/deliveryConfig.js +41 -15
- package/dist/config/deliveryConfig.js.map +1 -1
- package/dist/config/deliveryConfig.test.js +103 -2
- package/dist/config/deliveryConfig.test.js.map +1 -1
- package/dist/dispatch.d.ts +1 -0
- package/dist/dispatch.js +10 -4
- package/dist/dispatch.js.map +1 -1
- package/dist/fixtures/discovery.d.ts +8 -0
- package/dist/github/client.d.ts +2 -1
- package/dist/github/client.js +50 -26
- package/dist/github/client.js.map +1 -1
- package/dist/github/commandIdentityPolicy.js +7 -2
- package/dist/github/commandIdentityPolicy.js.map +1 -1
- package/dist/lifecycle.test.js +490 -27
- package/dist/lifecycle.test.js.map +1 -1
- package/dist/mcp/index.js +6 -3
- package/dist/mcp/index.js.map +1 -1
- package/dist/pr.d.ts +30 -1
- package/dist/pr.js +139 -19
- package/dist/pr.js.map +1 -1
- package/dist/review.d.ts +7 -0
- package/dist/review.js +47 -0
- package/dist/review.js.map +1 -1
- package/dist/review.test.js +147 -6
- package/dist/review.test.js.map +1 -1
- package/dist/verification.d.ts +1 -0
- package/dist/verification.js +150 -19
- package/dist/verification.js.map +1 -1
- package/package.json +1 -1
- package/plugins/ai-delivery/.claude-plugin/plugin.json +1 -1
- package/plugins/ai-delivery/README.md +1 -1
- package/plugins/ai-delivery/skills/pr-handoff/SKILL.md +14 -9
package/dist/lifecycle.test.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
|
-
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
3
3
|
import { createHash } from 'node:crypto';
|
|
4
|
-
import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, readdirSync, realpathSync, rmSync, symlinkSync, unlinkSync, writeFileSync, } from 'node:fs';
|
|
4
|
+
import { cpSync, chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, readdirSync, realpathSync, rmSync, symlinkSync, unlinkSync, writeFileSync, } from 'node:fs';
|
|
5
5
|
import { tmpdir } from 'node:os';
|
|
6
6
|
import { dirname, join } from 'node:path';
|
|
7
7
|
import { test, vi } from 'vitest';
|
|
@@ -17,28 +17,124 @@ import { executeTool, resumedIssueUpdate, startTrackedIssue } from './dispatch.j
|
|
|
17
17
|
import { defaultBaseRef, gitCommonDir } from './git.js';
|
|
18
18
|
import * as githubClient from './github/client.js';
|
|
19
19
|
import { createIssue, developIssue, listIssueSubissues, readyCheck, resumeCreatedIssue, updateIssue, } from './issue.js';
|
|
20
|
-
import { checkoutPr, finishIssue, listPrs, mergePr, prChecks, publishPr, submitFormalReview } from './pr.js';
|
|
20
|
+
import { checkoutPr, finishIssue, listPrs, mergePr, prChecks, prInfo, publishPr, submitFormalReview } from './pr.js';
|
|
21
21
|
import { updateIssueWorktreeDelivery } from './services/worktreeRegistry.js';
|
|
22
22
|
import { assertDeliveryRuntimeAdmitted } from './services/deliveryAdmission.js';
|
|
23
23
|
import { getDeliveryRecords } from './services/deliveryRecordService.js';
|
|
24
24
|
import { createIssuePhaseEvidence, verifyIssue } from './verification.js';
|
|
25
25
|
import { cleanupNonIssueWorktree, prepareIssueWorktree, preparePrWorktree, prepareStandaloneWorktree, } from './worktree.js';
|
|
26
|
+
const personalRoute = vi.hoisted(() => ({
|
|
27
|
+
active: false,
|
|
28
|
+
baseSha: '',
|
|
29
|
+
headSha: '',
|
|
30
|
+
reviews: [],
|
|
31
|
+
submitted: 0,
|
|
32
|
+
}));
|
|
26
33
|
vi.mock('./github/client.js', async (importOriginal) => {
|
|
27
34
|
const actual = await importOriginal();
|
|
28
35
|
return {
|
|
29
36
|
...actual,
|
|
30
|
-
createDeliveryGitHubClients: async (input) =>
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
37
|
+
createDeliveryGitHubClients: async (input) => {
|
|
38
|
+
if (personalRoute.active && input.config.roles.author.authSource === 'personal') {
|
|
39
|
+
const pr = () => ({
|
|
40
|
+
number: 23,
|
|
41
|
+
node_id: 'PR-23',
|
|
42
|
+
html_url: 'https://github.com/example/widget/pull/23',
|
|
43
|
+
head: { sha: personalRoute.headSha, ref: 'issue/17' },
|
|
44
|
+
base: { sha: personalRoute.baseSha, ref: 'main' },
|
|
45
|
+
state: 'open',
|
|
46
|
+
draft: true,
|
|
47
|
+
user: { login: 'host-user' },
|
|
48
|
+
});
|
|
49
|
+
if (input.role === 'reviewer') {
|
|
50
|
+
assert.equal(input.identity, 'synthetic-reviewer');
|
|
51
|
+
return {
|
|
52
|
+
...syntheticDiscoveryClients(),
|
|
53
|
+
authSource: 'app',
|
|
54
|
+
credentialSource: 'app:reviewer',
|
|
55
|
+
role: 'reviewer',
|
|
56
|
+
appActorLogin: async () => 'synthetic-reviewer[bot]',
|
|
57
|
+
rest: {
|
|
58
|
+
repos: { get: async () => ({ data: { full_name: 'example/widget' } }) },
|
|
59
|
+
pulls: {
|
|
60
|
+
get: async () => ({ data: pr() }),
|
|
61
|
+
listReviews: async () => ({ data: personalRoute.reviews }),
|
|
62
|
+
createReview: async (request) => {
|
|
63
|
+
assert.equal(request.event, 'APPROVE');
|
|
64
|
+
assert.equal(request.commit_id, personalRoute.headSha);
|
|
65
|
+
personalRoute.submitted += 1;
|
|
66
|
+
const review = {
|
|
67
|
+
id: 31,
|
|
68
|
+
body: request.body,
|
|
69
|
+
state: 'APPROVED',
|
|
70
|
+
commit_id: personalRoute.headSha,
|
|
71
|
+
user: { login: 'synthetic-reviewer[bot]' },
|
|
72
|
+
html_url: 'https://github.com/example/widget/pull/23#pullrequestreview-31',
|
|
73
|
+
};
|
|
74
|
+
personalRoute.reviews.push(review);
|
|
75
|
+
return { data: review };
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
graphql: async (query, variables) => query.includes('DeliveryReviewDecision')
|
|
80
|
+
? {
|
|
81
|
+
repository: {
|
|
82
|
+
pullRequest: { headRefOid: personalRoute.headSha, reviewDecision: 'REVIEW_REQUIRED' },
|
|
83
|
+
},
|
|
84
|
+
}
|
|
85
|
+
: syntheticDiscoveryClients().graphql(query, variables),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
assert.equal(input.identity, 'host-author');
|
|
89
|
+
const clients = await actual.createDeliveryGitHubClients({
|
|
90
|
+
...input,
|
|
91
|
+
env: { AUTHOR_TOKEN: 'selected-personal-token', GH_TOKEN: 'ambient-token' },
|
|
92
|
+
});
|
|
93
|
+
Object.assign(clients, {
|
|
94
|
+
authenticatedAuthor: async () => ({ actorLogin: 'host-user', credentialIdentity: 'user:37' }),
|
|
95
|
+
graphql: syntheticDiscoveryClients().graphql,
|
|
96
|
+
rest: {
|
|
97
|
+
issues: { get: async () => ({ data: { title: 'Synthetic issue' } }) },
|
|
98
|
+
git: { getRef: async () => ({ data: { object: { sha: personalRoute.baseSha } } }) },
|
|
99
|
+
pulls: {
|
|
100
|
+
list: async () => ({ data: [] }),
|
|
101
|
+
create: async () => ({ data: pr() }),
|
|
102
|
+
get: async () => ({ data: pr() }),
|
|
103
|
+
},
|
|
104
|
+
request: async () => Promise.reject(Object.assign(new Error('Rules unreadable'), { status: 403 })),
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
return clients;
|
|
108
|
+
}
|
|
109
|
+
const rest = new Octokit({
|
|
35
110
|
request: {
|
|
36
111
|
fetch: async () => {
|
|
37
112
|
throw new Error('Unexpected network request in synthetic lifecycle test.');
|
|
38
113
|
},
|
|
39
114
|
},
|
|
40
|
-
})
|
|
41
|
-
|
|
115
|
+
});
|
|
116
|
+
if (input.role === 'reviewer') {
|
|
117
|
+
Object.assign(rest.repos, { get: async () => ({ data: { full_name: 'example/widget' } }) });
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
...syntheticDiscoveryClients(),
|
|
121
|
+
authSource: input.role === 'author' && input.config.roles.author.authSource === 'personal' ? 'personal' : 'app',
|
|
122
|
+
role: input.role,
|
|
123
|
+
...(input.role === 'author'
|
|
124
|
+
? {
|
|
125
|
+
authenticatedAuthor: async () => ({
|
|
126
|
+
actorLogin: input.config.roles.author.authSource === 'personal' ? 'host-user' : 'synthetic-author[bot]',
|
|
127
|
+
credentialIdentity: input.config.roles.author.authSource === 'personal' ? 'user:37' : 'app:201',
|
|
128
|
+
}),
|
|
129
|
+
credentialSource: input.config.roles.author.authSource === 'personal' ? 'env:AUTHOR_TOKEN' : 'app:author',
|
|
130
|
+
}
|
|
131
|
+
: {}),
|
|
132
|
+
...(input.role === 'reviewer'
|
|
133
|
+
? { appActorLogin: async () => 'synthetic-reviewer', credentialSource: 'app:reviewer' }
|
|
134
|
+
: {}),
|
|
135
|
+
rest,
|
|
136
|
+
};
|
|
137
|
+
},
|
|
42
138
|
};
|
|
43
139
|
});
|
|
44
140
|
function git(root, ...args) {
|
|
@@ -100,14 +196,16 @@ async function fixture(options = {}) {
|
|
|
100
196
|
policy: { contract: 'RepositoryDeliveryPolicy@1', module: './policy.mjs' },
|
|
101
197
|
repository: 'example/widget',
|
|
102
198
|
roles: {
|
|
103
|
-
author:
|
|
104
|
-
credentialEnv: {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
199
|
+
author: options.personalAuthor
|
|
200
|
+
? { authSource: 'personal', credentialEnv: { token: 'AUTHOR_TOKEN' }, identity: 'host-author' }
|
|
201
|
+
: {
|
|
202
|
+
credentialEnv: {
|
|
203
|
+
appId: 'AUTHOR_APP_ID',
|
|
204
|
+
installationId: 'AUTHOR_INSTALLATION_ID',
|
|
205
|
+
privateKeyPath: 'AUTHOR_KEY_PATH',
|
|
206
|
+
},
|
|
207
|
+
identity: 'synthetic-author',
|
|
108
208
|
},
|
|
109
|
-
identity: 'synthetic-author',
|
|
110
|
-
},
|
|
111
209
|
reviewer: {
|
|
112
210
|
credentialEnv: {
|
|
113
211
|
appId: 'REVIEWER_APP_ID',
|
|
@@ -144,7 +242,7 @@ export default {
|
|
|
144
242
|
return { policyDigest, policyEvidence: { ...content, evidenceId: digest(content) },
|
|
145
243
|
requiredStages: [{ id: 'check', dependsOn: [], semanticInputKeys: ['source'],
|
|
146
244
|
resourceClass: 'source_only', commands: [{ label: 'count', argv: [process.execPath, '-e',
|
|
147
|
-
${JSON.stringify(`const fs=require('fs');const p=${JSON.stringify(counter)};fs.writeFileSync(p,String(Number(fs.existsSync(p)?fs.readFileSync(p,'utf8'):0)+1));`)}] }] }${options.twoStages
|
|
245
|
+
${JSON.stringify(options.firstStageScript ?? `const fs=require('fs');const p=${JSON.stringify(counter)};fs.writeFileSync(p,String(Number(fs.existsSync(p)?fs.readFileSync(p,'utf8'):0)+1));`)}] }] }${options.twoStages
|
|
148
246
|
? `, { id: 'second', dependsOn: ['check'], semanticInputKeys: ['source'],
|
|
149
247
|
resourceClass: 'source_only', commands: [{ label: 'retry', argv: [process.execPath, '-e',
|
|
150
248
|
${JSON.stringify(`const fs=require('fs');const p=${JSON.stringify(secondCounter)};fs.writeFileSync(p,String(Number(fs.existsSync(p)?fs.readFileSync(p,'utf8'):0)+1));if(fs.existsSync(${JSON.stringify(failSecond)}))process.exit(7);`)}] }] }`
|
|
@@ -607,6 +705,356 @@ test('issue worktree verification resumes a completed stage and rejects changed
|
|
|
607
705
|
rmSync(root, { recursive: true, force: true });
|
|
608
706
|
}
|
|
609
707
|
});
|
|
708
|
+
test('a progressing stage remains observable beyond a former short deadline', async () => {
|
|
709
|
+
const marker = join(tmpdir(), `ai-delivery-stage-start-${process.pid}-${Date.now()}`);
|
|
710
|
+
const { root } = await fixture({
|
|
711
|
+
firstStageScript: `const fs=require('fs');fs.writeFileSync(${JSON.stringify(marker)},'started');process.stdout.write('begin');setTimeout(()=>{process.stdout.write('end');},180);`,
|
|
712
|
+
});
|
|
713
|
+
try {
|
|
714
|
+
const row = await prepareIssueWorktree({
|
|
715
|
+
baseRef: 'main',
|
|
716
|
+
identity: 'synthetic-author',
|
|
717
|
+
issueNumber: 17,
|
|
718
|
+
repoRoot: root,
|
|
719
|
+
});
|
|
720
|
+
writeFileSync(join(row.path, 'change.txt'), 'feature\n');
|
|
721
|
+
git(row.path, 'add', 'change.txt');
|
|
722
|
+
git(row.path, 'commit', '-qm', 'synthetic progressing stage');
|
|
723
|
+
const progressSpy = vi.spyOn(process.stderr, 'write');
|
|
724
|
+
let completed = false;
|
|
725
|
+
const run = verifyIssue({ issueNumber: 17, repoRoot: row.path }).then((value) => {
|
|
726
|
+
completed = true;
|
|
727
|
+
return value;
|
|
728
|
+
});
|
|
729
|
+
const markerDeadline = Date.now() + 2_000;
|
|
730
|
+
while (!existsSync(marker) && Date.now() < markerDeadline)
|
|
731
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
732
|
+
assert.equal(existsSync(marker), true);
|
|
733
|
+
assert.equal(completed, false, 'stage must remain observable while its process runs');
|
|
734
|
+
assert.ok(progressSpy.mock.calls.some(([chunk]) => String(chunk).includes('"state":"running"') && String(chunk).includes('"stageId":"check"')));
|
|
735
|
+
await new Promise((resolve) => setTimeout(resolve, 40));
|
|
736
|
+
assert.equal(completed, false, 'useful work continues beyond the injected former deadline');
|
|
737
|
+
const result = await run;
|
|
738
|
+
assert.equal(result.stageReceipts.length, 1);
|
|
739
|
+
}
|
|
740
|
+
finally {
|
|
741
|
+
vi.restoreAllMocks();
|
|
742
|
+
rmSync(marker, { force: true });
|
|
743
|
+
rmSync(root, { recursive: true, force: true });
|
|
744
|
+
}
|
|
745
|
+
});
|
|
746
|
+
test('cancelling verification stops its owned descendant and preserves an unrelated process', async () => {
|
|
747
|
+
const marker = join(tmpdir(), `ai-delivery-stage-child-${process.pid}-${Date.now()}`);
|
|
748
|
+
const heartbeat = `${marker}-heartbeat`;
|
|
749
|
+
const unrelatedHeartbeat = `${marker}-unrelated`;
|
|
750
|
+
const { root } = await fixture({
|
|
751
|
+
firstStageScript: `const fs=require('fs');const {spawn}=require('child_process');const child=spawn(process.execPath,['-e',${JSON.stringify(`const fs=require('fs');setInterval(()=>fs.appendFileSync(${JSON.stringify(heartbeat)},'x'),20);`)}],{stdio:'ignore'});child.unref();fs.writeFileSync(${JSON.stringify(marker)},String(child.pid));setTimeout(()=>{},400);`,
|
|
752
|
+
});
|
|
753
|
+
let descendantPid;
|
|
754
|
+
const unrelated = spawn(process.execPath, ['-e', `const fs=require('fs');setInterval(()=>fs.appendFileSync(${JSON.stringify(unrelatedHeartbeat)},'x'),20);`], {
|
|
755
|
+
detached: true,
|
|
756
|
+
stdio: 'ignore',
|
|
757
|
+
});
|
|
758
|
+
unrelated.unref();
|
|
759
|
+
try {
|
|
760
|
+
const row = await prepareIssueWorktree({
|
|
761
|
+
baseRef: 'main',
|
|
762
|
+
identity: 'synthetic-author',
|
|
763
|
+
issueNumber: 17,
|
|
764
|
+
repoRoot: root,
|
|
765
|
+
});
|
|
766
|
+
writeFileSync(join(row.path, 'change.txt'), 'feature\n');
|
|
767
|
+
git(row.path, 'add', 'change.txt');
|
|
768
|
+
git(row.path, 'commit', '-qm', 'synthetic cancellable stage');
|
|
769
|
+
const controller = new AbortController();
|
|
770
|
+
const run = verifyIssue({
|
|
771
|
+
issueNumber: 17,
|
|
772
|
+
repoRoot: row.path,
|
|
773
|
+
signal: controller.signal,
|
|
774
|
+
});
|
|
775
|
+
const markerDeadline = Date.now() + 2_000;
|
|
776
|
+
while (!existsSync(marker) && Date.now() < markerDeadline)
|
|
777
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
778
|
+
assert.equal(existsSync(marker), true);
|
|
779
|
+
descendantPid = Number(readFileSync(marker, 'utf8'));
|
|
780
|
+
controller.abort();
|
|
781
|
+
await assert.rejects(run, /cancelled/u);
|
|
782
|
+
assert.equal(existsSync(join(root, '.git', 'ai-delivery', 'verification@1', 'stages', 'check')), false);
|
|
783
|
+
await new Promise((resolve) => setTimeout(resolve, 60));
|
|
784
|
+
const stoppedSize = existsSync(heartbeat) ? readFileSync(heartbeat).length : 0;
|
|
785
|
+
const unrelatedSize = existsSync(unrelatedHeartbeat) ? readFileSync(unrelatedHeartbeat).length : 0;
|
|
786
|
+
await new Promise((resolve) => setTimeout(resolve, 80));
|
|
787
|
+
assert.equal(existsSync(heartbeat) ? readFileSync(heartbeat).length : 0, stoppedSize);
|
|
788
|
+
assert.ok(readFileSync(unrelatedHeartbeat).length > unrelatedSize);
|
|
789
|
+
}
|
|
790
|
+
finally {
|
|
791
|
+
if (descendantPid !== undefined) {
|
|
792
|
+
try {
|
|
793
|
+
process.kill(descendantPid, 'SIGKILL');
|
|
794
|
+
}
|
|
795
|
+
catch {
|
|
796
|
+
/* already stopped */
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
if (unrelated.pid !== undefined) {
|
|
800
|
+
try {
|
|
801
|
+
process.kill(unrelated.pid, 'SIGKILL');
|
|
802
|
+
}
|
|
803
|
+
catch {
|
|
804
|
+
/* already stopped */
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
rmSync(marker, { force: true });
|
|
808
|
+
rmSync(heartbeat, { force: true });
|
|
809
|
+
rmSync(unrelatedHeartbeat, { force: true });
|
|
810
|
+
rmSync(root, { recursive: true, force: true });
|
|
811
|
+
}
|
|
812
|
+
});
|
|
813
|
+
test.each([
|
|
814
|
+
{ name: 'nonzero exit', terminate: 'process.exit(7)', expected: /exit 7/u },
|
|
815
|
+
{ name: 'signal', terminate: "process.kill(process.pid,'SIGTERM')", expected: /signal SIGTERM/u },
|
|
816
|
+
])('a stage $name stops descendants holding its output pipes open', async ({ terminate, expected }) => {
|
|
817
|
+
const marker = join(tmpdir(), `ai-delivery-failed-stage-${process.pid}-${Date.now()}`);
|
|
818
|
+
const heartbeat = `${marker}-heartbeat`;
|
|
819
|
+
const { root } = await fixture({
|
|
820
|
+
firstStageScript: `const fs=require('fs');const {spawn}=require('child_process');const child=spawn(process.execPath,['-e',${JSON.stringify(`const fs=require('fs');setInterval(()=>fs.appendFileSync(${JSON.stringify(heartbeat)},'x'),20);`)}],{stdio:['ignore','inherit','inherit']});child.unref();fs.writeFileSync(${JSON.stringify(marker)},String(child.pid));${terminate};`,
|
|
821
|
+
});
|
|
822
|
+
let descendantPid;
|
|
823
|
+
let timeout;
|
|
824
|
+
try {
|
|
825
|
+
const row = await prepareIssueWorktree({
|
|
826
|
+
baseRef: 'main',
|
|
827
|
+
identity: 'synthetic-author',
|
|
828
|
+
issueNumber: 17,
|
|
829
|
+
repoRoot: root,
|
|
830
|
+
});
|
|
831
|
+
writeFileSync(join(row.path, 'change.txt'), 'feature\n');
|
|
832
|
+
git(row.path, 'add', 'change.txt');
|
|
833
|
+
git(row.path, 'commit', '-qm', 'synthetic failing stage');
|
|
834
|
+
const controller = new AbortController();
|
|
835
|
+
const run = verifyIssue({ issueNumber: 17, repoRoot: row.path, signal: controller.signal });
|
|
836
|
+
const rejection = assert.rejects(run, expected);
|
|
837
|
+
const markerDeadline = Date.now() + 2_000;
|
|
838
|
+
while (!existsSync(marker) && Date.now() < markerDeadline)
|
|
839
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
840
|
+
assert.equal(existsSync(marker), true);
|
|
841
|
+
descendantPid = Number(readFileSync(marker, 'utf8'));
|
|
842
|
+
timeout = setTimeout(() => controller.abort(), 500);
|
|
843
|
+
await rejection;
|
|
844
|
+
assert.equal(existsSync(join(root, '.git', 'ai-delivery', 'verification@1', 'stages', 'check')), false);
|
|
845
|
+
await new Promise((resolve) => setTimeout(resolve, 60));
|
|
846
|
+
const stoppedSize = existsSync(heartbeat) ? readFileSync(heartbeat).length : 0;
|
|
847
|
+
await new Promise((resolve) => setTimeout(resolve, 80));
|
|
848
|
+
assert.equal(existsSync(heartbeat) ? readFileSync(heartbeat).length : 0, stoppedSize);
|
|
849
|
+
}
|
|
850
|
+
finally {
|
|
851
|
+
if (timeout !== undefined)
|
|
852
|
+
clearTimeout(timeout);
|
|
853
|
+
if (descendantPid !== undefined) {
|
|
854
|
+
try {
|
|
855
|
+
process.kill(descendantPid, 'SIGKILL');
|
|
856
|
+
}
|
|
857
|
+
catch {
|
|
858
|
+
/* already stopped */
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
rmSync(marker, { force: true });
|
|
862
|
+
rmSync(heartbeat, { force: true });
|
|
863
|
+
rmSync(root, { recursive: true, force: true });
|
|
864
|
+
}
|
|
865
|
+
});
|
|
866
|
+
test('MCP request cancellation stops verification and leaves no stage receipt', async () => {
|
|
867
|
+
const marker = join(tmpdir(), `ai-delivery-mcp-cancel-${process.pid}-${Date.now()}`);
|
|
868
|
+
const heartbeat = `${marker}-heartbeat`;
|
|
869
|
+
const { root, runtimeEntryPath } = await fixture({
|
|
870
|
+
firstStageScript: `const fs=require('fs');const {spawn}=require('child_process');const child=spawn(process.execPath,['-e',${JSON.stringify(`const fs=require('fs');setInterval(()=>fs.appendFileSync(${JSON.stringify(heartbeat)},'x'),20);`)}],{stdio:'ignore'});child.unref();fs.writeFileSync(${JSON.stringify(marker)},String(child.pid));setTimeout(()=>{},1000);`,
|
|
871
|
+
});
|
|
872
|
+
const server = createAiDeliveryMcpServer({ repoRoot: root, runtimeEntryPath, identity: 'synthetic-author' });
|
|
873
|
+
const client = new Client({ name: 'synthetic-cancelling-client', version: '1.0.0' });
|
|
874
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
875
|
+
let descendantPid;
|
|
876
|
+
try {
|
|
877
|
+
const row = await prepareIssueWorktree({
|
|
878
|
+
baseRef: 'main',
|
|
879
|
+
identity: 'synthetic-author',
|
|
880
|
+
issueNumber: 17,
|
|
881
|
+
repoRoot: root,
|
|
882
|
+
});
|
|
883
|
+
writeFileSync(join(row.path, 'change.txt'), 'feature\n');
|
|
884
|
+
git(row.path, 'add', 'change.txt');
|
|
885
|
+
git(row.path, 'commit', '-qm', 'synthetic MCP cancellable stage');
|
|
886
|
+
await server.connect(serverTransport);
|
|
887
|
+
await client.connect(clientTransport);
|
|
888
|
+
const controller = new AbortController();
|
|
889
|
+
const call = client.callTool({ name: 'issue_verify', arguments: { issueNumber: 17 } }, undefined, {
|
|
890
|
+
signal: controller.signal,
|
|
891
|
+
});
|
|
892
|
+
const markerDeadline = Date.now() + 2_000;
|
|
893
|
+
while (!existsSync(marker) && Date.now() < markerDeadline)
|
|
894
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
895
|
+
assert.equal(existsSync(marker), true);
|
|
896
|
+
descendantPid = Number(readFileSync(marker, 'utf8'));
|
|
897
|
+
controller.abort();
|
|
898
|
+
await assert.rejects(call, /abort/iu);
|
|
899
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
900
|
+
const stoppedSize = existsSync(heartbeat) ? readFileSync(heartbeat).length : 0;
|
|
901
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
902
|
+
assert.equal(existsSync(heartbeat) ? readFileSync(heartbeat).length : 0, stoppedSize);
|
|
903
|
+
assert.equal(existsSync(join(root, '.git', 'ai-delivery', 'verification@1', 'stages', 'check')), false);
|
|
904
|
+
}
|
|
905
|
+
finally {
|
|
906
|
+
await client.close();
|
|
907
|
+
await server.close();
|
|
908
|
+
if (descendantPid !== undefined) {
|
|
909
|
+
try {
|
|
910
|
+
process.kill(descendantPid, 'SIGKILL');
|
|
911
|
+
}
|
|
912
|
+
catch {
|
|
913
|
+
/* already stopped */
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
rmSync(marker, { force: true });
|
|
917
|
+
rmSync(heartbeat, { force: true });
|
|
918
|
+
rmSync(root, { recursive: true, force: true });
|
|
919
|
+
}
|
|
920
|
+
});
|
|
921
|
+
test('the stage output bound covers both streams and publishes no checkpoint on overflow', async () => {
|
|
922
|
+
const { root } = await fixture({
|
|
923
|
+
firstStageScript: `process.stdout.write(Buffer.alloc(4*1024*1024));process.stderr.write(Buffer.alloc(4*1024*1024+1));`,
|
|
924
|
+
});
|
|
925
|
+
try {
|
|
926
|
+
const row = await prepareIssueWorktree({
|
|
927
|
+
baseRef: 'main',
|
|
928
|
+
identity: 'synthetic-author',
|
|
929
|
+
issueNumber: 17,
|
|
930
|
+
repoRoot: root,
|
|
931
|
+
});
|
|
932
|
+
writeFileSync(join(row.path, 'change.txt'), 'feature\n');
|
|
933
|
+
git(row.path, 'add', 'change.txt');
|
|
934
|
+
git(row.path, 'commit', '-qm', 'synthetic output bound');
|
|
935
|
+
await assert.rejects(verifyIssue({ issueNumber: 17, repoRoot: row.path }), /captured output exceeded 8 MiB/u);
|
|
936
|
+
assert.equal(existsSync(join(root, '.git', 'ai-delivery', 'verification@1', 'stages', 'check')), false);
|
|
937
|
+
}
|
|
938
|
+
finally {
|
|
939
|
+
rmSync(root, { recursive: true, force: true });
|
|
940
|
+
}
|
|
941
|
+
});
|
|
942
|
+
test('public PR dry run retains a configured host author and App reviewer', async () => {
|
|
943
|
+
const { root } = await fixture({ personalAuthor: true });
|
|
944
|
+
try {
|
|
945
|
+
const row = await prepareIssueWorktree({
|
|
946
|
+
baseRef: 'main',
|
|
947
|
+
identity: 'host-author',
|
|
948
|
+
issueNumber: 17,
|
|
949
|
+
repoRoot: root,
|
|
950
|
+
});
|
|
951
|
+
writeFileSync(join(row.path, 'change.txt'), 'host author change\n');
|
|
952
|
+
git(row.path, 'add', 'change.txt');
|
|
953
|
+
git(row.path, 'commit', '-qm', 'synthetic host author change');
|
|
954
|
+
await verifyIssue({ issueNumber: 17, repoRoot: row.path });
|
|
955
|
+
const result = (await executeTool('issue_pr_create', { issueNumber: 17, dryRun: true }, { repoRoot: row.path, identity: 'host-author' }));
|
|
956
|
+
assert.equal(result.dryRun, true);
|
|
957
|
+
assert.deepEqual(result.reviewRoute.author, {
|
|
958
|
+
actorLogin: 'host-user',
|
|
959
|
+
authSource: 'personal',
|
|
960
|
+
credentialSource: 'env:AUTHOR_TOKEN',
|
|
961
|
+
identity: 'host-author',
|
|
962
|
+
});
|
|
963
|
+
assert.equal(result.reviewRoute.reviewer.actorLogin, 'synthetic-reviewer');
|
|
964
|
+
assert.equal(result.reviewRoute.approvalEligibility, 'unknown');
|
|
965
|
+
}
|
|
966
|
+
finally {
|
|
967
|
+
rmSync(root, { recursive: true, force: true });
|
|
968
|
+
}
|
|
969
|
+
}, 15_000);
|
|
970
|
+
test('public dispatch publishes with selected host token and submits the configured App review once', async () => {
|
|
971
|
+
const { root, runtimeEntryPath } = await fixture({ personalAuthor: true });
|
|
972
|
+
const originalPath = process.env.PATH;
|
|
973
|
+
try {
|
|
974
|
+
const row = await prepareIssueWorktree({
|
|
975
|
+
baseRef: 'main',
|
|
976
|
+
identity: 'host-author',
|
|
977
|
+
issueNumber: 17,
|
|
978
|
+
repoRoot: root,
|
|
979
|
+
});
|
|
980
|
+
writeFileSync(join(row.path, 'change.txt'), 'host author publication\n');
|
|
981
|
+
git(row.path, 'add', 'change.txt');
|
|
982
|
+
git(row.path, 'commit', '-qm', 'synthetic host publication');
|
|
983
|
+
const run = await verifyIssue({ issueNumber: 17, repoRoot: row.path });
|
|
984
|
+
personalRoute.baseSha = run.classification.base.sha;
|
|
985
|
+
personalRoute.headSha = run.classification.head.sha;
|
|
986
|
+
personalRoute.reviews.length = 0;
|
|
987
|
+
personalRoute.submitted = 0;
|
|
988
|
+
personalRoute.active = true;
|
|
989
|
+
const realGit = (originalPath ?? '')
|
|
990
|
+
.split(':')
|
|
991
|
+
.map((directory) => join(directory, 'git'))
|
|
992
|
+
.find(existsSync);
|
|
993
|
+
if (!realGit)
|
|
994
|
+
throw new Error('Git executable unavailable for public transport test.');
|
|
995
|
+
git(root, 'config', '--local', '--unset', `url.${join(root, '.git', 'remote.git')}.insteadOf`);
|
|
996
|
+
const shimDir = join(root, '.git', 'git-shim');
|
|
997
|
+
const pushMarker = join(root, '.git', 'selected-host-push');
|
|
998
|
+
mkdirSync(shimDir);
|
|
999
|
+
const shim = join(shimDir, 'git');
|
|
1000
|
+
writeFileSync(shim, `#!/bin/sh
|
|
1001
|
+
if [ "$1" = -c ] && [ "$2" = credential.helper= ] && [ "$3" = push ]; then
|
|
1002
|
+
[ "$AI_DELIVERY_GIT_TOKEN" = selected-personal-token ] || exit 91
|
|
1003
|
+
[ "$("$GIT_ASKPASS" Password)" = selected-personal-token ] || exit 92
|
|
1004
|
+
: > "${pushMarker}"
|
|
1005
|
+
exit 0
|
|
1006
|
+
fi
|
|
1007
|
+
exec "${realGit}" "$@"
|
|
1008
|
+
`);
|
|
1009
|
+
chmodSync(shim, 0o700);
|
|
1010
|
+
process.env.PATH = `${shimDir}:${originalPath ?? ''}`;
|
|
1011
|
+
const execution = { repoRoot: row.path, runtimeEntryPath, identity: 'host-author' };
|
|
1012
|
+
const published = (await executeTool('issue_pr_create', { issueNumber: 17 }, execution));
|
|
1013
|
+
assert.equal(published.prNumber, 23);
|
|
1014
|
+
assert.equal(existsSync(pushMarker), true);
|
|
1015
|
+
assert.equal(published.reviewRoute.author.actorLogin, 'host-user');
|
|
1016
|
+
assert.equal(published.reviewRoute.author.credentialSource, 'env:AUTHOR_TOKEN');
|
|
1017
|
+
assert.equal(published.reviewRoute.reviewer.actorLogin, 'synthetic-reviewer[bot]');
|
|
1018
|
+
const info = (await executeTool('issue_pr_info', { prNumber: 23 }, execution));
|
|
1019
|
+
assert.equal(info.authorLogin, 'host-user');
|
|
1020
|
+
const artifactContent = {
|
|
1021
|
+
authorIdentity: 'host-author',
|
|
1022
|
+
checks: ['verified exact diff'],
|
|
1023
|
+
diffScopeHash: digestValue(run.classification.changedPaths),
|
|
1024
|
+
elapsedMs: 1,
|
|
1025
|
+
findings: [],
|
|
1026
|
+
head: run.classification.head,
|
|
1027
|
+
issueNumber: 17,
|
|
1028
|
+
prNumber: 23,
|
|
1029
|
+
readOnly: true,
|
|
1030
|
+
requestedEffort: 'xhigh',
|
|
1031
|
+
requestedModel: 'gpt-6-astra',
|
|
1032
|
+
effectiveEffort: 'xhigh',
|
|
1033
|
+
effectiveModel: 'gpt-6-astra',
|
|
1034
|
+
reviewerIdentity: 'synthetic-reviewer',
|
|
1035
|
+
schemaVersion: 'ai-delivery.review-artifact@1',
|
|
1036
|
+
summary: 'Independent exact-head review',
|
|
1037
|
+
verdict: 'approve',
|
|
1038
|
+
};
|
|
1039
|
+
const artifact = JSON.stringify({ ...artifactContent, artifactId: digestValue(artifactContent) });
|
|
1040
|
+
const reviewInput = { issueNumber: 17, prNumber: 23, identity: 'synthetic-reviewer', artifact };
|
|
1041
|
+
const first = (await executeTool('issue_pr_review', reviewInput, execution));
|
|
1042
|
+
assert.equal(first.reviewState.status, 'still-required');
|
|
1043
|
+
assert.equal(personalRoute.submitted, 1);
|
|
1044
|
+
const second = (await executeTool('issue_pr_review', reviewInput, execution));
|
|
1045
|
+
assert.equal(second.receiptId, first.receiptId);
|
|
1046
|
+
assert.equal(personalRoute.submitted, 1);
|
|
1047
|
+
}
|
|
1048
|
+
finally {
|
|
1049
|
+
personalRoute.active = false;
|
|
1050
|
+
personalRoute.reviews.length = 0;
|
|
1051
|
+
if (originalPath === undefined)
|
|
1052
|
+
delete process.env.PATH;
|
|
1053
|
+
else
|
|
1054
|
+
process.env.PATH = originalPath;
|
|
1055
|
+
rmSync(root, { recursive: true, force: true });
|
|
1056
|
+
}
|
|
1057
|
+
}, 15_000);
|
|
610
1058
|
test('scratch start derives safe names and reports the requested worktree', async () => {
|
|
611
1059
|
const { root, runtimeEntryPath } = await fixture();
|
|
612
1060
|
try {
|
|
@@ -682,11 +1130,7 @@ test('MCP request rejects unsupported legacy options before lifecycle dispatch',
|
|
|
682
1130
|
await server.close();
|
|
683
1131
|
}
|
|
684
1132
|
});
|
|
685
|
-
|
|
686
|
-
{ remote: 'origin', divergentOrigin: false },
|
|
687
|
-
{ remote: 'upstream', divergentOrigin: false },
|
|
688
|
-
{ remote: 'upstream', divergentOrigin: true },
|
|
689
|
-
])('synthetic lifecycle finishes with remote $remote and divergent origin $divergentOrigin', async (routing) => {
|
|
1133
|
+
async function syntheticLifecycle(routing) {
|
|
690
1134
|
const { root } = await fixture(routing);
|
|
691
1135
|
const remoteName = routing.remote;
|
|
692
1136
|
assert.equal(defaultBaseRef(root, remoteName), `refs/remotes/${remoteName}/main`);
|
|
@@ -724,6 +1168,7 @@ test.each([
|
|
|
724
1168
|
let mergeSha = '';
|
|
725
1169
|
let stalePrReadbacks = 0;
|
|
726
1170
|
let review = null;
|
|
1171
|
+
let reviewDecision = 'REVIEW_REQUIRED';
|
|
727
1172
|
const calls = [];
|
|
728
1173
|
const createPayloads = [];
|
|
729
1174
|
const statuses = { Queued: 'todo', Active: 'active', Waiting: 'blocked', Shipped: 'done' };
|
|
@@ -755,11 +1200,13 @@ test.each([
|
|
|
755
1200
|
merge_commit_sha: mergeSha || null,
|
|
756
1201
|
mergeable: true,
|
|
757
1202
|
mergeable_state: 'clean',
|
|
758
|
-
user: { login: 'synthetic-author' },
|
|
1203
|
+
user: { login: 'synthetic-author[bot]' },
|
|
759
1204
|
head: { sha: headSha, ref: 'issue/17' },
|
|
760
1205
|
base: { sha: baseSha, ref: 'main' },
|
|
761
1206
|
});
|
|
762
1207
|
const graphql = async (query, variables = {}) => {
|
|
1208
|
+
if (query.includes('DeliveryReviewDecision'))
|
|
1209
|
+
return { repository: { pullRequest: { headRefOid: headSha, reviewDecision } } };
|
|
763
1210
|
if (query.includes('ProjectDeliveryConfiguration'))
|
|
764
1211
|
return {
|
|
765
1212
|
organization: {
|
|
@@ -894,6 +1341,10 @@ test.each([
|
|
|
894
1341
|
};
|
|
895
1342
|
const rest = {
|
|
896
1343
|
request: async (route, parameters) => {
|
|
1344
|
+
if (route === 'GET /repos/{owner}/{repo}/rules/branches/{branch}')
|
|
1345
|
+
return { data: [{ type: 'pull_request', parameters: { required_approving_review_count: 1 } }] };
|
|
1346
|
+
if (route === 'GET /repos/{owner}/{repo}/branches/{branch}/protection')
|
|
1347
|
+
throw Object.assign(new Error('Synthetic protected branch visibility unavailable'), { status: 403 });
|
|
897
1348
|
if (route === 'GET /orgs/{org}/issue-fields')
|
|
898
1349
|
return {
|
|
899
1350
|
data: [
|
|
@@ -1113,7 +1564,7 @@ test.each([
|
|
|
1113
1564
|
headSha = git(row.path, 'rev-parse', 'HEAD');
|
|
1114
1565
|
const run = await verifyIssue({ issueNumber, repoRoot: row.path });
|
|
1115
1566
|
await assert.rejects(publishPr(context, { issueNumber, body: 'Closes #17' }), /Delivery Impact/);
|
|
1116
|
-
await assert.rejects(publishPr(context, { issueNumber }), /Git push requires the selected author
|
|
1567
|
+
await assert.rejects(publishPr(context, { issueNumber }), /Git push requires the selected author credential token/);
|
|
1117
1568
|
// The public push requires a real author App installation token. Bind a synthetic
|
|
1118
1569
|
// publication at that external boundary so the remaining public phases run offline.
|
|
1119
1570
|
git(row.path, 'push', '-q', remoteName, `${headSha}:refs/heads/issue/17`);
|
|
@@ -1138,7 +1589,9 @@ test.each([
|
|
|
1138
1589
|
status: 'pr-published',
|
|
1139
1590
|
});
|
|
1140
1591
|
assert.equal((await listPrs(context)).pullRequests[0]?.number, prNumber);
|
|
1592
|
+
assert.equal((await prInfo(context, { prNumber })).authorLogin, 'synthetic-author[bot]');
|
|
1141
1593
|
assert.equal((await prChecks(context, prNumber)).combinedStatus, 'success');
|
|
1594
|
+
assert.equal((await prChecks(context, prNumber)).reviewState.status, 'still-required');
|
|
1142
1595
|
await assert.rejects(checkoutPr(context, prNumber), /open in-repository branch/u);
|
|
1143
1596
|
assert.equal(git(remote, 'rev-parse', 'refs/heads/issue/17'), headSha);
|
|
1144
1597
|
await assert.rejects(mergePr(context, { issueNumber, prNumber }), /submitted independent review/);
|
|
@@ -1173,8 +1626,11 @@ test.each([
|
|
|
1173
1626
|
await assert.rejects(submitFormalReview(personalReviewer, { artifact, issueNumber, prNumber }), /reviewer GitHub App role/);
|
|
1174
1627
|
const reviewResult = await submitFormalReview(reviewer, { artifact, issueNumber, prNumber });
|
|
1175
1628
|
assert.equal(reviewResult.githubReviewId, 31);
|
|
1629
|
+
assert.equal(reviewResult.reviewState.status, 'still-required');
|
|
1176
1630
|
await publishPr(context, { issueNumber, draft: false });
|
|
1177
1631
|
assert.equal(prDraft, false);
|
|
1632
|
+
await assert.rejects(mergePr(context, { issueNumber, prNumber }), /submitted APPROVED review.*still requires/u);
|
|
1633
|
+
reviewDecision = 'APPROVED';
|
|
1178
1634
|
checksPassed = false;
|
|
1179
1635
|
await assert.rejects(mergePr(context, { issueNumber, prNumber }), /failed or pending checks/);
|
|
1180
1636
|
checksPassed = true;
|
|
@@ -1200,7 +1656,9 @@ test.each([
|
|
|
1200
1656
|
const result = JSON.parse(originalResult);
|
|
1201
1657
|
const { resultId: _resultId, ...resultContent } = result;
|
|
1202
1658
|
const wrongResult = { ...resultContent, mergeSha: 'a'.repeat(40) };
|
|
1203
|
-
writeFileSync(resultPath, JSON.stringify({ ...wrongResult, resultId: digestValue(wrongResult) }), {
|
|
1659
|
+
writeFileSync(resultPath, JSON.stringify({ ...wrongResult, resultId: digestValue(wrongResult) }), {
|
|
1660
|
+
mode: 0o600,
|
|
1661
|
+
});
|
|
1204
1662
|
await assert.rejects(finishIssue(context, { issueNumber, prNumber, strategy: 'merge' }), /exact prepared merge intent/u);
|
|
1205
1663
|
writeFileSync(resultPath, originalResult, { mode: 0o600 });
|
|
1206
1664
|
const reviewedHead = headSha;
|
|
@@ -1263,5 +1721,10 @@ test.each([
|
|
|
1263
1721
|
finally {
|
|
1264
1722
|
rmSync(root, { recursive: true, force: true });
|
|
1265
1723
|
}
|
|
1266
|
-
}
|
|
1724
|
+
}
|
|
1725
|
+
test.each([
|
|
1726
|
+
{ remote: 'origin', divergentOrigin: false },
|
|
1727
|
+
{ remote: 'upstream', divergentOrigin: false },
|
|
1728
|
+
{ remote: 'upstream', divergentOrigin: true },
|
|
1729
|
+
])('synthetic lifecycle finishes with remote $remote and divergent origin $divergentOrigin', syntheticLifecycle, 15_000);
|
|
1267
1730
|
//# sourceMappingURL=lifecycle.test.js.map
|