@0xcraft/powershot 1.1.1 → 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 +37 -25
- package/dist/cli/reports.js +4 -3
- package/dist/cli/review-command.js +4 -1
- package/dist/cli/session-command.js +6 -0
- package/dist/config.js +5 -0
- package/dist/github/api.js +198 -0
- package/dist/github/inline-comments.js +3 -154
- package/dist/github/summary-comment.js +149 -0
- package/dist/ground.js +61 -8
- package/dist/lang/packs.js +97 -14
- package/dist/lang/parse-worker.js +15 -0
- package/dist/lang/python-deps.js +21 -8
- package/dist/manifest.js +32 -0
- package/dist/package-smoke.js +4 -0
- package/dist/plan.js +7 -0
- package/dist/report/markdown.js +31 -3
- package/dist/report/summary.js +103 -0
- package/dist/report/terminal.js +19 -1
- package/dist/report/viewer.js +22 -3
- package/dist/review.js +39 -18
- package/dist/selftest.js +665 -10
- package/dist/session.js +7 -1
- package/dist/verifiers/foreign-phantom-dep.js +8 -2
- package/docs/architecture.md +38 -11
- package/docs/ci.md +45 -12
- package/examples/github-actions/action.yml +4 -5
- 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
|
@@ -20,13 +20,14 @@ PowerShot reviews the failure modes that plausible-looking generated code tends
|
|
|
20
20
|
hide: invented APIs, undeclared dependencies, dropped guards, swallowed errors,
|
|
21
21
|
tests that prove nothing, bent expectations, stale callers, and duplicated helpers.
|
|
22
22
|
|
|
23
|
-
It asks
|
|
24
|
-
|
|
23
|
+
It asks self-contained parsers, manifests, and pre/post ASTs first, then uses compiler
|
|
24
|
+
types and reference graphs when the environment can supply them. Optional model judges
|
|
25
|
+
only handle questions that still require judgement.
|
|
25
26
|
|
|
26
27
|
<table>
|
|
27
28
|
<tr>
|
|
28
29
|
<td width="33%"><strong>Deterministic first</strong><br>Local checks need no model, key, tokens, or network calls.</td>
|
|
29
|
-
<td width="33%"><strong>Honest
|
|
30
|
+
<td width="33%"><strong>Honest coverage</strong><br>Portable, full, partial, and failed runs stay distinguishable.</td>
|
|
30
31
|
<td width="33%"><strong>CI-native output</strong><br>One run emits terminal, Markdown, JSON, SARIF, manifest, and Code Quality reports.</td>
|
|
31
32
|
</tr>
|
|
32
33
|
</table>
|
|
@@ -89,7 +90,7 @@ flowchart LR
|
|
|
89
90
|
|
|
90
91
|
1. **Snapshot** resolves the exact tree the review is about.
|
|
91
92
|
2. **Ground** builds the available type, syntax, dependency, and reference oracles.
|
|
92
|
-
3. **Plan** assigns checks and
|
|
93
|
+
3. **Plan** assigns baseline checks and enriched semantic capabilities to each file individually.
|
|
93
94
|
4. **Verify** runs deterministic checks and records what actually executed.
|
|
94
95
|
5. **Judge** optionally reviews bounded bundles of related files.
|
|
95
96
|
6. **Manifest** decides whether the result is complete, partial, or failed.
|
|
@@ -108,8 +109,11 @@ Every finding says where it came from:
|
|
|
108
109
|
| `verified` | `firm` | A deterministic heuristic fired; inspect the evidence |
|
|
109
110
|
| `judged` | `firm` or `tentative` | A model supplied the judgement and provenance |
|
|
110
111
|
|
|
111
|
-
|
|
112
|
-
|
|
112
|
+
Portable coverage is the default: self-contained oracles run without bootstrapping the
|
|
113
|
+
reviewed repository, while unavailable compiler/reference depth stays visible in the
|
|
114
|
+
manifest and reports. Set `"coverage": "strict"`, or explicitly select a check with
|
|
115
|
+
`--checks`, when a missing semantic oracle must make the run partial. An unavailable
|
|
116
|
+
oracle is never counted as a pass in either profile.
|
|
113
117
|
|
|
114
118
|
## Deterministic checks
|
|
115
119
|
|
|
@@ -174,33 +178,39 @@ commands.
|
|
|
174
178
|
|
|
175
179
|
```mermaid
|
|
176
180
|
flowchart LR
|
|
177
|
-
START["Selected files and checks"] --> ACCOUNT{"
|
|
178
|
-
ACCOUNT -- "yes" -->
|
|
181
|
+
START["Selected files and checks"] --> ACCOUNT{"Required work accounted for?"}
|
|
182
|
+
ACCOUNT -- "yes" --> DEPTH{"Enriched semantic depth available?"}
|
|
183
|
+
DEPTH -- "yes" --> FULL["full coverage"]
|
|
184
|
+
DEPTH -- "no · portable policy" --> PORTABLE["portable coverage · gaps named"]
|
|
185
|
+
FULL --> FINDINGS{"Findings?"}
|
|
186
|
+
PORTABLE --> FINDINGS
|
|
179
187
|
FINDINGS -- "no" --> CLEAN["exit 0 · complete and clean"]
|
|
180
188
|
FINDINGS -- "yes" --> FOUND["exit 1 · complete with findings"]
|
|
181
|
-
ACCOUNT -- "
|
|
189
|
+
ACCOUNT -- "required oracle or budget gap" --> PARTIAL["exit 3 · partial"]
|
|
182
190
|
ACCOUNT -- "required stage failed" --> FAILED["exit 3 · failed"]
|
|
183
191
|
|
|
184
192
|
classDef neutral fill:#172033,stroke:#57a6ff,color:#f0f6fc,stroke-width:2px
|
|
185
193
|
classDef good fill:#17251f,stroke:#4ac58b,color:#f0f6fc,stroke-width:2px
|
|
186
194
|
classDef warn fill:#2a2117,stroke:#f2b84b,color:#f0f6fc,stroke-width:2px
|
|
187
195
|
classDef bad fill:#2a191b,stroke:#ff675c,color:#f0f6fc,stroke-width:2px
|
|
188
|
-
class START,ACCOUNT,FINDINGS neutral
|
|
196
|
+
class START,ACCOUNT,DEPTH,FINDINGS neutral
|
|
189
197
|
class CLEAN good
|
|
190
|
-
class FOUND,PARTIAL warn
|
|
198
|
+
class FULL,PORTABLE,FOUND,PARTIAL warn
|
|
191
199
|
class FAILED bad
|
|
192
200
|
```
|
|
193
201
|
|
|
194
202
|
| Exit | Contract |
|
|
195
203
|
|---:|---|
|
|
196
|
-
| `0` | Review completed and found nothing at the selected severity |
|
|
197
|
-
| `1` | Review completed and reported findings |
|
|
204
|
+
| `0` | Review completed in full or portable coverage and found nothing at the selected severity |
|
|
205
|
+
| `1` | Review completed in full or portable coverage and reported findings |
|
|
198
206
|
| `2` | Command or Git input was invalid |
|
|
199
207
|
| `3` | Review is incomplete; findings may be missing |
|
|
200
208
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
209
|
+
A clean report names its effective severity threshold, deterministic/model mode, file
|
|
210
|
+
and check counts, and coverage level. A partial or failed review instead says it is not
|
|
211
|
+
a verdict and keeps the missing work visible. Use `--format manifest` to inspect file
|
|
212
|
+
dispositions, executed and unavailable checks, failures, judge units, and
|
|
213
|
+
`notLookedAt`.
|
|
204
214
|
|
|
205
215
|
## CI integration
|
|
206
216
|
|
|
@@ -211,8 +221,6 @@ The composite action is the shortest setup for GitHub:
|
|
|
211
221
|
with:
|
|
212
222
|
fetch-depth: 0
|
|
213
223
|
|
|
214
|
-
- run: npm ci --ignore-scripts
|
|
215
|
-
|
|
216
224
|
- uses: xcrft/powershot@v1
|
|
217
225
|
with:
|
|
218
226
|
verify-only: 'true'
|
|
@@ -222,11 +230,11 @@ The composite action is the shortest setup for GitHub:
|
|
|
222
230
|
fail-on-findings: 'true'
|
|
223
231
|
```
|
|
224
232
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
233
|
+
The default portable profile needs no install from the checked-out repository. That is
|
|
234
|
+
the safe default for private monorepos and fork pull requests: do not expose a package
|
|
235
|
+
registry credential merely to enrich a review of untrusted code. If a trusted job
|
|
236
|
+
already has dependencies, PowerShot uses their TypeScript declarations automatically.
|
|
237
|
+
Set `"coverage": "strict"` when missing compiler/reference oracles must block instead.
|
|
230
238
|
|
|
231
239
|
PowerShot discovers `tsconfig.json` and `tsconfig.*.json` along the ancestor chain of
|
|
232
240
|
each changed file. One review can use several independent package projects, skip
|
|
@@ -266,6 +274,7 @@ Anthropic, OpenAI, and Gemini providers are supported.
|
|
|
266
274
|
"judges": {
|
|
267
275
|
"enable": ["plausible-logic", "test-adequacy", "intent"]
|
|
268
276
|
},
|
|
277
|
+
"coverage": "portable",
|
|
269
278
|
"minSeverity": "low",
|
|
270
279
|
"ignore": ["**/generated/**"],
|
|
271
280
|
"promptCache": true
|
|
@@ -300,8 +309,11 @@ psh review --verify-only --absorb /tmp/powershot-findings.json
|
|
|
300
309
|
| C | Syntax-backed checks that do not require exception semantics |
|
|
301
310
|
| Solidity | Declared syntax-backed checks |
|
|
302
311
|
|
|
303
|
-
|
|
304
|
-
|
|
312
|
+
Every declared language is parsed in disposable, language-isolated workers. Sources
|
|
313
|
+
are sent in bounded batches, so a mixed-language monorepo does not accumulate every
|
|
314
|
+
compiled WASM grammar in one process. If a declared parser cannot run, the review
|
|
315
|
+
fails loudly; it is never silently waived. All eleven packs plus TypeScript and
|
|
316
|
+
JavaScript are exercised together by the integration suite.
|
|
305
317
|
|
|
306
318
|
## Project guide
|
|
307
319
|
|
package/dist/cli/reports.js
CHANGED
|
@@ -4,6 +4,7 @@ import { codeQuality } from '#app/report/codequality.js';
|
|
|
4
4
|
import { compact } from '#app/report/compact.js';
|
|
5
5
|
import { markdown } from '#app/report/markdown.js';
|
|
6
6
|
import { sarif } from '#app/report/sarif.js';
|
|
7
|
+
import { summarizeRun } from '#app/report/summary.js';
|
|
7
8
|
import { terminal } from '#app/report/terminal.js';
|
|
8
9
|
export const REPORT_FORMATS = ['text', 'compact', 'markdown', 'json', 'sarif', 'codequality', 'manifest'];
|
|
9
10
|
export function isReportFormat(value) {
|
|
@@ -37,13 +38,13 @@ export function renderReport(format, result, manifest, target) {
|
|
|
37
38
|
return sarif(result.findings);
|
|
38
39
|
if (format === 'json')
|
|
39
40
|
return jsonResult(result);
|
|
41
|
+
const summary = summarizeRun(manifest);
|
|
40
42
|
if (format === 'markdown')
|
|
41
|
-
return markdown(result.findings,
|
|
43
|
+
return markdown(result.findings, summary);
|
|
42
44
|
return terminal(result.findings, {
|
|
43
45
|
subtitle: target,
|
|
44
46
|
...result.stats,
|
|
45
|
-
|
|
46
|
-
notLookedAt: manifest.notLookedAt,
|
|
47
|
+
...summary,
|
|
47
48
|
});
|
|
48
49
|
}
|
|
49
50
|
export function publishReports(options) {
|
|
@@ -10,6 +10,7 @@ import { Trace } from '#app/otel.js';
|
|
|
10
10
|
import { PACKAGE_VERSION } from '#app/package-meta.js';
|
|
11
11
|
import { dim, yellow } from '#app/report/ansi.js';
|
|
12
12
|
import { progress, stage } from '#app/report/terminal.js';
|
|
13
|
+
import { summarizeRun } from '#app/report/summary.js';
|
|
13
14
|
import { review } from '#app/review.js';
|
|
14
15
|
import { scanPaths } from '#app/scan.js';
|
|
15
16
|
import { Session } from '#app/session.js';
|
|
@@ -222,9 +223,11 @@ export async function runReviewCommand(command, values, positionals) {
|
|
|
222
223
|
model: config.model,
|
|
223
224
|
tools: values.tools,
|
|
224
225
|
verifyOnly: values['verify-only'],
|
|
226
|
+
minSeverity: config.minSeverity,
|
|
225
227
|
},
|
|
226
228
|
files: result.plan?.items() ?? [],
|
|
227
229
|
skippedChecks: result.skippedChecks ?? [],
|
|
230
|
+
unavailableChecks: result.unavailableChecks ?? [],
|
|
228
231
|
findings: {
|
|
229
232
|
total: result.findings.length,
|
|
230
233
|
verified: result.stats.verified,
|
|
@@ -246,7 +249,7 @@ export async function runReviewCommand(command, values, positionals) {
|
|
|
246
249
|
record.notLookedAt.push(failure);
|
|
247
250
|
process.stderr.write(yellow(' ◇ manifest') + dim(' ' + gaps.join('; ')) + '\n');
|
|
248
251
|
}
|
|
249
|
-
session?.saveReport(result.findings,
|
|
252
|
+
session?.saveReport(result.findings, summarizeRun(record));
|
|
250
253
|
writeManifest(root, record);
|
|
251
254
|
publishReports({
|
|
252
255
|
format: values.format,
|
|
@@ -42,6 +42,12 @@ export function runSessionCommand(positionals) {
|
|
|
42
42
|
started: session.started,
|
|
43
43
|
state: session.report.state ?? 'unknown',
|
|
44
44
|
notLookedAt: session.report.notLookedAt ?? ['session predates verdict recording'],
|
|
45
|
+
coverage: session.report.coverage,
|
|
46
|
+
verifyOnly: session.report.verifyOnly,
|
|
47
|
+
minSeverity: session.report.minSeverity,
|
|
48
|
+
filesReviewed: session.report.filesReviewed,
|
|
49
|
+
deterministicChecks: session.report.deterministicChecks,
|
|
50
|
+
scopeDetails: session.report.scopeDetails,
|
|
45
51
|
}));
|
|
46
52
|
process.stdout.write(output + '\n');
|
|
47
53
|
return 0;
|
package/dist/config.js
CHANGED
|
@@ -9,6 +9,7 @@ const DEFAULTS = {
|
|
|
9
9
|
// security may duplicate SAST; convention needs repository idioms in the diff
|
|
10
10
|
judges: ['plausible-logic', 'test-adequacy', 'intent'],
|
|
11
11
|
minSeverity: 'low',
|
|
12
|
+
coverage: 'portable',
|
|
12
13
|
// findings in vendored trees are not decisions made by this repository
|
|
13
14
|
ignore: [
|
|
14
15
|
'**/node_modules/**', '**/dist/**', '**/build/**', '**/*.generated.*', '**/*.min.js',
|
|
@@ -33,6 +34,7 @@ export function policyChanged(root, baseRef) {
|
|
|
33
34
|
}
|
|
34
35
|
const KEYS = new Set([...Object.keys(DEFAULTS), 'checks']);
|
|
35
36
|
const PROVIDERS = new Set(['anthropic', 'openai', 'gemini']);
|
|
37
|
+
const COVERAGE = new Set(['portable', 'strict']);
|
|
36
38
|
/** A misspelled name in the config is the quietest way to get a clean review. */
|
|
37
39
|
export function validateConfig(raw, known) {
|
|
38
40
|
const problems = [];
|
|
@@ -50,6 +52,9 @@ export function validateConfig(raw, known) {
|
|
|
50
52
|
if (raw.minSeverity !== undefined && !SEVERITIES.includes(raw.minSeverity)) {
|
|
51
53
|
problems.push('minSeverity "' + String(raw.minSeverity) + '" is not one of: ' + SEVERITIES.join(', '));
|
|
52
54
|
}
|
|
55
|
+
if (raw.coverage !== undefined && !COVERAGE.has(String(raw.coverage))) {
|
|
56
|
+
problems.push('coverage "' + String(raw.coverage) + '" is not one of: ' + [...COVERAGE].join(', '));
|
|
57
|
+
}
|
|
53
58
|
for (const [field, names] of [['verifiers', known.verifiers], ['judges', known.judges]]) {
|
|
54
59
|
const value = raw[field];
|
|
55
60
|
if (value === undefined)
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { stripControl } from '#app/text.js';
|
|
2
|
+
const API_VERSION = '2022-11-28';
|
|
3
|
+
const API_TIMEOUT_MS = 20_000;
|
|
4
|
+
const MAX_API_PAGES = 100;
|
|
5
|
+
export function createReviewPayload(commitId, comments) {
|
|
6
|
+
return {
|
|
7
|
+
commit_id: commitId,
|
|
8
|
+
body: `PowerShot posted ${comments.length} proven verified finding(s) on changed lines.`,
|
|
9
|
+
event: 'COMMENT',
|
|
10
|
+
comments,
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
function record(value) {
|
|
14
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
15
|
+
}
|
|
16
|
+
function requiredString(value, name) {
|
|
17
|
+
if (typeof value !== 'string' || value.length === 0)
|
|
18
|
+
throw new Error(`GitHub API returned no ${name}`);
|
|
19
|
+
return value;
|
|
20
|
+
}
|
|
21
|
+
function nextLink(value) {
|
|
22
|
+
if (value === null)
|
|
23
|
+
return undefined;
|
|
24
|
+
for (const part of value.split(',')) {
|
|
25
|
+
const match = /^\s*<([^>]+)>;\s*rel="([^"]+)"\s*$/.exec(part);
|
|
26
|
+
if (match?.[2] === 'next')
|
|
27
|
+
return match[1];
|
|
28
|
+
}
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
function errorDetail(value) {
|
|
32
|
+
return stripControl(value).replace(/\r?\n/g, ' ').slice(0, 500);
|
|
33
|
+
}
|
|
34
|
+
function issueComment(value, index) {
|
|
35
|
+
if (!record(value) ||
|
|
36
|
+
!Number.isSafeInteger(value.id) ||
|
|
37
|
+
(value.body !== null && typeof value.body !== 'string')) {
|
|
38
|
+
throw new Error(`GitHub API issue comment ${index + 1} has an invalid contract`);
|
|
39
|
+
}
|
|
40
|
+
const user = record(value.user) && typeof value.user.login === 'string'
|
|
41
|
+
? { login: value.user.login }
|
|
42
|
+
: undefined;
|
|
43
|
+
return { id: Number(value.id), body: value.body ?? '', user };
|
|
44
|
+
}
|
|
45
|
+
/** Shared REST transport for the two pull-request publication adapters. */
|
|
46
|
+
export class GitHubPullRequestApi {
|
|
47
|
+
token;
|
|
48
|
+
pullNumber;
|
|
49
|
+
base;
|
|
50
|
+
pullPath;
|
|
51
|
+
issuePath;
|
|
52
|
+
issueCommentPath;
|
|
53
|
+
constructor(apiUrl, token, owner, repository, pullNumber) {
|
|
54
|
+
this.token = token;
|
|
55
|
+
this.pullNumber = pullNumber;
|
|
56
|
+
this.base = apiUrl.replace(/\/$/, '');
|
|
57
|
+
const repositoryPath = `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}`;
|
|
58
|
+
this.pullPath = `${repositoryPath}/pulls`;
|
|
59
|
+
this.issuePath = `${repositoryPath}/issues/${pullNumber}`;
|
|
60
|
+
this.issueCommentPath = `${repositoryPath}/issues/comments`;
|
|
61
|
+
}
|
|
62
|
+
url(endpoint) {
|
|
63
|
+
if (!endpoint.startsWith('http://') && !endpoint.startsWith('https://'))
|
|
64
|
+
return this.base + endpoint;
|
|
65
|
+
if (endpoint !== this.base && !endpoint.startsWith(this.base + '/')) {
|
|
66
|
+
throw new Error('GitHub pagination left the configured API origin');
|
|
67
|
+
}
|
|
68
|
+
return endpoint;
|
|
69
|
+
}
|
|
70
|
+
async request(method, endpoint, body, acceptedStatuses = []) {
|
|
71
|
+
const response = await fetch(this.url(endpoint), {
|
|
72
|
+
method,
|
|
73
|
+
headers: {
|
|
74
|
+
Accept: 'application/vnd.github+json',
|
|
75
|
+
Authorization: `Bearer ${this.token}`,
|
|
76
|
+
'User-Agent': 'PowerShot',
|
|
77
|
+
'X-GitHub-Api-Version': API_VERSION,
|
|
78
|
+
...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
|
|
79
|
+
},
|
|
80
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
81
|
+
signal: AbortSignal.timeout(API_TIMEOUT_MS),
|
|
82
|
+
});
|
|
83
|
+
const source = await response.text();
|
|
84
|
+
if (!response.ok) {
|
|
85
|
+
if (acceptedStatuses.includes(response.status))
|
|
86
|
+
return { data: undefined };
|
|
87
|
+
throw new Error(`GitHub API ${method} failed with ${response.status}: ${errorDetail(source)}`);
|
|
88
|
+
}
|
|
89
|
+
const data = source.length === 0 ? undefined : JSON.parse(source);
|
|
90
|
+
return { data, next: nextLink(response.headers.get('link')) };
|
|
91
|
+
}
|
|
92
|
+
async all(endpoint) {
|
|
93
|
+
const out = [];
|
|
94
|
+
const seen = new Set();
|
|
95
|
+
let next = endpoint + (endpoint.includes('?') ? '&' : '?') + 'per_page=100';
|
|
96
|
+
for (let page = 0; next !== undefined; page++) {
|
|
97
|
+
if (page >= MAX_API_PAGES)
|
|
98
|
+
throw new Error('GitHub API pagination exceeded its safety limit');
|
|
99
|
+
if (seen.has(next))
|
|
100
|
+
throw new Error('GitHub API returned a pagination cycle');
|
|
101
|
+
seen.add(next);
|
|
102
|
+
const response = await this.request('GET', next);
|
|
103
|
+
if (!Array.isArray(response.data))
|
|
104
|
+
throw new Error('GitHub API returned a non-array page');
|
|
105
|
+
out.push(...response.data);
|
|
106
|
+
next = response.next;
|
|
107
|
+
}
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
async headSha() {
|
|
111
|
+
const { data } = await this.request('GET', `${this.pullPath}/${this.pullNumber}`);
|
|
112
|
+
if (!record(data) || !record(data.head))
|
|
113
|
+
throw new Error('GitHub API returned no pull request head');
|
|
114
|
+
return requiredString(data.head.sha, 'pull request head SHA');
|
|
115
|
+
}
|
|
116
|
+
async listFiles() {
|
|
117
|
+
const values = await this.all(`${this.pullPath}/${this.pullNumber}/files`);
|
|
118
|
+
return values.map((value, index) => {
|
|
119
|
+
if (!record(value) || typeof value.filename !== 'string') {
|
|
120
|
+
throw new Error(`GitHub API pull file ${index + 1} has an invalid contract`);
|
|
121
|
+
}
|
|
122
|
+
if (value.patch !== undefined && typeof value.patch !== 'string') {
|
|
123
|
+
throw new Error(`GitHub API pull file ${index + 1} has an invalid patch`);
|
|
124
|
+
}
|
|
125
|
+
return { filename: value.filename, patch: value.patch };
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
async listReviewComments() {
|
|
129
|
+
const values = await this.all(`${this.pullPath}/${this.pullNumber}/comments`);
|
|
130
|
+
return values.map((value, index) => {
|
|
131
|
+
if (!record(value) ||
|
|
132
|
+
!Number.isSafeInteger(value.id) ||
|
|
133
|
+
typeof value.path !== 'string' ||
|
|
134
|
+
(value.line !== null && !Number.isSafeInteger(value.line)) ||
|
|
135
|
+
(value.body !== null && typeof value.body !== 'string') ||
|
|
136
|
+
(value.in_reply_to_id !== undefined && value.in_reply_to_id !== null && !Number.isSafeInteger(value.in_reply_to_id))) {
|
|
137
|
+
throw new Error(`GitHub API review comment ${index + 1} has an invalid contract`);
|
|
138
|
+
}
|
|
139
|
+
const user = record(value.user) && typeof value.user.login === 'string'
|
|
140
|
+
? { login: value.user.login }
|
|
141
|
+
: undefined;
|
|
142
|
+
return {
|
|
143
|
+
id: Number(value.id),
|
|
144
|
+
path: value.path,
|
|
145
|
+
line: value.line === null ? null : Number(value.line),
|
|
146
|
+
body: value.body ?? '',
|
|
147
|
+
user,
|
|
148
|
+
inReplyToId: value.in_reply_to_id === undefined || value.in_reply_to_id === null
|
|
149
|
+
? undefined
|
|
150
|
+
: Number(value.in_reply_to_id),
|
|
151
|
+
};
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
async createReview(commitId, comments) {
|
|
155
|
+
await this.request('POST', `${this.pullPath}/${this.pullNumber}/reviews`, createReviewPayload(commitId, comments));
|
|
156
|
+
}
|
|
157
|
+
async deleteReviewComment(id) {
|
|
158
|
+
await this.request('DELETE', `${this.pullPath}/comments/${id}`, undefined, [404]);
|
|
159
|
+
}
|
|
160
|
+
async listIssueComments() {
|
|
161
|
+
const values = await this.all(`${this.issuePath}/comments`);
|
|
162
|
+
return values.map(issueComment);
|
|
163
|
+
}
|
|
164
|
+
async createIssueComment(body) {
|
|
165
|
+
const { data } = await this.request('POST', `${this.issuePath}/comments`, { body });
|
|
166
|
+
return issueComment(data, 0);
|
|
167
|
+
}
|
|
168
|
+
async updateIssueComment(id, body) {
|
|
169
|
+
await this.request('PATCH', `${this.issueCommentPath}/${id}`, { body });
|
|
170
|
+
}
|
|
171
|
+
async deleteIssueComment(id) {
|
|
172
|
+
await this.request('DELETE', `${this.issueCommentPath}/${id}`, undefined, [404]);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
export function requiredEnvironment(name) {
|
|
176
|
+
const value = process.env[name];
|
|
177
|
+
if (value === undefined || value.length === 0)
|
|
178
|
+
throw new Error(`${name} is required`);
|
|
179
|
+
return value;
|
|
180
|
+
}
|
|
181
|
+
export function expectedHeadShaFromEnvironment() {
|
|
182
|
+
const value = requiredEnvironment('POWERSHOT_HEAD_SHA');
|
|
183
|
+
if (!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/i.test(value))
|
|
184
|
+
throw new Error('POWERSHOT_HEAD_SHA is invalid');
|
|
185
|
+
return value;
|
|
186
|
+
}
|
|
187
|
+
export function githubPullRequestApiFromEnvironment() {
|
|
188
|
+
const repository = requiredEnvironment('GITHUB_REPOSITORY').split('/');
|
|
189
|
+
if (repository.length !== 2 || !repository[0] || !repository[1]) {
|
|
190
|
+
throw new Error('GITHUB_REPOSITORY must be owner/repository');
|
|
191
|
+
}
|
|
192
|
+
const pullNumber = Number(requiredEnvironment('POWERSHOT_PR_NUMBER'));
|
|
193
|
+
if (!Number.isSafeInteger(pullNumber) || pullNumber < 1) {
|
|
194
|
+
throw new Error('POWERSHOT_PR_NUMBER must be positive');
|
|
195
|
+
}
|
|
196
|
+
return new GitHubPullRequestApi(requiredEnvironment('GITHUB_API_URL'), requiredEnvironment('GITHUB_TOKEN'), repository[0], repository[1], pullNumber);
|
|
197
|
+
}
|
|
198
|
+
//# sourceMappingURL=api.js.map
|
|
@@ -3,21 +3,11 @@ import { readFile } from 'node:fs/promises';
|
|
|
3
3
|
import { pathToFileURL } from 'node:url';
|
|
4
4
|
import { stripControl } from '#app/text.js';
|
|
5
5
|
import { SEVERITIES } from '#app/types.js';
|
|
6
|
+
import { githubPullRequestApiFromEnvironment, requiredEnvironment, } from './api.js';
|
|
6
7
|
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
8
|
const BOT_LOGIN = 'github-actions[bot]';
|
|
11
9
|
const MARKER_PATTERN = /<!-- powershot:inline:v1:[a-f0-9]{24} -->/;
|
|
12
10
|
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
11
|
function oneLine(value, limit) {
|
|
22
12
|
return stripControl(value).replace(/\r?\n/g, ' ').slice(0, limit);
|
|
23
13
|
}
|
|
@@ -189,11 +179,6 @@ export async function syncInlineComments(api, findings, expectedHeadSha, limit =
|
|
|
189
179
|
function record(value) {
|
|
190
180
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
191
181
|
}
|
|
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
182
|
export function parseReviewFindings(source) {
|
|
198
183
|
const document = JSON.parse(source);
|
|
199
184
|
if (!record(document) || !Array.isArray(document.findings)) {
|
|
@@ -232,148 +217,12 @@ export function parseReviewFindings(source) {
|
|
|
232
217
|
};
|
|
233
218
|
});
|
|
234
219
|
}
|
|
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
220
|
async function main() {
|
|
365
|
-
const
|
|
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');
|
|
221
|
+
const expectedHeadSha = requiredEnvironment('POWERSHOT_HEAD_SHA');
|
|
373
222
|
if (!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/i.test(expectedHeadSha))
|
|
374
223
|
throw new Error('POWERSHOT_HEAD_SHA is invalid');
|
|
375
224
|
const findings = parseReviewFindings(await readFile('powershot.json', 'utf8'));
|
|
376
|
-
const api =
|
|
225
|
+
const api = githubPullRequestApiFromEnvironment();
|
|
377
226
|
const result = await syncInlineComments(api, findings, expectedHeadSha);
|
|
378
227
|
if (result.outdated) {
|
|
379
228
|
process.stdout.write('PowerShot skipped inline comments because the pull request head changed.\n');
|