@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.
@@ -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
- ...syntheticDiscoveryClients(),
32
- authSource: 'app',
33
- role: input.role,
34
- rest: new Octokit({
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
- appId: 'AUTHOR_APP_ID',
106
- installationId: 'AUTHOR_INSTALLATION_ID',
107
- privateKeyPath: 'AUTHOR_KEY_PATH',
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
- test.each([
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 GitHub App installation token/);
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) }), { mode: 0o600 });
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