@0xcraft/powershot 1.1.2 → 1.1.4
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 +4 -3
- package/dist/cli/reports.js +4 -6
- package/dist/cli/review-command.js +4 -7
- package/dist/cli/session-command.js +5 -1
- package/dist/github/api.js +198 -0
- package/dist/github/inline-comments.js +3 -154
- package/dist/github/summary-comment.js +149 -0
- package/dist/ground.js +49 -8
- package/dist/langtest.js +93 -1
- package/dist/manifest.js +0 -17
- package/dist/package-smoke.js +4 -0
- package/dist/reinvention.js +109 -0
- package/dist/report/markdown.js +28 -16
- package/dist/report/sarif.js +1 -1
- package/dist/report/summary.js +103 -0
- package/dist/report/terminal.js +18 -11
- package/dist/report/viewer.js +18 -9
- package/dist/selftest.js +594 -22
- package/dist/session.js +6 -2
- package/dist/verifiers/foreign-reinvented.js +72 -26
- package/dist/verifiers/foreign-tokens.js +4 -1
- package/dist/verifiers/reinvented.js +28 -10
- package/docs/architecture.md +2 -1
- package/docs/ci.md +25 -0
- package/examples/github-actions/action.yml +4 -0
- package/package.json +1 -1
package/dist/selftest.js
CHANGED
|
@@ -22,7 +22,7 @@ import { stripControl } from './text.js';
|
|
|
22
22
|
import { review } from './review.js';
|
|
23
23
|
import { withTargetTree } from './snapshot.js';
|
|
24
24
|
import { loadConfig } from './config.js';
|
|
25
|
-
import { execFileSync } from 'node:child_process';
|
|
25
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
26
26
|
import { insideRepo, repoPath } from './fspolicy.js';
|
|
27
27
|
import { Budget, parseLimits } from './budget.js';
|
|
28
28
|
import { SelectionPlan, capabilitiesOf } from './plan.js';
|
|
@@ -48,12 +48,16 @@ import { compact } from './report/compact.js';
|
|
|
48
48
|
import { apiKey } from './judges/llm.js';
|
|
49
49
|
import { sarif } from './report/sarif.js';
|
|
50
50
|
import { markdown } from './report/markdown.js';
|
|
51
|
+
import { summarizeRun } from './report/summary.js';
|
|
51
52
|
import { wrap } from './report/terminal.js';
|
|
52
53
|
import { highlight, isJsx } from './report/highlight.js';
|
|
53
54
|
import { buildGround, normalizeName, readEnvManifest, relPath } from './ground.js';
|
|
55
|
+
import { implementationFingerprint, reinventionScope, typescriptImplementationFingerprint } from './reinvention.js';
|
|
54
56
|
import { incompleteReasons } from './bench.js';
|
|
55
57
|
import { PACKAGE_NAME, PACKAGE_VERSION } from './package-meta.js';
|
|
56
|
-
import { addedLinesFromPatch,
|
|
58
|
+
import { addedLinesFromPatch, inlineMarker, parseReviewFindings, reconcileInlineComments, selectInlineComments, syncInlineComments, } from './github/inline-comments.js';
|
|
59
|
+
import { createReviewPayload, GitHubPullRequestApi, } from './github/api.js';
|
|
60
|
+
import { LEGACY_SUMMARY_MARKER, summaryCommentBody, summaryMarker, syncSummaryComment, workflowCommentScope, } from './github/summary-comment.js';
|
|
57
61
|
const root = '/repo';
|
|
58
62
|
/** Build a Ground by hand so verifiers are testable without git or a real repo. */
|
|
59
63
|
function ground(files, deps = []) {
|
|
@@ -80,13 +84,26 @@ function ground(files, deps = []) {
|
|
|
80
84
|
const symbolIndex = new Map();
|
|
81
85
|
for (const sf of project.getSourceFiles()) {
|
|
82
86
|
const rel = sf.getFilePath().slice(root.length + 1);
|
|
87
|
+
const input = files.find((file) => file.path === rel);
|
|
83
88
|
for (const [name, decls] of sf.getExportedDeclarations()) {
|
|
84
89
|
const decl = decls[0];
|
|
85
90
|
if (!decl)
|
|
86
91
|
continue;
|
|
92
|
+
const fingerprint = typescriptImplementationFingerprint(decl);
|
|
93
|
+
if (!fingerprint)
|
|
94
|
+
continue;
|
|
87
95
|
const key = normalizeName(name);
|
|
88
96
|
const list = symbolIndex.get(key) ?? [];
|
|
89
|
-
|
|
97
|
+
const before = input?.before === undefined ? undefined : beforeProject.getSourceFile('/before/' + rel);
|
|
98
|
+
const existedInBase = (before?.getExportedDeclarations().get(name) ?? []).some((baseDeclaration) => typescriptImplementationFingerprint(baseDeclaration) === fingerprint);
|
|
99
|
+
list.push({
|
|
100
|
+
file: rel,
|
|
101
|
+
name,
|
|
102
|
+
line: decl.getStartLineNumber(),
|
|
103
|
+
fingerprint,
|
|
104
|
+
existedInBase,
|
|
105
|
+
scope: reinventionScope(root, rel),
|
|
106
|
+
});
|
|
90
107
|
symbolIndex.set(key, list);
|
|
91
108
|
}
|
|
92
109
|
}
|
|
@@ -143,14 +160,40 @@ check('resolves a scoped subpath to its package', () => {
|
|
|
143
160
|
});
|
|
144
161
|
console.log('\nreinvented');
|
|
145
162
|
check('fires when a helper already exists elsewhere', () => {
|
|
163
|
+
const existing = 'export function formatMinorUnits(n: number) { return n / 100 }\n';
|
|
146
164
|
const g = ground([
|
|
147
|
-
{ path: 'lib/currency.ts',
|
|
148
|
-
{ path: 'utils/money.ts', after:
|
|
165
|
+
{ path: 'lib/currency.ts', before: existing, after: existing },
|
|
166
|
+
{ path: 'utils/money.ts', after: existing },
|
|
149
167
|
]);
|
|
150
168
|
const found = reinvented.run(g);
|
|
151
169
|
assert.ok(found.length >= 1, 'expected a duplication finding');
|
|
152
170
|
assert.equal(found[0].confidence, 'firm'); // heuristic, never claims `proven`
|
|
153
171
|
});
|
|
172
|
+
check('silent when matching helpers are both new in the change', () => {
|
|
173
|
+
const added = 'export function formatMinorUnits(n: number) { return n / 100 }\n';
|
|
174
|
+
const g = ground([
|
|
175
|
+
{ path: 'lib/currency.ts', after: added },
|
|
176
|
+
{ path: 'utils/money.ts', after: added },
|
|
177
|
+
]);
|
|
178
|
+
assert.equal(fires(reinvented, g), false);
|
|
179
|
+
});
|
|
180
|
+
check('silent when the matching implementation already existed in the changed file', () => {
|
|
181
|
+
const existing = 'export function formatMinorUnits(n: number) { return n / 100 }\n';
|
|
182
|
+
const g = ground([
|
|
183
|
+
{ path: 'lib/currency.ts', before: existing, after: existing },
|
|
184
|
+
{ path: 'utils/money.ts', before: existing, after: existing },
|
|
185
|
+
]);
|
|
186
|
+
assert.equal(fires(reinvented, g), false);
|
|
187
|
+
});
|
|
188
|
+
check('silent when the candidate only became equivalent in this change', () => {
|
|
189
|
+
const before = 'export function formatMinorUnits(n: number) { return n / 10 }\n';
|
|
190
|
+
const after = 'export function formatMinorUnits(n: number) { return n / 100 }\n';
|
|
191
|
+
const g = ground([
|
|
192
|
+
{ path: 'lib/currency.ts', before, after },
|
|
193
|
+
{ path: 'utils/money.ts', after },
|
|
194
|
+
]);
|
|
195
|
+
assert.equal(fires(reinvented, g), false);
|
|
196
|
+
});
|
|
154
197
|
check('silent on a genuinely new name', () => {
|
|
155
198
|
const g = ground([
|
|
156
199
|
{ path: 'lib/currency.ts', after: 'export function formatMinorUnits(n: number) { return n / 100 }\n' },
|
|
@@ -165,6 +208,125 @@ check('silent on short and generic names', () => {
|
|
|
165
208
|
]);
|
|
166
209
|
assert.equal(fires(reinvented, g), false);
|
|
167
210
|
});
|
|
211
|
+
check('silent when only the helper name matches', () => {
|
|
212
|
+
const existing = 'export function runCheck(currentVersion: string, availableVersion: string) { return currentVersion !== availableVersion }\n';
|
|
213
|
+
const g = ground([
|
|
214
|
+
{ path: 'web/use-app-updater.ts', before: existing, after: existing },
|
|
215
|
+
{
|
|
216
|
+
path: 'scripts/check-import-boundaries.mjs',
|
|
217
|
+
after: 'function runCheck({ srcRoot, allowlist }) { return allowlist.filter((entry) => !entry.startsWith(srcRoot)) }\n',
|
|
218
|
+
},
|
|
219
|
+
]);
|
|
220
|
+
assert.equal(fires(reinvented, g), false);
|
|
221
|
+
});
|
|
222
|
+
await checkAsync('silent across package boundaries', async () => {
|
|
223
|
+
const dir = realpathSync(mkdtempSync(join(tmpdir(), 'psh-reinvented-scope-')));
|
|
224
|
+
try {
|
|
225
|
+
mkdirSync(join(dir, 'apps/web/src'), { recursive: true });
|
|
226
|
+
mkdirSync(join(dir, 'tools/scripts'), { recursive: true });
|
|
227
|
+
writeFileSync(join(dir, 'apps/web/package.json'), '{"name":"web","private":true}');
|
|
228
|
+
writeFileSync(join(dir, 'tools/scripts/package.json'), '{"name":"scripts","private":true}');
|
|
229
|
+
writeFileSync(join(dir, 'tsconfig.json'), '{"compilerOptions":{"allowJs":true},"include":["apps/**/*.mjs","tools/**/*.mjs"]}');
|
|
230
|
+
writeFileSync(join(dir, 'apps/web/src/normalize.mjs'), 'export function normalizePayload(value) { return value.trim() }\n');
|
|
231
|
+
writeFileSync(join(dir, 'tools/scripts/normalize.mjs'), 'function normalizePayload(value) { return value.trim() }\n');
|
|
232
|
+
const g = await buildGround(dir, [
|
|
233
|
+
{ path: 'tools/scripts/normalize.mjs', added: new Set([1]) },
|
|
234
|
+
]);
|
|
235
|
+
assert.equal(fires(reinvented, g), false);
|
|
236
|
+
}
|
|
237
|
+
finally {
|
|
238
|
+
rmSync(dir, { recursive: true, force: true });
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
await checkAsync('silent when only a new barrel alias gives the candidate a matching name', async () => {
|
|
242
|
+
const dir = realpathSync(mkdtempSync(join(tmpdir(), 'psh-reinvented-alias-')));
|
|
243
|
+
try {
|
|
244
|
+
mkdirSync(join(dir, 'lib'), { recursive: true });
|
|
245
|
+
mkdirSync(join(dir, 'scripts'), { recursive: true });
|
|
246
|
+
writeFileSync(join(dir, 'package.json'), '{"name":"fixture","private":true}');
|
|
247
|
+
writeFileSync(join(dir, 'tsconfig.json'), '{"include":["lib/**/*.ts","scripts/**/*.ts"]}');
|
|
248
|
+
writeFileSync(join(dir, 'lib/base.ts'), 'export function calculateVersion(current: string, available: string) { return current !== available }\n');
|
|
249
|
+
writeFileSync(join(dir, 'lib/index.ts'), "export { calculateVersion as runCheck } from './base.js'\n");
|
|
250
|
+
writeFileSync(join(dir, 'scripts/run-check.ts'), 'function runCheck(current: string, available: string) { return current !== available }\n');
|
|
251
|
+
const g = await buildGround(dir, [
|
|
252
|
+
{ path: 'lib/index.ts', added: new Set([1]), before: '' },
|
|
253
|
+
{ path: 'scripts/run-check.ts', added: new Set([1]) },
|
|
254
|
+
]);
|
|
255
|
+
assert.equal(fires(reinvented, g), false);
|
|
256
|
+
}
|
|
257
|
+
finally {
|
|
258
|
+
rmSync(dir, { recursive: true, force: true });
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
check('recognizes package boundaries for every declared language family', () => {
|
|
262
|
+
const dir = realpathSync(mkdtempSync(join(tmpdir(), 'psh-reinvented-markers-')));
|
|
263
|
+
try {
|
|
264
|
+
const packages = [
|
|
265
|
+
['typescript', 'package.json'],
|
|
266
|
+
['python', 'pyproject.toml'],
|
|
267
|
+
['rust', 'Cargo.toml'],
|
|
268
|
+
['go', 'go.mod'],
|
|
269
|
+
['jvm', 'build.gradle.kts'],
|
|
270
|
+
['c-cpp', 'CMakeLists.txt'],
|
|
271
|
+
['csharp', 'App.csproj'],
|
|
272
|
+
['php', 'composer.json'],
|
|
273
|
+
['ruby', 'Gemfile'],
|
|
274
|
+
['solidity', 'foundry.toml'],
|
|
275
|
+
];
|
|
276
|
+
for (const [name, marker] of packages) {
|
|
277
|
+
mkdirSync(join(dir, name, 'src'), { recursive: true });
|
|
278
|
+
writeFileSync(join(dir, name, marker), '');
|
|
279
|
+
assert.equal(reinventionScope(dir, name + '/src/file.txt'), name);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
finally {
|
|
283
|
+
rmSync(dir, { recursive: true, force: true });
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
check('implementation fingerprints cannot confuse token boundaries with token text', () => {
|
|
287
|
+
const oneToken = implementationFingerprint([{ type: '1', text: 'x\u00002\u0000y' }]);
|
|
288
|
+
const twoTokens = implementationFingerprint([{ type: '1', text: 'x' }, { type: '2', text: 'y' }]);
|
|
289
|
+
assert.notEqual(oneToken, twoTokens);
|
|
290
|
+
});
|
|
291
|
+
check('public CLI rejects the name-only repro and keeps an exact-match control', () => {
|
|
292
|
+
const dir = realpathSync(mkdtempSync(join(tmpdir(), 'psh-reinvented-cli-')));
|
|
293
|
+
const cli = join(process.cwd(), 'dist', 'cli.js');
|
|
294
|
+
const git = (...args) => {
|
|
295
|
+
execFileSync('git', args, { cwd: dir, stdio: ['ignore', 'ignore', 'pipe'] });
|
|
296
|
+
};
|
|
297
|
+
const run = () => {
|
|
298
|
+
const result = spawnSync(process.execPath, [cli, 'review', '--verify-only', '--checks', 'reinvented', '--from', 'HEAD~1', '--to', 'HEAD', '--format', 'compact'], { cwd: dir, env: { ...process.env, CI: 'true' }, encoding: 'utf8' });
|
|
299
|
+
return { status: result.status, stdout: result.stdout, stderr: result.stderr };
|
|
300
|
+
};
|
|
301
|
+
try {
|
|
302
|
+
git('init', '-q', '.');
|
|
303
|
+
git('config', 'user.name', 'PowerShot Tests');
|
|
304
|
+
git('config', 'user.email', 'tests@powershot.invalid');
|
|
305
|
+
mkdirSync(join(dir, 'src'), { recursive: true });
|
|
306
|
+
mkdirSync(join(dir, 'scripts'), { recursive: true });
|
|
307
|
+
writeFileSync(join(dir, 'package.json'), '{"name":"fixture","private":true}\n');
|
|
308
|
+
writeFileSync(join(dir, 'tsconfig.json'), '{"compilerOptions":{"allowJs":true},"include":["src/**/*.ts","scripts/**/*"]}\n');
|
|
309
|
+
const existing = 'export function runCheck(currentVersion: string, availableVersion: string) { return currentVersion !== availableVersion }\n';
|
|
310
|
+
writeFileSync(join(dir, 'src/use-app-updater.ts'), existing);
|
|
311
|
+
git('add', '.');
|
|
312
|
+
git('commit', '-q', '-m', 'base');
|
|
313
|
+
writeFileSync(join(dir, 'scripts/check-import-boundaries.mjs'), 'function runCheck({ srcRoot, allowlist }) { return allowlist.filter((entry) => !entry.startsWith(srcRoot)) }\n');
|
|
314
|
+
git('add', '.');
|
|
315
|
+
git('commit', '-q', '-m', 'different helper with same name');
|
|
316
|
+
const nameOnly = run();
|
|
317
|
+
assert.equal(nameOnly.status, 0, nameOnly.stderr || nameOnly.stdout);
|
|
318
|
+
assert.doesNotMatch(nameOnly.stdout, /\[reinvented\]/);
|
|
319
|
+
writeFileSync(join(dir, 'scripts/duplicate.ts'), existing);
|
|
320
|
+
git('add', '.');
|
|
321
|
+
git('commit', '-q', '-m', 'exact duplicate');
|
|
322
|
+
const exact = run();
|
|
323
|
+
assert.equal(exact.status, 1, exact.stderr || exact.stdout);
|
|
324
|
+
assert.match(exact.stdout, /\[reinvented\]/);
|
|
325
|
+
}
|
|
326
|
+
finally {
|
|
327
|
+
rmSync(dir, { recursive: true, force: true });
|
|
328
|
+
}
|
|
329
|
+
});
|
|
168
330
|
console.log('\ndropped-guard');
|
|
169
331
|
check('fires when an early-return guard disappears', () => {
|
|
170
332
|
const before = 'export function close(inv: any) {\n if (!inv.customer) return null\n return inv.total\n}\n';
|
|
@@ -584,30 +746,64 @@ check('a review that did not complete never renders as clean', () => {
|
|
|
584
746
|
});
|
|
585
747
|
assert.match(partial, /partial, not a verdict/);
|
|
586
748
|
assert.match(partial, /outside\.ts \(no types\)/);
|
|
749
|
+
const partialMarkdown = markdown([], {
|
|
750
|
+
state: 'partial', notLookedAt: ['outside.ts (no types)'],
|
|
751
|
+
verifyOnly: true, minSeverity: 'medium', filesReviewed: 4, deterministicChecks: 7,
|
|
752
|
+
});
|
|
753
|
+
assert.match(partialMarkdown, /This review is partial — not a verdict/);
|
|
754
|
+
assert.match(partialMarkdown, /<summary>Why this is not a verdict<\/summary>/);
|
|
755
|
+
assert.match(partialMarkdown, /4 files reviewed · 7 deterministic checks/);
|
|
756
|
+
assert.doesNotMatch(partialMarkdown.slice(0, partialMarkdown.indexOf('<details>')), /outside\\?\.ts/);
|
|
757
|
+
assert.match(partialMarkdown.replace(/\\/g, ''), /outside\.ts \(no types\)/);
|
|
587
758
|
});
|
|
588
759
|
check('portable coverage is a verdict, but never masquerades as full semantic coverage', () => {
|
|
589
760
|
const unavailable = [
|
|
590
|
-
'1 file
|
|
591
|
-
'2
|
|
761
|
+
'1 reviewed file lacked type information and a reference graph',
|
|
762
|
+
'2 checks requiring type information or a reference graph did not run: phantom-api, contract-drift',
|
|
592
763
|
];
|
|
593
764
|
const out = terminal([], {
|
|
594
765
|
subtitle: 'workspace', verified: 0, judged: 0, state: 'complete', notLookedAt: [],
|
|
595
|
-
coverage: 'portable',
|
|
766
|
+
coverage: 'portable', verifyOnly: true, minSeverity: 'medium',
|
|
767
|
+
filesReviewed: 1, deterministicChecks: 1, scopeDetails: unavailable,
|
|
596
768
|
});
|
|
597
|
-
assert.match(out, /No
|
|
598
|
-
assert.match(out, /
|
|
769
|
+
assert.match(out, /No medium-or-higher deterministic findings\./);
|
|
770
|
+
assert.match(out, /1 file reviewed · 1 deterministic check · portable coverage/);
|
|
771
|
+
assert.match(out, /1 reviewed file lacked type information and a reference graph/);
|
|
599
772
|
assert.doesNotMatch(out, /not a verdict/);
|
|
600
|
-
const
|
|
773
|
+
const selected = Array.from({ length: 20 }, (_, index) => ({
|
|
774
|
+
path: 'web/file-' + index + '.ts',
|
|
775
|
+
disposition: 'selected',
|
|
776
|
+
unavailable: index < 11 ? ['types', 'references'] : undefined,
|
|
777
|
+
}));
|
|
778
|
+
const waived = Array.from({ length: 16 }, (_, index) => ({
|
|
779
|
+
path: 'assets/file-' + index + '.json',
|
|
780
|
+
disposition: 'waived',
|
|
781
|
+
reason: 'no parser',
|
|
782
|
+
}));
|
|
783
|
+
const md = markdown([], summarizeRun({
|
|
601
784
|
state: 'complete', notLookedAt: [], coverage: 'portable',
|
|
602
|
-
|
|
603
|
-
|
|
785
|
+
engine: { verifyOnly: true, minSeverity: 'medium' },
|
|
786
|
+
files: [...selected, ...waived],
|
|
787
|
+
checks: {
|
|
788
|
+
ran: Array.from({ length: 19 }, (_, index) => 'check-' + index),
|
|
789
|
+
unavailable: [
|
|
604
790
|
{ check: 'phantom-api', missing: 'types' },
|
|
605
791
|
{ check: 'contract-drift', missing: 'references' },
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
assert.
|
|
792
|
+
{ check: 'dead-on-arrival', missing: 'references' },
|
|
793
|
+
],
|
|
794
|
+
},
|
|
795
|
+
}));
|
|
796
|
+
assert.match(md, /✅ \*\*No medium-or-higher deterministic findings\*\*/);
|
|
797
|
+
assert.match(md, /20 files reviewed · 19 deterministic checks · portable coverage/);
|
|
798
|
+
assert.match(md, /Model review was disabled \(`verify-only`\)\./);
|
|
799
|
+
assert.match(md, /<summary>Coverage details<\/summary>/);
|
|
800
|
+
const rendered = md.replace(/\\/g, '');
|
|
801
|
+
assert.match(rendered, /11 reviewed files? lacked type information and a reference graph/);
|
|
802
|
+
assert.match(rendered, /3 checks? requiring type information or a reference graph did not run: phantom-api, contract-drift, dead-on-arrival/);
|
|
803
|
+
assert.match(rendered, /16 changed files? not reviewed: no parser/);
|
|
804
|
+
assert.ok(md.indexOf('No medium-or-higher deterministic findings') < md.indexOf('Coverage details'));
|
|
805
|
+
assert.doesNotMatch(md, /web\/file-|assets\/file-/);
|
|
806
|
+
assert.doesNotMatch(md, /No findings in portable coverage\./);
|
|
611
807
|
});
|
|
612
808
|
check('findings are still shown when a stage failed, with the warning kept', () => {
|
|
613
809
|
const f = { id: 'F1', class: 'verified', check: 'phantom-dep', severity: 'high',
|
|
@@ -724,6 +920,353 @@ const sample = [
|
|
|
724
920
|
{ id: 'F2', class: 'judged', check: 'plausible-logic', severity: 'low', confidence: 'tentative',
|
|
725
921
|
file: 'src/b.ts', line: 9, title: 'off by one' },
|
|
726
922
|
];
|
|
923
|
+
const summaryHead = 'a'.repeat(40);
|
|
924
|
+
const summaryScope = 'xcrft/powershot/.github/workflows/review.yml:review';
|
|
925
|
+
const ownedSummaryMarker = summaryMarker(summaryScope);
|
|
926
|
+
const renderedSummary = (markdown) => summaryCommentBody(markdown, ownedSummaryMarker, summaryHead);
|
|
927
|
+
check('summary scope follows the workflow file and job, not its moving ref', () => {
|
|
928
|
+
assert.equal(workflowCommentScope('xcrft/powershot/.github/workflows/review.yml@refs/pull/6/merge', 'review'), summaryScope);
|
|
929
|
+
assert.equal(workflowCommentScope('xcrft/powershot/.github/workflows/review.yml@refs/heads/release@v1', 'review'), summaryScope);
|
|
930
|
+
});
|
|
931
|
+
await checkAsync('summary publishing creates a marked comment without touching another workflow', async () => {
|
|
932
|
+
const events = [];
|
|
933
|
+
const comments = [
|
|
934
|
+
{ id: 8, body: '## PR Analysis', user: { login: 'github-actions[bot]' } },
|
|
935
|
+
{ id: 9, body: ownedSummaryMarker, user: { login: 'human' } },
|
|
936
|
+
];
|
|
937
|
+
const api = {
|
|
938
|
+
headSha: async () => summaryHead,
|
|
939
|
+
listIssueComments: async () => [...comments],
|
|
940
|
+
createIssueComment: async (body) => {
|
|
941
|
+
events.push(`create:${body}`);
|
|
942
|
+
const created = { id: 10, body, user: { login: 'github-actions[bot]' } };
|
|
943
|
+
comments.push(created);
|
|
944
|
+
return created;
|
|
945
|
+
},
|
|
946
|
+
updateIssueComment: async (id) => { events.push(`update:${id}`); },
|
|
947
|
+
deleteIssueComment: async (id) => { events.push(`delete:${id}`); },
|
|
948
|
+
};
|
|
949
|
+
const result = await syncSummaryComment(api, '## PowerShot\n\nNo findings.\n', summaryHead, summaryScope);
|
|
950
|
+
assert.deepEqual(result, { state: 'created', commentId: 10, retired: 0 });
|
|
951
|
+
assert.deepEqual(events, [`create:${renderedSummary('## PowerShot\n\nNo findings.')}`]);
|
|
952
|
+
});
|
|
953
|
+
await checkAsync('summary reruns update the marked PowerShot comment, not the latest bot comment', async () => {
|
|
954
|
+
const events = [];
|
|
955
|
+
const api = {
|
|
956
|
+
headSha: async () => summaryHead,
|
|
957
|
+
listIssueComments: async () => [
|
|
958
|
+
{ id: 10, body: renderedSummary('## PowerShot\n\nOld'), user: { login: 'github-actions[bot]' } },
|
|
959
|
+
{ id: 11, body: '## Best Practices', user: { login: 'github-actions[bot]' } },
|
|
960
|
+
],
|
|
961
|
+
createIssueComment: async () => { throw new Error('must not create'); },
|
|
962
|
+
updateIssueComment: async (id, body) => { events.push(`update:${id}:${body}`); },
|
|
963
|
+
deleteIssueComment: async (id) => { events.push(`delete:${id}`); },
|
|
964
|
+
};
|
|
965
|
+
const markdown = '## PowerShot\n\nNew';
|
|
966
|
+
const result = await syncSummaryComment(api, markdown, summaryHead, summaryScope);
|
|
967
|
+
assert.deepEqual(result, { state: 'updated', commentId: 10, retired: 0 });
|
|
968
|
+
assert.deepEqual(events, [`update:10:${renderedSummary(markdown)}`]);
|
|
969
|
+
});
|
|
970
|
+
await checkAsync('summary reruns make no write when the marked body is current', async () => {
|
|
971
|
+
const body = renderedSummary('## PowerShot\n\nCurrent');
|
|
972
|
+
const api = {
|
|
973
|
+
headSha: async () => summaryHead,
|
|
974
|
+
listIssueComments: async () => [
|
|
975
|
+
{ id: 10, body, user: { login: 'github-actions[bot]' } },
|
|
976
|
+
{ id: 11, body: '## Best Practices', user: { login: 'github-actions[bot]' } },
|
|
977
|
+
],
|
|
978
|
+
createIssueComment: async () => { throw new Error('must not create'); },
|
|
979
|
+
updateIssueComment: async () => { throw new Error('must not update'); },
|
|
980
|
+
deleteIssueComment: async () => { throw new Error('must not delete'); },
|
|
981
|
+
};
|
|
982
|
+
assert.deepEqual(await syncSummaryComment(api, '## PowerShot\n\nCurrent', summaryHead, summaryScope), {
|
|
983
|
+
state: 'unchanged', commentId: 10, retired: 0,
|
|
984
|
+
});
|
|
985
|
+
});
|
|
986
|
+
await checkAsync('summary publishing migrates the newest legacy PowerShot body once', async () => {
|
|
987
|
+
const events = [];
|
|
988
|
+
const comments = [
|
|
989
|
+
{ id: 12, body: LEGACY_SUMMARY_MARKER, user: { login: 'github-actions[bot]' } },
|
|
990
|
+
{ id: 14, body: '## PowerShot\n\nLatest', user: { login: 'github-actions[bot]' } },
|
|
991
|
+
{ id: 15, body: '## PR Analysis', user: { login: 'github-actions[bot]' } },
|
|
992
|
+
];
|
|
993
|
+
const api = {
|
|
994
|
+
headSha: async () => summaryHead,
|
|
995
|
+
listIssueComments: async () => [...comments],
|
|
996
|
+
createIssueComment: async (body) => {
|
|
997
|
+
events.push('create');
|
|
998
|
+
const created = { id: 16, body, user: { login: 'github-actions[bot]' } };
|
|
999
|
+
comments.push(created);
|
|
1000
|
+
return created;
|
|
1001
|
+
},
|
|
1002
|
+
updateIssueComment: async () => { throw new Error('legacy comments must not be claimed with PATCH'); },
|
|
1003
|
+
deleteIssueComment: async (id) => {
|
|
1004
|
+
events.push(`delete:${id}`);
|
|
1005
|
+
const index = comments.findIndex((comment) => comment.id === id);
|
|
1006
|
+
if (index !== -1)
|
|
1007
|
+
comments.splice(index, 1);
|
|
1008
|
+
},
|
|
1009
|
+
};
|
|
1010
|
+
const result = await syncSummaryComment(api, '## PowerShot\n\nCurrent', summaryHead, summaryScope);
|
|
1011
|
+
assert.deepEqual(result, { state: 'migrated', commentId: 16, retired: 1 });
|
|
1012
|
+
assert.deepEqual(events, ['create', 'delete:14']);
|
|
1013
|
+
assert.equal(comments.some((comment) => comment.id === 12), true);
|
|
1014
|
+
});
|
|
1015
|
+
await checkAsync('summary publishing never patches a candidate from another pull request head', async () => {
|
|
1016
|
+
const previousHead = '9'.repeat(40);
|
|
1017
|
+
const comments = [
|
|
1018
|
+
{ id: 16, body: summaryCommentBody('old head', ownedSummaryMarker, previousHead), user: { login: 'github-actions[bot]' } },
|
|
1019
|
+
];
|
|
1020
|
+
const events = [];
|
|
1021
|
+
const api = {
|
|
1022
|
+
headSha: async () => summaryHead,
|
|
1023
|
+
listIssueComments: async () => [...comments],
|
|
1024
|
+
createIssueComment: async (body) => {
|
|
1025
|
+
events.push('create');
|
|
1026
|
+
const created = { id: 17, body, user: { login: 'github-actions[bot]' } };
|
|
1027
|
+
comments.push(created);
|
|
1028
|
+
return created;
|
|
1029
|
+
},
|
|
1030
|
+
updateIssueComment: async () => { throw new Error('must not patch a different-head candidate'); },
|
|
1031
|
+
deleteIssueComment: async (id) => {
|
|
1032
|
+
events.push(`delete:${id}`);
|
|
1033
|
+
const index = comments.findIndex((comment) => comment.id === id);
|
|
1034
|
+
if (index !== -1)
|
|
1035
|
+
comments.splice(index, 1);
|
|
1036
|
+
},
|
|
1037
|
+
};
|
|
1038
|
+
assert.deepEqual(await syncSummaryComment(api, 'current head', summaryHead, summaryScope), {
|
|
1039
|
+
state: 'created', commentId: 17, retired: 1,
|
|
1040
|
+
});
|
|
1041
|
+
assert.deepEqual(events, ['create', 'delete:16']);
|
|
1042
|
+
assert.deepEqual(comments.map((comment) => comment.id), [17]);
|
|
1043
|
+
});
|
|
1044
|
+
await checkAsync('summary ownership requires the marker at the start of the bot comment', async () => {
|
|
1045
|
+
const events = [];
|
|
1046
|
+
const comments = [{
|
|
1047
|
+
id: 16,
|
|
1048
|
+
body: `## PR Analysis\n\nQuoted output: ${ownedSummaryMarker}`,
|
|
1049
|
+
user: { login: 'github-actions[bot]' },
|
|
1050
|
+
}];
|
|
1051
|
+
const api = {
|
|
1052
|
+
headSha: async () => summaryHead,
|
|
1053
|
+
listIssueComments: async () => [...comments],
|
|
1054
|
+
createIssueComment: async (body) => {
|
|
1055
|
+
events.push('create');
|
|
1056
|
+
const created = { id: 17, body, user: { login: 'github-actions[bot]' } };
|
|
1057
|
+
comments.push(created);
|
|
1058
|
+
return created;
|
|
1059
|
+
},
|
|
1060
|
+
updateIssueComment: async (id) => { events.push(`update:${id}`); },
|
|
1061
|
+
deleteIssueComment: async (id) => { events.push(`delete:${id}`); },
|
|
1062
|
+
};
|
|
1063
|
+
assert.deepEqual(await syncSummaryComment(api, '## PowerShot\n\nCurrent', summaryHead, summaryScope), {
|
|
1064
|
+
state: 'created', commentId: 17, retired: 0,
|
|
1065
|
+
});
|
|
1066
|
+
assert.deepEqual(events, ['create']);
|
|
1067
|
+
});
|
|
1068
|
+
await checkAsync('summary markers isolate different workflow jobs using the same bot', async () => {
|
|
1069
|
+
const otherMarker = summaryMarker('xcrft/powershot/.github/workflows/audit.yml:audit');
|
|
1070
|
+
const comments = [
|
|
1071
|
+
{
|
|
1072
|
+
id: 18,
|
|
1073
|
+
body: summaryCommentBody('## PowerShot\n\nAudit', otherMarker, summaryHead),
|
|
1074
|
+
user: { login: 'github-actions[bot]' },
|
|
1075
|
+
},
|
|
1076
|
+
];
|
|
1077
|
+
const api = {
|
|
1078
|
+
headSha: async () => summaryHead,
|
|
1079
|
+
listIssueComments: async () => [...comments],
|
|
1080
|
+
createIssueComment: async (body) => {
|
|
1081
|
+
const created = { id: 19, body, user: { login: 'github-actions[bot]' } };
|
|
1082
|
+
comments.push(created);
|
|
1083
|
+
return created;
|
|
1084
|
+
},
|
|
1085
|
+
updateIssueComment: async () => { throw new Error('must not update another workflow'); },
|
|
1086
|
+
deleteIssueComment: async () => { throw new Error('must not delete another workflow'); },
|
|
1087
|
+
};
|
|
1088
|
+
const result = await syncSummaryComment(api, '## PowerShot\n\nReview', summaryHead, summaryScope);
|
|
1089
|
+
assert.deepEqual(result, { state: 'created', commentId: 19, retired: 0 });
|
|
1090
|
+
assert.equal(comments[0]?.body.includes('Audit'), true);
|
|
1091
|
+
});
|
|
1092
|
+
await checkAsync('summary publishing makes no writes for an outdated pull request head', async () => {
|
|
1093
|
+
const api = {
|
|
1094
|
+
headSha: async () => 'b'.repeat(40),
|
|
1095
|
+
listIssueComments: async () => { throw new Error('must not list'); },
|
|
1096
|
+
createIssueComment: async () => { throw new Error('must not create'); },
|
|
1097
|
+
updateIssueComment: async () => { throw new Error('must not update'); },
|
|
1098
|
+
deleteIssueComment: async () => { throw new Error('must not delete'); },
|
|
1099
|
+
};
|
|
1100
|
+
assert.deepEqual(await syncSummaryComment(api, 'report', summaryHead, summaryScope), {
|
|
1101
|
+
state: 'outdated', retired: 0,
|
|
1102
|
+
});
|
|
1103
|
+
});
|
|
1104
|
+
await checkAsync('summary publishing makes no writes when the head changes during reads', async () => {
|
|
1105
|
+
let headReads = 0;
|
|
1106
|
+
const api = {
|
|
1107
|
+
headSha: async () => ++headReads === 1 ? summaryHead : 'b'.repeat(40),
|
|
1108
|
+
listIssueComments: async () => [],
|
|
1109
|
+
createIssueComment: async () => { throw new Error('must not create'); },
|
|
1110
|
+
updateIssueComment: async () => { throw new Error('must not update'); },
|
|
1111
|
+
deleteIssueComment: async () => { throw new Error('must not delete'); },
|
|
1112
|
+
};
|
|
1113
|
+
assert.deepEqual(await syncSummaryComment(api, 'report', summaryHead, summaryScope), {
|
|
1114
|
+
state: 'outdated', retired: 0,
|
|
1115
|
+
});
|
|
1116
|
+
});
|
|
1117
|
+
await checkAsync('summary publishing retires its own new comment when the head changes after create', async () => {
|
|
1118
|
+
let headReads = 0;
|
|
1119
|
+
const comments = [];
|
|
1120
|
+
const api = {
|
|
1121
|
+
headSha: async () => ++headReads < 3 ? summaryHead : 'b'.repeat(40),
|
|
1122
|
+
listIssueComments: async () => [...comments],
|
|
1123
|
+
createIssueComment: async (body) => {
|
|
1124
|
+
const created = { id: 19, body, user: { login: 'github-actions[bot]' } };
|
|
1125
|
+
comments.push(created);
|
|
1126
|
+
return created;
|
|
1127
|
+
},
|
|
1128
|
+
updateIssueComment: async () => { throw new Error('must not update'); },
|
|
1129
|
+
deleteIssueComment: async (id) => {
|
|
1130
|
+
const index = comments.findIndex((comment) => comment.id === id);
|
|
1131
|
+
if (index !== -1)
|
|
1132
|
+
comments.splice(index, 1);
|
|
1133
|
+
},
|
|
1134
|
+
};
|
|
1135
|
+
assert.deepEqual(await syncSummaryComment(api, 'report', summaryHead, summaryScope), {
|
|
1136
|
+
state: 'outdated', retired: 1,
|
|
1137
|
+
});
|
|
1138
|
+
assert.deepEqual(comments, []);
|
|
1139
|
+
});
|
|
1140
|
+
await checkAsync('summary publishing stops after a same-head update when the pull request head changes', async () => {
|
|
1141
|
+
const nextHead = 'b'.repeat(40);
|
|
1142
|
+
let headReads = 0;
|
|
1143
|
+
const oldCandidate = { id: 19, body: renderedSummary('old'), user: { login: 'github-actions[bot]' } };
|
|
1144
|
+
const newCandidate = {
|
|
1145
|
+
id: 20,
|
|
1146
|
+
body: summaryCommentBody('new head', ownedSummaryMarker, nextHead),
|
|
1147
|
+
user: { login: 'github-actions[bot]' },
|
|
1148
|
+
};
|
|
1149
|
+
const comments = [oldCandidate];
|
|
1150
|
+
const api = {
|
|
1151
|
+
headSha: async () => ++headReads < 3 ? summaryHead : nextHead,
|
|
1152
|
+
listIssueComments: async () => [...comments],
|
|
1153
|
+
createIssueComment: async () => { throw new Error('must not create'); },
|
|
1154
|
+
updateIssueComment: async (id, body) => {
|
|
1155
|
+
assert.equal(id, oldCandidate.id);
|
|
1156
|
+
oldCandidate.body = body;
|
|
1157
|
+
comments.push(newCandidate);
|
|
1158
|
+
},
|
|
1159
|
+
deleteIssueComment: async () => { throw new Error('must not delete after the head changes'); },
|
|
1160
|
+
};
|
|
1161
|
+
assert.deepEqual(await syncSummaryComment(api, 'updated old head', summaryHead, summaryScope), {
|
|
1162
|
+
state: 'outdated', retired: 0,
|
|
1163
|
+
});
|
|
1164
|
+
assert.equal(comments[1]?.body, newCandidate.body);
|
|
1165
|
+
});
|
|
1166
|
+
await checkAsync('summary reruns retire only older duplicates with the same scoped marker', async () => {
|
|
1167
|
+
const comments = [
|
|
1168
|
+
{ id: 20, body: renderedSummary('old'), user: { login: 'github-actions[bot]' } },
|
|
1169
|
+
{ id: 21, body: renderedSummary('old'), user: { login: 'github-actions[bot]' } },
|
|
1170
|
+
{ id: 22, body: 'human note', user: { login: 'human' } },
|
|
1171
|
+
];
|
|
1172
|
+
const api = {
|
|
1173
|
+
headSha: async () => summaryHead,
|
|
1174
|
+
listIssueComments: async () => [...comments],
|
|
1175
|
+
createIssueComment: async () => { throw new Error('must not create'); },
|
|
1176
|
+
updateIssueComment: async (id, body) => {
|
|
1177
|
+
const comment = comments.find((candidate) => candidate.id === id);
|
|
1178
|
+
if (comment)
|
|
1179
|
+
comment.body = body;
|
|
1180
|
+
},
|
|
1181
|
+
deleteIssueComment: async (id) => {
|
|
1182
|
+
const index = comments.findIndex((comment) => comment.id === id);
|
|
1183
|
+
if (index !== -1)
|
|
1184
|
+
comments.splice(index, 1);
|
|
1185
|
+
},
|
|
1186
|
+
};
|
|
1187
|
+
assert.deepEqual(await syncSummaryComment(api, 'current', summaryHead, summaryScope), {
|
|
1188
|
+
state: 'updated', commentId: 21, retired: 1,
|
|
1189
|
+
});
|
|
1190
|
+
assert.deepEqual(comments.map((comment) => comment.id), [21, 22]);
|
|
1191
|
+
});
|
|
1192
|
+
await checkAsync('concurrent first summary runs converge on one marked comment', async () => {
|
|
1193
|
+
let releaseInitialReads = () => undefined;
|
|
1194
|
+
const initialReads = new Promise((resolve) => { releaseInitialReads = resolve; });
|
|
1195
|
+
let reads = 0;
|
|
1196
|
+
let nextId = 20;
|
|
1197
|
+
const comments = [];
|
|
1198
|
+
const api = {
|
|
1199
|
+
headSha: async () => summaryHead,
|
|
1200
|
+
listIssueComments: async () => {
|
|
1201
|
+
if (reads < 2) {
|
|
1202
|
+
const snapshot = [...comments];
|
|
1203
|
+
reads++;
|
|
1204
|
+
if (reads === 2)
|
|
1205
|
+
releaseInitialReads();
|
|
1206
|
+
await initialReads;
|
|
1207
|
+
return snapshot;
|
|
1208
|
+
}
|
|
1209
|
+
return [...comments];
|
|
1210
|
+
},
|
|
1211
|
+
createIssueComment: async (body) => {
|
|
1212
|
+
const created = { id: nextId++, body, user: { login: 'github-actions[bot]' } };
|
|
1213
|
+
comments.push(created);
|
|
1214
|
+
return created;
|
|
1215
|
+
},
|
|
1216
|
+
updateIssueComment: async () => { throw new Error('must not update'); },
|
|
1217
|
+
deleteIssueComment: async (id) => {
|
|
1218
|
+
const index = comments.findIndex((comment) => comment.id === id);
|
|
1219
|
+
if (index !== -1)
|
|
1220
|
+
comments.splice(index, 1);
|
|
1221
|
+
},
|
|
1222
|
+
};
|
|
1223
|
+
await Promise.all([
|
|
1224
|
+
syncSummaryComment(api, '## PowerShot\n\nCurrent', summaryHead, summaryScope),
|
|
1225
|
+
syncSummaryComment(api, '## PowerShot\n\nCurrent', summaryHead, summaryScope),
|
|
1226
|
+
]);
|
|
1227
|
+
assert.equal(comments.filter((comment) => comment.body.startsWith(ownedSummaryMarker)).length, 1);
|
|
1228
|
+
});
|
|
1229
|
+
await checkAsync('different workflows replace a shared legacy summary without cross-scope PATCH', async () => {
|
|
1230
|
+
let releaseInitialReads = () => undefined;
|
|
1231
|
+
const initialReads = new Promise((resolve) => { releaseInitialReads = resolve; });
|
|
1232
|
+
let reads = 0;
|
|
1233
|
+
let nextId = 31;
|
|
1234
|
+
const legacy = { id: 30, body: '## PowerShot\n\nLegacy', user: { login: 'github-actions[bot]' } };
|
|
1235
|
+
const comments = [legacy];
|
|
1236
|
+
const api = {
|
|
1237
|
+
headSha: async () => summaryHead,
|
|
1238
|
+
listIssueComments: async () => {
|
|
1239
|
+
if (reads < 2) {
|
|
1240
|
+
const snapshot = [...comments];
|
|
1241
|
+
reads++;
|
|
1242
|
+
if (reads === 2)
|
|
1243
|
+
releaseInitialReads();
|
|
1244
|
+
await initialReads;
|
|
1245
|
+
return snapshot;
|
|
1246
|
+
}
|
|
1247
|
+
return [...comments];
|
|
1248
|
+
},
|
|
1249
|
+
createIssueComment: async (body) => {
|
|
1250
|
+
const created = { id: nextId++, body, user: { login: 'github-actions[bot]' } };
|
|
1251
|
+
comments.push(created);
|
|
1252
|
+
return created;
|
|
1253
|
+
},
|
|
1254
|
+
updateIssueComment: async () => { throw new Error('legacy ownership must not use PATCH'); },
|
|
1255
|
+
deleteIssueComment: async (id) => {
|
|
1256
|
+
const index = comments.findIndex((comment) => comment.id === id);
|
|
1257
|
+
if (index !== -1)
|
|
1258
|
+
comments.splice(index, 1);
|
|
1259
|
+
},
|
|
1260
|
+
};
|
|
1261
|
+
const auditScope = 'xcrft/powershot/.github/workflows/audit.yml:audit';
|
|
1262
|
+
await Promise.all([
|
|
1263
|
+
syncSummaryComment(api, 'review', summaryHead, summaryScope),
|
|
1264
|
+
syncSummaryComment(api, 'audit', summaryHead, auditScope),
|
|
1265
|
+
]);
|
|
1266
|
+
assert.equal(comments.some((comment) => comment.id === legacy.id), false);
|
|
1267
|
+
assert.equal(comments.filter((comment) => comment.body.startsWith(summaryMarker(summaryScope))).length, 1);
|
|
1268
|
+
assert.equal(comments.filter((comment) => comment.body.startsWith(summaryMarker(auditScope))).length, 1);
|
|
1269
|
+
});
|
|
727
1270
|
check('GitHub patches expose only added right-side lines for inline comments', () => {
|
|
728
1271
|
const patch = [
|
|
729
1272
|
'@@ -1,3 +1,4 @@',
|
|
@@ -842,10 +1385,20 @@ await checkAsync('the GitHub client paginates files, submits the review contract
|
|
|
842
1385
|
{ id: 10, path: 'a.ts', line: 1, body: 'reply', in_reply_to_id: 9, user: { login: 'human' } },
|
|
843
1386
|
]);
|
|
844
1387
|
}
|
|
1388
|
+
if (method === 'GET' && url.includes('/issues/7/comments')) {
|
|
1389
|
+
return json([{ id: 21, body: 'summary', user: { login: 'github-actions[bot]' } }]);
|
|
1390
|
+
}
|
|
845
1391
|
if (method === 'POST' && url.endsWith('/pulls/7/reviews'))
|
|
846
1392
|
return json({ id: 1 });
|
|
1393
|
+
if (method === 'POST' && url.endsWith('/issues/7/comments')) {
|
|
1394
|
+
return json({ id: 22, body: 'new summary', user: { login: 'github-actions[bot]' } }, { status: 201 });
|
|
1395
|
+
}
|
|
1396
|
+
if (method === 'PATCH' && url.endsWith('/issues/comments/21'))
|
|
1397
|
+
return json({ id: 21 });
|
|
847
1398
|
if (method === 'DELETE' && url.endsWith('/pulls/comments/9'))
|
|
848
1399
|
return json({ message: 'gone' }, { status: 404 });
|
|
1400
|
+
if (method === 'DELETE' && url.endsWith('/issues/comments/21'))
|
|
1401
|
+
return new Response(undefined, { status: 204 });
|
|
849
1402
|
return json({ message: 'unexpected request' }, { status: 500 });
|
|
850
1403
|
};
|
|
851
1404
|
globalThis.fetch = fakeFetch;
|
|
@@ -861,11 +1414,19 @@ await checkAsync('the GitHub client paginates files, submits the review contract
|
|
|
861
1414
|
assert.equal(comments[1]?.inReplyToId, 9);
|
|
862
1415
|
await api.createReview('a'.repeat(40), [{ path: 'a.ts', line: 1, side: 'RIGHT', body: 'finding' }]);
|
|
863
1416
|
await api.deleteReviewComment(9);
|
|
1417
|
+
assert.deepEqual(await api.listIssueComments(), [
|
|
1418
|
+
{ id: 21, body: 'summary', user: { login: 'github-actions[bot]' } },
|
|
1419
|
+
]);
|
|
1420
|
+
await api.updateIssueComment(21, 'updated summary');
|
|
1421
|
+
assert.deepEqual(await api.createIssueComment('new summary'), {
|
|
1422
|
+
id: 22, body: 'new summary', user: { login: 'github-actions[bot]' },
|
|
1423
|
+
});
|
|
1424
|
+
await api.deleteIssueComment(21);
|
|
864
1425
|
}
|
|
865
1426
|
finally {
|
|
866
1427
|
globalThis.fetch = originalFetch;
|
|
867
1428
|
}
|
|
868
|
-
const submitted = calls.find((call) => call.method === 'POST');
|
|
1429
|
+
const submitted = calls.find((call) => call.method === 'POST' && call.url.endsWith('/pulls/7/reviews'));
|
|
869
1430
|
assert.ok(submitted?.body);
|
|
870
1431
|
assert.deepEqual(JSON.parse(submitted.body), {
|
|
871
1432
|
commit_id: 'a'.repeat(40),
|
|
@@ -874,6 +1435,9 @@ await checkAsync('the GitHub client paginates files, submits the review contract
|
|
|
874
1435
|
comments: [{ path: 'a.ts', line: 1, side: 'RIGHT', body: 'finding' }],
|
|
875
1436
|
});
|
|
876
1437
|
assert.equal(calls.filter((call) => call.url.includes('/files')).length, 2);
|
|
1438
|
+
assert.equal(calls.some((call) => call.method === 'PATCH' && call.url.endsWith('/issues/comments/21') && call.body === '{"body":"updated summary"}'), true);
|
|
1439
|
+
assert.equal(calls.some((call) => call.method === 'POST' && call.url.endsWith('/issues/7/comments') && call.body === '{"body":"new summary"}'), true);
|
|
1440
|
+
assert.equal(calls.some((call) => call.method === 'DELETE' && call.url.endsWith('/issues/comments/21')), true);
|
|
877
1441
|
});
|
|
878
1442
|
await checkAsync('inline synchronization creates one review before removing stale comments', async () => {
|
|
879
1443
|
const finding = {
|
|
@@ -976,6 +1540,11 @@ check('the public action persists judge answers and publishes only a verdict', (
|
|
|
976
1540
|
assert.match(action, /inline-comments:\s*\n\s+description: [^\n]+\n\s+default: 'false'/);
|
|
977
1541
|
assert.match(action, /Post inline comments[\s\S]+inputs\.inline-comments == 'true'[\s\S]+steps\.review\.outputs\.complete == 'true'/);
|
|
978
1542
|
assert.match(action, /node "\$GITHUB_ACTION_PATH\/dist\/github\/inline-comments\.js"/);
|
|
1543
|
+
assert.doesNotMatch(action, /--edit-last/);
|
|
1544
|
+
assert.match(action, /node "\$GITHUB_ACTION_PATH\/dist\/github\/summary-comment\.js"/);
|
|
1545
|
+
assert.match(action, /POWERSHOT_HEAD_SHA: \$\{\{ github\.event\.pull_request\.head\.sha \}\}/);
|
|
1546
|
+
assert.match(action, /GITHUB_WORKFLOW_REF: \$\{\{ github\.workflow_ref \}\}/);
|
|
1547
|
+
assert.match(action, /GITHUB_JOB: \$\{\{ github\.job \}\}/);
|
|
979
1548
|
assert.match(action, /--report manifest=powershot\.manifest\.json/);
|
|
980
1549
|
assert.match(action, /coverage=\$COVERAGE/);
|
|
981
1550
|
assert.match(action, /m\.coverage === "full" \|\| m\.coverage === "portable" \? m\.coverage : "unknown"/);
|
|
@@ -988,6 +1557,7 @@ check('published CI examples preserve one verdict and its exit status', () => {
|
|
|
988
1557
|
assert.match(action, /upload-sarif: 'true'/);
|
|
989
1558
|
assert.match(action, /inline-comments: 'true'/);
|
|
990
1559
|
assert.match(action, /runs-on: ubuntu-24\.04/);
|
|
1560
|
+
assert.match(action, /concurrency:[\s\S]+github\.workflow[\s\S]+cancel-in-progress: true/);
|
|
991
1561
|
assert.doesNotMatch(action, /npm ci|NPM_AUTH_TOKEN|NODE_AUTH_TOKEN/);
|
|
992
1562
|
assert.match(action, /uses: xcrft\/powershot@v1/);
|
|
993
1563
|
assert.match(github, /npm install --global --ignore-scripts @0xcraft\/powershot@1\.1\.2/);
|
|
@@ -1019,10 +1589,12 @@ check('the viewer labels a complete portable session', () => {
|
|
|
1019
1589
|
const html = viewer([], {
|
|
1020
1590
|
id: 'portable', target: 'workspace', started: '2026-01-01T10:00:00Z',
|
|
1021
1591
|
state: 'complete', notLookedAt: [], coverage: 'portable',
|
|
1022
|
-
|
|
1592
|
+
verifyOnly: true, minSeverity: 'medium', filesReviewed: 1, deterministicChecks: 2,
|
|
1593
|
+
scopeDetails: ['1 reviewed file lacked type information'],
|
|
1023
1594
|
});
|
|
1024
|
-
assert.match(html, /
|
|
1025
|
-
assert.match(html, /No
|
|
1595
|
+
assert.match(html, /1 file reviewed · 2 deterministic checks · portable coverage/);
|
|
1596
|
+
assert.match(html, /No medium-or-higher deterministic findings\./);
|
|
1597
|
+
assert.match(html, /<summary>Coverage details<\/summary>/);
|
|
1026
1598
|
});
|
|
1027
1599
|
check('the viewer escapes content rather than rendering it', () => {
|
|
1028
1600
|
const nasty = [{ ...sample[0], title: '<img src=x onerror=alert(1)>' }];
|