@0xcraft/powershot 1.1.2 → 1.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -206,8 +206,9 @@ flowchart LR
206
206
  | `2` | Command or Git input was invalid |
207
207
  | `3` | Review is incomplete; findings may be missing |
208
208
 
209
- “No findings in portable coverage”, “No findings”, and “PowerShot could not look” are
210
- intentionally different outcomes. Use `--format manifest` to inspect `coverage`, file
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
211
212
  dispositions, executed and unavailable checks, failures, judge units, and
212
213
  `notLookedAt`.
213
214
 
@@ -1,10 +1,10 @@
1
1
  import { writeFileSync } from 'node:fs';
2
- import { unavailableCoverage } from '#app/manifest.js';
3
2
  import { dim } from '#app/report/ansi.js';
4
3
  import { codeQuality } from '#app/report/codequality.js';
5
4
  import { compact } from '#app/report/compact.js';
6
5
  import { markdown } from '#app/report/markdown.js';
7
6
  import { sarif } from '#app/report/sarif.js';
7
+ import { summarizeRun } from '#app/report/summary.js';
8
8
  import { terminal } from '#app/report/terminal.js';
9
9
  export const REPORT_FORMATS = ['text', 'compact', 'markdown', 'json', 'sarif', 'codequality', 'manifest'];
10
10
  export function isReportFormat(value) {
@@ -38,15 +38,13 @@ export function renderReport(format, result, manifest, target) {
38
38
  return sarif(result.findings);
39
39
  if (format === 'json')
40
40
  return jsonResult(result);
41
+ const summary = summarizeRun(manifest);
41
42
  if (format === 'markdown')
42
- return markdown(result.findings, manifest);
43
+ return markdown(result.findings, summary);
43
44
  return terminal(result.findings, {
44
45
  subtitle: target,
45
46
  ...result.stats,
46
- state: manifest.state,
47
- notLookedAt: manifest.notLookedAt,
48
- coverage: manifest.coverage,
49
- unavailableCoverage: unavailableCoverage(manifest),
47
+ ...summary,
50
48
  });
51
49
  }
52
50
  export function publishReports(options) {
@@ -5,11 +5,12 @@ import { loadConfig, policyChanged } from '#app/config.js';
5
5
  import { absorbDelegated, delegateBrief } from '#app/delegate.js';
6
6
  import { baseRefOf, checkRange, headSha, repoRoot, shaOf } from '#app/git.js';
7
7
  import { JUDGES } from '#app/judges/prompts.js';
8
- import { RunManifest, coverageProblems, hashOf, unavailableCoverage, writeManifest } from '#app/manifest.js';
8
+ import { RunManifest, coverageProblems, hashOf, writeManifest } from '#app/manifest.js';
9
9
  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,6 +223,7 @@ 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 ?? [],
@@ -247,12 +249,7 @@ export async function runReviewCommand(command, values, positionals) {
247
249
  record.notLookedAt.push(failure);
248
250
  process.stderr.write(yellow(' ◇ manifest') + dim(' ' + gaps.join('; ')) + '\n');
249
251
  }
250
- session?.saveReport(result.findings, {
251
- state: record.state,
252
- notLookedAt: record.notLookedAt,
253
- coverage: record.coverage,
254
- unavailableCoverage: unavailableCoverage(record),
255
- });
252
+ session?.saveReport(result.findings, summarizeRun(record));
256
253
  writeManifest(root, record);
257
254
  publishReports({
258
255
  format: values.format,
@@ -43,7 +43,11 @@ export function runSessionCommand(positionals) {
43
43
  state: session.report.state ?? 'unknown',
44
44
  notLookedAt: session.report.notLookedAt ?? ['session predates verdict recording'],
45
45
  coverage: session.report.coverage,
46
- unavailableCoverage: session.report.unavailableCoverage,
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,
47
51
  }));
48
52
  process.stdout.write(output + '\n');
49
53
  return 0;
@@ -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');
@@ -0,0 +1,149 @@
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 { expectedHeadShaFromEnvironment, githubPullRequestApiFromEnvironment, requiredEnvironment, } from './api.js';
6
+ export const LEGACY_SUMMARY_MARKER = '<!-- powershot:summary:v1 -->';
7
+ const BOT_LOGIN = 'github-actions[bot]';
8
+ const LEGACY_HEADING = /^## PowerShot(?:\r?\n|$)/;
9
+ /** Scope ownership to one workflow job without exposing its repository path in the comment. */
10
+ export function summaryMarker(scope) {
11
+ if (scope.length === 0)
12
+ throw new Error('PowerShot summary scope is required');
13
+ const digest = createHash('sha256').update(scope).digest('hex').slice(0, 24);
14
+ return `<!-- powershot:summary:v2:${digest} -->`;
15
+ }
16
+ /** Stable workflow identity: keep the repository/path, discard its moving ref. */
17
+ export function workflowCommentScope(workflowRef, job) {
18
+ const structuralSeparator = workflowRef.indexOf('@refs/');
19
+ const separator = structuralSeparator === -1 ? workflowRef.lastIndexOf('@') : structuralSeparator;
20
+ if (separator < 1 || separator === workflowRef.length - 1) {
21
+ throw new Error('GITHUB_WORKFLOW_REF must contain a workflow path and ref');
22
+ }
23
+ if (job.length === 0)
24
+ throw new Error('GITHUB_JOB is required');
25
+ return `${workflowRef.slice(0, separator)}:${job}`;
26
+ }
27
+ function headMarker(headSha) {
28
+ return `<!-- powershot:head:${headSha.toLowerCase()} -->`;
29
+ }
30
+ export function summaryCommentBody(markdown, marker, headSha) {
31
+ const report = markdown.trimEnd();
32
+ const ownership = `${marker}\n${headMarker(headSha)}`;
33
+ return report.length === 0 ? ownership : `${ownership}\n\n${report}`;
34
+ }
35
+ function owns(body, marker) {
36
+ return body === marker || body.startsWith(marker + '\n') || body.startsWith(marker + '\r\n');
37
+ }
38
+ function ownsHead(body, marker, headSha) {
39
+ const ownership = `${marker}\n${headMarker(headSha)}`;
40
+ return body === ownership || body.startsWith(ownership + '\n') || body.startsWith(ownership + '\r\n');
41
+ }
42
+ function latest(comments) {
43
+ return comments.reduce((selected, comment) => selected === undefined || comment.id > selected.id ? comment : selected, undefined);
44
+ }
45
+ async function retireComments(api, comments, keepId) {
46
+ const staleIds = comments
47
+ .filter((comment) => comment.id !== keepId)
48
+ .map((comment) => comment.id)
49
+ .filter((id, index, ids) => ids.indexOf(id) === index)
50
+ .sort((left, right) => left - right);
51
+ for (const id of staleIds)
52
+ await api.deleteIssueComment(id);
53
+ return staleIds.length;
54
+ }
55
+ async function retireCreatedIfUnchanged(api, id, body) {
56
+ const created = (await api.listIssueComments())
57
+ .find((comment) => comment.id === id && comment.body === body);
58
+ if (created === undefined)
59
+ return 0;
60
+ await api.deleteIssueComment(id);
61
+ return 1;
62
+ }
63
+ async function reconcileCandidate(api, marker, body, expectedHeadSha, state, previousHeadComments, createdByThisRun, legacy) {
64
+ // REST has no atomic create-if-absent. Relisting makes same-head participants
65
+ // converge without allowing an old-head run to patch or retire a newer candidate.
66
+ const reconciled = (await api.listIssueComments()).filter((comment) => comment.user?.login === BOT_LOGIN &&
67
+ owns(comment.body, marker) &&
68
+ ownsHead(comment.body, marker, expectedHeadSha));
69
+ if (await api.headSha() !== expectedHeadSha) {
70
+ const retired = createdByThisRun === undefined
71
+ ? 0
72
+ : await retireCreatedIfUnchanged(api, createdByThisRun.id, body);
73
+ return { state: 'outdated', retired };
74
+ }
75
+ const keep = latest(reconciled);
76
+ if (keep === undefined)
77
+ throw new Error('GitHub API did not return the summary comment it created');
78
+ const retired = await retireComments(api, [...reconciled, ...previousHeadComments, ...(legacy === undefined ? [] : [legacy])], keep.id);
79
+ if (await api.headSha() !== expectedHeadSha) {
80
+ const createdRetired = createdByThisRun === undefined
81
+ ? 0
82
+ : await retireCreatedIfUnchanged(api, createdByThisRun.id, body);
83
+ return { state: 'outdated', retired: retired + createdRetired };
84
+ }
85
+ return { state, commentId: keep.id, retired };
86
+ }
87
+ async function createCandidate(api, marker, body, expectedHeadSha, state, previousHeadComments, legacy) {
88
+ const created = await api.createIssueComment(body);
89
+ if (await api.headSha() !== expectedHeadSha) {
90
+ const retired = await retireCreatedIfUnchanged(api, created.id, body);
91
+ return { state: 'outdated', retired };
92
+ }
93
+ return reconcileCandidate(api, marker, body, expectedHeadSha, state, previousHeadComments, created, legacy);
94
+ }
95
+ /** Reconcile only this workflow's PowerShot summary against the current pull-request head. */
96
+ export async function syncSummaryComment(api, markdown, expectedHeadSha, scope) {
97
+ if (await api.headSha() !== expectedHeadSha)
98
+ return { state: 'outdated', retired: 0 };
99
+ const marker = summaryMarker(scope);
100
+ const body = summaryCommentBody(markdown, marker, expectedHeadSha);
101
+ const comments = await api.listIssueComments();
102
+ if (await api.headSha() !== expectedHeadSha)
103
+ return { state: 'outdated', retired: 0 };
104
+ const botComments = comments.filter((comment) => comment.user?.login === BOT_LOGIN);
105
+ const markedComments = botComments.filter((comment) => owns(comment.body, marker));
106
+ const currentHeadComments = markedComments.filter((comment) => ownsHead(comment.body, marker, expectedHeadSha));
107
+ const previousHeadComments = markedComments.filter((comment) => !ownsHead(comment.body, marker, expectedHeadSha));
108
+ const candidate = latest(currentHeadComments);
109
+ if (candidate !== undefined) {
110
+ const state = candidate.body === body ? 'unchanged' : 'updated';
111
+ if (state === 'updated') {
112
+ // A comment id never changes head ownership. This PATCH can race only with
113
+ // another run for the same head, never with a newer pull-request head.
114
+ await api.updateIssueComment(candidate.id, body);
115
+ if (await api.headSha() !== expectedHeadSha)
116
+ return { state: 'outdated', retired: 0 };
117
+ }
118
+ return reconcileCandidate(api, marker, body, expectedHeadSha, state, previousHeadComments);
119
+ }
120
+ // Legacy comments have no workflow or head identity, so claiming one with
121
+ // PATCH would let two workflows overwrite each other. Create the scoped,
122
+ // head-owned replacement first and retire only the legacy snapshot later.
123
+ const legacy = latest(botComments.filter((comment) => owns(comment.body, LEGACY_SUMMARY_MARKER) || LEGACY_HEADING.test(comment.body)));
124
+ if (legacy !== undefined) {
125
+ return createCandidate(api, marker, body, expectedHeadSha, 'migrated', previousHeadComments, legacy);
126
+ }
127
+ return createCandidate(api, marker, body, expectedHeadSha, 'created', previousHeadComments);
128
+ }
129
+ function oneLine(value, limit) {
130
+ return stripControl(value).replace(/\r?\n/g, ' ').slice(0, limit);
131
+ }
132
+ async function main() {
133
+ const result = await syncSummaryComment(githubPullRequestApiFromEnvironment(), await readFile('powershot.md', 'utf8'), expectedHeadShaFromEnvironment(), workflowCommentScope(requiredEnvironment('GITHUB_WORKFLOW_REF'), requiredEnvironment('GITHUB_JOB')));
134
+ if (result.state === 'outdated') {
135
+ process.stdout.write('PowerShot skipped the summary because the pull request head changed.\n');
136
+ return;
137
+ }
138
+ const retired = result.retired === 0 ? '' : ` ${result.retired} duplicate(s) retired.`;
139
+ process.stdout.write(`PowerShot summary comment: ${result.state}.${retired}\n`);
140
+ }
141
+ const entry = process.argv[1];
142
+ if (entry !== undefined && import.meta.url === pathToFileURL(entry).href) {
143
+ main().catch((error) => {
144
+ const message = error instanceof Error ? error.message : String(error);
145
+ process.stderr.write('PowerShot summary comment failed: ' + oneLine(message, 1_000) + '\n');
146
+ process.exitCode = 1;
147
+ });
148
+ }
149
+ //# sourceMappingURL=summary-comment.js.map
package/dist/manifest.js CHANGED
@@ -2,23 +2,6 @@ import { createHash } from 'node:crypto';
2
2
  import { mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  export const SCHEMA = 'powershot.run/v1';
5
- /** Human-readable optional depth, kept separate from verdict-blocking notLookedAt. */
6
- export function unavailableCoverage(record) {
7
- const out = [];
8
- const files = (record.files ?? []).filter((file) => file.unavailable?.length);
9
- if (files.length > 0) {
10
- out.push(files.length + ' file(s) without enriched semantic coverage: ' +
11
- files.slice(0, 5).map((file) => file.path + ' (' + file.unavailable.join(', ') + ')').join(', ') +
12
- (files.length > 5 ? ', …' : ''));
13
- }
14
- const checks = record.checks?.unavailable ?? [];
15
- if (checks.length > 0) {
16
- out.push(checks.length + ' enriched check(s) unavailable: ' +
17
- checks.slice(0, 8).map((check) => check.check + ' (no ' + check.missing + ')').join(', ') +
18
- (checks.length > 8 ? ', …' : ''));
19
- }
20
- return out;
21
- }
22
5
  /** The single state machine behind manifests, benches, renderers and exit codes. */
23
6
  export function completionOf(parts) {
24
7
  const waivedUnits = parts.units.filter((unit) => unit.outcome === 'waived').length;
@@ -64,8 +64,12 @@ 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', 'api.js')))
68
+ throw new Error('GitHub REST runtime is missing');
67
69
  if (!existsSync(join(installed, 'dist', 'github', 'inline-comments.js')))
68
70
  throw new Error('inline review runtime is missing');
71
+ if (!existsSync(join(installed, 'dist', 'github', 'summary-comment.js')))
72
+ throw new Error('summary comment runtime is missing');
69
73
  if (!existsSync(join(installed, 'examples', 'github-actions', 'cli.yml')))
70
74
  throw new Error('CI example is missing');
71
75
  if (!existsSync(join(installed, 'examples', 'gitlab', '.gitlab-ci.yml')))