@testzugang/pi-pr-findings 0.1.0
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 +15 -0
- package/extensions/pr-findings/index.ts +969 -0
- package/package.json +30 -0
- package/skills/pr-findings/SKILL.md +72 -0
- package/skills/pr-findings/fetch.sh +86 -0
- package/skills/pr-findings/format.py +229 -0
package/README.md
ADDED
|
@@ -0,0 +1,969 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Type } from "typebox";
|
|
3
|
+
|
|
4
|
+
type ExecResult = {
|
|
5
|
+
code: number | null;
|
|
6
|
+
stdout?: string;
|
|
7
|
+
stderr?: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
type Severity = "blocker" | "warning" | "nit" | "all";
|
|
11
|
+
type WaitMode = "new-review-activity" | "checks-finished";
|
|
12
|
+
|
|
13
|
+
type FindingsOptions = {
|
|
14
|
+
prNumber?: number;
|
|
15
|
+
repo?: string;
|
|
16
|
+
unresolved: boolean;
|
|
17
|
+
severity: Severity;
|
|
18
|
+
includeStale: boolean;
|
|
19
|
+
mine: boolean;
|
|
20
|
+
waitForNextReview: boolean;
|
|
21
|
+
waitMode: WaitMode;
|
|
22
|
+
waitTimeoutSec: number;
|
|
23
|
+
waitPollSec: number;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
type ActivitySnapshot = {
|
|
27
|
+
summaryCount: number;
|
|
28
|
+
inlineCount: number;
|
|
29
|
+
latestAtMs: number;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
type PrSummaryComment = {
|
|
33
|
+
id: string;
|
|
34
|
+
author: string;
|
|
35
|
+
createdAt: string;
|
|
36
|
+
body: string;
|
|
37
|
+
url: string;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
type InlineComment = {
|
|
41
|
+
id: string;
|
|
42
|
+
author: string;
|
|
43
|
+
createdAt: string;
|
|
44
|
+
path: string;
|
|
45
|
+
line: number | null;
|
|
46
|
+
body: string;
|
|
47
|
+
commitId: string;
|
|
48
|
+
url: string;
|
|
49
|
+
isOutdated: boolean;
|
|
50
|
+
isResolved: boolean;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
type FindingsData = {
|
|
54
|
+
pr: {
|
|
55
|
+
number: number;
|
|
56
|
+
state: string;
|
|
57
|
+
headSha: string;
|
|
58
|
+
url: string;
|
|
59
|
+
};
|
|
60
|
+
viewerLogin: string;
|
|
61
|
+
checks: Array<{
|
|
62
|
+
name: string;
|
|
63
|
+
conclusion: string;
|
|
64
|
+
url: string;
|
|
65
|
+
}>;
|
|
66
|
+
summaryComments: PrSummaryComment[];
|
|
67
|
+
inlineComments: InlineComment[];
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const EXEC_TIMEOUT_MS = 20_000;
|
|
71
|
+
|
|
72
|
+
const DROP_AUTHORS = new Set([
|
|
73
|
+
"sonarqubecloud[bot]",
|
|
74
|
+
"sonarcloud[bot]",
|
|
75
|
+
"dependabot[bot]",
|
|
76
|
+
"github-actions[bot]",
|
|
77
|
+
]);
|
|
78
|
+
|
|
79
|
+
const SECTION_HEADER_RE =
|
|
80
|
+
/^#{1,6}\s*(?:๐ด|๐ก|๐ต|โ
)?\s*(blockers?|warnings?|nits?|approvals?|strengths?|suggestions?).*$/gim;
|
|
81
|
+
const NUMBERED_ITEM_RE = /^\s*\d+\.\s+/gm;
|
|
82
|
+
|
|
83
|
+
const DEFAULTS = {
|
|
84
|
+
severity: "all" as Severity,
|
|
85
|
+
unresolved: false,
|
|
86
|
+
includeStale: false,
|
|
87
|
+
mine: false,
|
|
88
|
+
waitForNextReview: false,
|
|
89
|
+
waitMode: "new-review-activity" as WaitMode,
|
|
90
|
+
waitTimeoutSec: 60,
|
|
91
|
+
waitPollSec: 30,
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
export default function registerPrFindings(pi: ExtensionAPI) {
|
|
95
|
+
pi.registerTool({
|
|
96
|
+
name: "pr_findings",
|
|
97
|
+
label: "PR Findings",
|
|
98
|
+
description:
|
|
99
|
+
"Fetch GitHub PR review findings via gh, grouped by severity, with optional wait for fresh review activity.",
|
|
100
|
+
promptSnippet: "Fetch GitHub PR findings and summarize by severity",
|
|
101
|
+
promptGuidelines: [
|
|
102
|
+
"Use waitForNextReview=true after pushing fixes so you don't read stale findings before the next review run finishes.",
|
|
103
|
+
"Default wait timeout is 60s with 30s polling when waiting is enabled.",
|
|
104
|
+
],
|
|
105
|
+
parameters: Type.Object({
|
|
106
|
+
prNumber: Type.Optional(Type.Number({ description: "PR number" })),
|
|
107
|
+
repo: Type.Optional(
|
|
108
|
+
Type.String({ description: "Repository in owner/repo format" }),
|
|
109
|
+
),
|
|
110
|
+
unresolved: Type.Optional(
|
|
111
|
+
Type.Boolean({ description: "Show unresolved findings only" }),
|
|
112
|
+
),
|
|
113
|
+
severity: Type.Optional(
|
|
114
|
+
Type.Union([
|
|
115
|
+
Type.Literal("blocker"),
|
|
116
|
+
Type.Literal("warning"),
|
|
117
|
+
Type.Literal("nit"),
|
|
118
|
+
Type.Literal("all"),
|
|
119
|
+
]),
|
|
120
|
+
),
|
|
121
|
+
includeStale: Type.Optional(
|
|
122
|
+
Type.Boolean({ description: "Include stale findings on old commits" }),
|
|
123
|
+
),
|
|
124
|
+
mine: Type.Optional(
|
|
125
|
+
Type.Boolean({
|
|
126
|
+
description: "Show only findings authored by current gh user",
|
|
127
|
+
}),
|
|
128
|
+
),
|
|
129
|
+
waitForNextReview: Type.Optional(
|
|
130
|
+
Type.Boolean({
|
|
131
|
+
description:
|
|
132
|
+
"Wait for new review activity before reading findings (useful right after a push)",
|
|
133
|
+
}),
|
|
134
|
+
),
|
|
135
|
+
waitMode: Type.Optional(
|
|
136
|
+
Type.Union([
|
|
137
|
+
Type.Literal("new-review-activity"),
|
|
138
|
+
Type.Literal("checks-finished"),
|
|
139
|
+
]),
|
|
140
|
+
),
|
|
141
|
+
waitTimeoutSec: Type.Optional(
|
|
142
|
+
Type.Number({
|
|
143
|
+
description: "Timeout for waiting mode in seconds (default 60)",
|
|
144
|
+
}),
|
|
145
|
+
),
|
|
146
|
+
waitPollSec: Type.Optional(
|
|
147
|
+
Type.Number({
|
|
148
|
+
description:
|
|
149
|
+
"Polling interval for waiting mode in seconds (default 30)",
|
|
150
|
+
}),
|
|
151
|
+
),
|
|
152
|
+
}),
|
|
153
|
+
async execute(_toolCallId, params) {
|
|
154
|
+
await ensureGhAvailable(pi);
|
|
155
|
+
|
|
156
|
+
const options = normalizeOptions(params);
|
|
157
|
+
const repo = await resolveRepo(pi, options.repo);
|
|
158
|
+
const prNumber = await resolvePrNumber(pi, repo, options.prNumber);
|
|
159
|
+
|
|
160
|
+
let waitSummary = "";
|
|
161
|
+
if (options.waitForNextReview) {
|
|
162
|
+
if (options.waitMode === "checks-finished") {
|
|
163
|
+
const waited = await waitForChecksFinished(
|
|
164
|
+
pi,
|
|
165
|
+
repo,
|
|
166
|
+
prNumber,
|
|
167
|
+
options,
|
|
168
|
+
);
|
|
169
|
+
waitSummary = waited
|
|
170
|
+
? "Waited for checks to finish before collecting findings."
|
|
171
|
+
: "Wait timeout reached before checks finished; collected current findings.";
|
|
172
|
+
} else {
|
|
173
|
+
const waited = await waitForNewReviewActivity(
|
|
174
|
+
pi,
|
|
175
|
+
repo,
|
|
176
|
+
prNumber,
|
|
177
|
+
options,
|
|
178
|
+
);
|
|
179
|
+
waitSummary = waited
|
|
180
|
+
? "Detected new review activity before collecting findings."
|
|
181
|
+
: "Wait timeout reached before new review activity; collected current findings.";
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const data = await fetchFindingsData(pi, repo, prNumber);
|
|
186
|
+
const report = renderMarkdown(data, options);
|
|
187
|
+
const closing = closingLine(report);
|
|
188
|
+
const text = waitSummary
|
|
189
|
+
? `${report}\n${closing}\n\n${waitSummary}`
|
|
190
|
+
: `${report}\n${closing}`;
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
content: [{ type: "text", text }],
|
|
194
|
+
details: {
|
|
195
|
+
repo,
|
|
196
|
+
prNumber,
|
|
197
|
+
waitForNextReview: options.waitForNextReview,
|
|
198
|
+
waitMode: options.waitMode,
|
|
199
|
+
waitTimeoutSec: options.waitTimeoutSec,
|
|
200
|
+
waitPollSec: options.waitPollSec,
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function normalizeOptions(params: Record<string, unknown>): FindingsOptions {
|
|
208
|
+
const severity = normalizeSeverity(params.severity);
|
|
209
|
+
const waitMode = normalizeWaitMode(params.waitMode);
|
|
210
|
+
const waitTimeoutSec = clampPositiveNumber(
|
|
211
|
+
params.waitTimeoutSec,
|
|
212
|
+
DEFAULTS.waitTimeoutSec,
|
|
213
|
+
5,
|
|
214
|
+
3600,
|
|
215
|
+
);
|
|
216
|
+
const waitPollSec = clampPositiveNumber(
|
|
217
|
+
params.waitPollSec,
|
|
218
|
+
DEFAULTS.waitPollSec,
|
|
219
|
+
5,
|
|
220
|
+
600,
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
prNumber:
|
|
225
|
+
typeof params.prNumber === "number" && Number.isInteger(params.prNumber)
|
|
226
|
+
? params.prNumber
|
|
227
|
+
: undefined,
|
|
228
|
+
repo: normalizeRepo(params.repo),
|
|
229
|
+
unresolved: params.unresolved === true,
|
|
230
|
+
severity,
|
|
231
|
+
includeStale: params.includeStale === true,
|
|
232
|
+
mine: params.mine === true,
|
|
233
|
+
waitForNextReview: params.waitForNextReview === true,
|
|
234
|
+
waitMode,
|
|
235
|
+
waitTimeoutSec,
|
|
236
|
+
waitPollSec,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function normalizeSeverity(value: unknown): Severity {
|
|
241
|
+
if (value === "blocker" || value === "warning" || value === "nit") {
|
|
242
|
+
return value;
|
|
243
|
+
}
|
|
244
|
+
return DEFAULTS.severity;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function normalizeWaitMode(value: unknown): WaitMode {
|
|
248
|
+
if (value === "checks-finished") return "checks-finished";
|
|
249
|
+
return DEFAULTS.waitMode;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function clampPositiveNumber(
|
|
253
|
+
value: unknown,
|
|
254
|
+
fallback: number,
|
|
255
|
+
min: number,
|
|
256
|
+
max: number,
|
|
257
|
+
): number {
|
|
258
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
|
|
259
|
+
return Math.max(min, Math.min(max, Math.round(value)));
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function normalizeRepo(value: unknown): string | undefined {
|
|
263
|
+
if (typeof value !== "string") return undefined;
|
|
264
|
+
const repo = value.trim();
|
|
265
|
+
if (!repo) return undefined;
|
|
266
|
+
return repo;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async function ensureGhAvailable(pi: ExtensionAPI): Promise<void> {
|
|
270
|
+
const result = await exec(pi, "gh", ["--version"], EXEC_TIMEOUT_MS);
|
|
271
|
+
if (result.code !== 0) {
|
|
272
|
+
throw new Error("gh CLI is required. Install gh and run `gh auth login`.");
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async function resolveRepo(pi: ExtensionAPI, repo?: string): Promise<string> {
|
|
277
|
+
if (repo) return repo;
|
|
278
|
+
const resolved = await ghText(pi, [
|
|
279
|
+
"repo",
|
|
280
|
+
"view",
|
|
281
|
+
"--json",
|
|
282
|
+
"nameWithOwner",
|
|
283
|
+
"-q",
|
|
284
|
+
".nameWithOwner",
|
|
285
|
+
]);
|
|
286
|
+
if (!resolved) {
|
|
287
|
+
throw new Error("cannot determine repo โ pass repo as owner/repo");
|
|
288
|
+
}
|
|
289
|
+
return resolved;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async function resolvePrNumber(
|
|
293
|
+
pi: ExtensionAPI,
|
|
294
|
+
repo: string,
|
|
295
|
+
prNumber?: number,
|
|
296
|
+
): Promise<number> {
|
|
297
|
+
if (prNumber && prNumber > 0) return prNumber;
|
|
298
|
+
const resolved = await ghText(pi, [
|
|
299
|
+
"pr",
|
|
300
|
+
"view",
|
|
301
|
+
"-R",
|
|
302
|
+
repo,
|
|
303
|
+
"--json",
|
|
304
|
+
"number",
|
|
305
|
+
"-q",
|
|
306
|
+
".number",
|
|
307
|
+
]);
|
|
308
|
+
const num = Number.parseInt(resolved, 10);
|
|
309
|
+
if (!Number.isInteger(num) || num <= 0) {
|
|
310
|
+
throw new Error("no PR for current branch โ pass a PR number");
|
|
311
|
+
}
|
|
312
|
+
return num;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
async function waitForNewReviewActivity(
|
|
316
|
+
pi: ExtensionAPI,
|
|
317
|
+
repo: string,
|
|
318
|
+
prNumber: number,
|
|
319
|
+
options: FindingsOptions,
|
|
320
|
+
): Promise<boolean> {
|
|
321
|
+
const baseline = await fetchActivitySnapshot(pi, repo, prNumber);
|
|
322
|
+
const deadline = Date.now() + options.waitTimeoutSec * 1000;
|
|
323
|
+
|
|
324
|
+
while (Date.now() < deadline) {
|
|
325
|
+
await sleep(options.waitPollSec * 1000);
|
|
326
|
+
const current = await fetchActivitySnapshot(pi, repo, prNumber);
|
|
327
|
+
if (hasNewActivity(current, baseline)) {
|
|
328
|
+
return true;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return false;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async function waitForChecksFinished(
|
|
336
|
+
pi: ExtensionAPI,
|
|
337
|
+
repo: string,
|
|
338
|
+
prNumber: number,
|
|
339
|
+
options: FindingsOptions,
|
|
340
|
+
): Promise<boolean> {
|
|
341
|
+
const deadline = Date.now() + options.waitTimeoutSec * 1000;
|
|
342
|
+
|
|
343
|
+
while (Date.now() < deadline) {
|
|
344
|
+
await sleep(options.waitPollSec * 1000);
|
|
345
|
+
const checks = await fetchChecks(pi, repo, prNumber);
|
|
346
|
+
if (allChecksTerminal(checks)) {
|
|
347
|
+
return true;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return false;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function hasNewActivity(
|
|
355
|
+
current: ActivitySnapshot,
|
|
356
|
+
baseline: ActivitySnapshot,
|
|
357
|
+
): boolean {
|
|
358
|
+
if (current.summaryCount > baseline.summaryCount) return true;
|
|
359
|
+
if (current.inlineCount > baseline.inlineCount) return true;
|
|
360
|
+
if (current.latestAtMs > baseline.latestAtMs) return true;
|
|
361
|
+
return false;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
async function fetchActivitySnapshot(
|
|
365
|
+
pi: ExtensionAPI,
|
|
366
|
+
repo: string,
|
|
367
|
+
prNumber: number,
|
|
368
|
+
): Promise<ActivitySnapshot> {
|
|
369
|
+
const prView = await ghJson<{
|
|
370
|
+
comments?: Array<{ createdAt?: string }>;
|
|
371
|
+
reviews?: Array<{ submittedAt?: string }>;
|
|
372
|
+
}>(pi, [
|
|
373
|
+
"pr",
|
|
374
|
+
"view",
|
|
375
|
+
String(prNumber),
|
|
376
|
+
"-R",
|
|
377
|
+
repo,
|
|
378
|
+
"--json",
|
|
379
|
+
"comments,reviews",
|
|
380
|
+
]);
|
|
381
|
+
|
|
382
|
+
const inline = await ghJson<Array<{ created_at?: string }>>(pi, [
|
|
383
|
+
"api",
|
|
384
|
+
"-H",
|
|
385
|
+
"Accept: application/vnd.github+json",
|
|
386
|
+
`repos/${repo}/pulls/${prNumber}/comments?per_page=100`,
|
|
387
|
+
]);
|
|
388
|
+
|
|
389
|
+
const summaryTimes = [
|
|
390
|
+
...(prView.comments ?? []).map((c) => c.createdAt ?? ""),
|
|
391
|
+
...(prView.reviews ?? []).map((r) => r.submittedAt ?? ""),
|
|
392
|
+
];
|
|
393
|
+
const inlineTimes = (inline ?? []).map((c) => c.created_at ?? "");
|
|
394
|
+
const latestAtMs = Math.max(
|
|
395
|
+
...[...summaryTimes, ...inlineTimes].map((iso) => parseIsoToMs(iso)),
|
|
396
|
+
0,
|
|
397
|
+
);
|
|
398
|
+
|
|
399
|
+
return {
|
|
400
|
+
summaryCount:
|
|
401
|
+
(prView.comments ?? []).length + (prView.reviews ?? []).length,
|
|
402
|
+
inlineCount: (inline ?? []).length,
|
|
403
|
+
latestAtMs,
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
async function fetchFindingsData(
|
|
408
|
+
pi: ExtensionAPI,
|
|
409
|
+
repo: string,
|
|
410
|
+
prNumber: number,
|
|
411
|
+
): Promise<FindingsData> {
|
|
412
|
+
const owner = repo.split("/")[0] ?? "";
|
|
413
|
+
const name = repo.split("/")[1] ?? "";
|
|
414
|
+
if (!owner || !name) {
|
|
415
|
+
throw new Error("repo must be owner/repo");
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
await ensureGhVersion(pi);
|
|
419
|
+
|
|
420
|
+
const prView = await ghJson<{
|
|
421
|
+
number: number;
|
|
422
|
+
state: string;
|
|
423
|
+
headRefOid: string;
|
|
424
|
+
url: string;
|
|
425
|
+
comments?: Array<{
|
|
426
|
+
id?: string;
|
|
427
|
+
author?: { login?: string };
|
|
428
|
+
createdAt?: string;
|
|
429
|
+
body?: string;
|
|
430
|
+
url?: string;
|
|
431
|
+
}>;
|
|
432
|
+
reviews?: Array<{
|
|
433
|
+
id?: string;
|
|
434
|
+
author?: { login?: string };
|
|
435
|
+
submittedAt?: string;
|
|
436
|
+
body?: string;
|
|
437
|
+
url?: string;
|
|
438
|
+
}>;
|
|
439
|
+
statusCheckRollup?: Array<{
|
|
440
|
+
name?: string;
|
|
441
|
+
context?: string;
|
|
442
|
+
conclusion?: string;
|
|
443
|
+
state?: string;
|
|
444
|
+
detailsUrl?: string;
|
|
445
|
+
targetUrl?: string;
|
|
446
|
+
}>;
|
|
447
|
+
}>(pi, [
|
|
448
|
+
"pr",
|
|
449
|
+
"view",
|
|
450
|
+
String(prNumber),
|
|
451
|
+
"-R",
|
|
452
|
+
repo,
|
|
453
|
+
"--json",
|
|
454
|
+
"number,state,headRefOid,url,comments,reviews,statusCheckRollup",
|
|
455
|
+
]);
|
|
456
|
+
|
|
457
|
+
const inline = await ghJson<
|
|
458
|
+
Array<{
|
|
459
|
+
id?: number;
|
|
460
|
+
user?: { login?: string };
|
|
461
|
+
created_at?: string;
|
|
462
|
+
path?: string;
|
|
463
|
+
line?: number;
|
|
464
|
+
original_line?: number;
|
|
465
|
+
body?: string;
|
|
466
|
+
commit_id?: string;
|
|
467
|
+
html_url?: string;
|
|
468
|
+
}>
|
|
469
|
+
>(pi, [
|
|
470
|
+
"api",
|
|
471
|
+
"-H",
|
|
472
|
+
"Accept: application/vnd.github+json",
|
|
473
|
+
`repos/${owner}/${name}/pulls/${prNumber}/comments?per_page=100`,
|
|
474
|
+
]);
|
|
475
|
+
|
|
476
|
+
const threads = await ghJson<{
|
|
477
|
+
data?: {
|
|
478
|
+
repository?: {
|
|
479
|
+
pullRequest?: {
|
|
480
|
+
reviewThreads?: {
|
|
481
|
+
nodes?: Array<{
|
|
482
|
+
isResolved?: boolean;
|
|
483
|
+
isOutdated?: boolean;
|
|
484
|
+
comments?: { nodes?: Array<{ databaseId?: number }> };
|
|
485
|
+
}>;
|
|
486
|
+
};
|
|
487
|
+
};
|
|
488
|
+
};
|
|
489
|
+
};
|
|
490
|
+
}>(pi, [
|
|
491
|
+
"api",
|
|
492
|
+
"graphql",
|
|
493
|
+
"-f",
|
|
494
|
+
"query=query($owner:String!,$name:String!,$num:Int!){repository(owner:$owner,name:$name){pullRequest(number:$num){reviewThreads(first:100){nodes{isResolved isOutdated comments(first:100){nodes{databaseId}}}}}}}",
|
|
495
|
+
"-F",
|
|
496
|
+
`owner=${owner}`,
|
|
497
|
+
"-F",
|
|
498
|
+
`name=${name}`,
|
|
499
|
+
"-F",
|
|
500
|
+
`num=${prNumber}`,
|
|
501
|
+
]);
|
|
502
|
+
|
|
503
|
+
const viewerLogin = await ghText(pi, ["api", "user", "-q", ".login"]);
|
|
504
|
+
|
|
505
|
+
const resolvedMap = buildResolvedMap(threads);
|
|
506
|
+
const headSha = prView.headRefOid ?? "";
|
|
507
|
+
|
|
508
|
+
const summaryComments: PrSummaryComment[] = [
|
|
509
|
+
...((prView.comments ?? []).map((c) => ({
|
|
510
|
+
id: String(c.id ?? ""),
|
|
511
|
+
author: c.author?.login ?? "unknown",
|
|
512
|
+
createdAt: c.createdAt ?? "",
|
|
513
|
+
body: c.body ?? "",
|
|
514
|
+
url: c.url ?? "",
|
|
515
|
+
})) as PrSummaryComment[]),
|
|
516
|
+
...((prView.reviews ?? [])
|
|
517
|
+
.filter((r) => (r.body ?? "") !== "")
|
|
518
|
+
.map((r) => ({
|
|
519
|
+
id: String(r.id ?? ""),
|
|
520
|
+
author: r.author?.login ?? "unknown",
|
|
521
|
+
createdAt: r.submittedAt ?? "",
|
|
522
|
+
body: r.body ?? "",
|
|
523
|
+
url: r.url ?? "",
|
|
524
|
+
})) as PrSummaryComment[]),
|
|
525
|
+
];
|
|
526
|
+
|
|
527
|
+
const inlineComments: InlineComment[] = (inline ?? []).map((c) => {
|
|
528
|
+
const id = String(c.id ?? "");
|
|
529
|
+
const resolved = resolvedMap[id] ?? {
|
|
530
|
+
isOutdated: false,
|
|
531
|
+
isResolved: false,
|
|
532
|
+
};
|
|
533
|
+
return {
|
|
534
|
+
id,
|
|
535
|
+
author: c.user?.login ?? "unknown",
|
|
536
|
+
createdAt: c.created_at ?? "",
|
|
537
|
+
path: c.path ?? "?",
|
|
538
|
+
line: c.line ?? c.original_line ?? null,
|
|
539
|
+
body: c.body ?? "",
|
|
540
|
+
commitId: c.commit_id ?? "",
|
|
541
|
+
url: c.html_url ?? "",
|
|
542
|
+
isOutdated:
|
|
543
|
+
resolved.isOutdated ||
|
|
544
|
+
Boolean((c.commit_id ?? "") && (c.commit_id ?? "") !== headSha),
|
|
545
|
+
isResolved: resolved.isResolved,
|
|
546
|
+
};
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
return {
|
|
550
|
+
pr: {
|
|
551
|
+
number: prView.number,
|
|
552
|
+
state: prView.state ?? "OPEN",
|
|
553
|
+
headSha,
|
|
554
|
+
url: prView.url ?? "",
|
|
555
|
+
},
|
|
556
|
+
viewerLogin,
|
|
557
|
+
checks: (prView.statusCheckRollup ?? []).map((check) => ({
|
|
558
|
+
name: check.name ?? check.context ?? "check",
|
|
559
|
+
conclusion: check.conclusion ?? check.state ?? "PENDING",
|
|
560
|
+
url: check.detailsUrl ?? check.targetUrl ?? "",
|
|
561
|
+
})),
|
|
562
|
+
summaryComments,
|
|
563
|
+
inlineComments,
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function buildResolvedMap(threads: {
|
|
568
|
+
data?: {
|
|
569
|
+
repository?: {
|
|
570
|
+
pullRequest?: {
|
|
571
|
+
reviewThreads?: {
|
|
572
|
+
nodes?: Array<{
|
|
573
|
+
isResolved?: boolean;
|
|
574
|
+
isOutdated?: boolean;
|
|
575
|
+
comments?: { nodes?: Array<{ databaseId?: number }> };
|
|
576
|
+
}>;
|
|
577
|
+
};
|
|
578
|
+
};
|
|
579
|
+
};
|
|
580
|
+
};
|
|
581
|
+
}): Record<string, { isResolved: boolean; isOutdated: boolean }> {
|
|
582
|
+
const out: Record<string, { isResolved: boolean; isOutdated: boolean }> = {};
|
|
583
|
+
const nodes =
|
|
584
|
+
threads.data?.repository?.pullRequest?.reviewThreads?.nodes ?? [];
|
|
585
|
+
|
|
586
|
+
for (const thread of nodes) {
|
|
587
|
+
const isResolved = thread.isResolved === true;
|
|
588
|
+
const isOutdated = thread.isOutdated === true;
|
|
589
|
+
for (const comment of thread.comments?.nodes ?? []) {
|
|
590
|
+
if (typeof comment.databaseId !== "number") continue;
|
|
591
|
+
out[String(comment.databaseId)] = { isResolved, isOutdated };
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
return out;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
async function ensureGhVersion(pi: ExtensionAPI): Promise<void> {
|
|
599
|
+
const version = await ghText(pi, ["--version"]);
|
|
600
|
+
const firstLine = version.split("\n")[0] ?? "";
|
|
601
|
+
const raw = firstLine.split(" ")[2] ?? "";
|
|
602
|
+
const [majorText, minorText] = raw.split(".");
|
|
603
|
+
const major = Number.parseInt(majorText ?? "0", 10);
|
|
604
|
+
const minor = Number.parseInt(minorText ?? "0", 10);
|
|
605
|
+
if (major < 2 || (major === 2 && minor < 40)) {
|
|
606
|
+
throw new Error(
|
|
607
|
+
`gh ${raw || "unknown"} is too old โ need >= 2.40 for statusCheckRollup`,
|
|
608
|
+
);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
async function fetchChecks(
|
|
613
|
+
pi: ExtensionAPI,
|
|
614
|
+
repo: string,
|
|
615
|
+
prNumber: number,
|
|
616
|
+
): Promise<Array<{ conclusion?: string; state?: string }>> {
|
|
617
|
+
const prView = await ghJson<{
|
|
618
|
+
statusCheckRollup?: Array<{ conclusion?: string; state?: string }>;
|
|
619
|
+
}>(pi, [
|
|
620
|
+
"pr",
|
|
621
|
+
"view",
|
|
622
|
+
String(prNumber),
|
|
623
|
+
"-R",
|
|
624
|
+
repo,
|
|
625
|
+
"--json",
|
|
626
|
+
"statusCheckRollup",
|
|
627
|
+
]);
|
|
628
|
+
|
|
629
|
+
return prView.statusCheckRollup ?? [];
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function allChecksTerminal(
|
|
633
|
+
checks: Array<{ conclusion?: string; state?: string }>,
|
|
634
|
+
): boolean {
|
|
635
|
+
if (checks.length === 0) return true;
|
|
636
|
+
return checks.every((check) => {
|
|
637
|
+
const value = (check.conclusion ?? check.state ?? "").toUpperCase();
|
|
638
|
+
return ![
|
|
639
|
+
"PENDING",
|
|
640
|
+
"IN_PROGRESS",
|
|
641
|
+
"QUEUED",
|
|
642
|
+
"WAITING",
|
|
643
|
+
"REQUESTED",
|
|
644
|
+
].includes(value);
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function renderMarkdown(data: FindingsData, options: FindingsOptions): string {
|
|
649
|
+
const findings: Record<"blocker" | "warning" | "nit" | "info", string[]> = {
|
|
650
|
+
blocker: [],
|
|
651
|
+
warning: [],
|
|
652
|
+
nit: [],
|
|
653
|
+
info: [],
|
|
654
|
+
};
|
|
655
|
+
|
|
656
|
+
let staleDropped = 0;
|
|
657
|
+
|
|
658
|
+
const keep = (author: string, isResolved?: boolean): boolean => {
|
|
659
|
+
const lower = author.toLowerCase();
|
|
660
|
+
if (DROP_AUTHORS.has(lower)) return false;
|
|
661
|
+
if (options.mine && lower !== data.viewerLogin.toLowerCase()) return false;
|
|
662
|
+
if (options.unresolved && isResolved) return false;
|
|
663
|
+
return true;
|
|
664
|
+
};
|
|
665
|
+
|
|
666
|
+
for (const c of data.inlineComments) {
|
|
667
|
+
if (!keep(c.author, c.isResolved)) continue;
|
|
668
|
+
if (c.isOutdated && !options.includeStale) {
|
|
669
|
+
staleDropped += 1;
|
|
670
|
+
continue;
|
|
671
|
+
}
|
|
672
|
+
const sev = classify(c.body);
|
|
673
|
+
const lineNo = c.line ?? "?";
|
|
674
|
+
findings[sev].push(
|
|
675
|
+
`- \`${c.path}:${lineNo}\` โ ${excerpt(c.body)} ([link](${c.url}))`,
|
|
676
|
+
);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
for (const c of data.summaryComments) {
|
|
680
|
+
if (!keep(c.author, false)) continue;
|
|
681
|
+
|
|
682
|
+
const split = splitBotSummary(c.body);
|
|
683
|
+
if (split) {
|
|
684
|
+
for (const part of split) {
|
|
685
|
+
findings[part.severity].push(
|
|
686
|
+
`- _${c.author}_: ${excerpt(part.item)} ([link](${c.url}))`,
|
|
687
|
+
);
|
|
688
|
+
}
|
|
689
|
+
continue;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
const sev = classify(c.body);
|
|
693
|
+
findings[sev].push(
|
|
694
|
+
`- _${c.author}_ (${humanizeAge(c.createdAt)}): ${excerpt(c.body)} ([link](${c.url}))`,
|
|
695
|
+
);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
if (options.severity !== "all") {
|
|
699
|
+
for (const key of Object.keys(findings) as Array<
|
|
700
|
+
"blocker" | "warning" | "nit" | "info"
|
|
701
|
+
>) {
|
|
702
|
+
if (key !== options.severity) findings[key] = [];
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
const shortSha = data.pr.headSha.slice(0, 7);
|
|
707
|
+
const parts: string[] = [];
|
|
708
|
+
parts.push(
|
|
709
|
+
`## PR #${data.pr.number} findings โ revision \`${shortSha}\` ${stateBadge(data.pr.state)}`.trim(),
|
|
710
|
+
);
|
|
711
|
+
|
|
712
|
+
if (data.checks.length > 0) {
|
|
713
|
+
const renderedChecks = data.checks
|
|
714
|
+
.map((check) => {
|
|
715
|
+
const failed = ["FAILURE", "ERROR"].includes(
|
|
716
|
+
(check.conclusion ?? "").toUpperCase(),
|
|
717
|
+
);
|
|
718
|
+
const details = failed && check.url ? ` ([details](${check.url}))` : "";
|
|
719
|
+
return `${checkIcon(check.conclusion)} ${check.name}${details}`;
|
|
720
|
+
})
|
|
721
|
+
.join(" ยท ");
|
|
722
|
+
parts.push(`\n**Status checks:** ${renderedChecks}`);
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
const sections: Array<[keyof typeof findings, string]> = [
|
|
726
|
+
["blocker", "### ๐ด Blockers"],
|
|
727
|
+
["warning", "### ๐ก Warnings"],
|
|
728
|
+
["nit", "### ๐ต Nits"],
|
|
729
|
+
["info", "### โ
Approvals / strengths"],
|
|
730
|
+
];
|
|
731
|
+
|
|
732
|
+
for (const [key, header] of sections) {
|
|
733
|
+
const items = findings[key];
|
|
734
|
+
if (items.length === 0) continue;
|
|
735
|
+
parts.push(`\n${header} (${items.length})\n`);
|
|
736
|
+
parts.push(...items);
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
if (staleDropped > 0 && !options.includeStale) {
|
|
740
|
+
parts.push(
|
|
741
|
+
`\n---\n<small>${staleDropped} stale finding(s) on older commits skipped (use \`--include-stale\` to show).</small>`,
|
|
742
|
+
);
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
const total = Object.values(findings).reduce(
|
|
746
|
+
(sum, items) => sum + items.length,
|
|
747
|
+
0,
|
|
748
|
+
);
|
|
749
|
+
if (total === 0) {
|
|
750
|
+
parts.push("\n_No findings._");
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
return `${parts.join("\n")}\n`;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
function classify(body: string): "blocker" | "warning" | "nit" | "info" {
|
|
757
|
+
const text = body.toLowerCase();
|
|
758
|
+
|
|
759
|
+
if (matchesAny(text, ["๐ด", "blocker", "must fix", "critical", "breaks"])) {
|
|
760
|
+
return "blocker";
|
|
761
|
+
}
|
|
762
|
+
if (
|
|
763
|
+
matchesAny(text, ["๐ก", "warning", "should", "leak", "race", "missing"])
|
|
764
|
+
) {
|
|
765
|
+
return "warning";
|
|
766
|
+
}
|
|
767
|
+
if (
|
|
768
|
+
matchesAny(text, [
|
|
769
|
+
"๐ต",
|
|
770
|
+
"nit",
|
|
771
|
+
"suggestion",
|
|
772
|
+
"consider",
|
|
773
|
+
"could be",
|
|
774
|
+
"minor",
|
|
775
|
+
])
|
|
776
|
+
) {
|
|
777
|
+
return "nit";
|
|
778
|
+
}
|
|
779
|
+
if (matchesAny(text, ["approved", "lgtm", "strength", "looks good"])) {
|
|
780
|
+
return "info";
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
return "warning";
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
function matchesAny(text: string, patterns: string[]): boolean {
|
|
787
|
+
return patterns.some((pattern) => {
|
|
788
|
+
if (
|
|
789
|
+
pattern.length === 1 ||
|
|
790
|
+
pattern.includes("๐ด") ||
|
|
791
|
+
pattern.includes("๐ก") ||
|
|
792
|
+
pattern.includes("๐ต")
|
|
793
|
+
) {
|
|
794
|
+
return text.includes(pattern.toLowerCase());
|
|
795
|
+
}
|
|
796
|
+
return new RegExp(`\\b${escapeRegex(pattern)}\\b`, "i").test(text);
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
function escapeRegex(value: string): string {
|
|
801
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
function splitBotSummary(body: string): Array<{
|
|
805
|
+
severity: "blocker" | "warning" | "nit" | "info";
|
|
806
|
+
item: string;
|
|
807
|
+
}> | null {
|
|
808
|
+
const sectionHeaderRe = new RegExp(
|
|
809
|
+
SECTION_HEADER_RE.source,
|
|
810
|
+
SECTION_HEADER_RE.flags,
|
|
811
|
+
);
|
|
812
|
+
const matches = Array.from(body.matchAll(sectionHeaderRe));
|
|
813
|
+
if (matches.length === 0) return null;
|
|
814
|
+
|
|
815
|
+
const out: Array<{
|
|
816
|
+
severity: "blocker" | "warning" | "nit" | "info";
|
|
817
|
+
item: string;
|
|
818
|
+
}> = [];
|
|
819
|
+
|
|
820
|
+
for (let i = 0; i < matches.length; i += 1) {
|
|
821
|
+
const current = matches[i];
|
|
822
|
+
const section = current?.[1] ?? "";
|
|
823
|
+
const start =
|
|
824
|
+
current?.index !== undefined ? current.index + current[0].length : 0;
|
|
825
|
+
const end =
|
|
826
|
+
i + 1 < matches.length && matches[i + 1]?.index !== undefined
|
|
827
|
+
? (matches[i + 1]?.index as number)
|
|
828
|
+
: body.length;
|
|
829
|
+
|
|
830
|
+
const sectionBody = body.slice(start, end).trim();
|
|
831
|
+
const severity = sectionToSeverity(section);
|
|
832
|
+
const items = sectionBody
|
|
833
|
+
.split(NUMBERED_ITEM_RE)
|
|
834
|
+
.map((item) => item.trim())
|
|
835
|
+
.filter(Boolean);
|
|
836
|
+
|
|
837
|
+
if (items.length === 0) {
|
|
838
|
+
if (sectionBody) out.push({ severity, item: sectionBody });
|
|
839
|
+
continue;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
for (const item of items) {
|
|
843
|
+
out.push({ severity, item });
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
return out.length > 0 ? out : null;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
function sectionToSeverity(
|
|
851
|
+
section: string,
|
|
852
|
+
): "blocker" | "warning" | "nit" | "info" {
|
|
853
|
+
const value = section.toLowerCase();
|
|
854
|
+
if (value.includes("blocker")) return "blocker";
|
|
855
|
+
if (value.includes("warning")) return "warning";
|
|
856
|
+
if (value.includes("nit") || value.includes("suggestion")) return "nit";
|
|
857
|
+
if (value.includes("approval") || value.includes("strength")) return "info";
|
|
858
|
+
return "warning";
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
function parseIsoToMs(iso: string): number {
|
|
862
|
+
if (!iso) return 0;
|
|
863
|
+
const ms = Date.parse(iso);
|
|
864
|
+
return Number.isFinite(ms) ? ms : 0;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
function humanizeAge(iso: string): string {
|
|
868
|
+
const ms = parseIsoToMs(iso);
|
|
869
|
+
if (ms <= 0) return "";
|
|
870
|
+
const secs = Math.max(0, Math.floor((Date.now() - ms) / 1000));
|
|
871
|
+
if (secs < 60) return `${secs}s ago`;
|
|
872
|
+
if (secs < 3600) return `${Math.floor(secs / 60)}m ago`;
|
|
873
|
+
if (secs < 86400) return `${Math.floor(secs / 3600)}h ago`;
|
|
874
|
+
return `${Math.floor(secs / 86400)}d ago`;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
function excerpt(body: string, limit = 200): string {
|
|
878
|
+
const text = body.split(/\s+/).filter(Boolean).join(" ");
|
|
879
|
+
if (text.length <= limit) return text;
|
|
880
|
+
return `${text.slice(0, limit - 1)}โฆ`;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
function checkIcon(conclusion: string): string {
|
|
884
|
+
const value = (conclusion || "").toUpperCase();
|
|
885
|
+
const map: Record<string, string> = {
|
|
886
|
+
SUCCESS: "โ
",
|
|
887
|
+
FAILURE: "โ",
|
|
888
|
+
ERROR: "โ",
|
|
889
|
+
CANCELLED: "โช",
|
|
890
|
+
SKIPPED: "โช",
|
|
891
|
+
PENDING: "โณ",
|
|
892
|
+
IN_PROGRESS: "โณ",
|
|
893
|
+
NEUTRAL: "โ",
|
|
894
|
+
};
|
|
895
|
+
return map[value] ?? "โ";
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
function stateBadge(state: string): string {
|
|
899
|
+
const value = (state || "").toUpperCase();
|
|
900
|
+
if (value === "OPEN" || !value) return "";
|
|
901
|
+
return `_(PR is ${value})_`;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
function closingLine(report: string): string {
|
|
905
|
+
if (report.includes("### ๐ด Blockers")) {
|
|
906
|
+
return "Address blockers before merge.";
|
|
907
|
+
}
|
|
908
|
+
if (report.includes("### ๐ก Warnings")) {
|
|
909
|
+
return "Review warnings before merge.";
|
|
910
|
+
}
|
|
911
|
+
if (report.includes("### ๐ต Nits")) {
|
|
912
|
+
return "Nits only โ safe to merge if you skip them.";
|
|
913
|
+
}
|
|
914
|
+
return "No unresolved findings remain.";
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
async function ghText(pi: ExtensionAPI, args: string[]): Promise<string> {
|
|
918
|
+
const result = await exec(pi, "gh", args, EXEC_TIMEOUT_MS);
|
|
919
|
+
if (result.code !== 0) {
|
|
920
|
+
throw new Error(formatGhError(result));
|
|
921
|
+
}
|
|
922
|
+
return result.stdout?.trim() ?? "";
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
async function ghJson<T>(pi: ExtensionAPI, args: string[]): Promise<T> {
|
|
926
|
+
const result = await exec(pi, "gh", args, EXEC_TIMEOUT_MS);
|
|
927
|
+
if (result.code !== 0) {
|
|
928
|
+
throw new Error(formatGhError(result));
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
const text = result.stdout?.trim() ?? "";
|
|
932
|
+
if (!text) {
|
|
933
|
+
throw new Error("gh returned empty response");
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
try {
|
|
937
|
+
return JSON.parse(text) as T;
|
|
938
|
+
} catch (error) {
|
|
939
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
940
|
+
throw new Error(`invalid gh JSON response: ${message}`);
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
function formatGhError(result: ExecResult): string {
|
|
945
|
+
const raw = (
|
|
946
|
+
result.stderr?.trim() ||
|
|
947
|
+
result.stdout?.trim() ||
|
|
948
|
+
"gh command failed"
|
|
949
|
+
).trim();
|
|
950
|
+
if (raw.includes("authentication") || raw.includes("gh auth login")) {
|
|
951
|
+
return "gh is not authenticated. Run `gh auth login`.";
|
|
952
|
+
}
|
|
953
|
+
return raw;
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
async function exec(
|
|
957
|
+
pi: ExtensionAPI,
|
|
958
|
+
command: string,
|
|
959
|
+
args: string[],
|
|
960
|
+
timeoutMs: number,
|
|
961
|
+
): Promise<ExecResult> {
|
|
962
|
+
return pi.exec(command, args, { timeout: timeoutMs });
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
function sleep(ms: number): Promise<void> {
|
|
966
|
+
return new Promise((resolve) => {
|
|
967
|
+
setTimeout(resolve, ms);
|
|
968
|
+
});
|
|
969
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@testzugang/pi-pr-findings",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Fetch and group GitHub PR review findings by severity in Pi",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package"
|
|
7
|
+
],
|
|
8
|
+
"license": "MIT",
|
|
9
|
+
"files": [
|
|
10
|
+
"extensions",
|
|
11
|
+
"skills",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"pi": {
|
|
15
|
+
"extensions": [
|
|
16
|
+
"./extensions"
|
|
17
|
+
],
|
|
18
|
+
"skills": [
|
|
19
|
+
"./skills"
|
|
20
|
+
]
|
|
21
|
+
},
|
|
22
|
+
"peerDependencies": {
|
|
23
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
24
|
+
"typebox": "*"
|
|
25
|
+
},
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "public",
|
|
28
|
+
"provenance": true
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: pr-findings
|
|
3
|
+
description: Use when the user asks for GitHub PR review findings, review comments, unresolved findings, bot feedback, or reviewer feedback grouped by severity.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# PR Findings
|
|
7
|
+
|
|
8
|
+
## Goal
|
|
9
|
+
|
|
10
|
+
Fetch GitHub PR review findings through the `pr_findings` tool (backed by `gh`), format by severity, and print the tool output verbatim.
|
|
11
|
+
|
|
12
|
+
## Inputs
|
|
13
|
+
|
|
14
|
+
Infer from the user request (or `/skill:pr-findings ...` args):
|
|
15
|
+
|
|
16
|
+
- PR number (optional)
|
|
17
|
+
- Repository `owner/repo` (optional)
|
|
18
|
+
- `--unresolved`
|
|
19
|
+
- `--severity blocker|warning|nit|all`
|
|
20
|
+
- `--include-stale`
|
|
21
|
+
- `--mine`
|
|
22
|
+
- `--wait-for-next-review` (optional)
|
|
23
|
+
- `--wait-timeout-sec <n>` (default 60)
|
|
24
|
+
- `--wait-poll-sec <n>` (default 30)
|
|
25
|
+
|
|
26
|
+
## Workflow
|
|
27
|
+
|
|
28
|
+
1. Build tool params from user input.
|
|
29
|
+
2. Call `pr_findings`.
|
|
30
|
+
3. Print returned Markdown verbatim (do not paraphrase).
|
|
31
|
+
|
|
32
|
+
## Tool Call
|
|
33
|
+
|
|
34
|
+
```text
|
|
35
|
+
pr_findings({
|
|
36
|
+
prNumber?: number,
|
|
37
|
+
repo?: "owner/repo",
|
|
38
|
+
unresolved?: boolean,
|
|
39
|
+
severity?: "blocker" | "warning" | "nit" | "all",
|
|
40
|
+
includeStale?: boolean,
|
|
41
|
+
mine?: boolean,
|
|
42
|
+
waitForNextReview?: boolean,
|
|
43
|
+
waitMode?: "new-review-activity" | "checks-finished",
|
|
44
|
+
waitTimeoutSec?: number,
|
|
45
|
+
waitPollSec?: number
|
|
46
|
+
})
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Default wait behavior when enabled:
|
|
50
|
+
|
|
51
|
+
- `waitMode="new-review-activity"`
|
|
52
|
+
- `waitTimeoutSec=60`
|
|
53
|
+
- `waitPollSec=30`
|
|
54
|
+
|
|
55
|
+
## Recommended Usage After Push
|
|
56
|
+
|
|
57
|
+
Use waiting mode after pushing fixes so findings are not read too early:
|
|
58
|
+
|
|
59
|
+
```text
|
|
60
|
+
pr_findings({ waitForNextReview: true, unresolved: true })
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Edge Cases
|
|
64
|
+
|
|
65
|
+
| Situation | Response |
|
|
66
|
+
| --------------------------- | ---------------------------------------------------------- |
|
|
67
|
+
| `gh` not authenticated | Tell the user to run `gh auth login`. |
|
|
68
|
+
| No PR for current branch | Ask for a PR number. |
|
|
69
|
+
| Cannot determine repository | Ask for `owner/repo`. |
|
|
70
|
+
| PR is closed or merged | Still fetch findings; report includes PR state. |
|
|
71
|
+
| `gh` too old | Surface error requiring `gh >= 2.40`. |
|
|
72
|
+
| Wait timed out | Report that timeout was reached and show current findings. |
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
PR_NUMBER="${1:?PR number required}"
|
|
5
|
+
REPO="${2:-$(gh repo view --json nameWithOwner -q .nameWithOwner 2>/dev/null || true)}"
|
|
6
|
+
|
|
7
|
+
if [[ -z "${REPO}" ]]; then
|
|
8
|
+
echo "fetch.sh: cannot determine repo โ pass <owner/repo> as second argument" >&2
|
|
9
|
+
exit 2
|
|
10
|
+
fi
|
|
11
|
+
|
|
12
|
+
OWNER="${REPO%/*}"
|
|
13
|
+
NAME="${REPO#*/}"
|
|
14
|
+
|
|
15
|
+
GH_VERSION="$(gh --version | head -n1 | awk '{print $3}')"
|
|
16
|
+
GH_MAJOR="${GH_VERSION%%.*}"
|
|
17
|
+
GH_REST="${GH_VERSION#*.}"
|
|
18
|
+
GH_MINOR="${GH_REST%%.*}"
|
|
19
|
+
if (( GH_MAJOR < 2 )) || { (( GH_MAJOR == 2 )) && (( GH_MINOR < 40 )); }; then
|
|
20
|
+
echo "fetch.sh: gh ${GH_VERSION} is too old โ need >= 2.40 for statusCheckRollup" >&2
|
|
21
|
+
exit 3
|
|
22
|
+
fi
|
|
23
|
+
|
|
24
|
+
PR_VIEW=$(gh pr view "$PR_NUMBER" -R "$REPO" \
|
|
25
|
+
--json number,state,headRefOid,url,comments,reviews,statusCheckRollup)
|
|
26
|
+
|
|
27
|
+
INLINE=$(gh api --paginate -H "Accept: application/vnd.github+json" \
|
|
28
|
+
"repos/$OWNER/$NAME/pulls/$PR_NUMBER/comments?per_page=100")
|
|
29
|
+
|
|
30
|
+
THREADS=$(gh api graphql -f query='
|
|
31
|
+
query($owner:String!,$name:String!,$num:Int!){
|
|
32
|
+
repository(owner:$owner,name:$name){
|
|
33
|
+
pullRequest(number:$num){
|
|
34
|
+
reviewThreads(first:100){
|
|
35
|
+
nodes{ isResolved isOutdated comments(first:100){ nodes{ databaseId } } }
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}' -F owner="$OWNER" -F name="$NAME" -F num="$PR_NUMBER")
|
|
40
|
+
|
|
41
|
+
VIEWER=$(gh api user -q .login)
|
|
42
|
+
|
|
43
|
+
RESOLVED_MAP=$(echo "$THREADS" | jq -c '
|
|
44
|
+
[.data.repository.pullRequest.reviewThreads.nodes[]
|
|
45
|
+
| . as $t
|
|
46
|
+
| $t.comments.nodes[]
|
|
47
|
+
| { key: (.databaseId|tostring),
|
|
48
|
+
value: { isResolved: $t.isResolved, isOutdated: $t.isOutdated } }
|
|
49
|
+
] | from_entries')
|
|
50
|
+
|
|
51
|
+
HEAD_SHA=$(echo "$PR_VIEW" | jq -r .headRefOid)
|
|
52
|
+
|
|
53
|
+
jq -n \
|
|
54
|
+
--argjson pr "$PR_VIEW" \
|
|
55
|
+
--argjson inline "$INLINE" \
|
|
56
|
+
--argjson resolved "$RESOLVED_MAP" \
|
|
57
|
+
--arg viewer "$VIEWER" \
|
|
58
|
+
--arg head "$HEAD_SHA" \
|
|
59
|
+
'{
|
|
60
|
+
pr: { number: $pr.number, state: $pr.state, headSha: $head, url: $pr.url },
|
|
61
|
+
viewerLogin: $viewer,
|
|
62
|
+
checks: ($pr.statusCheckRollup // [] | map({ name: (.name // .context // "check"), conclusion: (.conclusion // .state // "PENDING"), url: (.detailsUrl // .targetUrl // "") })),
|
|
63
|
+
summaryComments: (
|
|
64
|
+
($pr.comments // [] | map({
|
|
65
|
+
id: (.id|tostring), author: (.author.login // "unknown"),
|
|
66
|
+
createdAt: .createdAt, body: .body, url: (.url // "")
|
|
67
|
+
}))
|
|
68
|
+
+
|
|
69
|
+
($pr.reviews // [] | map(select((.body // "") != "")) | map({
|
|
70
|
+
id: (.id|tostring), author: (.author.login // "unknown"),
|
|
71
|
+
createdAt: .submittedAt, body: .body, url: (.url // "")
|
|
72
|
+
}))
|
|
73
|
+
),
|
|
74
|
+
inlineComments: ($inline | map({
|
|
75
|
+
id: (.id|tostring),
|
|
76
|
+
author: (.user.login // "unknown"),
|
|
77
|
+
createdAt: .created_at,
|
|
78
|
+
path: .path,
|
|
79
|
+
line: (.line // .original_line),
|
|
80
|
+
body: .body,
|
|
81
|
+
commitId: .commit_id,
|
|
82
|
+
url: .html_url,
|
|
83
|
+
isOutdated: ($resolved[(.id|tostring)].isOutdated // ((.commit_id // "") != $head)),
|
|
84
|
+
isResolved: ($resolved[(.id|tostring)].isResolved // false)
|
|
85
|
+
}))
|
|
86
|
+
}'
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Read fetch.sh JSON on stdin, emit a Markdown findings report on stdout."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
import sys
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
|
|
11
|
+
DROP_AUTHORS = {"sonarqubecloud[bot]", "sonarcloud[bot]", "dependabot[bot]", "github-actions[bot]"}
|
|
12
|
+
|
|
13
|
+
SEVERITY_PATTERNS = [
|
|
14
|
+
("blocker", [r"๐ด", r"\bblocker\b", r"\bmust fix\b", r"\bcritical\b", r"\bbreaks\b"]),
|
|
15
|
+
("warning", [r"๐ก", r"\bwarning\b", r"\bshould\b", r"\bleak\b", r"\brace\b", r"\bmissing\b"]),
|
|
16
|
+
("nit", [r"๐ต", r"\bnit\b", r"\bsuggestion\b", r"\bconsider\b", r"\bcould be\b", r"\bminor\b"]),
|
|
17
|
+
("info", [r"\bapproved\b", r"\blgtm\b", r"\bstrength\b", r"\blooks good\b"]),
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
SECTION_HEADER_RE = re.compile(
|
|
21
|
+
r"^#{1,6}\s*(?:๐ด|๐ก|๐ต|โ
)?\s*(blockers?|warnings?|nits?|approvals?|strengths?|suggestions?).*$",
|
|
22
|
+
re.IGNORECASE | re.MULTILINE,
|
|
23
|
+
)
|
|
24
|
+
NUMBERED_ITEM_RE = re.compile(r"^\s*\d+\.\s+", re.MULTILINE)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def classify(body: str) -> str:
|
|
28
|
+
text = body.lower()
|
|
29
|
+
for sev, patterns in SEVERITY_PATTERNS:
|
|
30
|
+
for p in patterns:
|
|
31
|
+
if re.search(p, text):
|
|
32
|
+
return sev
|
|
33
|
+
return "warning"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def section_to_severity(section: str) -> str:
|
|
37
|
+
s = section.lower()
|
|
38
|
+
if "blocker" in s:
|
|
39
|
+
return "blocker"
|
|
40
|
+
if "warning" in s:
|
|
41
|
+
return "warning"
|
|
42
|
+
if "nit" in s or "suggestion" in s:
|
|
43
|
+
return "nit"
|
|
44
|
+
if "approval" in s or "strength" in s:
|
|
45
|
+
return "info"
|
|
46
|
+
return "warning"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def split_bot_summary(body: str) -> list[tuple[str, str]] | None:
|
|
50
|
+
"""If the comment has section headers like '### ๐ก Warnings', split into (severity, item) tuples."""
|
|
51
|
+
matches = list(SECTION_HEADER_RE.finditer(body))
|
|
52
|
+
if not matches:
|
|
53
|
+
return None
|
|
54
|
+
out: list[tuple[str, str]] = []
|
|
55
|
+
for i, m in enumerate(matches):
|
|
56
|
+
sev = section_to_severity(m.group(1))
|
|
57
|
+
start = m.end()
|
|
58
|
+
end = matches[i + 1].start() if i + 1 < len(matches) else len(body)
|
|
59
|
+
section_body = body[start:end].strip()
|
|
60
|
+
items = NUMBERED_ITEM_RE.split(section_body)
|
|
61
|
+
items = [it.strip() for it in items if it.strip()]
|
|
62
|
+
if not items:
|
|
63
|
+
if section_body:
|
|
64
|
+
out.append((sev, section_body))
|
|
65
|
+
continue
|
|
66
|
+
for item in items:
|
|
67
|
+
out.append((sev, item))
|
|
68
|
+
return out or None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def humanize_age(iso: str) -> str:
|
|
72
|
+
if not iso:
|
|
73
|
+
return ""
|
|
74
|
+
try:
|
|
75
|
+
dt = datetime.fromisoformat(iso.replace("Z", "+00:00"))
|
|
76
|
+
except ValueError:
|
|
77
|
+
return iso
|
|
78
|
+
delta = datetime.now(timezone.utc) - dt
|
|
79
|
+
secs = int(delta.total_seconds())
|
|
80
|
+
if secs < 60:
|
|
81
|
+
return f"{secs}s ago"
|
|
82
|
+
if secs < 3600:
|
|
83
|
+
return f"{secs // 60}m ago"
|
|
84
|
+
if secs < 86400:
|
|
85
|
+
return f"{secs // 3600}h ago"
|
|
86
|
+
return f"{secs // 86400}d ago"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def excerpt(body: str, limit: int = 200) -> str:
|
|
90
|
+
text = " ".join(body.split())
|
|
91
|
+
return text if len(text) <= limit else text[: limit - 1] + "โฆ"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def check_icon(conclusion: str) -> str:
|
|
95
|
+
c = (conclusion or "").upper()
|
|
96
|
+
return {
|
|
97
|
+
"SUCCESS": "โ
",
|
|
98
|
+
"FAILURE": "โ",
|
|
99
|
+
"ERROR": "โ",
|
|
100
|
+
"CANCELLED": "โช",
|
|
101
|
+
"SKIPPED": "โช",
|
|
102
|
+
"PENDING": "โณ",
|
|
103
|
+
"IN_PROGRESS": "โณ",
|
|
104
|
+
"NEUTRAL": "โ",
|
|
105
|
+
}.get(c, "โ")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def state_badge(state: str) -> str:
|
|
109
|
+
s = (state or "").upper()
|
|
110
|
+
if s == "OPEN":
|
|
111
|
+
return ""
|
|
112
|
+
return f"_(PR is {s})_"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def render(data: dict, args: argparse.Namespace) -> str:
|
|
116
|
+
pr = data.get("pr", {})
|
|
117
|
+
viewer = data.get("viewerLogin", "")
|
|
118
|
+
checks = data.get("checks", []) or []
|
|
119
|
+
summary_comments = data.get("summaryComments", []) or []
|
|
120
|
+
inline_comments = data.get("inlineComments", []) or []
|
|
121
|
+
|
|
122
|
+
findings: dict[str, list[str]] = {"blocker": [], "warning": [], "nit": [], "info": []}
|
|
123
|
+
stale_dropped = 0
|
|
124
|
+
|
|
125
|
+
def keep(c: dict) -> bool:
|
|
126
|
+
author = (c.get("author") or "").lower()
|
|
127
|
+
if author in DROP_AUTHORS:
|
|
128
|
+
return False
|
|
129
|
+
if args.mine and author != (viewer or "").lower():
|
|
130
|
+
return False
|
|
131
|
+
if args.unresolved and c.get("isResolved"):
|
|
132
|
+
return False
|
|
133
|
+
return True
|
|
134
|
+
|
|
135
|
+
# Inline comments
|
|
136
|
+
for c in inline_comments:
|
|
137
|
+
if not keep(c):
|
|
138
|
+
continue
|
|
139
|
+
if c.get("isOutdated") and not args.include_stale:
|
|
140
|
+
stale_dropped += 1
|
|
141
|
+
continue
|
|
142
|
+
sev = classify(c.get("body", ""))
|
|
143
|
+
loc = f"`{c.get('path','?')}:{c.get('line','?')}`"
|
|
144
|
+
line = f"- {loc} โ {excerpt(c.get('body',''))} ([link]({c.get('url','')}))"
|
|
145
|
+
findings[sev].append(line)
|
|
146
|
+
|
|
147
|
+
# Summary/review comments โ bots may pack multiple findings into one
|
|
148
|
+
for c in summary_comments:
|
|
149
|
+
if not keep(c):
|
|
150
|
+
continue
|
|
151
|
+
body = c.get("body", "")
|
|
152
|
+
author = c.get("author", "?")
|
|
153
|
+
url = c.get("url", "")
|
|
154
|
+
split = split_bot_summary(body)
|
|
155
|
+
if split:
|
|
156
|
+
for sev, item in split:
|
|
157
|
+
line = f"- _{author}_: {excerpt(item)} ([link]({url}))"
|
|
158
|
+
findings[sev].append(line)
|
|
159
|
+
else:
|
|
160
|
+
sev = classify(body)
|
|
161
|
+
line = f"- _{author}_ ({humanize_age(c.get('createdAt',''))}): {excerpt(body)} ([link]({url}))"
|
|
162
|
+
findings[sev].append(line)
|
|
163
|
+
|
|
164
|
+
# Severity filter
|
|
165
|
+
if args.severity != "all":
|
|
166
|
+
for sev in list(findings.keys()):
|
|
167
|
+
if sev != args.severity:
|
|
168
|
+
findings[sev] = []
|
|
169
|
+
|
|
170
|
+
# Header
|
|
171
|
+
short_sha = (pr.get("headSha") or "")[:7]
|
|
172
|
+
parts: list[str] = []
|
|
173
|
+
parts.append(f"## PR #{pr.get('number','?')} findings โ revision `{short_sha}` {state_badge(pr.get('state',''))}".rstrip())
|
|
174
|
+
|
|
175
|
+
if checks:
|
|
176
|
+
rendered_checks = " ยท ".join(
|
|
177
|
+
f"{check_icon(ch.get('conclusion',''))} {ch.get('name','?')}"
|
|
178
|
+
+ (f" ([details]({ch['url']}))" if ch.get("conclusion","").upper() in {"FAILURE","ERROR"} and ch.get("url") else "")
|
|
179
|
+
for ch in checks
|
|
180
|
+
)
|
|
181
|
+
parts.append(f"\n**Status checks:** {rendered_checks}")
|
|
182
|
+
|
|
183
|
+
section_headers = [
|
|
184
|
+
("blocker", "### ๐ด Blockers"),
|
|
185
|
+
("warning", "### ๐ก Warnings"),
|
|
186
|
+
("nit", "### ๐ต Nits"),
|
|
187
|
+
("info", "### โ
Approvals / strengths"),
|
|
188
|
+
]
|
|
189
|
+
for key, header in section_headers:
|
|
190
|
+
items = findings[key]
|
|
191
|
+
if not items:
|
|
192
|
+
continue
|
|
193
|
+
parts.append(f"\n{header} ({len(items)})\n")
|
|
194
|
+
parts.extend(items)
|
|
195
|
+
|
|
196
|
+
if stale_dropped and not args.include_stale:
|
|
197
|
+
parts.append(f"\n---\n<small>{stale_dropped} stale finding(s) on older commits skipped (use `--include-stale` to show).</small>")
|
|
198
|
+
|
|
199
|
+
total = sum(len(v) for v in findings.values())
|
|
200
|
+
if total == 0:
|
|
201
|
+
parts.append("\n_No findings._")
|
|
202
|
+
|
|
203
|
+
return "\n".join(parts) + "\n"
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def main() -> int:
|
|
207
|
+
p = argparse.ArgumentParser()
|
|
208
|
+
p.add_argument("--severity", choices=["blocker", "warning", "nit", "all"], default="all")
|
|
209
|
+
p.add_argument("--unresolved", action="store_true")
|
|
210
|
+
p.add_argument("--include-stale", action="store_true")
|
|
211
|
+
p.add_argument("--mine", action="store_true")
|
|
212
|
+
args = p.parse_args()
|
|
213
|
+
|
|
214
|
+
raw = sys.stdin.read()
|
|
215
|
+
if not raw.strip():
|
|
216
|
+
print("format.py: empty input on stdin", file=sys.stderr)
|
|
217
|
+
return 2
|
|
218
|
+
try:
|
|
219
|
+
data = json.loads(raw)
|
|
220
|
+
except json.JSONDecodeError as e:
|
|
221
|
+
print(f"format.py: invalid JSON on stdin: {e}", file=sys.stderr)
|
|
222
|
+
return 2
|
|
223
|
+
|
|
224
|
+
sys.stdout.write(render(data, args))
|
|
225
|
+
return 0
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
if __name__ == "__main__":
|
|
229
|
+
sys.exit(main())
|