@0xcraft/powershot 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -0
- package/dist/github/inline-comments.js +392 -0
- package/dist/package-smoke.js +2 -0
- package/dist/selftest.js +216 -3
- package/docs/ci.md +20 -4
- package/examples/github-actions/action.yml +3 -1
- package/examples/github-actions/cli.yml +1 -1
- package/examples/gitlab/.gitlab-ci.yml +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -214,13 +214,22 @@ The composite action is the shortest setup for GitHub:
|
|
|
214
214
|
- uses: xcrft/powershot@v1
|
|
215
215
|
with:
|
|
216
216
|
verify-only: 'true'
|
|
217
|
+
upload-sarif: 'true'
|
|
217
218
|
comment: 'true'
|
|
219
|
+
inline-comments: 'true'
|
|
218
220
|
fail-on-findings: 'true'
|
|
219
221
|
```
|
|
220
222
|
|
|
221
223
|
`@v1` follows compatible `1.x` releases. Pin the action to a full commit SHA in a
|
|
222
224
|
protected required workflow when immutable dependencies are required.
|
|
223
225
|
|
|
226
|
+
`inline-comments` is opt-in. It posts at most ten `verified` + `proven` findings of
|
|
227
|
+
`medium` severity or higher as one GitHub review, and only when GitHub confirms the
|
|
228
|
+
finding line was added by the pull request. Reruns keep matching bot comments and
|
|
229
|
+
retire stale PowerShot copies that have no replies. Human comments and discussions
|
|
230
|
+
are preserved. Every finding still stays in the full report and, when `comment` is
|
|
231
|
+
enabled, the summary comment.
|
|
232
|
+
|
|
224
233
|
The [CI guide](docs/ci.md) covers exit handling, Git history, one-run/many-report
|
|
225
234
|
artifacts, GitLab Code Quality, local parity, and recommended gate policies.
|
|
226
235
|
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { pathToFileURL } from 'node:url';
|
|
4
|
+
import { stripControl } from '#app/text.js';
|
|
5
|
+
import { SEVERITIES } from '#app/types.js';
|
|
6
|
+
const INLINE_COMMENT_LIMIT = 10;
|
|
7
|
+
const API_VERSION = '2022-11-28';
|
|
8
|
+
const API_TIMEOUT_MS = 20_000;
|
|
9
|
+
const MAX_API_PAGES = 100;
|
|
10
|
+
const BOT_LOGIN = 'github-actions[bot]';
|
|
11
|
+
const MARKER_PATTERN = /<!-- powershot:inline:v1:[a-f0-9]{24} -->/;
|
|
12
|
+
const COMMONMARK_PUNCTUATION = new Set(`!"#$%&'()*+,-./:;<=>?@[\\]^_\`{|}~`);
|
|
13
|
+
export function createReviewPayload(commitId, comments) {
|
|
14
|
+
return {
|
|
15
|
+
commit_id: commitId,
|
|
16
|
+
body: `PowerShot posted ${comments.length} proven verified finding(s) on changed lines.`,
|
|
17
|
+
event: 'COMMENT',
|
|
18
|
+
comments,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
function oneLine(value, limit) {
|
|
22
|
+
return stripControl(value).replace(/\r?\n/g, ' ').slice(0, limit);
|
|
23
|
+
}
|
|
24
|
+
/** Untrusted finding prose rendered as literal CommonMark without mentions or HTML. */
|
|
25
|
+
function literal(value, limit = 1_200) {
|
|
26
|
+
let out = '';
|
|
27
|
+
for (const char of oneLine(value, limit)) {
|
|
28
|
+
if (char === '&')
|
|
29
|
+
out += '&';
|
|
30
|
+
else if (char === '<')
|
|
31
|
+
out += '<';
|
|
32
|
+
else if (char === '>')
|
|
33
|
+
out += '>';
|
|
34
|
+
else if (char === '@')
|
|
35
|
+
out += '@';
|
|
36
|
+
else
|
|
37
|
+
out += COMMONMARK_PUNCTUATION.has(char) ? '\\' + char : char;
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
function code(value) {
|
|
42
|
+
return oneLine(value, 160).replace(/`/g, '');
|
|
43
|
+
}
|
|
44
|
+
/** Stable across reruns; unlike the display id, it does not depend on finding order. */
|
|
45
|
+
export function inlineMarker(finding) {
|
|
46
|
+
const key = JSON.stringify([finding.check, finding.file, finding.line, finding.title]);
|
|
47
|
+
const digest = createHash('sha256').update(key).digest('hex').slice(0, 24);
|
|
48
|
+
return `<!-- powershot:inline:v1:${digest} -->`;
|
|
49
|
+
}
|
|
50
|
+
function inlineBody(finding) {
|
|
51
|
+
const body = [
|
|
52
|
+
`**PowerShot · ${finding.severity.toUpperCase()} · \`${code(finding.check)}\` · verified/proven**`,
|
|
53
|
+
'',
|
|
54
|
+
literal(finding.title),
|
|
55
|
+
];
|
|
56
|
+
if (finding.evidence) {
|
|
57
|
+
body.push('', `> _${literal(finding.evidence.oracle, 240)}_: ${literal(finding.evidence.detail)}`);
|
|
58
|
+
}
|
|
59
|
+
body.push('', inlineMarker(finding));
|
|
60
|
+
return body.join('\n');
|
|
61
|
+
}
|
|
62
|
+
/** Parse right-side line numbers from the unified patch returned by GitHub. */
|
|
63
|
+
export function addedLinesFromPatch(patch) {
|
|
64
|
+
const added = new Set();
|
|
65
|
+
let rightLine;
|
|
66
|
+
for (const raw of patch.split('\n')) {
|
|
67
|
+
const line = raw.endsWith('\r') ? raw.slice(0, -1) : raw;
|
|
68
|
+
const hunk = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line);
|
|
69
|
+
if (hunk) {
|
|
70
|
+
rightLine = Number(hunk[1]);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (rightLine === undefined || line === '\')
|
|
74
|
+
continue;
|
|
75
|
+
if (line.startsWith('+')) {
|
|
76
|
+
added.add(rightLine);
|
|
77
|
+
rightLine++;
|
|
78
|
+
}
|
|
79
|
+
else if (line.startsWith('-')) {
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
else if (line.startsWith(' ')) {
|
|
83
|
+
rightLine++;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return added;
|
|
87
|
+
}
|
|
88
|
+
function compareText(left, right) {
|
|
89
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Select the small, high-confidence subset suitable for review comments.
|
|
93
|
+
*
|
|
94
|
+
* A missing or truncated GitHub patch is deliberately ineligible: the finding is
|
|
95
|
+
* still in the full report, but there is no proven right-side location to attach to.
|
|
96
|
+
*/
|
|
97
|
+
export function selectInlineComments(findings, files, limit = INLINE_COMMENT_LIMIT) {
|
|
98
|
+
const changed = new Map();
|
|
99
|
+
for (const file of files) {
|
|
100
|
+
if (file.patch === undefined)
|
|
101
|
+
continue;
|
|
102
|
+
const lines = changed.get(file.filename) ?? new Set();
|
|
103
|
+
for (const line of addedLinesFromPatch(file.patch))
|
|
104
|
+
lines.add(line);
|
|
105
|
+
changed.set(file.filename, lines);
|
|
106
|
+
}
|
|
107
|
+
const eligible = findings
|
|
108
|
+
.filter((finding) => finding.class === 'verified' &&
|
|
109
|
+
finding.confidence === 'proven' &&
|
|
110
|
+
SEVERITIES.indexOf(finding.severity) >= SEVERITIES.indexOf('medium') &&
|
|
111
|
+
changed.get(finding.file)?.has(finding.line) === true)
|
|
112
|
+
.sort((left, right) => SEVERITIES.indexOf(right.severity) - SEVERITIES.indexOf(left.severity) ||
|
|
113
|
+
compareText(left.file, right.file) ||
|
|
114
|
+
left.line - right.line ||
|
|
115
|
+
compareText(left.check, right.check) ||
|
|
116
|
+
compareText(left.title, right.title));
|
|
117
|
+
const out = [];
|
|
118
|
+
const seen = new Set();
|
|
119
|
+
const requested = Number.isFinite(limit) ? Math.floor(limit) : 0;
|
|
120
|
+
const bounded = Math.min(INLINE_COMMENT_LIMIT, Math.max(0, requested));
|
|
121
|
+
if (bounded === 0)
|
|
122
|
+
return out;
|
|
123
|
+
for (const finding of eligible) {
|
|
124
|
+
const marker = inlineMarker(finding);
|
|
125
|
+
if (seen.has(marker))
|
|
126
|
+
continue;
|
|
127
|
+
seen.add(marker);
|
|
128
|
+
out.push({ path: finding.file, line: finding.line, side: 'RIGHT', body: inlineBody(finding) });
|
|
129
|
+
if (out.length === bounded)
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
function markerIn(body) {
|
|
135
|
+
return MARKER_PATTERN.exec(body)?.[0];
|
|
136
|
+
}
|
|
137
|
+
/** Plan an idempotent rerun without deleting any human-authored comment. */
|
|
138
|
+
export function reconcileInlineComments(desired, existing) {
|
|
139
|
+
const managed = existing.filter((comment) => comment.user?.login === BOT_LOGIN && markerIn(comment.body) !== undefined);
|
|
140
|
+
const repliedTo = new Set(existing.flatMap((comment) => comment.inReplyToId === undefined ? [] : [comment.inReplyToId]));
|
|
141
|
+
const used = new Set();
|
|
142
|
+
const create = [];
|
|
143
|
+
let kept = 0;
|
|
144
|
+
for (const wanted of desired) {
|
|
145
|
+
const marker = markerIn(wanted.body);
|
|
146
|
+
const matches = managed.filter((comment) => !used.has(comment.id) &&
|
|
147
|
+
markerIn(comment.body) === marker &&
|
|
148
|
+
comment.path === wanted.path &&
|
|
149
|
+
comment.line === wanted.line &&
|
|
150
|
+
comment.body === wanted.body);
|
|
151
|
+
const match = matches.find((comment) => repliedTo.has(comment.id)) ?? matches[0];
|
|
152
|
+
if (match) {
|
|
153
|
+
used.add(match.id);
|
|
154
|
+
kept++;
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
create.push(wanted);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
const staleIds = [...new Set(managed
|
|
161
|
+
.filter((comment) => !used.has(comment.id) && !repliedTo.has(comment.id))
|
|
162
|
+
.map((comment) => comment.id))]
|
|
163
|
+
.sort((left, right) => left - right);
|
|
164
|
+
return { create, staleIds, kept };
|
|
165
|
+
}
|
|
166
|
+
/** Create missing comments as one review, then retire superseded bot comments. */
|
|
167
|
+
export async function syncInlineComments(api, findings, expectedHeadSha, limit = INLINE_COMMENT_LIMIT) {
|
|
168
|
+
if (await api.headSha() !== expectedHeadSha) {
|
|
169
|
+
return { outdated: true, desired: 0, created: 0, kept: 0, retired: 0 };
|
|
170
|
+
}
|
|
171
|
+
const [files, existing] = await Promise.all([api.listFiles(), api.listReviewComments()]);
|
|
172
|
+
if (await api.headSha() !== expectedHeadSha) {
|
|
173
|
+
return { outdated: true, desired: 0, created: 0, kept: 0, retired: 0 };
|
|
174
|
+
}
|
|
175
|
+
const desired = selectInlineComments(findings, files, limit);
|
|
176
|
+
const plan = reconcileInlineComments(desired, existing);
|
|
177
|
+
if (plan.create.length > 0)
|
|
178
|
+
await api.createReview(expectedHeadSha, plan.create);
|
|
179
|
+
for (const id of plan.staleIds)
|
|
180
|
+
await api.deleteReviewComment(id);
|
|
181
|
+
return {
|
|
182
|
+
outdated: false,
|
|
183
|
+
desired: desired.length,
|
|
184
|
+
created: plan.create.length,
|
|
185
|
+
kept: plan.kept,
|
|
186
|
+
retired: plan.staleIds.length,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
function record(value) {
|
|
190
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
191
|
+
}
|
|
192
|
+
function requiredString(value, name) {
|
|
193
|
+
if (typeof value !== 'string' || value.length === 0)
|
|
194
|
+
throw new Error(`GitHub API returned no ${name}`);
|
|
195
|
+
return value;
|
|
196
|
+
}
|
|
197
|
+
export function parseReviewFindings(source) {
|
|
198
|
+
const document = JSON.parse(source);
|
|
199
|
+
if (!record(document) || !Array.isArray(document.findings)) {
|
|
200
|
+
throw new Error('powershot.json does not contain a findings array');
|
|
201
|
+
}
|
|
202
|
+
return document.findings.map((value, index) => {
|
|
203
|
+
if (!record(value))
|
|
204
|
+
throw new Error(`powershot.json finding ${index + 1} is not an object`);
|
|
205
|
+
if (typeof value.id !== 'string' ||
|
|
206
|
+
(value.class !== 'verified' && value.class !== 'judged') ||
|
|
207
|
+
typeof value.check !== 'string' ||
|
|
208
|
+
!SEVERITIES.includes(value.severity) ||
|
|
209
|
+
(value.confidence !== 'proven' && value.confidence !== 'firm' && value.confidence !== 'tentative') ||
|
|
210
|
+
typeof value.file !== 'string' ||
|
|
211
|
+
!Number.isSafeInteger(value.line) || Number(value.line) < 1 ||
|
|
212
|
+
typeof value.title !== 'string') {
|
|
213
|
+
throw new Error(`powershot.json finding ${index + 1} has an invalid contract`);
|
|
214
|
+
}
|
|
215
|
+
let evidence;
|
|
216
|
+
if (value.evidence !== undefined) {
|
|
217
|
+
if (!record(value.evidence) || typeof value.evidence.oracle !== 'string' || typeof value.evidence.detail !== 'string') {
|
|
218
|
+
throw new Error(`powershot.json finding ${index + 1} has invalid evidence`);
|
|
219
|
+
}
|
|
220
|
+
evidence = { oracle: value.evidence.oracle, detail: value.evidence.detail };
|
|
221
|
+
}
|
|
222
|
+
return {
|
|
223
|
+
id: value.id,
|
|
224
|
+
class: value.class,
|
|
225
|
+
check: value.check,
|
|
226
|
+
severity: value.severity,
|
|
227
|
+
confidence: value.confidence,
|
|
228
|
+
file: value.file,
|
|
229
|
+
line: Number(value.line),
|
|
230
|
+
title: value.title,
|
|
231
|
+
evidence,
|
|
232
|
+
};
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
function nextLink(value) {
|
|
236
|
+
if (value === null)
|
|
237
|
+
return undefined;
|
|
238
|
+
for (const part of value.split(',')) {
|
|
239
|
+
const match = /^\s*<([^>]+)>;\s*rel="([^"]+)"\s*$/.exec(part);
|
|
240
|
+
if (match?.[2] === 'next')
|
|
241
|
+
return match[1];
|
|
242
|
+
}
|
|
243
|
+
return undefined;
|
|
244
|
+
}
|
|
245
|
+
function errorDetail(value) {
|
|
246
|
+
return oneLine(value, 500);
|
|
247
|
+
}
|
|
248
|
+
export class GitHubPullRequestApi {
|
|
249
|
+
token;
|
|
250
|
+
pullNumber;
|
|
251
|
+
base;
|
|
252
|
+
pullPath;
|
|
253
|
+
constructor(apiUrl, token, owner, repository, pullNumber) {
|
|
254
|
+
this.token = token;
|
|
255
|
+
this.pullNumber = pullNumber;
|
|
256
|
+
this.base = apiUrl.replace(/\/$/, '');
|
|
257
|
+
this.pullPath = `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/pulls`;
|
|
258
|
+
}
|
|
259
|
+
url(endpoint) {
|
|
260
|
+
if (!endpoint.startsWith('http://') && !endpoint.startsWith('https://'))
|
|
261
|
+
return this.base + endpoint;
|
|
262
|
+
if (endpoint !== this.base && !endpoint.startsWith(this.base + '/')) {
|
|
263
|
+
throw new Error('GitHub pagination left the configured API origin');
|
|
264
|
+
}
|
|
265
|
+
return endpoint;
|
|
266
|
+
}
|
|
267
|
+
async request(method, endpoint, body, acceptedStatuses = []) {
|
|
268
|
+
const response = await fetch(this.url(endpoint), {
|
|
269
|
+
method,
|
|
270
|
+
headers: {
|
|
271
|
+
Accept: 'application/vnd.github+json',
|
|
272
|
+
Authorization: `Bearer ${this.token}`,
|
|
273
|
+
'User-Agent': 'PowerShot',
|
|
274
|
+
'X-GitHub-Api-Version': API_VERSION,
|
|
275
|
+
...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
|
|
276
|
+
},
|
|
277
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
278
|
+
signal: AbortSignal.timeout(API_TIMEOUT_MS),
|
|
279
|
+
});
|
|
280
|
+
const source = await response.text();
|
|
281
|
+
if (!response.ok) {
|
|
282
|
+
if (acceptedStatuses.includes(response.status))
|
|
283
|
+
return { data: undefined };
|
|
284
|
+
throw new Error(`GitHub API ${method} failed with ${response.status}: ${errorDetail(source)}`);
|
|
285
|
+
}
|
|
286
|
+
const data = source.length === 0 ? undefined : JSON.parse(source);
|
|
287
|
+
return { data, next: nextLink(response.headers.get('link')) };
|
|
288
|
+
}
|
|
289
|
+
async all(endpoint) {
|
|
290
|
+
const out = [];
|
|
291
|
+
const seen = new Set();
|
|
292
|
+
let next = endpoint + (endpoint.includes('?') ? '&' : '?') + 'per_page=100';
|
|
293
|
+
for (let page = 0; next !== undefined; page++) {
|
|
294
|
+
if (page >= MAX_API_PAGES)
|
|
295
|
+
throw new Error('GitHub API pagination exceeded its safety limit');
|
|
296
|
+
if (seen.has(next))
|
|
297
|
+
throw new Error('GitHub API returned a pagination cycle');
|
|
298
|
+
seen.add(next);
|
|
299
|
+
const response = await this.request('GET', next);
|
|
300
|
+
if (!Array.isArray(response.data))
|
|
301
|
+
throw new Error('GitHub API returned a non-array page');
|
|
302
|
+
out.push(...response.data);
|
|
303
|
+
next = response.next;
|
|
304
|
+
}
|
|
305
|
+
return out;
|
|
306
|
+
}
|
|
307
|
+
async headSha() {
|
|
308
|
+
const { data } = await this.request('GET', `${this.pullPath}/${this.pullNumber}`);
|
|
309
|
+
if (!record(data) || !record(data.head))
|
|
310
|
+
throw new Error('GitHub API returned no pull request head');
|
|
311
|
+
return requiredString(data.head.sha, 'pull request head SHA');
|
|
312
|
+
}
|
|
313
|
+
async listFiles() {
|
|
314
|
+
const values = await this.all(`${this.pullPath}/${this.pullNumber}/files`);
|
|
315
|
+
return values.map((value, index) => {
|
|
316
|
+
if (!record(value) || typeof value.filename !== 'string') {
|
|
317
|
+
throw new Error(`GitHub API pull file ${index + 1} has an invalid contract`);
|
|
318
|
+
}
|
|
319
|
+
if (value.patch !== undefined && typeof value.patch !== 'string') {
|
|
320
|
+
throw new Error(`GitHub API pull file ${index + 1} has an invalid patch`);
|
|
321
|
+
}
|
|
322
|
+
return { filename: value.filename, patch: value.patch };
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
async listReviewComments() {
|
|
326
|
+
const values = await this.all(`${this.pullPath}/${this.pullNumber}/comments`);
|
|
327
|
+
return values.map((value, index) => {
|
|
328
|
+
if (!record(value) ||
|
|
329
|
+
!Number.isSafeInteger(value.id) ||
|
|
330
|
+
typeof value.path !== 'string' ||
|
|
331
|
+
(value.line !== null && !Number.isSafeInteger(value.line)) ||
|
|
332
|
+
(value.body !== null && typeof value.body !== 'string') ||
|
|
333
|
+
(value.in_reply_to_id !== undefined && value.in_reply_to_id !== null && !Number.isSafeInteger(value.in_reply_to_id))) {
|
|
334
|
+
throw new Error(`GitHub API review comment ${index + 1} has an invalid contract`);
|
|
335
|
+
}
|
|
336
|
+
const user = record(value.user) && typeof value.user.login === 'string'
|
|
337
|
+
? { login: value.user.login }
|
|
338
|
+
: undefined;
|
|
339
|
+
return {
|
|
340
|
+
id: Number(value.id),
|
|
341
|
+
path: value.path,
|
|
342
|
+
line: value.line === null ? null : Number(value.line),
|
|
343
|
+
body: value.body ?? '',
|
|
344
|
+
user,
|
|
345
|
+
inReplyToId: value.in_reply_to_id === undefined || value.in_reply_to_id === null
|
|
346
|
+
? undefined
|
|
347
|
+
: Number(value.in_reply_to_id),
|
|
348
|
+
};
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
async createReview(commitId, comments) {
|
|
352
|
+
await this.request('POST', `${this.pullPath}/${this.pullNumber}/reviews`, createReviewPayload(commitId, comments));
|
|
353
|
+
}
|
|
354
|
+
async deleteReviewComment(id) {
|
|
355
|
+
await this.request('DELETE', `${this.pullPath}/comments/${id}`, undefined, [404]);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
function environment(name) {
|
|
359
|
+
const value = process.env[name];
|
|
360
|
+
if (value === undefined || value.length === 0)
|
|
361
|
+
throw new Error(`${name} is required`);
|
|
362
|
+
return value;
|
|
363
|
+
}
|
|
364
|
+
async function main() {
|
|
365
|
+
const repository = environment('GITHUB_REPOSITORY').split('/');
|
|
366
|
+
if (repository.length !== 2 || !repository[0] || !repository[1]) {
|
|
367
|
+
throw new Error('GITHUB_REPOSITORY must be owner/repository');
|
|
368
|
+
}
|
|
369
|
+
const pullNumber = Number(environment('POWERSHOT_PR_NUMBER'));
|
|
370
|
+
if (!Number.isSafeInteger(pullNumber) || pullNumber < 1)
|
|
371
|
+
throw new Error('POWERSHOT_PR_NUMBER must be positive');
|
|
372
|
+
const expectedHeadSha = environment('POWERSHOT_HEAD_SHA');
|
|
373
|
+
if (!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/i.test(expectedHeadSha))
|
|
374
|
+
throw new Error('POWERSHOT_HEAD_SHA is invalid');
|
|
375
|
+
const findings = parseReviewFindings(await readFile('powershot.json', 'utf8'));
|
|
376
|
+
const api = new GitHubPullRequestApi(environment('GITHUB_API_URL'), environment('GITHUB_TOKEN'), repository[0], repository[1], pullNumber);
|
|
377
|
+
const result = await syncInlineComments(api, findings, expectedHeadSha);
|
|
378
|
+
if (result.outdated) {
|
|
379
|
+
process.stdout.write('PowerShot skipped inline comments because the pull request head changed.\n');
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
process.stdout.write(`PowerShot inline comments: ${result.created} created, ${result.kept} kept, ${result.retired} retired.\n`);
|
|
383
|
+
}
|
|
384
|
+
const entry = process.argv[1];
|
|
385
|
+
if (entry !== undefined && import.meta.url === pathToFileURL(entry).href) {
|
|
386
|
+
main().catch((error) => {
|
|
387
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
388
|
+
process.stderr.write('PowerShot inline comments failed: ' + oneLine(message, 1_000) + '\n');
|
|
389
|
+
process.exitCode = 1;
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
//# sourceMappingURL=inline-comments.js.map
|
package/dist/package-smoke.js
CHANGED
|
@@ -64,6 +64,8 @@ try {
|
|
|
64
64
|
throw new Error('architecture guide is missing');
|
|
65
65
|
if (!existsSync(join(installed, 'docs', 'ci.md')))
|
|
66
66
|
throw new Error('CI guide is missing');
|
|
67
|
+
if (!existsSync(join(installed, 'dist', 'github', 'inline-comments.js')))
|
|
68
|
+
throw new Error('inline review runtime is missing');
|
|
67
69
|
if (!existsSync(join(installed, 'examples', 'github-actions', 'cli.yml')))
|
|
68
70
|
throw new Error('CI example is missing');
|
|
69
71
|
if (!existsSync(join(installed, 'examples', 'gitlab', '.gitlab-ci.yml')))
|
package/dist/selftest.js
CHANGED
|
@@ -53,6 +53,7 @@ import { highlight, isJsx } from './report/highlight.js';
|
|
|
53
53
|
import { normalizeName, readEnvManifest, relPath } from './ground.js';
|
|
54
54
|
import { incompleteReasons } from './bench.js';
|
|
55
55
|
import { PACKAGE_NAME, PACKAGE_VERSION } from './package-meta.js';
|
|
56
|
+
import { addedLinesFromPatch, createReviewPayload, GitHubPullRequestApi, inlineMarker, parseReviewFindings, reconcileInlineComments, selectInlineComments, syncInlineComments, } from './github/inline-comments.js';
|
|
56
57
|
const root = '/repo';
|
|
57
58
|
/** Build a Ground by hand so verifiers are testable without git or a real repo. */
|
|
58
59
|
function ground(files, deps = []) {
|
|
@@ -653,6 +654,210 @@ const sample = [
|
|
|
653
654
|
{ id: 'F2', class: 'judged', check: 'plausible-logic', severity: 'low', confidence: 'tentative',
|
|
654
655
|
file: 'src/b.ts', line: 9, title: 'off by one' },
|
|
655
656
|
];
|
|
657
|
+
check('GitHub patches expose only added right-side lines for inline comments', () => {
|
|
658
|
+
const patch = [
|
|
659
|
+
'@@ -1,3 +1,4 @@',
|
|
660
|
+
' context',
|
|
661
|
+
'-old',
|
|
662
|
+
'+new',
|
|
663
|
+
'+extra',
|
|
664
|
+
' tail',
|
|
665
|
+
'@@ -20,0 +22,2 @@',
|
|
666
|
+
'+later',
|
|
667
|
+
'\',
|
|
668
|
+
'+latest',
|
|
669
|
+
].join('\n');
|
|
670
|
+
assert.deepEqual([...addedLinesFromPatch(patch)], [2, 3, 22, 23]);
|
|
671
|
+
});
|
|
672
|
+
check('inline review selects only proven medium-or-higher verified findings on added lines', () => {
|
|
673
|
+
const finding = (overrides) => ({
|
|
674
|
+
id: 'F', class: 'verified', check: 'phantom-dep', severity: 'high', confidence: 'proven',
|
|
675
|
+
file: 'src/a.ts', line: 3, title: 'finding', ...overrides,
|
|
676
|
+
});
|
|
677
|
+
const findings = [
|
|
678
|
+
finding({ id: 'critical', severity: 'critical', line: 4, title: 'critical finding' }),
|
|
679
|
+
finding({ id: 'medium', severity: 'medium', line: 3, title: 'medium finding' }),
|
|
680
|
+
finding({ id: 'context', line: 2 }),
|
|
681
|
+
finding({ id: 'low', severity: 'low' }),
|
|
682
|
+
finding({ id: 'judged', class: 'judged' }),
|
|
683
|
+
finding({ id: 'firm', confidence: 'firm' }),
|
|
684
|
+
finding({ id: 'other-file', file: 'src/missing.ts' }),
|
|
685
|
+
];
|
|
686
|
+
const files = [{ filename: 'src/a.ts', patch: '@@ -2,1 +2,3 @@\n context\n+first\n+second' }];
|
|
687
|
+
assert.deepEqual(selectInlineComments(findings, files).map((comment) => comment.line), [4, 3]);
|
|
688
|
+
assert.deepEqual(selectInlineComments(findings, files, 1).map((comment) => comment.line), [4]);
|
|
689
|
+
assert.deepEqual(selectInlineComments(findings, files, 0), []);
|
|
690
|
+
const many = Array.from({ length: 12 }, (_, index) => finding({ line: index + 3, title: `finding ${index}` }));
|
|
691
|
+
const manyPatch = '@@ -2,0 +3,12 @@\n' + many.map((_, index) => `+line ${index}`).join('\n');
|
|
692
|
+
assert.equal(selectInlineComments(many, [{ filename: 'src/a.ts', patch: manyPatch }], 100).length, 10);
|
|
693
|
+
});
|
|
694
|
+
check('inline comment markdown renders finding prose literally and carries a stable marker', () => {
|
|
695
|
+
const finding = {
|
|
696
|
+
id: 'F1', class: 'verified', check: 'check`id', severity: 'high', confidence: 'proven',
|
|
697
|
+
file: 'src/a.ts', line: 3, title: '@team <img src=x> [click](https://example.invalid)',
|
|
698
|
+
evidence: { oracle: 'manifest', detail: 'line one\n> forged quote' },
|
|
699
|
+
};
|
|
700
|
+
const [comment] = selectInlineComments([finding], [{ filename: finding.file, patch: '@@ -2,0 +3,1 @@\n+line' }]);
|
|
701
|
+
assert.ok(comment);
|
|
702
|
+
assert.equal(comment.body.includes('@team'), false);
|
|
703
|
+
assert.equal(comment.body.includes('<img'), false);
|
|
704
|
+
assert.equal(comment.body.includes('[click]('), false);
|
|
705
|
+
assert.equal(comment.body.endsWith(inlineMarker(finding)), true);
|
|
706
|
+
assert.equal(comment.body.match(/<!-- powershot:inline:/g)?.length, 1);
|
|
707
|
+
});
|
|
708
|
+
check('inline publishing rejects a malformed machine report instead of silently dropping it', () => {
|
|
709
|
+
assert.deepEqual(parseReviewFindings(JSON.stringify({ findings: [sample[0]] })), [sample[0]]);
|
|
710
|
+
assert.throws(() => parseReviewFindings(JSON.stringify({ findings: [{ ...sample[0], line: 0 }] })), /invalid contract/);
|
|
711
|
+
assert.throws(() => parseReviewFindings('{}'), /findings array/);
|
|
712
|
+
});
|
|
713
|
+
check('inline reruns keep exact bot comments, batch only missing ones, and retire stale bot copies', () => {
|
|
714
|
+
const findings = [
|
|
715
|
+
{ id: 'F1', class: 'verified', check: 'a', severity: 'critical', confidence: 'proven', file: 'a.ts', line: 1, title: 'one' },
|
|
716
|
+
{ id: 'F2', class: 'verified', check: 'b', severity: 'high', confidence: 'proven', file: 'b.ts', line: 2, title: 'two' },
|
|
717
|
+
];
|
|
718
|
+
const desired = selectInlineComments(findings, [
|
|
719
|
+
{ filename: 'a.ts', patch: '@@ -0,0 +1,1 @@\n+one' },
|
|
720
|
+
{ filename: 'b.ts', patch: '@@ -1,0 +2,1 @@\n+two' },
|
|
721
|
+
]);
|
|
722
|
+
const existing = [
|
|
723
|
+
{ id: 1, path: desired[0].path, line: desired[0].line, body: desired[0].body, user: { login: 'github-actions[bot]' } },
|
|
724
|
+
{ id: 2, path: desired[0].path, line: desired[0].line, body: desired[0].body, user: { login: 'github-actions[bot]' } },
|
|
725
|
+
{ id: 3, path: desired[0].path, line: desired[0].line, body: 'old\n' + inlineMarker(findings[0]), user: { login: 'github-actions[bot]' } },
|
|
726
|
+
{ id: 4, path: desired[1].path, line: desired[1].line, body: desired[1].body, user: { login: 'human' } },
|
|
727
|
+
{ id: 5, path: desired[0].path, line: desired[0].line, body: 'discussion', user: { login: 'human' }, inReplyToId: 2 },
|
|
728
|
+
{ id: 6, path: desired[0].path, line: desired[0].line, body: 'discussed old\n' + inlineMarker(findings[0]), user: { login: 'github-actions[bot]' } },
|
|
729
|
+
{ id: 7, path: desired[0].path, line: desired[0].line, body: 'still investigating', user: { login: 'human' }, inReplyToId: 6 },
|
|
730
|
+
];
|
|
731
|
+
const plan = reconcileInlineComments(desired, existing);
|
|
732
|
+
assert.equal(plan.kept, 1);
|
|
733
|
+
assert.deepEqual(plan.create, [desired[1]]);
|
|
734
|
+
assert.deepEqual(plan.staleIds, [1, 3]);
|
|
735
|
+
const settled = reconcileInlineComments(desired, desired.map((comment, index) => ({
|
|
736
|
+
id: index + 10, path: comment.path, line: comment.line, body: comment.body,
|
|
737
|
+
user: { login: 'github-actions[bot]' },
|
|
738
|
+
})));
|
|
739
|
+
assert.deepEqual(settled, { create: [], staleIds: [], kept: 2 });
|
|
740
|
+
});
|
|
741
|
+
check('the batched GitHub review carries the required comment body and current commit', () => {
|
|
742
|
+
const comment = { path: 'a.ts', line: 1, side: 'RIGHT', body: 'finding' };
|
|
743
|
+
const payload = createReviewPayload('a'.repeat(40), [comment]);
|
|
744
|
+
assert.equal(payload.commit_id, 'a'.repeat(40));
|
|
745
|
+
assert.equal(payload.event, 'COMMENT');
|
|
746
|
+
assert.match(payload.body, /1 proven verified finding/);
|
|
747
|
+
assert.deepEqual(payload.comments, [comment]);
|
|
748
|
+
});
|
|
749
|
+
await checkAsync('the GitHub client paginates files, submits the review contract, and treats delete 404 as settled', async () => {
|
|
750
|
+
const originalFetch = globalThis.fetch;
|
|
751
|
+
const calls = [];
|
|
752
|
+
const json = (value, init = {}) => new Response(JSON.stringify(value), { status: 200, ...init });
|
|
753
|
+
const fakeFetch = async (input, init) => {
|
|
754
|
+
const url = String(input);
|
|
755
|
+
const method = init?.method ?? 'GET';
|
|
756
|
+
const body = typeof init?.body === 'string' ? init.body : undefined;
|
|
757
|
+
calls.push({ url, method, body });
|
|
758
|
+
assert.equal(new Headers(init?.headers).get('authorization'), 'Bearer token');
|
|
759
|
+
if (url.endsWith('/pulls/7'))
|
|
760
|
+
return json({ head: { sha: 'a'.repeat(40) } });
|
|
761
|
+
if (url.includes('/pulls/7/files') && !url.includes('page=2')) {
|
|
762
|
+
return json([{ filename: 'a.ts', patch: '@@ -0,0 +1,1 @@\n+one' }], {
|
|
763
|
+
headers: { link: '<https://api.github.test/repos/acme/repo/pulls/7/files?per_page=100&page=2>; rel="next"' },
|
|
764
|
+
});
|
|
765
|
+
}
|
|
766
|
+
if (url.includes('/pulls/7/files') && url.includes('page=2')) {
|
|
767
|
+
return json([{ filename: 'b.bin' }]);
|
|
768
|
+
}
|
|
769
|
+
if (url.includes('/pulls/7/comments')) {
|
|
770
|
+
return json([
|
|
771
|
+
{ id: 9, path: 'a.ts', line: 1, body: 'old', user: { login: 'github-actions[bot]' } },
|
|
772
|
+
{ id: 10, path: 'a.ts', line: 1, body: 'reply', in_reply_to_id: 9, user: { login: 'human' } },
|
|
773
|
+
]);
|
|
774
|
+
}
|
|
775
|
+
if (method === 'POST' && url.endsWith('/pulls/7/reviews'))
|
|
776
|
+
return json({ id: 1 });
|
|
777
|
+
if (method === 'DELETE' && url.endsWith('/pulls/comments/9'))
|
|
778
|
+
return json({ message: 'gone' }, { status: 404 });
|
|
779
|
+
return json({ message: 'unexpected request' }, { status: 500 });
|
|
780
|
+
};
|
|
781
|
+
globalThis.fetch = fakeFetch;
|
|
782
|
+
try {
|
|
783
|
+
const api = new GitHubPullRequestApi('https://api.github.test', 'token', 'acme', 'repo', 7);
|
|
784
|
+
assert.equal(await api.headSha(), 'a'.repeat(40));
|
|
785
|
+
assert.deepEqual(await api.listFiles(), [
|
|
786
|
+
{ filename: 'a.ts', patch: '@@ -0,0 +1,1 @@\n+one' },
|
|
787
|
+
{ filename: 'b.bin', patch: undefined },
|
|
788
|
+
]);
|
|
789
|
+
const comments = await api.listReviewComments();
|
|
790
|
+
assert.equal(comments[0]?.id, 9);
|
|
791
|
+
assert.equal(comments[1]?.inReplyToId, 9);
|
|
792
|
+
await api.createReview('a'.repeat(40), [{ path: 'a.ts', line: 1, side: 'RIGHT', body: 'finding' }]);
|
|
793
|
+
await api.deleteReviewComment(9);
|
|
794
|
+
}
|
|
795
|
+
finally {
|
|
796
|
+
globalThis.fetch = originalFetch;
|
|
797
|
+
}
|
|
798
|
+
const submitted = calls.find((call) => call.method === 'POST');
|
|
799
|
+
assert.ok(submitted?.body);
|
|
800
|
+
assert.deepEqual(JSON.parse(submitted.body), {
|
|
801
|
+
commit_id: 'a'.repeat(40),
|
|
802
|
+
body: 'PowerShot posted 1 proven verified finding(s) on changed lines.',
|
|
803
|
+
event: 'COMMENT',
|
|
804
|
+
comments: [{ path: 'a.ts', line: 1, side: 'RIGHT', body: 'finding' }],
|
|
805
|
+
});
|
|
806
|
+
assert.equal(calls.filter((call) => call.url.includes('/files')).length, 2);
|
|
807
|
+
});
|
|
808
|
+
await checkAsync('inline synchronization creates one review before removing stale comments', async () => {
|
|
809
|
+
const finding = {
|
|
810
|
+
id: 'F1', class: 'verified', check: 'a', severity: 'high', confidence: 'proven',
|
|
811
|
+
file: 'a.ts', line: 1, title: 'one',
|
|
812
|
+
};
|
|
813
|
+
const events = [];
|
|
814
|
+
const api = {
|
|
815
|
+
headSha: async () => 'a'.repeat(40),
|
|
816
|
+
listFiles: async () => [{ filename: 'a.ts', patch: '@@ -0,0 +1,1 @@\n+one' }],
|
|
817
|
+
listReviewComments: async () => [{
|
|
818
|
+
id: 7, path: 'old.ts', line: 1,
|
|
819
|
+
body: 'old\n<!-- powershot:inline:v1:0123456789abcdef01234567 -->',
|
|
820
|
+
user: { login: 'github-actions[bot]' },
|
|
821
|
+
}],
|
|
822
|
+
createReview: async (commitId, comments) => {
|
|
823
|
+
events.push(`create:${commitId}:${comments.length}`);
|
|
824
|
+
},
|
|
825
|
+
deleteReviewComment: async (id) => {
|
|
826
|
+
events.push(`delete:${id}`);
|
|
827
|
+
},
|
|
828
|
+
};
|
|
829
|
+
const result = await syncInlineComments(api, [finding], 'a'.repeat(40));
|
|
830
|
+
assert.deepEqual(events, [`create:${'a'.repeat(40)}:1`, 'delete:7']);
|
|
831
|
+
assert.deepEqual(result, { outdated: false, desired: 1, created: 1, kept: 0, retired: 1 });
|
|
832
|
+
});
|
|
833
|
+
await checkAsync('inline synchronization makes no writes for an outdated pull request head', async () => {
|
|
834
|
+
let reads = 0;
|
|
835
|
+
const api = {
|
|
836
|
+
headSha: async () => 'b'.repeat(40),
|
|
837
|
+
listFiles: async () => { reads++; return []; },
|
|
838
|
+
listReviewComments: async () => { reads++; return []; },
|
|
839
|
+
createReview: async () => { throw new Error('must not create'); },
|
|
840
|
+
deleteReviewComment: async () => { throw new Error('must not delete'); },
|
|
841
|
+
};
|
|
842
|
+
const result = await syncInlineComments(api, [], 'a'.repeat(40));
|
|
843
|
+
assert.equal(reads, 0);
|
|
844
|
+
assert.deepEqual(result, { outdated: true, desired: 0, created: 0, kept: 0, retired: 0 });
|
|
845
|
+
});
|
|
846
|
+
await checkAsync('inline synchronization makes no writes when the pull request head changes during reads', async () => {
|
|
847
|
+
let headReads = 0;
|
|
848
|
+
let writes = 0;
|
|
849
|
+
const api = {
|
|
850
|
+
headSha: async () => ++headReads === 1 ? 'a'.repeat(40) : 'b'.repeat(40),
|
|
851
|
+
listFiles: async () => [{ filename: 'a.ts', patch: '@@ -0,0 +1,1 @@\n+one' }],
|
|
852
|
+
listReviewComments: async () => [],
|
|
853
|
+
createReview: async () => { writes++; },
|
|
854
|
+
deleteReviewComment: async () => { writes++; },
|
|
855
|
+
};
|
|
856
|
+
const result = await syncInlineComments(api, [sample[0]], 'a'.repeat(40));
|
|
857
|
+
assert.equal(headReads, 2);
|
|
858
|
+
assert.equal(writes, 0);
|
|
859
|
+
assert.deepEqual(result, { outdated: true, desired: 0, created: 0, kept: 0, retired: 0 });
|
|
860
|
+
});
|
|
656
861
|
check('nested modules use the native package import map', () => {
|
|
657
862
|
const manifest = JSON.parse(readFileSync(join(process.cwd(), 'package.json'), 'utf8'));
|
|
658
863
|
assert.equal(manifest.imports?.['#app/*.js'], './dist/*.js');
|
|
@@ -694,16 +899,24 @@ check('the public action persists judge answers and publishes only a verdict', (
|
|
|
694
899
|
assert.match(action, /uses: actions\/cache@[a-f0-9]{40}/);
|
|
695
900
|
assert.match(action, /POWERSHOT_CACHE_DIR: \$\{\{ runner\.temp \}\}\/powershot-cache/);
|
|
696
901
|
assert.match(action, /restore-keys:/);
|
|
697
|
-
assert.match(action, /
|
|
902
|
+
assert.match(action, /upload-sarif:\s*\n\s+description: [^\n]+\n\s+default: 'true'/);
|
|
903
|
+
assert.match(action, /Upload SARIF[\s\S]+inputs\.upload-sarif == 'true'[\s\S]+steps\.review\.outputs\.complete == 'true'/);
|
|
904
|
+
assert.match(action, /inline-comments:\s*\n\s+description: [^\n]+\n\s+default: 'false'/);
|
|
905
|
+
assert.match(action, /Post inline comments[\s\S]+inputs\.inline-comments == 'true'[\s\S]+steps\.review\.outputs\.complete == 'true'/);
|
|
906
|
+
assert.match(action, /node "\$GITHUB_ACTION_PATH\/dist\/github\/inline-comments\.js"/);
|
|
698
907
|
});
|
|
699
908
|
check('published CI examples preserve one verdict and its exit status', () => {
|
|
909
|
+
const action = readFileSync(join(process.cwd(), 'examples', 'github-actions', 'action.yml'), 'utf8');
|
|
700
910
|
const github = readFileSync(join(process.cwd(), 'examples', 'github-actions', 'cli.yml'), 'utf8');
|
|
701
911
|
const gitlab = readFileSync(join(process.cwd(), 'examples', 'gitlab', '.gitlab-ci.yml'), 'utf8');
|
|
702
|
-
assert.match(
|
|
912
|
+
assert.match(action, /upload-sarif: 'true'/);
|
|
913
|
+
assert.match(action, /inline-comments: 'true'/);
|
|
914
|
+
assert.match(action, /runs-on: ubuntu-24\.04/);
|
|
915
|
+
assert.match(github, /npm install --global --ignore-scripts @0xcraft\/powershot@1\.1\.0/);
|
|
703
916
|
assert.equal(github.match(/psh review/g)?.length, 1);
|
|
704
917
|
assert.match(github, /--report markdown=powershot\.md[\s\S]+--report sarif=powershot\.sarif/);
|
|
705
918
|
assert.match(github, /\|\| STATUS=\$\?[\s\S]+case "\$STATUS"/);
|
|
706
|
-
assert.match(gitlab, /npm install --global --ignore-scripts @0xcraft\/powershot@1\.
|
|
919
|
+
assert.match(gitlab, /npm install --global --ignore-scripts @0xcraft\/powershot@1\.1\.0/);
|
|
707
920
|
assert.equal(gitlab.match(/psh review/g)?.length, 1);
|
|
708
921
|
assert.match(gitlab, /--format codequality > gl-code-quality-report\.json \|\| STATUS=\$\?/);
|
|
709
922
|
assert.match(gitlab, /test "\$STATUS" -le 1 \|\| exit "\$STATUS"/);
|
package/docs/ci.md
CHANGED
|
@@ -26,8 +26,9 @@ There are two independent decisions:
|
|
|
26
26
|
|
|
27
27
|
## GitHub Action
|
|
28
28
|
|
|
29
|
-
The action runs one review, adds a job summary, uploads SARIF, and can
|
|
30
|
-
pull-request comment.
|
|
29
|
+
The action runs one review, adds a job summary, uploads SARIF when enabled, and can
|
|
30
|
+
maintain one pull-request comment. Its opt-in inline mode posts a bounded batched
|
|
31
|
+
review on changed lines.
|
|
31
32
|
|
|
32
33
|
```yaml
|
|
33
34
|
name: PowerShot
|
|
@@ -42,7 +43,7 @@ permissions:
|
|
|
42
43
|
|
|
43
44
|
jobs:
|
|
44
45
|
review:
|
|
45
|
-
runs-on: ubuntu-
|
|
46
|
+
runs-on: ubuntu-24.04
|
|
46
47
|
steps:
|
|
47
48
|
- uses: actions/checkout@v7
|
|
48
49
|
with:
|
|
@@ -51,10 +52,23 @@ jobs:
|
|
|
51
52
|
- uses: xcrft/powershot@v1
|
|
52
53
|
with:
|
|
53
54
|
verify-only: 'true'
|
|
55
|
+
upload-sarif: 'true'
|
|
54
56
|
comment: 'true'
|
|
57
|
+
inline-comments: 'true'
|
|
55
58
|
fail-on-findings: 'true'
|
|
56
59
|
```
|
|
57
60
|
|
|
61
|
+
Set `upload-sarif: 'false'` and omit `security-events: write` when GitHub code scanning
|
|
62
|
+
is unavailable or the workflow should not publish SARIF.
|
|
63
|
+
|
|
64
|
+
`inline-comments: 'true'` requires `pull-requests: write`. It publishes at most ten
|
|
65
|
+
findings as one review. Only deterministic `verified` findings with `proven`
|
|
66
|
+
confidence, severity `medium` or higher, and a GitHub-confirmed added line qualify.
|
|
67
|
+
Findings on context lines or files whose patch GitHub omitted stay in the full report.
|
|
68
|
+
Reruns preserve exact bot comments, create only missing comments, and remove stale
|
|
69
|
+
PowerShot inline copies without replies. Human comments and replied-to discussions
|
|
70
|
+
are never modified.
|
|
71
|
+
|
|
58
72
|
The major tag follows compatible `1.x` releases. Pin a full commit SHA in a protected
|
|
59
73
|
required workflow when immutable dependencies are required. The copy-paste version
|
|
60
74
|
lives at [`examples/github-actions/action.yml`](../examples/github-actions/action.yml).
|
|
@@ -67,7 +81,9 @@ lives at [`examples/github-actions/action.yml`](../examples/github-actions/actio
|
|
|
67
81
|
| `anthropic-api-key` | empty | Enable configured model judges |
|
|
68
82
|
| `min-severity` | `low` | Filter findings below a severity |
|
|
69
83
|
| `checks` | empty | Select comma-separated check ids |
|
|
84
|
+
| `upload-sarif` | `true` | Upload a complete SARIF report to GitHub code scanning |
|
|
70
85
|
| `comment` | `true` | Maintain a pull-request comment |
|
|
86
|
+
| `inline-comments` | `false` | Post up to ten proven verified findings as one inline review |
|
|
71
87
|
| `fail-on-findings` | `false` | Turn a complete finding verdict into a failed job |
|
|
72
88
|
| `approve` | `false` | Approve only a complete, clean review |
|
|
73
89
|
|
|
@@ -83,7 +99,7 @@ step inside a larger quality job. The complete example is
|
|
|
83
99
|
The core pattern is:
|
|
84
100
|
|
|
85
101
|
```bash
|
|
86
|
-
npm install --global --ignore-scripts @0xcraft/powershot@1.
|
|
102
|
+
npm install --global --ignore-scripts @0xcraft/powershot@1.1.0
|
|
87
103
|
|
|
88
104
|
STATUS=0
|
|
89
105
|
psh review --verify-only \
|
|
@@ -10,7 +10,7 @@ permissions:
|
|
|
10
10
|
|
|
11
11
|
jobs:
|
|
12
12
|
review:
|
|
13
|
-
runs-on: ubuntu-
|
|
13
|
+
runs-on: ubuntu-24.04
|
|
14
14
|
steps:
|
|
15
15
|
- uses: actions/checkout@v7
|
|
16
16
|
with:
|
|
@@ -19,5 +19,7 @@ jobs:
|
|
|
19
19
|
- uses: xcrft/powershot@v1
|
|
20
20
|
with:
|
|
21
21
|
verify-only: 'true'
|
|
22
|
+
upload-sarif: 'true'
|
|
22
23
|
comment: 'true'
|
|
24
|
+
inline-comments: 'true'
|
|
23
25
|
fail-on-findings: 'true'
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@0xcraft/powershot",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
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>",
|