@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 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 compilers, parsers, manifests, reference graphs, and pre/post ASTs first.
24
- Optional model judges only handle questions that still require judgement.
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 completion</strong><br>A partial run cannot present itself as a clean verdict.</td>
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 missing capabilities to each file individually.
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
- PowerShot stays silent when a required oracle is unavailable. The missing capability
112
- is written to the manifest instead of being counted as a pass.
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{"Everything accounted for?"}
178
- ACCOUNT -- "yes" --> FINDINGS{"Findings?"}
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 -- "capability or budget gap" --> PARTIAL["exit 3 · partial"]
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
- “No findings” and “PowerShot could not look” are intentionally different pipeline
202
- outcomes. Use `--format manifest` to inspect file dispositions, executed checks,
203
- skips, failures, judge units, and `notLookedAt`.
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
- 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.
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
- PowerShot loads a bounded number of grammars per run. Unsupported or unavailable
304
- coverage is named in the manifest.
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
 
@@ -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, manifest);
43
+ return markdown(result.findings, summary);
42
44
  return terminal(result.findings, {
43
45
  subtitle: target,
44
46
  ...result.stats,
45
- state: manifest.state,
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, { state: record.state, notLookedAt: record.notLookedAt });
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 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');
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 = new GitHubPullRequestApi(environment('GITHUB_API_URL'), environment('GITHUB_TOKEN'), repository[0], repository[1], pullNumber);
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');