@0xcraft/powershot 1.0.1 → 1.1.1
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 +22 -0
- package/dist/github/inline-comments.js +392 -0
- package/dist/ground.js +236 -87
- package/dist/judges/tools.js +7 -7
- package/dist/package-smoke.js +2 -0
- package/dist/review.js +4 -2
- package/dist/selftest.js +366 -4
- package/dist/verifiers/phantom-config.js +7 -6
- package/docs/architecture.md +15 -1
- package/docs/ci.md +23 -3
- package/examples/github-actions/action.yml +7 -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
|
@@ -211,17 +211,39 @@ The composite action is the shortest setup for GitHub:
|
|
|
211
211
|
with:
|
|
212
212
|
fetch-depth: 0
|
|
213
213
|
|
|
214
|
+
- run: npm ci --ignore-scripts
|
|
215
|
+
|
|
214
216
|
- uses: xcrft/powershot@v1
|
|
215
217
|
with:
|
|
216
218
|
verify-only: 'true'
|
|
217
219
|
upload-sarif: 'true'
|
|
218
220
|
comment: 'true'
|
|
221
|
+
inline-comments: 'true'
|
|
219
222
|
fail-on-findings: 'true'
|
|
220
223
|
```
|
|
221
224
|
|
|
225
|
+
Install the checked-out project's dependencies before PowerShot so TypeScript can
|
|
226
|
+
resolve its declared ambient types. The example disables lifecycle scripts because
|
|
227
|
+
pull-request code is untrusted; use the equivalent safe install for another package
|
|
228
|
+
manager. In a monorepo, install from the workspace root or add safe install steps for
|
|
229
|
+
the affected package roots.
|
|
230
|
+
|
|
231
|
+
PowerShot discovers `tsconfig.json` and `tsconfig.*.json` along the ancestor chain of
|
|
232
|
+
each changed file. One review can use several independent package projects, skip
|
|
233
|
+
empty solution configs in favour of their leaf configs, and type-check test files
|
|
234
|
+
that a production config excludes. Discovery is change-scoped: unrelated packages
|
|
235
|
+
and configless source trees are not crawled just to build the TypeScript ground.
|
|
236
|
+
|
|
222
237
|
`@v1` follows compatible `1.x` releases. Pin the action to a full commit SHA in a
|
|
223
238
|
protected required workflow when immutable dependencies are required.
|
|
224
239
|
|
|
240
|
+
`inline-comments` is opt-in. It posts at most ten `verified` + `proven` findings of
|
|
241
|
+
`medium` severity or higher as one GitHub review, and only when GitHub confirms the
|
|
242
|
+
finding line was added by the pull request. Reruns keep matching bot comments and
|
|
243
|
+
retire stale PowerShot copies that have no replies. Human comments and discussions
|
|
244
|
+
are preserved. Every finding still stays in the full report and, when `comment` is
|
|
245
|
+
enabled, the summary comment.
|
|
246
|
+
|
|
225
247
|
The [CI guide](docs/ci.md) covers exit handling, Git history, one-run/many-report
|
|
226
248
|
artifacts, GitLab Code Quality, local parity, and recommended gate policies.
|
|
227
249
|
|
|
@@ -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
|