@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 +3 -2
- package/dist/cli/reports.js +4 -6
- package/dist/cli/review-command.js +4 -7
- package/dist/cli/session-command.js +5 -1
- 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/manifest.js +0 -17
- package/dist/package-smoke.js +4 -0
- package/dist/report/markdown.js +28 -16
- package/dist/report/summary.js +103 -0
- package/dist/report/terminal.js +18 -11
- package/dist/report/viewer.js +18 -9
- package/dist/selftest.js +431 -18
- package/dist/session.js +6 -2
- package/docs/architecture.md +1 -0
- package/docs/ci.md +25 -0
- package/examples/github-actions/action.yml +4 -0
- package/package.json +1 -1
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
|
-
|
|
210
|
-
|
|
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
|
|
package/dist/cli/reports.js
CHANGED
|
@@ -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,
|
|
43
|
+
return markdown(result.findings, summary);
|
|
43
44
|
return terminal(result.findings, {
|
|
44
45
|
subtitle: target,
|
|
45
46
|
...result.stats,
|
|
46
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
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');
|
|
@@ -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;
|
package/dist/package-smoke.js
CHANGED
|
@@ -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')))
|