@aviaratech/ai-delivery 0.2.1 → 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
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',
@@ -841,6 +939,122 @@ test('the stage output bound covers both streams and publishes no checkpoint on
841
939
  rmSync(root, { recursive: true, force: true });
842
940
  }
843
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);
844
1058
  test('scratch start derives safe names and reports the requested worktree', async () => {
845
1059
  const { root, runtimeEntryPath } = await fixture();
846
1060
  try {
@@ -916,11 +1130,7 @@ test('MCP request rejects unsupported legacy options before lifecycle dispatch',
916
1130
  await server.close();
917
1131
  }
918
1132
  });
919
- test.each([
920
- { remote: 'origin', divergentOrigin: false },
921
- { remote: 'upstream', divergentOrigin: false },
922
- { remote: 'upstream', divergentOrigin: true },
923
- ])('synthetic lifecycle finishes with remote $remote and divergent origin $divergentOrigin', async (routing) => {
1133
+ async function syntheticLifecycle(routing) {
924
1134
  const { root } = await fixture(routing);
925
1135
  const remoteName = routing.remote;
926
1136
  assert.equal(defaultBaseRef(root, remoteName), `refs/remotes/${remoteName}/main`);
@@ -958,6 +1168,7 @@ test.each([
958
1168
  let mergeSha = '';
959
1169
  let stalePrReadbacks = 0;
960
1170
  let review = null;
1171
+ let reviewDecision = 'REVIEW_REQUIRED';
961
1172
  const calls = [];
962
1173
  const createPayloads = [];
963
1174
  const statuses = { Queued: 'todo', Active: 'active', Waiting: 'blocked', Shipped: 'done' };
@@ -989,11 +1200,13 @@ test.each([
989
1200
  merge_commit_sha: mergeSha || null,
990
1201
  mergeable: true,
991
1202
  mergeable_state: 'clean',
992
- user: { login: 'synthetic-author' },
1203
+ user: { login: 'synthetic-author[bot]' },
993
1204
  head: { sha: headSha, ref: 'issue/17' },
994
1205
  base: { sha: baseSha, ref: 'main' },
995
1206
  });
996
1207
  const graphql = async (query, variables = {}) => {
1208
+ if (query.includes('DeliveryReviewDecision'))
1209
+ return { repository: { pullRequest: { headRefOid: headSha, reviewDecision } } };
997
1210
  if (query.includes('ProjectDeliveryConfiguration'))
998
1211
  return {
999
1212
  organization: {
@@ -1128,6 +1341,10 @@ test.each([
1128
1341
  };
1129
1342
  const rest = {
1130
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 });
1131
1348
  if (route === 'GET /orgs/{org}/issue-fields')
1132
1349
  return {
1133
1350
  data: [
@@ -1347,7 +1564,7 @@ test.each([
1347
1564
  headSha = git(row.path, 'rev-parse', 'HEAD');
1348
1565
  const run = await verifyIssue({ issueNumber, repoRoot: row.path });
1349
1566
  await assert.rejects(publishPr(context, { issueNumber, body: 'Closes #17' }), /Delivery Impact/);
1350
- 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/);
1351
1568
  // The public push requires a real author App installation token. Bind a synthetic
1352
1569
  // publication at that external boundary so the remaining public phases run offline.
1353
1570
  git(row.path, 'push', '-q', remoteName, `${headSha}:refs/heads/issue/17`);
@@ -1372,7 +1589,9 @@ test.each([
1372
1589
  status: 'pr-published',
1373
1590
  });
1374
1591
  assert.equal((await listPrs(context)).pullRequests[0]?.number, prNumber);
1592
+ assert.equal((await prInfo(context, { prNumber })).authorLogin, 'synthetic-author[bot]');
1375
1593
  assert.equal((await prChecks(context, prNumber)).combinedStatus, 'success');
1594
+ assert.equal((await prChecks(context, prNumber)).reviewState.status, 'still-required');
1376
1595
  await assert.rejects(checkoutPr(context, prNumber), /open in-repository branch/u);
1377
1596
  assert.equal(git(remote, 'rev-parse', 'refs/heads/issue/17'), headSha);
1378
1597
  await assert.rejects(mergePr(context, { issueNumber, prNumber }), /submitted independent review/);
@@ -1407,8 +1626,11 @@ test.each([
1407
1626
  await assert.rejects(submitFormalReview(personalReviewer, { artifact, issueNumber, prNumber }), /reviewer GitHub App role/);
1408
1627
  const reviewResult = await submitFormalReview(reviewer, { artifact, issueNumber, prNumber });
1409
1628
  assert.equal(reviewResult.githubReviewId, 31);
1629
+ assert.equal(reviewResult.reviewState.status, 'still-required');
1410
1630
  await publishPr(context, { issueNumber, draft: false });
1411
1631
  assert.equal(prDraft, false);
1632
+ await assert.rejects(mergePr(context, { issueNumber, prNumber }), /submitted APPROVED review.*still requires/u);
1633
+ reviewDecision = 'APPROVED';
1412
1634
  checksPassed = false;
1413
1635
  await assert.rejects(mergePr(context, { issueNumber, prNumber }), /failed or pending checks/);
1414
1636
  checksPassed = true;
@@ -1434,7 +1656,9 @@ test.each([
1434
1656
  const result = JSON.parse(originalResult);
1435
1657
  const { resultId: _resultId, ...resultContent } = result;
1436
1658
  const wrongResult = { ...resultContent, mergeSha: 'a'.repeat(40) };
1437
- writeFileSync(resultPath, JSON.stringify({ ...wrongResult, resultId: digestValue(wrongResult) }), { mode: 0o600 });
1659
+ writeFileSync(resultPath, JSON.stringify({ ...wrongResult, resultId: digestValue(wrongResult) }), {
1660
+ mode: 0o600,
1661
+ });
1438
1662
  await assert.rejects(finishIssue(context, { issueNumber, prNumber, strategy: 'merge' }), /exact prepared merge intent/u);
1439
1663
  writeFileSync(resultPath, originalResult, { mode: 0o600 });
1440
1664
  const reviewedHead = headSha;
@@ -1497,5 +1721,10 @@ test.each([
1497
1721
  finally {
1498
1722
  rmSync(root, { recursive: true, force: true });
1499
1723
  }
1500
- });
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);
1501
1730
  //# sourceMappingURL=lifecycle.test.js.map