@0xcraft/powershot 1.1.2 → 1.1.3
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 +3 -2
- 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/manifest.js +0 -17
- package/dist/package-smoke.js +4 -0
- package/dist/report/markdown.js +28 -16
- 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 +431 -18
- package/dist/session.js +6 -2
- package/docs/architecture.md +1 -0
- package/docs/ci.md +25 -0
- package/examples/github-actions/action.yml +4 -0
- package/package.json +1 -1
package/dist/selftest.js
CHANGED
|
@@ -48,12 +48,15 @@ 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';
|
|
54
55
|
import { incompleteReasons } from './bench.js';
|
|
55
56
|
import { PACKAGE_NAME, PACKAGE_VERSION } from './package-meta.js';
|
|
56
|
-
import { addedLinesFromPatch,
|
|
57
|
+
import { addedLinesFromPatch, inlineMarker, parseReviewFindings, reconcileInlineComments, selectInlineComments, syncInlineComments, } from './github/inline-comments.js';
|
|
58
|
+
import { createReviewPayload, GitHubPullRequestApi, } from './github/api.js';
|
|
59
|
+
import { LEGACY_SUMMARY_MARKER, summaryCommentBody, summaryMarker, syncSummaryComment, workflowCommentScope, } from './github/summary-comment.js';
|
|
57
60
|
const root = '/repo';
|
|
58
61
|
/** Build a Ground by hand so verifiers are testable without git or a real repo. */
|
|
59
62
|
function ground(files, deps = []) {
|
|
@@ -584,30 +587,64 @@ check('a review that did not complete never renders as clean', () => {
|
|
|
584
587
|
});
|
|
585
588
|
assert.match(partial, /partial, not a verdict/);
|
|
586
589
|
assert.match(partial, /outside\.ts \(no types\)/);
|
|
590
|
+
const partialMarkdown = markdown([], {
|
|
591
|
+
state: 'partial', notLookedAt: ['outside.ts (no types)'],
|
|
592
|
+
verifyOnly: true, minSeverity: 'medium', filesReviewed: 4, deterministicChecks: 7,
|
|
593
|
+
});
|
|
594
|
+
assert.match(partialMarkdown, /This review is partial — not a verdict/);
|
|
595
|
+
assert.match(partialMarkdown, /<summary>Why this is not a verdict<\/summary>/);
|
|
596
|
+
assert.match(partialMarkdown, /4 files reviewed · 7 deterministic checks/);
|
|
597
|
+
assert.doesNotMatch(partialMarkdown.slice(0, partialMarkdown.indexOf('<details>')), /outside\\?\.ts/);
|
|
598
|
+
assert.match(partialMarkdown.replace(/\\/g, ''), /outside\.ts \(no types\)/);
|
|
587
599
|
});
|
|
588
600
|
check('portable coverage is a verdict, but never masquerades as full semantic coverage', () => {
|
|
589
601
|
const unavailable = [
|
|
590
|
-
'1 file
|
|
591
|
-
'2
|
|
602
|
+
'1 reviewed file lacked type information and a reference graph',
|
|
603
|
+
'2 checks requiring type information or a reference graph did not run: phantom-api, contract-drift',
|
|
592
604
|
];
|
|
593
605
|
const out = terminal([], {
|
|
594
606
|
subtitle: 'workspace', verified: 0, judged: 0, state: 'complete', notLookedAt: [],
|
|
595
|
-
coverage: 'portable',
|
|
607
|
+
coverage: 'portable', verifyOnly: true, minSeverity: 'medium',
|
|
608
|
+
filesReviewed: 1, deterministicChecks: 1, scopeDetails: unavailable,
|
|
596
609
|
});
|
|
597
|
-
assert.match(out, /No
|
|
598
|
-
assert.match(out, /
|
|
610
|
+
assert.match(out, /No medium-or-higher deterministic findings\./);
|
|
611
|
+
assert.match(out, /1 file reviewed · 1 deterministic check · portable coverage/);
|
|
612
|
+
assert.match(out, /1 reviewed file lacked type information and a reference graph/);
|
|
599
613
|
assert.doesNotMatch(out, /not a verdict/);
|
|
600
|
-
const
|
|
614
|
+
const selected = Array.from({ length: 20 }, (_, index) => ({
|
|
615
|
+
path: 'web/file-' + index + '.ts',
|
|
616
|
+
disposition: 'selected',
|
|
617
|
+
unavailable: index < 11 ? ['types', 'references'] : undefined,
|
|
618
|
+
}));
|
|
619
|
+
const waived = Array.from({ length: 16 }, (_, index) => ({
|
|
620
|
+
path: 'assets/file-' + index + '.json',
|
|
621
|
+
disposition: 'waived',
|
|
622
|
+
reason: 'no parser',
|
|
623
|
+
}));
|
|
624
|
+
const md = markdown([], summarizeRun({
|
|
601
625
|
state: 'complete', notLookedAt: [], coverage: 'portable',
|
|
602
|
-
|
|
603
|
-
|
|
626
|
+
engine: { verifyOnly: true, minSeverity: 'medium' },
|
|
627
|
+
files: [...selected, ...waived],
|
|
628
|
+
checks: {
|
|
629
|
+
ran: Array.from({ length: 19 }, (_, index) => 'check-' + index),
|
|
630
|
+
unavailable: [
|
|
604
631
|
{ check: 'phantom-api', missing: 'types' },
|
|
605
632
|
{ check: 'contract-drift', missing: 'references' },
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
assert.
|
|
633
|
+
{ check: 'dead-on-arrival', missing: 'references' },
|
|
634
|
+
],
|
|
635
|
+
},
|
|
636
|
+
}));
|
|
637
|
+
assert.match(md, /✅ \*\*No medium-or-higher deterministic findings\*\*/);
|
|
638
|
+
assert.match(md, /20 files reviewed · 19 deterministic checks · portable coverage/);
|
|
639
|
+
assert.match(md, /Model review was disabled \(`verify-only`\)\./);
|
|
640
|
+
assert.match(md, /<summary>Coverage details<\/summary>/);
|
|
641
|
+
const rendered = md.replace(/\\/g, '');
|
|
642
|
+
assert.match(rendered, /11 reviewed files? lacked type information and a reference graph/);
|
|
643
|
+
assert.match(rendered, /3 checks? requiring type information or a reference graph did not run: phantom-api, contract-drift, dead-on-arrival/);
|
|
644
|
+
assert.match(rendered, /16 changed files? not reviewed: no parser/);
|
|
645
|
+
assert.ok(md.indexOf('No medium-or-higher deterministic findings') < md.indexOf('Coverage details'));
|
|
646
|
+
assert.doesNotMatch(md, /web\/file-|assets\/file-/);
|
|
647
|
+
assert.doesNotMatch(md, /No findings in portable coverage\./);
|
|
611
648
|
});
|
|
612
649
|
check('findings are still shown when a stage failed, with the warning kept', () => {
|
|
613
650
|
const f = { id: 'F1', class: 'verified', check: 'phantom-dep', severity: 'high',
|
|
@@ -724,6 +761,353 @@ const sample = [
|
|
|
724
761
|
{ id: 'F2', class: 'judged', check: 'plausible-logic', severity: 'low', confidence: 'tentative',
|
|
725
762
|
file: 'src/b.ts', line: 9, title: 'off by one' },
|
|
726
763
|
];
|
|
764
|
+
const summaryHead = 'a'.repeat(40);
|
|
765
|
+
const summaryScope = 'xcrft/powershot/.github/workflows/review.yml:review';
|
|
766
|
+
const ownedSummaryMarker = summaryMarker(summaryScope);
|
|
767
|
+
const renderedSummary = (markdown) => summaryCommentBody(markdown, ownedSummaryMarker, summaryHead);
|
|
768
|
+
check('summary scope follows the workflow file and job, not its moving ref', () => {
|
|
769
|
+
assert.equal(workflowCommentScope('xcrft/powershot/.github/workflows/review.yml@refs/pull/6/merge', 'review'), summaryScope);
|
|
770
|
+
assert.equal(workflowCommentScope('xcrft/powershot/.github/workflows/review.yml@refs/heads/release@v1', 'review'), summaryScope);
|
|
771
|
+
});
|
|
772
|
+
await checkAsync('summary publishing creates a marked comment without touching another workflow', async () => {
|
|
773
|
+
const events = [];
|
|
774
|
+
const comments = [
|
|
775
|
+
{ id: 8, body: '## PR Analysis', user: { login: 'github-actions[bot]' } },
|
|
776
|
+
{ id: 9, body: ownedSummaryMarker, user: { login: 'human' } },
|
|
777
|
+
];
|
|
778
|
+
const api = {
|
|
779
|
+
headSha: async () => summaryHead,
|
|
780
|
+
listIssueComments: async () => [...comments],
|
|
781
|
+
createIssueComment: async (body) => {
|
|
782
|
+
events.push(`create:${body}`);
|
|
783
|
+
const created = { id: 10, body, user: { login: 'github-actions[bot]' } };
|
|
784
|
+
comments.push(created);
|
|
785
|
+
return created;
|
|
786
|
+
},
|
|
787
|
+
updateIssueComment: async (id) => { events.push(`update:${id}`); },
|
|
788
|
+
deleteIssueComment: async (id) => { events.push(`delete:${id}`); },
|
|
789
|
+
};
|
|
790
|
+
const result = await syncSummaryComment(api, '## PowerShot\n\nNo findings.\n', summaryHead, summaryScope);
|
|
791
|
+
assert.deepEqual(result, { state: 'created', commentId: 10, retired: 0 });
|
|
792
|
+
assert.deepEqual(events, [`create:${renderedSummary('## PowerShot\n\nNo findings.')}`]);
|
|
793
|
+
});
|
|
794
|
+
await checkAsync('summary reruns update the marked PowerShot comment, not the latest bot comment', async () => {
|
|
795
|
+
const events = [];
|
|
796
|
+
const api = {
|
|
797
|
+
headSha: async () => summaryHead,
|
|
798
|
+
listIssueComments: async () => [
|
|
799
|
+
{ id: 10, body: renderedSummary('## PowerShot\n\nOld'), user: { login: 'github-actions[bot]' } },
|
|
800
|
+
{ id: 11, body: '## Best Practices', user: { login: 'github-actions[bot]' } },
|
|
801
|
+
],
|
|
802
|
+
createIssueComment: async () => { throw new Error('must not create'); },
|
|
803
|
+
updateIssueComment: async (id, body) => { events.push(`update:${id}:${body}`); },
|
|
804
|
+
deleteIssueComment: async (id) => { events.push(`delete:${id}`); },
|
|
805
|
+
};
|
|
806
|
+
const markdown = '## PowerShot\n\nNew';
|
|
807
|
+
const result = await syncSummaryComment(api, markdown, summaryHead, summaryScope);
|
|
808
|
+
assert.deepEqual(result, { state: 'updated', commentId: 10, retired: 0 });
|
|
809
|
+
assert.deepEqual(events, [`update:10:${renderedSummary(markdown)}`]);
|
|
810
|
+
});
|
|
811
|
+
await checkAsync('summary reruns make no write when the marked body is current', async () => {
|
|
812
|
+
const body = renderedSummary('## PowerShot\n\nCurrent');
|
|
813
|
+
const api = {
|
|
814
|
+
headSha: async () => summaryHead,
|
|
815
|
+
listIssueComments: async () => [
|
|
816
|
+
{ id: 10, body, user: { login: 'github-actions[bot]' } },
|
|
817
|
+
{ id: 11, body: '## Best Practices', user: { login: 'github-actions[bot]' } },
|
|
818
|
+
],
|
|
819
|
+
createIssueComment: async () => { throw new Error('must not create'); },
|
|
820
|
+
updateIssueComment: async () => { throw new Error('must not update'); },
|
|
821
|
+
deleteIssueComment: async () => { throw new Error('must not delete'); },
|
|
822
|
+
};
|
|
823
|
+
assert.deepEqual(await syncSummaryComment(api, '## PowerShot\n\nCurrent', summaryHead, summaryScope), {
|
|
824
|
+
state: 'unchanged', commentId: 10, retired: 0,
|
|
825
|
+
});
|
|
826
|
+
});
|
|
827
|
+
await checkAsync('summary publishing migrates the newest legacy PowerShot body once', async () => {
|
|
828
|
+
const events = [];
|
|
829
|
+
const comments = [
|
|
830
|
+
{ id: 12, body: LEGACY_SUMMARY_MARKER, user: { login: 'github-actions[bot]' } },
|
|
831
|
+
{ id: 14, body: '## PowerShot\n\nLatest', user: { login: 'github-actions[bot]' } },
|
|
832
|
+
{ id: 15, body: '## PR Analysis', user: { login: 'github-actions[bot]' } },
|
|
833
|
+
];
|
|
834
|
+
const api = {
|
|
835
|
+
headSha: async () => summaryHead,
|
|
836
|
+
listIssueComments: async () => [...comments],
|
|
837
|
+
createIssueComment: async (body) => {
|
|
838
|
+
events.push('create');
|
|
839
|
+
const created = { id: 16, body, user: { login: 'github-actions[bot]' } };
|
|
840
|
+
comments.push(created);
|
|
841
|
+
return created;
|
|
842
|
+
},
|
|
843
|
+
updateIssueComment: async () => { throw new Error('legacy comments must not be claimed with PATCH'); },
|
|
844
|
+
deleteIssueComment: async (id) => {
|
|
845
|
+
events.push(`delete:${id}`);
|
|
846
|
+
const index = comments.findIndex((comment) => comment.id === id);
|
|
847
|
+
if (index !== -1)
|
|
848
|
+
comments.splice(index, 1);
|
|
849
|
+
},
|
|
850
|
+
};
|
|
851
|
+
const result = await syncSummaryComment(api, '## PowerShot\n\nCurrent', summaryHead, summaryScope);
|
|
852
|
+
assert.deepEqual(result, { state: 'migrated', commentId: 16, retired: 1 });
|
|
853
|
+
assert.deepEqual(events, ['create', 'delete:14']);
|
|
854
|
+
assert.equal(comments.some((comment) => comment.id === 12), true);
|
|
855
|
+
});
|
|
856
|
+
await checkAsync('summary publishing never patches a candidate from another pull request head', async () => {
|
|
857
|
+
const previousHead = '9'.repeat(40);
|
|
858
|
+
const comments = [
|
|
859
|
+
{ id: 16, body: summaryCommentBody('old head', ownedSummaryMarker, previousHead), user: { login: 'github-actions[bot]' } },
|
|
860
|
+
];
|
|
861
|
+
const events = [];
|
|
862
|
+
const api = {
|
|
863
|
+
headSha: async () => summaryHead,
|
|
864
|
+
listIssueComments: async () => [...comments],
|
|
865
|
+
createIssueComment: async (body) => {
|
|
866
|
+
events.push('create');
|
|
867
|
+
const created = { id: 17, body, user: { login: 'github-actions[bot]' } };
|
|
868
|
+
comments.push(created);
|
|
869
|
+
return created;
|
|
870
|
+
},
|
|
871
|
+
updateIssueComment: async () => { throw new Error('must not patch a different-head candidate'); },
|
|
872
|
+
deleteIssueComment: async (id) => {
|
|
873
|
+
events.push(`delete:${id}`);
|
|
874
|
+
const index = comments.findIndex((comment) => comment.id === id);
|
|
875
|
+
if (index !== -1)
|
|
876
|
+
comments.splice(index, 1);
|
|
877
|
+
},
|
|
878
|
+
};
|
|
879
|
+
assert.deepEqual(await syncSummaryComment(api, 'current head', summaryHead, summaryScope), {
|
|
880
|
+
state: 'created', commentId: 17, retired: 1,
|
|
881
|
+
});
|
|
882
|
+
assert.deepEqual(events, ['create', 'delete:16']);
|
|
883
|
+
assert.deepEqual(comments.map((comment) => comment.id), [17]);
|
|
884
|
+
});
|
|
885
|
+
await checkAsync('summary ownership requires the marker at the start of the bot comment', async () => {
|
|
886
|
+
const events = [];
|
|
887
|
+
const comments = [{
|
|
888
|
+
id: 16,
|
|
889
|
+
body: `## PR Analysis\n\nQuoted output: ${ownedSummaryMarker}`,
|
|
890
|
+
user: { login: 'github-actions[bot]' },
|
|
891
|
+
}];
|
|
892
|
+
const api = {
|
|
893
|
+
headSha: async () => summaryHead,
|
|
894
|
+
listIssueComments: async () => [...comments],
|
|
895
|
+
createIssueComment: async (body) => {
|
|
896
|
+
events.push('create');
|
|
897
|
+
const created = { id: 17, body, user: { login: 'github-actions[bot]' } };
|
|
898
|
+
comments.push(created);
|
|
899
|
+
return created;
|
|
900
|
+
},
|
|
901
|
+
updateIssueComment: async (id) => { events.push(`update:${id}`); },
|
|
902
|
+
deleteIssueComment: async (id) => { events.push(`delete:${id}`); },
|
|
903
|
+
};
|
|
904
|
+
assert.deepEqual(await syncSummaryComment(api, '## PowerShot\n\nCurrent', summaryHead, summaryScope), {
|
|
905
|
+
state: 'created', commentId: 17, retired: 0,
|
|
906
|
+
});
|
|
907
|
+
assert.deepEqual(events, ['create']);
|
|
908
|
+
});
|
|
909
|
+
await checkAsync('summary markers isolate different workflow jobs using the same bot', async () => {
|
|
910
|
+
const otherMarker = summaryMarker('xcrft/powershot/.github/workflows/audit.yml:audit');
|
|
911
|
+
const comments = [
|
|
912
|
+
{
|
|
913
|
+
id: 18,
|
|
914
|
+
body: summaryCommentBody('## PowerShot\n\nAudit', otherMarker, summaryHead),
|
|
915
|
+
user: { login: 'github-actions[bot]' },
|
|
916
|
+
},
|
|
917
|
+
];
|
|
918
|
+
const api = {
|
|
919
|
+
headSha: async () => summaryHead,
|
|
920
|
+
listIssueComments: async () => [...comments],
|
|
921
|
+
createIssueComment: async (body) => {
|
|
922
|
+
const created = { id: 19, body, user: { login: 'github-actions[bot]' } };
|
|
923
|
+
comments.push(created);
|
|
924
|
+
return created;
|
|
925
|
+
},
|
|
926
|
+
updateIssueComment: async () => { throw new Error('must not update another workflow'); },
|
|
927
|
+
deleteIssueComment: async () => { throw new Error('must not delete another workflow'); },
|
|
928
|
+
};
|
|
929
|
+
const result = await syncSummaryComment(api, '## PowerShot\n\nReview', summaryHead, summaryScope);
|
|
930
|
+
assert.deepEqual(result, { state: 'created', commentId: 19, retired: 0 });
|
|
931
|
+
assert.equal(comments[0]?.body.includes('Audit'), true);
|
|
932
|
+
});
|
|
933
|
+
await checkAsync('summary publishing makes no writes for an outdated pull request head', async () => {
|
|
934
|
+
const api = {
|
|
935
|
+
headSha: async () => 'b'.repeat(40),
|
|
936
|
+
listIssueComments: async () => { throw new Error('must not list'); },
|
|
937
|
+
createIssueComment: async () => { throw new Error('must not create'); },
|
|
938
|
+
updateIssueComment: async () => { throw new Error('must not update'); },
|
|
939
|
+
deleteIssueComment: async () => { throw new Error('must not delete'); },
|
|
940
|
+
};
|
|
941
|
+
assert.deepEqual(await syncSummaryComment(api, 'report', summaryHead, summaryScope), {
|
|
942
|
+
state: 'outdated', retired: 0,
|
|
943
|
+
});
|
|
944
|
+
});
|
|
945
|
+
await checkAsync('summary publishing makes no writes when the head changes during reads', async () => {
|
|
946
|
+
let headReads = 0;
|
|
947
|
+
const api = {
|
|
948
|
+
headSha: async () => ++headReads === 1 ? summaryHead : 'b'.repeat(40),
|
|
949
|
+
listIssueComments: async () => [],
|
|
950
|
+
createIssueComment: async () => { throw new Error('must not create'); },
|
|
951
|
+
updateIssueComment: async () => { throw new Error('must not update'); },
|
|
952
|
+
deleteIssueComment: async () => { throw new Error('must not delete'); },
|
|
953
|
+
};
|
|
954
|
+
assert.deepEqual(await syncSummaryComment(api, 'report', summaryHead, summaryScope), {
|
|
955
|
+
state: 'outdated', retired: 0,
|
|
956
|
+
});
|
|
957
|
+
});
|
|
958
|
+
await checkAsync('summary publishing retires its own new comment when the head changes after create', async () => {
|
|
959
|
+
let headReads = 0;
|
|
960
|
+
const comments = [];
|
|
961
|
+
const api = {
|
|
962
|
+
headSha: async () => ++headReads < 3 ? summaryHead : 'b'.repeat(40),
|
|
963
|
+
listIssueComments: async () => [...comments],
|
|
964
|
+
createIssueComment: async (body) => {
|
|
965
|
+
const created = { id: 19, body, user: { login: 'github-actions[bot]' } };
|
|
966
|
+
comments.push(created);
|
|
967
|
+
return created;
|
|
968
|
+
},
|
|
969
|
+
updateIssueComment: async () => { throw new Error('must not update'); },
|
|
970
|
+
deleteIssueComment: async (id) => {
|
|
971
|
+
const index = comments.findIndex((comment) => comment.id === id);
|
|
972
|
+
if (index !== -1)
|
|
973
|
+
comments.splice(index, 1);
|
|
974
|
+
},
|
|
975
|
+
};
|
|
976
|
+
assert.deepEqual(await syncSummaryComment(api, 'report', summaryHead, summaryScope), {
|
|
977
|
+
state: 'outdated', retired: 1,
|
|
978
|
+
});
|
|
979
|
+
assert.deepEqual(comments, []);
|
|
980
|
+
});
|
|
981
|
+
await checkAsync('summary publishing stops after a same-head update when the pull request head changes', async () => {
|
|
982
|
+
const nextHead = 'b'.repeat(40);
|
|
983
|
+
let headReads = 0;
|
|
984
|
+
const oldCandidate = { id: 19, body: renderedSummary('old'), user: { login: 'github-actions[bot]' } };
|
|
985
|
+
const newCandidate = {
|
|
986
|
+
id: 20,
|
|
987
|
+
body: summaryCommentBody('new head', ownedSummaryMarker, nextHead),
|
|
988
|
+
user: { login: 'github-actions[bot]' },
|
|
989
|
+
};
|
|
990
|
+
const comments = [oldCandidate];
|
|
991
|
+
const api = {
|
|
992
|
+
headSha: async () => ++headReads < 3 ? summaryHead : nextHead,
|
|
993
|
+
listIssueComments: async () => [...comments],
|
|
994
|
+
createIssueComment: async () => { throw new Error('must not create'); },
|
|
995
|
+
updateIssueComment: async (id, body) => {
|
|
996
|
+
assert.equal(id, oldCandidate.id);
|
|
997
|
+
oldCandidate.body = body;
|
|
998
|
+
comments.push(newCandidate);
|
|
999
|
+
},
|
|
1000
|
+
deleteIssueComment: async () => { throw new Error('must not delete after the head changes'); },
|
|
1001
|
+
};
|
|
1002
|
+
assert.deepEqual(await syncSummaryComment(api, 'updated old head', summaryHead, summaryScope), {
|
|
1003
|
+
state: 'outdated', retired: 0,
|
|
1004
|
+
});
|
|
1005
|
+
assert.equal(comments[1]?.body, newCandidate.body);
|
|
1006
|
+
});
|
|
1007
|
+
await checkAsync('summary reruns retire only older duplicates with the same scoped marker', async () => {
|
|
1008
|
+
const comments = [
|
|
1009
|
+
{ id: 20, body: renderedSummary('old'), user: { login: 'github-actions[bot]' } },
|
|
1010
|
+
{ id: 21, body: renderedSummary('old'), user: { login: 'github-actions[bot]' } },
|
|
1011
|
+
{ id: 22, body: 'human note', user: { login: 'human' } },
|
|
1012
|
+
];
|
|
1013
|
+
const api = {
|
|
1014
|
+
headSha: async () => summaryHead,
|
|
1015
|
+
listIssueComments: async () => [...comments],
|
|
1016
|
+
createIssueComment: async () => { throw new Error('must not create'); },
|
|
1017
|
+
updateIssueComment: async (id, body) => {
|
|
1018
|
+
const comment = comments.find((candidate) => candidate.id === id);
|
|
1019
|
+
if (comment)
|
|
1020
|
+
comment.body = body;
|
|
1021
|
+
},
|
|
1022
|
+
deleteIssueComment: async (id) => {
|
|
1023
|
+
const index = comments.findIndex((comment) => comment.id === id);
|
|
1024
|
+
if (index !== -1)
|
|
1025
|
+
comments.splice(index, 1);
|
|
1026
|
+
},
|
|
1027
|
+
};
|
|
1028
|
+
assert.deepEqual(await syncSummaryComment(api, 'current', summaryHead, summaryScope), {
|
|
1029
|
+
state: 'updated', commentId: 21, retired: 1,
|
|
1030
|
+
});
|
|
1031
|
+
assert.deepEqual(comments.map((comment) => comment.id), [21, 22]);
|
|
1032
|
+
});
|
|
1033
|
+
await checkAsync('concurrent first summary runs converge on one marked comment', async () => {
|
|
1034
|
+
let releaseInitialReads = () => undefined;
|
|
1035
|
+
const initialReads = new Promise((resolve) => { releaseInitialReads = resolve; });
|
|
1036
|
+
let reads = 0;
|
|
1037
|
+
let nextId = 20;
|
|
1038
|
+
const comments = [];
|
|
1039
|
+
const api = {
|
|
1040
|
+
headSha: async () => summaryHead,
|
|
1041
|
+
listIssueComments: async () => {
|
|
1042
|
+
if (reads < 2) {
|
|
1043
|
+
const snapshot = [...comments];
|
|
1044
|
+
reads++;
|
|
1045
|
+
if (reads === 2)
|
|
1046
|
+
releaseInitialReads();
|
|
1047
|
+
await initialReads;
|
|
1048
|
+
return snapshot;
|
|
1049
|
+
}
|
|
1050
|
+
return [...comments];
|
|
1051
|
+
},
|
|
1052
|
+
createIssueComment: async (body) => {
|
|
1053
|
+
const created = { id: nextId++, body, user: { login: 'github-actions[bot]' } };
|
|
1054
|
+
comments.push(created);
|
|
1055
|
+
return created;
|
|
1056
|
+
},
|
|
1057
|
+
updateIssueComment: async () => { throw new Error('must not update'); },
|
|
1058
|
+
deleteIssueComment: async (id) => {
|
|
1059
|
+
const index = comments.findIndex((comment) => comment.id === id);
|
|
1060
|
+
if (index !== -1)
|
|
1061
|
+
comments.splice(index, 1);
|
|
1062
|
+
},
|
|
1063
|
+
};
|
|
1064
|
+
await Promise.all([
|
|
1065
|
+
syncSummaryComment(api, '## PowerShot\n\nCurrent', summaryHead, summaryScope),
|
|
1066
|
+
syncSummaryComment(api, '## PowerShot\n\nCurrent', summaryHead, summaryScope),
|
|
1067
|
+
]);
|
|
1068
|
+
assert.equal(comments.filter((comment) => comment.body.startsWith(ownedSummaryMarker)).length, 1);
|
|
1069
|
+
});
|
|
1070
|
+
await checkAsync('different workflows replace a shared legacy summary without cross-scope PATCH', async () => {
|
|
1071
|
+
let releaseInitialReads = () => undefined;
|
|
1072
|
+
const initialReads = new Promise((resolve) => { releaseInitialReads = resolve; });
|
|
1073
|
+
let reads = 0;
|
|
1074
|
+
let nextId = 31;
|
|
1075
|
+
const legacy = { id: 30, body: '## PowerShot\n\nLegacy', user: { login: 'github-actions[bot]' } };
|
|
1076
|
+
const comments = [legacy];
|
|
1077
|
+
const api = {
|
|
1078
|
+
headSha: async () => summaryHead,
|
|
1079
|
+
listIssueComments: async () => {
|
|
1080
|
+
if (reads < 2) {
|
|
1081
|
+
const snapshot = [...comments];
|
|
1082
|
+
reads++;
|
|
1083
|
+
if (reads === 2)
|
|
1084
|
+
releaseInitialReads();
|
|
1085
|
+
await initialReads;
|
|
1086
|
+
return snapshot;
|
|
1087
|
+
}
|
|
1088
|
+
return [...comments];
|
|
1089
|
+
},
|
|
1090
|
+
createIssueComment: async (body) => {
|
|
1091
|
+
const created = { id: nextId++, body, user: { login: 'github-actions[bot]' } };
|
|
1092
|
+
comments.push(created);
|
|
1093
|
+
return created;
|
|
1094
|
+
},
|
|
1095
|
+
updateIssueComment: async () => { throw new Error('legacy ownership must not use PATCH'); },
|
|
1096
|
+
deleteIssueComment: async (id) => {
|
|
1097
|
+
const index = comments.findIndex((comment) => comment.id === id);
|
|
1098
|
+
if (index !== -1)
|
|
1099
|
+
comments.splice(index, 1);
|
|
1100
|
+
},
|
|
1101
|
+
};
|
|
1102
|
+
const auditScope = 'xcrft/powershot/.github/workflows/audit.yml:audit';
|
|
1103
|
+
await Promise.all([
|
|
1104
|
+
syncSummaryComment(api, 'review', summaryHead, summaryScope),
|
|
1105
|
+
syncSummaryComment(api, 'audit', summaryHead, auditScope),
|
|
1106
|
+
]);
|
|
1107
|
+
assert.equal(comments.some((comment) => comment.id === legacy.id), false);
|
|
1108
|
+
assert.equal(comments.filter((comment) => comment.body.startsWith(summaryMarker(summaryScope))).length, 1);
|
|
1109
|
+
assert.equal(comments.filter((comment) => comment.body.startsWith(summaryMarker(auditScope))).length, 1);
|
|
1110
|
+
});
|
|
727
1111
|
check('GitHub patches expose only added right-side lines for inline comments', () => {
|
|
728
1112
|
const patch = [
|
|
729
1113
|
'@@ -1,3 +1,4 @@',
|
|
@@ -842,10 +1226,20 @@ await checkAsync('the GitHub client paginates files, submits the review contract
|
|
|
842
1226
|
{ id: 10, path: 'a.ts', line: 1, body: 'reply', in_reply_to_id: 9, user: { login: 'human' } },
|
|
843
1227
|
]);
|
|
844
1228
|
}
|
|
1229
|
+
if (method === 'GET' && url.includes('/issues/7/comments')) {
|
|
1230
|
+
return json([{ id: 21, body: 'summary', user: { login: 'github-actions[bot]' } }]);
|
|
1231
|
+
}
|
|
845
1232
|
if (method === 'POST' && url.endsWith('/pulls/7/reviews'))
|
|
846
1233
|
return json({ id: 1 });
|
|
1234
|
+
if (method === 'POST' && url.endsWith('/issues/7/comments')) {
|
|
1235
|
+
return json({ id: 22, body: 'new summary', user: { login: 'github-actions[bot]' } }, { status: 201 });
|
|
1236
|
+
}
|
|
1237
|
+
if (method === 'PATCH' && url.endsWith('/issues/comments/21'))
|
|
1238
|
+
return json({ id: 21 });
|
|
847
1239
|
if (method === 'DELETE' && url.endsWith('/pulls/comments/9'))
|
|
848
1240
|
return json({ message: 'gone' }, { status: 404 });
|
|
1241
|
+
if (method === 'DELETE' && url.endsWith('/issues/comments/21'))
|
|
1242
|
+
return new Response(undefined, { status: 204 });
|
|
849
1243
|
return json({ message: 'unexpected request' }, { status: 500 });
|
|
850
1244
|
};
|
|
851
1245
|
globalThis.fetch = fakeFetch;
|
|
@@ -861,11 +1255,19 @@ await checkAsync('the GitHub client paginates files, submits the review contract
|
|
|
861
1255
|
assert.equal(comments[1]?.inReplyToId, 9);
|
|
862
1256
|
await api.createReview('a'.repeat(40), [{ path: 'a.ts', line: 1, side: 'RIGHT', body: 'finding' }]);
|
|
863
1257
|
await api.deleteReviewComment(9);
|
|
1258
|
+
assert.deepEqual(await api.listIssueComments(), [
|
|
1259
|
+
{ id: 21, body: 'summary', user: { login: 'github-actions[bot]' } },
|
|
1260
|
+
]);
|
|
1261
|
+
await api.updateIssueComment(21, 'updated summary');
|
|
1262
|
+
assert.deepEqual(await api.createIssueComment('new summary'), {
|
|
1263
|
+
id: 22, body: 'new summary', user: { login: 'github-actions[bot]' },
|
|
1264
|
+
});
|
|
1265
|
+
await api.deleteIssueComment(21);
|
|
864
1266
|
}
|
|
865
1267
|
finally {
|
|
866
1268
|
globalThis.fetch = originalFetch;
|
|
867
1269
|
}
|
|
868
|
-
const submitted = calls.find((call) => call.method === 'POST');
|
|
1270
|
+
const submitted = calls.find((call) => call.method === 'POST' && call.url.endsWith('/pulls/7/reviews'));
|
|
869
1271
|
assert.ok(submitted?.body);
|
|
870
1272
|
assert.deepEqual(JSON.parse(submitted.body), {
|
|
871
1273
|
commit_id: 'a'.repeat(40),
|
|
@@ -874,6 +1276,9 @@ await checkAsync('the GitHub client paginates files, submits the review contract
|
|
|
874
1276
|
comments: [{ path: 'a.ts', line: 1, side: 'RIGHT', body: 'finding' }],
|
|
875
1277
|
});
|
|
876
1278
|
assert.equal(calls.filter((call) => call.url.includes('/files')).length, 2);
|
|
1279
|
+
assert.equal(calls.some((call) => call.method === 'PATCH' && call.url.endsWith('/issues/comments/21') && call.body === '{"body":"updated summary"}'), true);
|
|
1280
|
+
assert.equal(calls.some((call) => call.method === 'POST' && call.url.endsWith('/issues/7/comments') && call.body === '{"body":"new summary"}'), true);
|
|
1281
|
+
assert.equal(calls.some((call) => call.method === 'DELETE' && call.url.endsWith('/issues/comments/21')), true);
|
|
877
1282
|
});
|
|
878
1283
|
await checkAsync('inline synchronization creates one review before removing stale comments', async () => {
|
|
879
1284
|
const finding = {
|
|
@@ -976,6 +1381,11 @@ check('the public action persists judge answers and publishes only a verdict', (
|
|
|
976
1381
|
assert.match(action, /inline-comments:\s*\n\s+description: [^\n]+\n\s+default: 'false'/);
|
|
977
1382
|
assert.match(action, /Post inline comments[\s\S]+inputs\.inline-comments == 'true'[\s\S]+steps\.review\.outputs\.complete == 'true'/);
|
|
978
1383
|
assert.match(action, /node "\$GITHUB_ACTION_PATH\/dist\/github\/inline-comments\.js"/);
|
|
1384
|
+
assert.doesNotMatch(action, /--edit-last/);
|
|
1385
|
+
assert.match(action, /node "\$GITHUB_ACTION_PATH\/dist\/github\/summary-comment\.js"/);
|
|
1386
|
+
assert.match(action, /POWERSHOT_HEAD_SHA: \$\{\{ github\.event\.pull_request\.head\.sha \}\}/);
|
|
1387
|
+
assert.match(action, /GITHUB_WORKFLOW_REF: \$\{\{ github\.workflow_ref \}\}/);
|
|
1388
|
+
assert.match(action, /GITHUB_JOB: \$\{\{ github\.job \}\}/);
|
|
979
1389
|
assert.match(action, /--report manifest=powershot\.manifest\.json/);
|
|
980
1390
|
assert.match(action, /coverage=\$COVERAGE/);
|
|
981
1391
|
assert.match(action, /m\.coverage === "full" \|\| m\.coverage === "portable" \? m\.coverage : "unknown"/);
|
|
@@ -988,6 +1398,7 @@ check('published CI examples preserve one verdict and its exit status', () => {
|
|
|
988
1398
|
assert.match(action, /upload-sarif: 'true'/);
|
|
989
1399
|
assert.match(action, /inline-comments: 'true'/);
|
|
990
1400
|
assert.match(action, /runs-on: ubuntu-24\.04/);
|
|
1401
|
+
assert.match(action, /concurrency:[\s\S]+github\.workflow[\s\S]+cancel-in-progress: true/);
|
|
991
1402
|
assert.doesNotMatch(action, /npm ci|NPM_AUTH_TOKEN|NODE_AUTH_TOKEN/);
|
|
992
1403
|
assert.match(action, /uses: xcrft\/powershot@v1/);
|
|
993
1404
|
assert.match(github, /npm install --global --ignore-scripts @0xcraft\/powershot@1\.1\.2/);
|
|
@@ -1019,10 +1430,12 @@ check('the viewer labels a complete portable session', () => {
|
|
|
1019
1430
|
const html = viewer([], {
|
|
1020
1431
|
id: 'portable', target: 'workspace', started: '2026-01-01T10:00:00Z',
|
|
1021
1432
|
state: 'complete', notLookedAt: [], coverage: 'portable',
|
|
1022
|
-
|
|
1433
|
+
verifyOnly: true, minSeverity: 'medium', filesReviewed: 1, deterministicChecks: 2,
|
|
1434
|
+
scopeDetails: ['1 reviewed file lacked type information'],
|
|
1023
1435
|
});
|
|
1024
|
-
assert.match(html, /
|
|
1025
|
-
assert.match(html, /No
|
|
1436
|
+
assert.match(html, /1 file reviewed · 2 deterministic checks · portable coverage/);
|
|
1437
|
+
assert.match(html, /No medium-or-higher deterministic findings\./);
|
|
1438
|
+
assert.match(html, /<summary>Coverage details<\/summary>/);
|
|
1026
1439
|
});
|
|
1027
1440
|
check('the viewer escapes content rather than rendering it', () => {
|
|
1028
1441
|
const nasty = [{ ...sample[0], title: '<img src=x onerror=alert(1)>' }];
|
package/dist/session.js
CHANGED
|
@@ -105,9 +105,13 @@ export class Session {
|
|
|
105
105
|
verified: findings.filter((f) => f.class === 'verified').length,
|
|
106
106
|
judged: findings.filter((f) => f.class === 'judged').length,
|
|
107
107
|
state: verdict.state,
|
|
108
|
-
notLookedAt: verdict.notLookedAt,
|
|
108
|
+
notLookedAt: [...verdict.notLookedAt],
|
|
109
109
|
coverage: verdict.coverage,
|
|
110
|
-
|
|
110
|
+
verifyOnly: verdict.verifyOnly,
|
|
111
|
+
minSeverity: verdict.minSeverity,
|
|
112
|
+
filesReviewed: verdict.filesReviewed,
|
|
113
|
+
deterministicChecks: verdict.deterministicChecks,
|
|
114
|
+
scopeDetails: verdict.scopeDetails ? [...verdict.scopeDetails] : undefined,
|
|
111
115
|
};
|
|
112
116
|
this.save();
|
|
113
117
|
}
|
package/docs/architecture.md
CHANGED
|
@@ -66,6 +66,7 @@ engine. The engine does not depend on a workflow provider or terminal layout.
|
|
|
66
66
|
| `src/judges/` | Prompt data, bounded model loop, tool adapter | Git target selection |
|
|
67
67
|
| `src/lang/` | Language-pack data, isolated parser workers, optional language oracles | Cross-run policy |
|
|
68
68
|
| `src/report/` | Pure output adapters | Re-running or reinterpreting a review |
|
|
69
|
+
| `src/github/` | GitHub REST transport and pull-request publication reconciliation | Review decisions or report rendering |
|
|
69
70
|
| `src/bench.ts` | Historical and labelled evaluation | Production command dispatch |
|
|
70
71
|
| `src/session.ts`, `src/cache.ts` | Reuse of completed judge work | Completion decisions |
|
|
71
72
|
|
package/docs/ci.md
CHANGED
|
@@ -41,6 +41,10 @@ name: PowerShot
|
|
|
41
41
|
on:
|
|
42
42
|
pull_request:
|
|
43
43
|
|
|
44
|
+
concurrency:
|
|
45
|
+
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
|
|
46
|
+
cancel-in-progress: true
|
|
47
|
+
|
|
44
48
|
permissions:
|
|
45
49
|
contents: read
|
|
46
50
|
pull-requests: write
|
|
@@ -77,6 +81,27 @@ and bounded batches, so all declared languages can coexist in one monorepo revie
|
|
|
77
81
|
Set `upload-sarif: 'false'` and omit `security-events: write` when GitHub code scanning
|
|
78
82
|
is unavailable or the workflow should not publish SARIF.
|
|
79
83
|
|
|
84
|
+
`comment: 'true'` maintains one summary through a hidden marker scoped to the caller's
|
|
85
|
+
workflow file and job. Reruns update only that exact `github-actions[bot]` comment, so
|
|
86
|
+
another workflow or job using the same bot identity is left alone. Each candidate also
|
|
87
|
+
records its pull-request head. A new head gets a new candidate, which means an old run
|
|
88
|
+
never patches or retires the current head's comment. The first scoped run replaces the
|
|
89
|
+
newest unmarked legacy `## PowerShot` summary from v1.1.2 or older without claiming the
|
|
90
|
+
ambiguous legacy comment through `PATCH`.
|
|
91
|
+
|
|
92
|
+
The comment leads with the verdict, effective severity threshold, review mode, and
|
|
93
|
+
aggregate file/check counts. Portable gaps and files outside parser coverage stay
|
|
94
|
+
visible under a collapsed coverage section without filling the timeline with paths.
|
|
95
|
+
The generated `powershot.manifest.json` keeps the per-file accounting for workflows
|
|
96
|
+
that want to persist it as an artifact.
|
|
97
|
+
|
|
98
|
+
PowerShot checks the target head throughout reconciliation and removes its own
|
|
99
|
+
just-created candidate if it observes a changed head. Simultaneous same-head runs
|
|
100
|
+
relist and converge on one scoped candidate when they complete. Keep the example's
|
|
101
|
+
`concurrency` block to reduce overlap and canceled stale work. GitHub's issue-comment
|
|
102
|
+
REST API has no atomic create-if-absent operation, and cancellation cannot stop a REST
|
|
103
|
+
request already in flight, so concurrency reduces but cannot eliminate that window.
|
|
104
|
+
|
|
80
105
|
`inline-comments: 'true'` requires `pull-requests: write`. It publishes at most ten
|
|
81
106
|
findings as one review. Only deterministic `verified` findings with `proven`
|
|
82
107
|
confidence, severity `medium` or higher, and a GitHub-confirmed added line qualify.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@0xcraft/powershot",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.3",
|
|
4
4
|
"description": "Oracle-first code review for machine-written code, with deterministic verification and CI-ready reports.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "aglumova <alina.glumova@gmail.com>",
|