@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.
Files changed (143) hide show
  1. package/CONTRIBUTING.md +166 -16
  2. package/README.md +75 -446
  3. package/dist/agent.d.ts +5 -11
  4. package/dist/agent.js +4 -8
  5. package/dist/agent.js.map +1 -1
  6. package/dist/agentReadiness.test.d.ts +1 -0
  7. package/dist/agentReadiness.test.js +139 -0
  8. package/dist/agentReadiness.test.js.map +1 -0
  9. package/dist/cli.js +27 -111
  10. package/dist/cli.js.map +1 -1
  11. package/dist/config/deliveryConfig.d.ts +44 -55
  12. package/dist/config/deliveryConfig.js +85 -88
  13. package/dist/config/deliveryConfig.js.map +1 -1
  14. package/dist/config/deliveryConfig.test.js +13 -5
  15. package/dist/config/deliveryConfig.test.js.map +1 -1
  16. package/dist/contributorChecks.test.d.ts +1 -0
  17. package/dist/contributorChecks.test.js +1010 -0
  18. package/dist/contributorChecks.test.js.map +1 -0
  19. package/dist/currentConsumer.test.d.ts +1 -0
  20. package/dist/currentConsumer.test.js +832 -0
  21. package/dist/currentConsumer.test.js.map +1 -0
  22. package/dist/delivery/common.js +7 -2
  23. package/dist/delivery/common.js.map +1 -1
  24. package/dist/delivery/delivery.test.js +2 -3
  25. package/dist/delivery/delivery.test.js.map +1 -1
  26. package/dist/delivery/index.d.ts +0 -6
  27. package/dist/delivery/index.js +0 -3
  28. package/dist/delivery/index.js.map +1 -1
  29. package/dist/delivery/legacy.d.ts +8 -0
  30. package/dist/delivery/legacy.js +5 -0
  31. package/dist/delivery/legacy.js.map +1 -0
  32. package/dist/delivery/policy.js +2 -13
  33. package/dist/delivery/policy.js.map +1 -1
  34. package/dist/dependencyCompatibility.test.d.ts +1 -0
  35. package/dist/dependencyCompatibility.test.js +175 -0
  36. package/dist/dependencyCompatibility.test.js.map +1 -0
  37. package/dist/directoryIndependent.test.d.ts +1 -0
  38. package/dist/directoryIndependent.test.js +352 -0
  39. package/dist/directoryIndependent.test.js.map +1 -0
  40. package/dist/dispatch.d.ts +29 -3
  41. package/dist/dispatch.js +166 -457
  42. package/dist/dispatch.js.map +1 -1
  43. package/dist/genericCompatibility.test.js +0 -35
  44. package/dist/genericCompatibility.test.js.map +1 -1
  45. package/dist/git.js +10 -3
  46. package/dist/git.js.map +1 -1
  47. package/dist/gitProcess.d.ts +6 -0
  48. package/dist/gitProcess.js +56 -0
  49. package/dist/gitProcess.js.map +1 -0
  50. package/dist/github/discovery.d.ts +1 -0
  51. package/dist/github/discovery.js +1 -1
  52. package/dist/github/discovery.js.map +1 -1
  53. package/dist/github/discovery.test.js +25 -20
  54. package/dist/github/discovery.test.js.map +1 -1
  55. package/dist/github/nativeIssueMetadata.d.ts +2 -2
  56. package/dist/github/nativeIssueMetadata.js.map +1 -1
  57. package/dist/github/projectDelivery.d.ts +1 -1
  58. package/dist/github/projectDelivery.js.map +1 -1
  59. package/dist/github/repo.d.ts +7 -1
  60. package/dist/github/repo.js +58 -2
  61. package/dist/github/repo.js.map +1 -1
  62. package/dist/issue.d.ts +18 -6
  63. package/dist/issue.js +136 -319
  64. package/dist/issue.js.map +1 -1
  65. package/dist/issueJournal.d.ts +1 -1
  66. package/dist/issueJournal.test.js +88 -6
  67. package/dist/issueJournal.test.js.map +1 -1
  68. package/dist/lifecycle.test.js +40 -7371
  69. package/dist/lifecycle.test.js.map +1 -1
  70. package/dist/logger.js +4 -4
  71. package/dist/logger.js.map +1 -1
  72. package/dist/mcp/tools.d.ts +32 -42
  73. package/dist/mcp/tools.js +51 -42
  74. package/dist/mcp/tools.js.map +1 -1
  75. package/dist/pluginInstaller.d.ts +1 -1
  76. package/dist/pluginInstaller.js +8 -2
  77. package/dist/pluginInstaller.js.map +1 -1
  78. package/dist/pluginInstaller.test.js +213 -2
  79. package/dist/pluginInstaller.test.js.map +1 -1
  80. package/dist/pluginPackaging.test.js +225 -2
  81. package/dist/pluginPackaging.test.js.map +1 -1
  82. package/dist/pr.d.ts +73 -114
  83. package/dist/pr.js +837 -1085
  84. package/dist/pr.js.map +1 -1
  85. package/dist/pr.test.js +453 -236
  86. package/dist/pr.test.js.map +1 -1
  87. package/dist/prAssociation.test.d.ts +1 -0
  88. package/dist/prAssociation.test.js +1336 -0
  89. package/dist/prAssociation.test.js.map +1 -0
  90. package/dist/prDispatch.test.d.ts +1 -0
  91. package/dist/prDispatch.test.js +96 -0
  92. package/dist/prDispatch.test.js.map +1 -0
  93. package/dist/releaseReconciliation.test.d.ts +1 -0
  94. package/dist/releaseReconciliation.test.js +318 -0
  95. package/dist/releaseReconciliation.test.js.map +1 -0
  96. package/dist/releaseWorkflow.test.d.ts +1 -0
  97. package/dist/releaseWorkflow.test.js +447 -0
  98. package/dist/releaseWorkflow.test.js.map +1 -0
  99. package/dist/review.d.ts +37 -7
  100. package/dist/review.js +106 -45
  101. package/dist/review.js.map +1 -1
  102. package/dist/review.test.js +30 -71
  103. package/dist/review.test.js.map +1 -1
  104. package/dist/services/agentReadinessService.d.ts +1 -1
  105. package/dist/services/agentReadinessService.js +4 -1
  106. package/dist/services/agentReadinessService.js.map +1 -1
  107. package/dist/services/deliveryAdmission.d.ts +4 -4
  108. package/dist/services/deliveryAdmission.js.map +1 -1
  109. package/dist/setup.test.js +10 -5
  110. package/dist/setup.test.js.map +1 -1
  111. package/dist/stagedChecks.test.d.ts +1 -0
  112. package/dist/stagedChecks.test.js +228 -0
  113. package/dist/stagedChecks.test.js.map +1 -0
  114. package/dist/start.test.d.ts +1 -0
  115. package/dist/start.test.js +72 -0
  116. package/dist/start.test.js.map +1 -0
  117. package/dist/stdoutRegression.test.d.ts +1 -0
  118. package/dist/stdoutRegression.test.js +326 -0
  119. package/dist/stdoutRegression.test.js.map +1 -0
  120. package/dist/verification.d.ts +1 -27
  121. package/dist/verification.js +5 -1332
  122. package/dist/verification.js.map +1 -1
  123. package/dist/worktree.d.ts +1 -31
  124. package/dist/worktree.js +5 -200
  125. package/dist/worktree.js.map +1 -1
  126. package/dist/worktreeTransition.d.ts +1 -1
  127. package/dist/worktreeTransition.js +10 -5
  128. package/dist/worktreeTransition.js.map +1 -1
  129. package/dist/worktreeTransition.test.js +2 -3
  130. package/dist/worktreeTransition.test.js.map +1 -1
  131. package/package.json +9 -6
  132. package/plugins/ai-delivery/.claude-plugin/plugin.json +2 -2
  133. package/plugins/ai-delivery/README.md +3 -3
  134. package/plugins/ai-delivery/plugin.json +1 -1
  135. package/plugins/ai-delivery/runtime/dist/THIRD-PARTY-NOTICES.md +1 -1
  136. package/plugins/ai-delivery/runtime/dist/cli.js +50 -59
  137. package/plugins/ai-delivery/runtime/package.json +1 -1
  138. package/plugins/ai-delivery/skills/intake-create/SKILL.md +1 -1
  139. package/plugins/ai-delivery/skills/pr-handoff/SKILL.md +14 -21
  140. package/plugins/ai-delivery/skills/worktree-lifecycle/SKILL.md +12 -23
  141. package/dist/services/deliveryRecordService.d.ts +0 -47
  142. package/dist/services/deliveryRecordService.js +0 -199
  143. package/dist/services/deliveryRecordService.js.map +0 -1
package/dist/pr.test.js CHANGED
@@ -1,255 +1,472 @@
1
1
  import assert from 'node:assert/strict';
2
- import { execFileSync } from 'node:child_process';
3
- import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
4
- import { tmpdir } from 'node:os';
5
- import { join } from 'node:path';
6
- import { test } from 'vitest';
7
- import { digestValue } from './delivery/index.js';
8
- import { mergeWithExactLease, persistMergedResult } from './pr.js';
9
- import { getIssueWorktreeStrict, updateIssueWorktreeDelivery } from './services/worktreeRegistry.js';
10
- import { cleanupMergedIssueWorktree, prepareIssueWorktree } from './worktree.js';
11
- function git(root, ...args) {
12
- return execFileSync('git', args, { cwd: root, encoding: 'utf8' }).trim();
13
- }
14
- async function fixture() {
15
- const root = mkdtempSync(join(tmpdir(), 'ai-delivery-pr-'));
16
- git(root, 'init', '-q', '-b', 'main');
17
- git(root, 'config', 'user.name', 'Local Test');
18
- git(root, 'config', 'user.email', 'local@example.test');
19
- writeFileSync(join(root, '.gitignore'), '.issue-cli/\n.worktrees/\n');
20
- writeFileSync(join(root, 'initial.txt'), 'base\n');
21
- git(root, 'add', '.');
22
- git(root, 'commit', '-qm', 'base');
23
- const baseSha = git(root, 'rev-parse', 'HEAD');
24
- git(root, 'remote', 'add', 'origin', 'https://github.com/example/widget.git');
25
- git(root, 'update-ref', 'refs/remotes/origin/main', baseSha);
26
- const row = await prepareIssueWorktree({ baseRef: 'main', identity: 'author', issueNumber: 17, repoRoot: root });
27
- writeFileSync(join(row.path, 'feature.txt'), 'feature\n');
28
- git(row.path, 'add', '.');
29
- git(row.path, 'commit', '-qm', 'feature');
30
- return {
31
- root,
32
- path: row.path,
33
- baseSha,
34
- headSha: git(row.path, 'rev-parse', 'HEAD'),
35
- headTree: git(row.path, 'rev-parse', 'HEAD^{tree}'),
2
+ import { describe, it } from 'vitest';
3
+ import { publishPr, mergePr, finishIssue } from './pr.js';
4
+ import { digestValue } from './delivery/common.js';
5
+ const head = 'a'.repeat(40);
6
+ const merge = 'b'.repeat(40);
7
+ function fixture(state = 'clean', option = {}) {
8
+ let merged = false;
9
+ let closes = 0;
10
+ let publicationBody;
11
+ let publicationTitle;
12
+ let publicationDraft;
13
+ const merges = [];
14
+ const artifactContent = {
15
+ authorIdentity: option.reviewBinding === 'wrong-role' ? 'collaborator' : 'author',
16
+ reviewerIdentity: 'reviewer',
17
+ checks: ['retained checks'],
18
+ diffScopeHash: digestValue([option.reviewBinding === 'wrong-scope' ? 'elsewhere.ts' : 'change.ts']),
19
+ elapsedMs: 1,
20
+ findings: [],
21
+ head: {
22
+ sha: option.reviewBinding === 'stale-head' ? 'e'.repeat(40) : head,
23
+ tree: option.reviewBinding === 'wrong-tree' ? 'f'.repeat(40) : '0'.repeat(40),
24
+ },
25
+ issueNumber: option.reviewBinding === 'wrong-issue' ? 18 : 17,
26
+ prNumber: option.reviewBinding === 'wrong-pr' ? 24 : 23,
27
+ readOnly: true,
28
+ requestedEffort: 'xhigh',
29
+ requestedModel: 'gpt-6-astra',
30
+ effectiveEffort: 'unknown',
31
+ effectiveModel: 'unknown',
32
+ schemaVersion: 'ai-delivery.review-artifact@1',
33
+ summary: 'Independent review.',
34
+ verdict: 'approve',
36
35
  };
37
- }
38
- test('exact-lease merge refuses a cleanly mergeable diverged head before GitHub mutation', async () => {
39
- const state = await fixture();
40
- try {
41
- writeFileSync(join(state.root, 'base-only.txt'), 'base change\n');
42
- git(state.root, 'add', '.');
43
- git(state.root, 'commit', '-qm', 'base change');
44
- const divergedBase = git(state.root, 'rev-parse', 'HEAD');
45
- assert.notEqual(divergedBase, state.baseSha);
46
- assert.equal(git(state.root, 'merge-tree', '--write-tree', divergedBase, state.headSha).length, 40);
47
- let remoteCalls = 0;
48
- const context = {
49
- root: state.root,
50
- clients: {
51
- rest: {
52
- pulls: {
53
- get: () => {
54
- remoteCalls += 1;
55
- },
36
+ const artifact = {
37
+ ...artifactContent,
38
+ artifactId: option.reviewBinding === 'invalid-digest' ? `sha256:${'d'.repeat(64)}` : digestValue(artifactContent),
39
+ };
40
+ const marker = `<!-- ai-delivery-review-artifact: ${artifact.artifactId} -->`;
41
+ const reviewBody = option.reviewBinding === 'marker-only'
42
+ ? marker
43
+ : `${artifact.summary}\n\n${marker}\n<!-- ai-delivery-review-artifact-data: ${Buffer.from(JSON.stringify(artifact)).toString('base64url')} -->`;
44
+ const pr = () => ({
45
+ number: 23,
46
+ node_id: 'PR23',
47
+ changed_files: 1,
48
+ title: publicationTitle ?? 'Improve widget',
49
+ body: publicationBody ?? (option.nonClosing ? 'References example/widget#17\n\nImprove widget.' : 'Closes #17'),
50
+ html_url: 'https://github.com/example/widget/pull/23',
51
+ state: merged ? 'closed' : 'open',
52
+ draft: publicationDraft ?? false,
53
+ merged,
54
+ merge_commit_sha: merged ? merge : null,
55
+ mergeable: state === 'clean',
56
+ mergeable_state: state,
57
+ head: { sha: head, ref: 'issue/17', repo: { full_name: 'example/widget' } },
58
+ base: { sha: 'c'.repeat(40), ref: 'main', repo: { full_name: 'example/widget' } },
59
+ user: { login: 'author' },
60
+ });
61
+ const rest = {
62
+ request: async () => ({ data: [] }),
63
+ repos: { get: async () => ({ data: { node_id: 'REPO', default_branch: 'main', full_name: 'example/widget' } }) },
64
+ git: {
65
+ getCommit: async () => ({ data: { sha: head, tree: { sha: '0'.repeat(40) } } }),
66
+ getRef: async ({ ref }) => ({
67
+ data: {
68
+ ref: `refs/${ref}`,
69
+ object: {
70
+ type: 'commit',
71
+ sha: ref === 'heads/main' ? (option.baseMoved ? 'e'.repeat(40) : 'c'.repeat(40)) : head,
56
72
  },
57
73
  },
74
+ }),
75
+ },
76
+ pulls: {
77
+ get: async () => ({ data: { ...pr(), head: { ...pr().head, sha: option.headMoved ? 'f'.repeat(40) : head } } }),
78
+ list: async () => ({ data: [] }),
79
+ listFiles: async () => ({ data: [{ filename: 'change.ts' }] }),
80
+ create: async (input) => {
81
+ assert.equal(input.body, 'Plain explanation\n\nCloses #17');
82
+ assert.equal(typeof input.title, 'string');
83
+ assert.equal(typeof input.draft, 'boolean');
84
+ publicationBody = input.body;
85
+ publicationTitle = input.title;
86
+ publicationDraft = input.draft;
87
+ return { data: pr() };
58
88
  },
59
- };
60
- await assert.rejects(mergeWithExactLease(context, {
61
- baseBranch: 'main',
62
- baseSha: divergedBase,
63
- headBranch: 'issue/17',
64
- headSha: state.headSha,
65
- headTree: state.headTree,
66
- prNumber: 21,
67
- title: 'feature',
68
- worktreePath: state.path,
69
- }), /ancestor/);
70
- assert.equal(remoteCalls, 0);
71
- assert.equal(git(state.root, 'rev-parse', 'main'), divergedBase);
72
- }
73
- finally {
74
- rmSync(state.root, { recursive: true, force: true });
75
- }
76
- });
77
- test('remote squash recovery persists receipt, repairs registry, and cleans with exact readback', async () => {
78
- const state = await fixture();
79
- try {
80
- await updateIssueWorktreeDelivery({
81
- branch: 'issue/17',
82
- issueNumber: 17,
83
- path: state.path,
84
- prNumber: 21,
85
- projectRoot: state.root,
86
- status: 'pr-published',
87
- });
88
- writeFileSync(join(state.root, 'base-only.txt'), 'base change\n');
89
- git(state.root, 'add', '.');
90
- git(state.root, 'commit', '-qm', 'base change');
91
- const baseSha = git(state.root, 'rev-parse', 'HEAD');
92
- const mergeTree = git(state.root, 'merge-tree', '--write-tree', baseSha, state.headSha);
93
- assert.notEqual(mergeTree, state.headTree);
94
- const mergeSha = git(state.root, 'commit-tree', mergeTree, '-p', baseSha, '-m', 'squash feature');
95
- git(state.root, 'reset', '--hard', mergeSha);
96
- const evidenceId = digestValue('merge evidence');
97
- const reviewReceiptId = digestValue('review receipt');
98
- const content = {
99
- baseBranch: 'main',
100
- baseSha,
101
- evidenceId,
102
- exactBaseHeadLease: false,
103
- headBranch: 'issue/17',
104
- headSha: state.headSha,
105
- headTree: state.headTree,
106
- issueNumber: 17,
107
- prNumber: 21,
108
- reviewReceiptId,
109
- schemaVersion: 'ai-delivery.merge-intent@2',
110
- strategy: 'squash',
111
- };
112
- const intent = { ...content, intentId: digestValue(content) };
113
- let reportedTree = state.headTree;
114
- const mock = {
115
- clients: {
116
- rest: {
117
- pulls: {
118
- get: async () => ({
119
- data: {
120
- state: 'closed',
121
- merged_at: '2026-09-24T00:00:00Z',
122
- merge_commit_sha: mergeSha,
123
- head: { sha: state.headSha, ref: 'issue/17' },
124
- base: { ref: 'main' },
125
- },
126
- }),
89
+ getReview: async () => ({
90
+ data: {
91
+ id: 99,
92
+ user: { login: 'reviewer[bot]' },
93
+ state: option.reviewMoved ? 'DISMISSED' : 'APPROVED',
94
+ commit_id: head,
95
+ body: reviewBody,
96
+ html_url: 'https://github.com/example/widget/pull/23#pullrequestreview-99',
97
+ },
98
+ }),
99
+ listReviews: async () => ({
100
+ data: [
101
+ {
102
+ id: 99,
103
+ user: { login: 'reviewer[bot]' },
104
+ state: 'APPROVED',
105
+ commit_id: head,
106
+ body: reviewBody,
107
+ },
108
+ ],
109
+ }),
110
+ merge: async (input) => {
111
+ merges.push(input);
112
+ merged = true;
113
+ if (option.mergeAckLost)
114
+ throw new Error('Merge acknowledgement lost');
115
+ return { data: { merged: true, sha: merge } };
116
+ },
117
+ },
118
+ issues: {
119
+ get: async () => ({
120
+ data: {
121
+ number: 17,
122
+ state: closes ? 'closed' : 'open',
123
+ node_id: 'ISSUE',
124
+ title: 'Improve widget',
125
+ body: merged && option.retainedIssueDrift ? 'Changed criteria' : 'Retained criteria',
126
+ html_url: 'https://github.com/example/widget/issues/17',
127
+ },
128
+ }),
129
+ update: async (input) => {
130
+ assert.equal(input.state, 'closed');
131
+ if (option.failedClose && closes === 0) {
132
+ closes++;
133
+ throw new Error('Interrupted close');
134
+ }
135
+ closes++;
136
+ return { data: { number: 17, state: 'closed' } };
137
+ },
138
+ },
139
+ };
140
+ const graphql = async (query) => {
141
+ if (query.includes('DeliveryNonClosingReferences'))
142
+ return {
143
+ repository: {
144
+ id: 'REPO',
145
+ nameWithOwner: 'example/widget',
146
+ issue: {
147
+ id: 'ISSUE',
148
+ number: 17,
149
+ state: 'OPEN',
150
+ timelineItems: {
151
+ nodes: [
152
+ {
153
+ id: 'REFERENCE',
154
+ actor: { login: 'author' },
155
+ isCrossRepository: false,
156
+ willCloseTarget: false,
157
+ source: {
158
+ __typename: 'PullRequest',
159
+ id: 'PR23',
160
+ number: 23,
161
+ repository: { id: 'REPO', nameWithOwner: 'example/widget' },
162
+ },
163
+ target: {
164
+ __typename: 'Issue',
165
+ id: 'ISSUE',
166
+ number: 17,
167
+ repository: { id: 'REPO', nameWithOwner: 'example/widget' },
168
+ },
169
+ },
170
+ ],
171
+ pageInfo: { hasNextPage: false, endCursor: null },
172
+ },
127
173
  },
128
- git: {
129
- getRef: async ({ ref }) => {
130
- if (ref !== 'heads/main')
131
- throw Object.assign(new Error('head branch deleted'), { status: 404 });
132
- return { data: { object: { sha: mergeSha } } };
174
+ },
175
+ };
176
+ if (query.includes('DeliveryNonClosingCommits'))
177
+ return {
178
+ repository: {
179
+ nameWithOwner: 'example/widget',
180
+ pullRequest: {
181
+ id: 'PR23',
182
+ number: 23,
183
+ headRefOid: head,
184
+ baseRefOid: 'c'.repeat(40),
185
+ baseRefName: 'main',
186
+ commits: {
187
+ totalCount: 1,
188
+ nodes: [
189
+ {
190
+ commit: {
191
+ oid: head,
192
+ message: option.commitMessage ?? 'Improve widget',
193
+ parents: {
194
+ totalCount: 1,
195
+ nodes: [{ oid: 'c'.repeat(40) }],
196
+ pageInfo: { hasNextPage: false, endCursor: null },
197
+ },
198
+ },
199
+ },
200
+ ],
201
+ pageInfo: { hasNextPage: false, endCursor: null },
133
202
  },
134
- getCommit: async () => ({
135
- data: { sha: mergeSha, tree: { sha: reportedTree }, parents: [{ sha: baseSha }] },
136
- }),
137
203
  },
138
204
  },
139
- },
140
- };
141
- const path = join(state.root, '.git', 'ai-delivery', 'merges', '17', `${state.headSha}.json`);
142
- const input = { branch: 'issue/17', intent, path, worktreePath: state.path, expectedSha: mergeSha };
143
- await assert.rejects(persistMergedResult({ ...mock, root: state.root }, input), /Remote merge ref, branch or commit readback/u);
144
- assert.equal(existsSync(path), false);
145
- reportedTree = mergeTree;
146
- await assert.rejects(persistMergedResult({ ...mock, root: join(state.root, 'wrong-registry') }, input));
147
- assert.equal(existsSync(path), false);
148
- assert.equal(getIssueWorktreeStrict(17, state.root).status, 'pr-published');
149
- const receipt = await persistMergedResult({ ...mock, root: state.root }, input);
150
- assert.equal(JSON.parse(readFileSync(path, 'utf8')).mergeSha, mergeSha);
151
- assert.equal(getIssueWorktreeStrict(17, state.root).status, 'merged');
152
- assert.equal(receipt.schemaVersion, 'ai-delivery.merge@3');
153
- if (receipt.schemaVersion !== 'ai-delivery.merge@3')
154
- throw new Error('Expected current merge receipt.');
155
- git(state.root, 'update-ref', 'refs/remotes/origin/main', mergeSha);
156
- const attestation = {
157
- baseBranch: receipt.baseBranch,
158
- baseSha: receipt.baseSha,
159
- headBranch: receipt.headBranch,
160
- headSha: receipt.headSha,
161
- mergeSha: receipt.mergeSha,
162
- mergeTree: receipt.mergeTree,
163
- remoteBaseSha: mergeSha,
164
- remoteHeadSha: '0'.repeat(40),
165
- strategy: receipt.strategy,
166
- };
167
- await assert.rejects(cleanupMergedIssueWorktree({ issueNumber: 17, repoRoot: state.root, merge: attestation }), /attestation/);
168
- assert.equal(existsSync(state.path), true);
169
- await cleanupMergedIssueWorktree({
205
+ };
206
+ if (query.includes('DeliveryReviewAccess'))
207
+ return {
208
+ repository: {
209
+ pullRequest: {
210
+ reviews: {
211
+ nodes: option.reviewAccessUnknown
212
+ ? []
213
+ : [
214
+ {
215
+ fullDatabaseId: 99,
216
+ author: { login: 'reviewer' },
217
+ authorCanPushToRepository: option.reviewCannotPush !== true,
218
+ commit: { oid: head },
219
+ state: 'APPROVED',
220
+ },
221
+ ],
222
+ },
223
+ },
224
+ },
225
+ };
226
+ if (query.includes('DeliveryClosingIssues'))
227
+ return {
228
+ repository: {
229
+ nameWithOwner: 'example/widget',
230
+ pullRequest: {
231
+ id: 'PR23',
232
+ number: 23,
233
+ closingIssuesReferences: {
234
+ nodes: option.nonClosing
235
+ ? []
236
+ : [{ number: option.wrongClosingIssue ? 18 : 17, repository: { nameWithOwner: 'example/widget' } }],
237
+ pageInfo: { hasNextPage: false, endCursor: null },
238
+ },
239
+ },
240
+ },
241
+ };
242
+ if (option.deletedAfterMerge && merged && query.includes('DeliveryIssueBranches'))
243
+ return {
244
+ repository: { issue: { linkedBranches: { nodes: [], pageInfo: { hasNextPage: false, endCursor: null } } } },
245
+ };
246
+ if (query.includes('DeliveryIssueBranches'))
247
+ return {
248
+ repository: {
249
+ issue: {
250
+ linkedBranches: {
251
+ nodes: [
252
+ {
253
+ ref: {
254
+ name: option.unrelatedBranch ? 'other/17' : 'issue/17',
255
+ target: { oid: head },
256
+ repository: { nameWithOwner: 'example/widget' },
257
+ },
258
+ },
259
+ ],
260
+ pageInfo: { hasNextPage: false, endCursor: null },
261
+ },
262
+ },
263
+ },
264
+ };
265
+ if (query.includes('blockedBy'))
266
+ return {
267
+ repository: {
268
+ issue: { blockedBy: { nodes: [], pageInfo: { hasNextPage: false, endCursor: null } }, parent: null },
269
+ },
270
+ };
271
+ return { repository: { pullRequest: { headRefOid: head, reviewDecision: option.reviewDecision ?? 'APPROVED' } } };
272
+ };
273
+ const reviewer = { role: 'reviewer', authSource: 'app', appActorLogin: async () => 'reviewer[bot]', rest, graphql };
274
+ const context = {
275
+ root: '/nonexistent-launch-directory',
276
+ repo: { owner: 'example', repo: 'widget' },
277
+ config: {
278
+ schemaVersion: 'ai-delivery.github@1',
279
+ repository: 'example/widget',
280
+ roles: { author: { authSource: 'personal', identity: 'author' }, reviewer: { identity: 'reviewer' } },
281
+ },
282
+ clients: {
283
+ role: 'author',
284
+ authSource: 'personal',
285
+ authenticatedAuthor: async () => ({ actorLogin: 'author', credentialIdentity: 'user:1' }),
286
+ rest,
287
+ graphql,
288
+ },
289
+ reviewerClients: reviewer,
290
+ };
291
+ return { context, merges, closes: () => closes };
292
+ }
293
+ describe('remote GitHub lifecycle', () => {
294
+ for (const reviewBinding of [
295
+ 'stale-head',
296
+ 'wrong-tree',
297
+ 'wrong-scope',
298
+ 'invalid-digest',
299
+ 'marker-only',
300
+ 'wrong-issue',
301
+ 'wrong-pr',
302
+ 'wrong-role',
303
+ ]) {
304
+ it(`refuses ${reviewBinding} artifact binding despite remapped current API commit and CLEAN approval`, async () => {
305
+ const { context, merges, closes } = fixture('clean', { reviewBinding });
306
+ await assert.rejects(finishIssue(context, { issueNumber: 17, prNumber: 23 }), /artifact|binding|review|scope|tree/u);
307
+ assert.equal(merges.length, 0);
308
+ assert.equal(closes(), 0);
309
+ });
310
+ }
311
+ for (const option of [{ reviewCannotPush: true }, { reviewAccessUnknown: true }]) {
312
+ it(`refuses an unqualified App review despite aggregate human approval: ${JSON.stringify(option)}`, async () => {
313
+ const { context, merges } = fixture('clean', option);
314
+ await assert.rejects(mergePr(context, { issueNumber: 17, prNumber: 23 }), /qualifying|count|eligible/u);
315
+ assert.equal(merges.length, 0);
316
+ });
317
+ }
318
+ it('refuses publication of a collaborator PR before promotion', async () => {
319
+ const { context } = fixture();
320
+ context.clients.rest.pulls.list = (async () => ({
321
+ data: [{ number: 23 }],
322
+ }));
323
+ const get = context.clients.rest.pulls.get;
324
+ context.clients.rest.pulls.get = (async (...args) => {
325
+ const response = await get(...args);
326
+ return { ...response, data: { ...response.data, user: { ...response.data.user, login: 'collaborator' } } };
327
+ });
328
+ await assert.rejects(publishPr(context, { issueNumber: 17, dryRun: true }), /author/u);
329
+ });
330
+ it('refuses a non-default PR base before publication', async () => {
331
+ const { context } = fixture();
332
+ await assert.rejects(publishPr(context, { issueNumber: 17, baseBranch: 'release' }), /default branch/u);
333
+ });
334
+ it('refuses conflicting prior PR metadata during dry-run before promotion', async () => {
335
+ const { context } = fixture();
336
+ context.clients.rest.pulls.list = (async () => ({
337
+ data: [{ number: 23 }],
338
+ }));
339
+ await assert.rejects(publishPr(context, { issueNumber: 17, body: 'Different intent', dryRun: true }), /conflict/u);
340
+ });
341
+ it('reads every PR match page before refusing ambiguous branch publication', async () => {
342
+ const { context } = fixture();
343
+ context.clients.rest.pulls.list = (async (input) => ({
344
+ data: input.page === 2 ? [{ number: 24 }] : Array.from({ length: 100 }, () => ({ number: 23 })),
345
+ }));
346
+ await assert.rejects(publishPr(context, { issueNumber: 17, dryRun: true }), /conflict/u);
347
+ });
348
+ it('creates a PR from its remote issue branch with a plain body and no local evidence', async () => {
349
+ const { context } = fixture();
350
+ const result = await publishPr(context, {
170
351
  issueNumber: 17,
171
- repoRoot: state.root,
172
- merge: { ...attestation, remoteHeadSha: null },
352
+ title: 'Improve widget',
353
+ body: 'Plain explanation',
354
+ draft: false,
355
+ });
356
+ assert.equal(result.prNumber, 23);
357
+ });
358
+ for (const state of ['blocked', 'behind', 'dirty', 'unstable', null]) {
359
+ it(`refuses GitHub merge eligibility ${String(state)} without a local receipt fallback`, async () => {
360
+ const { context, merges } = fixture(state);
361
+ await assert.rejects(mergePr(context, { issueNumber: 17, prNumber: 23 }), /GitHub|mergeable|clean|CLEAN/u);
362
+ assert.equal(merges.length, 0);
173
363
  });
174
- assert.equal(existsSync(state.path), false);
175
364
  }
176
- finally {
177
- rmSync(state.root, { recursive: true, force: true });
365
+ it('uses the independent reviewed SHA as the server merge precondition', async () => {
366
+ const { context, merges } = fixture();
367
+ await mergePr(context, { issueNumber: 17, prNumber: 23 });
368
+ assert.equal(merges.length, 1);
369
+ assert.equal(merges[0]?.sha, head);
370
+ });
371
+ it('finishes remotely and reuses the merged PR on an interrupted close retry', async () => {
372
+ const { context, merges, closes } = fixture();
373
+ await finishIssue(context, { issueNumber: 17, prNumber: 23 });
374
+ await finishIssue(context, { issueNumber: 17, prNumber: 23 });
375
+ assert.equal(merges.length, 1);
376
+ assert.equal(closes(), 1);
377
+ });
378
+ for (const [name, option] of [
379
+ ['moved base', { baseMoved: true }],
380
+ ['dismissed review readback', { reviewMoved: true }],
381
+ ['moved reviewed head', { headMoved: true }],
382
+ ['unknown decision', { reviewDecision: 'UNKNOWN' }],
383
+ ['required review', { reviewDecision: 'REVIEW_REQUIRED' }],
384
+ ['unrelated linked branch', { unrelatedBranch: true }],
385
+ ]) {
386
+ it(`refuses ${name} without merge or issue closure`, async () => {
387
+ const { context, merges, closes } = fixture('clean', option);
388
+ await assert.rejects(finishIssue(context, { issueNumber: 17, prNumber: 23 }));
389
+ assert.equal(merges.length, 0);
390
+ assert.equal(closes(), 0);
391
+ });
178
392
  }
393
+ it('finishes a merged PR after its issue branch was deleted', async () => {
394
+ const { context, merges, closes } = fixture('clean', { deletedAfterMerge: true });
395
+ await finishIssue(context, { issueNumber: 17, prNumber: 23 });
396
+ await finishIssue(context, { issueNumber: 17, prNumber: 23 });
397
+ assert.equal(merges.length, 1);
398
+ assert.equal(closes(), 1);
399
+ });
400
+ it('never closes another issue after merged-PR association readback disagrees', async () => {
401
+ const { context, closes, merges } = fixture('clean', { wrongClosingIssue: true });
402
+ await assert.rejects(finishIssue(context, { issueNumber: 17, prNumber: 23 }));
403
+ assert.equal(merges.length, 0);
404
+ assert.equal(closes(), 0);
405
+ });
179
406
  });
180
- test('ordinary divergent merge preserves the combined tree through receipt recovery', async () => {
181
- const state = await fixture();
182
- try {
183
- await updateIssueWorktreeDelivery({
184
- branch: 'issue/17',
185
- issueNumber: 17,
186
- path: state.path,
187
- prNumber: 21,
188
- projectRoot: state.root,
189
- status: 'pr-published',
407
+ describe('retained issue guarded squash delivery', () => {
408
+ const input = { issueNumber: 17, prNumber: 23, reviewedHeadSha: head, nonClosing: true };
409
+ it('sends explicit safe squash messages at the approved SHA and reuses merged readback without closing', async () => {
410
+ const { context, merges, closes } = fixture('clean', { nonClosing: true });
411
+ const preview = await mergePr(context, { ...input, dryRun: true });
412
+ assert.ok('commit_title' in preview && 'commit_message' in preview);
413
+ assert.equal(preview.commit_title, 'Improve widget');
414
+ assert.equal(preview.commit_message, 'References example/widget#17\n\nDelivered by PR #23.');
415
+ assert.equal(merges.length, 0);
416
+ const result = await mergePr(context, input);
417
+ assert.equal(result.mergeSha, merge);
418
+ assert.equal(merges[0].sha, head);
419
+ assert.equal(merges[0].merge_method, 'squash');
420
+ assert.equal(merges[0].commit_title, preview.commit_title);
421
+ assert.equal(merges[0].commit_message, preview.commit_message);
422
+ assert.equal((await mergePr(context, input)).reused, true);
423
+ assert.equal(merges.length, 1);
424
+ assert.equal(closes(), 0);
425
+ });
426
+ for (const strategy of ['merge', 'rebase']) {
427
+ it(`refuses ${strategy} before any lifecycle mutation`, async () => {
428
+ const { context, merges, closes } = fixture('clean', { nonClosing: true });
429
+ await assert.rejects(mergePr(context, { ...input, strategy }), /squash/u);
430
+ assert.equal(merges.length, 0);
431
+ assert.equal(closes(), 0);
190
432
  });
191
- writeFileSync(join(state.root, 'base-only.txt'), 'base change\n');
192
- git(state.root, 'add', '.');
193
- git(state.root, 'commit', '-qm', 'base change');
194
- const baseSha = git(state.root, 'rev-parse', 'HEAD');
195
- const mergeTree = git(state.root, 'merge-tree', '--write-tree', baseSha, state.headSha);
196
- assert.notEqual(mergeTree, state.headTree);
197
- const mergeSha = git(state.root, 'commit-tree', mergeTree, '-p', baseSha, '-p', state.headSha, '-m', 'merge feature');
198
- git(state.root, 'reset', '--hard', mergeSha);
199
- const content = {
200
- baseBranch: 'main',
201
- baseSha,
202
- evidenceId: digestValue('merge evidence'),
203
- exactBaseHeadLease: false,
204
- headBranch: 'issue/17',
205
- headSha: state.headSha,
206
- headTree: state.headTree,
207
- issueNumber: 17,
208
- prNumber: 21,
209
- reviewReceiptId: digestValue('review receipt'),
210
- schemaVersion: 'ai-delivery.merge-intent@2',
211
- strategy: 'merge',
212
- };
213
- const intent = { ...content, intentId: digestValue(content) };
214
- const mock = {
215
- clients: {
216
- rest: {
217
- pulls: {
218
- get: async () => ({
219
- data: {
220
- state: 'closed',
221
- merged_at: '2026-09-24T00:00:00Z',
222
- merge_commit_sha: mergeSha,
223
- head: { sha: state.headSha, ref: 'issue/17' },
224
- base: { ref: 'main' },
225
- },
226
- }),
227
- },
228
- git: {
229
- getRef: async ({ ref }) => ({
230
- data: {
231
- object: {
232
- sha: ref === 'heads/main' ? mergeSha : state.headSha,
233
- },
234
- },
235
- }),
236
- getCommit: async () => ({
237
- data: { sha: mergeSha, tree: { sha: mergeTree }, parents: [{ sha: baseSha }, { sha: state.headSha }] },
238
- }),
239
- },
240
- },
241
- },
242
- };
243
- const path = join(state.root, '.git', 'ai-delivery', 'merges', '17', `${state.headSha}.json`);
244
- const input = { branch: 'issue/17', intent, path, worktreePath: state.path, expectedSha: mergeSha };
245
- await assert.rejects(persistMergedResult({ ...mock, root: join(state.root, 'wrong-registry') }, input));
246
- assert.equal(getIssueWorktreeStrict(17, state.root).status, 'pr-published');
247
- const receipt = await persistMergedResult({ ...mock, root: state.root }, input);
248
- assert.equal(receipt.mergeTree, mergeTree);
249
- assert.equal(getIssueWorktreeStrict(17, state.root).status, 'merged');
250
433
  }
251
- finally {
252
- rmSync(state.root, { recursive: true, force: true });
434
+ it('refuses finish before even reading or merging the PR', async () => {
435
+ const { context, merges, closes } = fixture('clean', { nonClosing: true });
436
+ context.clients.rest.pulls.get = (() => {
437
+ throw new Error('No lifecycle call allowed');
438
+ });
439
+ await assert.rejects(finishIssue(context, input), /cannot finish/u);
440
+ assert.equal(merges.length, 0);
441
+ assert.equal(closes(), 0);
442
+ });
443
+ for (const option of [
444
+ { reviewCannotPush: true },
445
+ { reviewAccessUnknown: true },
446
+ { reviewDecision: 'REVIEW_REQUIRED' },
447
+ { baseMoved: true },
448
+ { headMoved: true },
449
+ { commitMessage: 'Fixes: other/repo#5' },
450
+ ]) {
451
+ it(`preserves fail-closed merge guards: ${JSON.stringify(option)}`, async () => {
452
+ const { context, merges, closes } = fixture('clean', { ...option, nonClosing: true });
453
+ await assert.rejects(mergePr(context, input));
454
+ assert.equal(merges.length, 0);
455
+ assert.equal(closes(), 0);
456
+ });
253
457
  }
458
+ it('reports post-merge issue criteria drift without reopening or replaying the merge', async () => {
459
+ const { context, merges, closes } = fixture('clean', { nonClosing: true, retainedIssueDrift: true });
460
+ await assert.rejects(mergePr(context, input), /criteria drifted/u);
461
+ assert.equal(merges.length, 1);
462
+ assert.equal(closes(), 0);
463
+ });
464
+ it('reconciles an uncertain merge acknowledgement through authoritative merged readback', async () => {
465
+ const { context, merges, closes } = fixture('clean', { nonClosing: true, mergeAckLost: true });
466
+ await assert.rejects(mergePr(context, input), /acknowledgement lost/u);
467
+ assert.equal((await mergePr(context, input)).reused, true);
468
+ assert.equal(merges.length, 1);
469
+ assert.equal(closes(), 0);
470
+ });
254
471
  });
255
472
  //# sourceMappingURL=pr.test.js.map