pr-prism 1.1.3 → 1.1.6
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/.github/dependabot.yml +26 -0
- package/.github/workflows/ci.yml +1 -0
- package/.github/workflows/dependabot-auto-merge.yml +4 -10
- package/package.json +12 -6
- package/scripts/lib/sanitize.test.ts +545 -0
- package/scripts/lib/sanitize.ts +152 -0
- package/scripts/lib/scrape-issues.ts +204 -0
- package/scripts/lib/shared.test.ts +20 -0
- package/scripts/lib/shared.ts +74 -0
- package/scripts/pr-prism.ts +260 -0
- package/scripts/resolve-pr-threads.ts +59 -12
- package/scripts/scrape-pr-reviews.ts +47 -42
- package/.github/workflows/release.yml +0 -123
- package/pr-reviews/.gitkeep +0 -0
- package/pr-reviews/.scraped-ids.json +0 -34
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
import { execSync, spawnSync } from "node:child_process";
|
|
3
|
+
import { readdirSync, readFileSync, existsSync } from "node:fs";
|
|
4
|
+
import { join, dirname } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { defineWizard, runWizard } from "grimoire-wizard";
|
|
7
|
+
import { scrapeIssue } from "./lib/scrape-issues.js";
|
|
8
|
+
|
|
9
|
+
const SCRIPTS_DIR = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
10
|
+
const SIDECAR_DIR = "pr-reviews";
|
|
11
|
+
|
|
12
|
+
function tryDetectRepo(): string | null {
|
|
13
|
+
try {
|
|
14
|
+
const remote = execSync("git remote get-url origin", { encoding: "utf-8", stdio: "pipe" }).trim();
|
|
15
|
+
const m = remote.match(/github\.com[:/]([^/]+)\/([^/\s]+?)(?:\.git)?$/);
|
|
16
|
+
return m ? `${m[1]}/${m[2]}` : null;
|
|
17
|
+
} catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface ThreadsSidecar { prNumber: number; owner: string; repo: string; threadIds: string[]; }
|
|
23
|
+
|
|
24
|
+
function delegate(script: string, args: string[]): never {
|
|
25
|
+
const result = spawnSync(
|
|
26
|
+
join(SCRIPTS_DIR, "node_modules", ".bin", "tsx"),
|
|
27
|
+
[join(SCRIPTS_DIR, "scripts", script), ...args],
|
|
28
|
+
{ stdio: "inherit" },
|
|
29
|
+
);
|
|
30
|
+
process.exit(result.status ?? 1);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const BACK = "__BACK__" as const;
|
|
34
|
+
|
|
35
|
+
async function prompt(id: string, message: string, options: { value: string; label: string }[], goBackOnEsc = false): Promise<string> {
|
|
36
|
+
const config = defineWizard({
|
|
37
|
+
meta: { name: "pr-prism" },
|
|
38
|
+
steps: [{ id, type: "select" as const, message, options }],
|
|
39
|
+
});
|
|
40
|
+
const answers = await runWizard(config, {
|
|
41
|
+
onCancel: goBackOnEsc ? (() => {}) : (() => process.exit(0)),
|
|
42
|
+
});
|
|
43
|
+
const selected = answers?.[id];
|
|
44
|
+
if (!selected) return goBackOnEsc ? BACK : (process.exit(0), "");
|
|
45
|
+
return selected as string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function findSidecars(): { prNumber: number; threadCount: number }[] {
|
|
49
|
+
if (!existsSync(SIDECAR_DIR)) return [];
|
|
50
|
+
return readdirSync(SIDECAR_DIR)
|
|
51
|
+
.filter((f) => f.startsWith(".threads-") && f.endsWith(".json"))
|
|
52
|
+
.flatMap((f) => {
|
|
53
|
+
try {
|
|
54
|
+
const raw = JSON.parse(readFileSync(join(SIDECAR_DIR, f), "utf-8")) as Partial<ThreadsSidecar>;
|
|
55
|
+
return typeof raw.prNumber === "number" && Array.isArray(raw.threadIds)
|
|
56
|
+
? [{ prNumber: raw.prNumber, threadCount: raw.threadIds.length }]
|
|
57
|
+
: [];
|
|
58
|
+
} catch {
|
|
59
|
+
return [];
|
|
60
|
+
}
|
|
61
|
+
})
|
|
62
|
+
.sort((a, b) => b.prNumber - a.prNumber);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function handleResolveFlow(args: string[], fromMenu = false): Promise<boolean> {
|
|
66
|
+
if (args.length > 0) { delegate("resolve-pr-threads.ts", args); return true; }
|
|
67
|
+
|
|
68
|
+
const sidecars = findSidecars();
|
|
69
|
+
if (sidecars.length === 0) {
|
|
70
|
+
delegate("resolve-pr-threads.ts", []);
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const prChoice = await prompt("pr", "Which PR to resolve threads for?", sidecars.map((s) => ({
|
|
75
|
+
value: String(s.prNumber),
|
|
76
|
+
label: `PR #${s.prNumber} (${s.threadCount} thread${s.threadCount === 1 ? "" : "s"})`,
|
|
77
|
+
})), fromMenu);
|
|
78
|
+
if (prChoice === BACK) return false;
|
|
79
|
+
|
|
80
|
+
const mode = await prompt("mode", "How do you want to resolve?", [
|
|
81
|
+
{ value: "all", label: "Resolve all scraped threads" },
|
|
82
|
+
{ value: "auto", label: "Smart auto-resolve (outdated + applied suggestions)" },
|
|
83
|
+
{ value: "preview", label: "Dry-run preview (no changes)" },
|
|
84
|
+
{ value: "auto-preview", label: "Smart auto-resolve preview (dry run)" },
|
|
85
|
+
{ value: "unresolve", label: "Re-open resolved threads" },
|
|
86
|
+
], true);
|
|
87
|
+
if (mode === BACK) return false;
|
|
88
|
+
|
|
89
|
+
const flags: string[] = [prChoice];
|
|
90
|
+
if (mode === "auto") flags.push("--auto");
|
|
91
|
+
if (mode === "preview") flags.push("--dry-run");
|
|
92
|
+
if (mode === "auto-preview") flags.push("--auto", "--dry-run");
|
|
93
|
+
if (mode === "unresolve") flags.push("--unresolve");
|
|
94
|
+
|
|
95
|
+
delegate("resolve-pr-threads.ts", flags);
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function handleIssueFlow(args: string[], fromMenu = false): Promise<boolean> {
|
|
100
|
+
const hasPositional = args.some((a, i) => !a.startsWith("--") && args[i - 1] !== "--repo" && args[i - 1] !== "--state");
|
|
101
|
+
if (hasPositional) { await scrapeIssue(args); return true; }
|
|
102
|
+
|
|
103
|
+
const state = await prompt("state", "Which issues to list?", [
|
|
104
|
+
{ value: "open", label: "Open issues" },
|
|
105
|
+
{ value: "closed", label: "Closed issues" },
|
|
106
|
+
{ value: "all", label: "All issues" },
|
|
107
|
+
], fromMenu);
|
|
108
|
+
if (state === BACK) return false;
|
|
109
|
+
|
|
110
|
+
const stateArgs = state !== "open" ? ["--state", state] : [];
|
|
111
|
+
await scrapeIssue([...args, ...stateArgs]);
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
interface GhRepo { nameWithOwner: string; isPrivate: boolean; description: string; }
|
|
116
|
+
|
|
117
|
+
function listUserRepos(visibility: "public" | "private"): GhRepo[] {
|
|
118
|
+
try {
|
|
119
|
+
const out = execSync(
|
|
120
|
+
`gh repo list --json nameWithOwner,isPrivate,description --limit 30 --${visibility}`,
|
|
121
|
+
{ encoding: "utf-8", stdio: "pipe" },
|
|
122
|
+
).trim();
|
|
123
|
+
return JSON.parse(out) as GhRepo[];
|
|
124
|
+
} catch {
|
|
125
|
+
return [];
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function selectRepo(): Promise<string | null | typeof BACK> {
|
|
130
|
+
const detected = tryDetectRepo();
|
|
131
|
+
const useLabel = detected ? `Use current repo (${detected})` : "Auto-detect from git remote";
|
|
132
|
+
|
|
133
|
+
while (true) {
|
|
134
|
+
const choice = await prompt("repo", "Which repository?", [
|
|
135
|
+
{ value: "current", label: useLabel },
|
|
136
|
+
{ value: "my-public", label: "Pick from my public repos" },
|
|
137
|
+
{ value: "my-private", label: "Pick from my private repos" },
|
|
138
|
+
{ value: "other", label: "Enter owner/repo manually" },
|
|
139
|
+
], true);
|
|
140
|
+
if (choice === BACK) return BACK;
|
|
141
|
+
if (choice === "current") return null;
|
|
142
|
+
|
|
143
|
+
if (choice === "my-public" || choice === "my-private") {
|
|
144
|
+
const visibility = choice === "my-public" ? "public" : "private";
|
|
145
|
+
console.log(`\nLoading ${visibility} repos…`);
|
|
146
|
+
const repos = listUserRepos(visibility);
|
|
147
|
+
if (repos.length === 0) {
|
|
148
|
+
console.log(`No ${visibility} repos found.\n`);
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
const picked = await prompt("pick-repo", `Select a ${visibility} repo:`, repos.map((r) => ({
|
|
152
|
+
value: r.nameWithOwner,
|
|
153
|
+
label: `${r.nameWithOwner}${r.description ? " — " + r.description.slice(0, 60) : ""}`,
|
|
154
|
+
})), true);
|
|
155
|
+
if (picked === BACK) continue;
|
|
156
|
+
return picked;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const config = defineWizard({
|
|
160
|
+
meta: { name: "pr-prism" },
|
|
161
|
+
steps: [{ id: "repo", type: "text" as const, message: "Enter owner/repo (e.g. facebook/react):" }],
|
|
162
|
+
});
|
|
163
|
+
const answers = await runWizard(config, { onCancel: () => {} });
|
|
164
|
+
const repo = (answers?.repo as string)?.trim();
|
|
165
|
+
if (!repo) continue;
|
|
166
|
+
if (!repo.includes("/")) {
|
|
167
|
+
console.log("Invalid format. Use: owner/repo\n");
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
return repo;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function mainMenu(): Promise<void> {
|
|
175
|
+
while (true) {
|
|
176
|
+
const action = await prompt("action", "pr-prism — what do you want to do?", [
|
|
177
|
+
{ value: "pr", label: "Scrape PR reviews" },
|
|
178
|
+
{ value: "issue", label: "Scrape issue comments" },
|
|
179
|
+
{ value: "resolve", label: "Resolve PR review threads" },
|
|
180
|
+
]);
|
|
181
|
+
|
|
182
|
+
if (action === "resolve") {
|
|
183
|
+
const done = await handleResolveFlow([], true);
|
|
184
|
+
if (!done) continue;
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const repoOverride = await selectRepo();
|
|
189
|
+
if (repoOverride === BACK) continue;
|
|
190
|
+
const repoArgs = repoOverride ? ["--repo", repoOverride] : [];
|
|
191
|
+
|
|
192
|
+
switch (action) {
|
|
193
|
+
case "pr": delegate("scrape-pr-reviews.ts", repoArgs); break;
|
|
194
|
+
case "issue": {
|
|
195
|
+
const done = await handleIssueFlow(repoArgs, true);
|
|
196
|
+
if (!done) continue;
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function main(): Promise<void> {
|
|
204
|
+
const args = process.argv.slice(2);
|
|
205
|
+
const subcommand = args[0];
|
|
206
|
+
const rest = args.slice(1);
|
|
207
|
+
|
|
208
|
+
switch (subcommand) {
|
|
209
|
+
case "pr":
|
|
210
|
+
delegate("scrape-pr-reviews.ts", rest);
|
|
211
|
+
break;
|
|
212
|
+
|
|
213
|
+
case "issue":
|
|
214
|
+
await handleIssueFlow(rest);
|
|
215
|
+
break;
|
|
216
|
+
|
|
217
|
+
case "resolve":
|
|
218
|
+
await handleResolveFlow(rest);
|
|
219
|
+
break;
|
|
220
|
+
|
|
221
|
+
case "help":
|
|
222
|
+
case "--help":
|
|
223
|
+
case "-h":
|
|
224
|
+
console.log(`
|
|
225
|
+
pr-prism — strip noise from GitHub PRs and issues for LLM agents
|
|
226
|
+
|
|
227
|
+
USAGE
|
|
228
|
+
pr-prism interactive menu
|
|
229
|
+
pr-prism pr [number|url] scrape PR review comments
|
|
230
|
+
pr-prism issue [number|url] scrape issue comments
|
|
231
|
+
pr-prism resolve [number] [flags] resolve PR review threads
|
|
232
|
+
|
|
233
|
+
OPTIONS
|
|
234
|
+
--repo owner/repo target a different repo (default: auto-detect)
|
|
235
|
+
--state open|closed filter issues by state (default: open)
|
|
236
|
+
|
|
237
|
+
RESOLVE FLAGS
|
|
238
|
+
--auto smart auto-resolve (re-fetches live state)
|
|
239
|
+
--thread PRRT_xxx resolve specific thread(s) by ID (repeatable)
|
|
240
|
+
--dry-run preview without mutating
|
|
241
|
+
--tag-agents post @mention comment after resolving
|
|
242
|
+
--unresolve re-open resolved threads
|
|
243
|
+
--comment "…" attach custom message
|
|
244
|
+
`);
|
|
245
|
+
break;
|
|
246
|
+
|
|
247
|
+
default:
|
|
248
|
+
if (subcommand?.startsWith("http")) {
|
|
249
|
+
if (subcommand.includes("/pull/")) {
|
|
250
|
+
delegate("scrape-pr-reviews.ts", [subcommand, ...rest]);
|
|
251
|
+
} else {
|
|
252
|
+
await scrapeIssue([subcommand, ...rest]);
|
|
253
|
+
}
|
|
254
|
+
break;
|
|
255
|
+
}
|
|
256
|
+
await mainMenu();
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
main().catch((err: unknown) => { console.error((err as Error).message); process.exit(1); });
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* pnpm run pr-resolve — latest .threads-*.json
|
|
12
12
|
* pnpm run pr-resolve -- 42 — explicit PR number
|
|
13
13
|
* pnpm run pr-resolve -- 42 --auto — smart auto-resolve (re-fetches from GitHub)
|
|
14
|
+
* pnpm run pr-resolve -- 42 --thread PRRT_xxx — resolve specific thread(s)
|
|
14
15
|
* pnpm run pr-resolve -- 42 --auto --dry-run — preview auto-resolve classifications
|
|
15
16
|
* pnpm run pr-resolve -- 42 --auto --tag-agents — auto-resolve + tag agents
|
|
16
17
|
* pnpm run pr-resolve -- 42 --dry-run — preview without mutating
|
|
@@ -133,7 +134,7 @@ function mutateThread(threadId: string, unresolve: boolean): boolean {
|
|
|
133
134
|
const reqFile = join(tmpdir(), ".pr-resolve-req.json");
|
|
134
135
|
writeFileSync(reqFile, JSON.stringify({ query: mutation, variables: { id: threadId } }), "utf-8");
|
|
135
136
|
try {
|
|
136
|
-
const result = JSON.parse(run(`gh api https://api.github.com/graphql --input ${reqFile}`));
|
|
137
|
+
const result = JSON.parse(run(`gh api https://api.github.com/graphql --input "${reqFile}"`));
|
|
137
138
|
const key = unresolve ? "unresolveReviewThread" : "resolveReviewThread";
|
|
138
139
|
return result.data?.[key]?.thread != null;
|
|
139
140
|
} catch {
|
|
@@ -157,7 +158,7 @@ function fetchThreadsLive(owner: string, repo: string, prNumber: number): AutoPa
|
|
|
157
158
|
const reqFile = join(tmpdir(), ".pr-auto-resolve-req.json");
|
|
158
159
|
writeFileSync(reqFile, JSON.stringify({ query: AUTO_GRAPHQL_QUERY, variables: { owner, repo, prNumber } }), "utf-8");
|
|
159
160
|
try {
|
|
160
|
-
return JSON.parse(run(`gh api https://api.github.com/graphql --input ${reqFile}`)) as AutoPayload;
|
|
161
|
+
return JSON.parse(run(`gh api https://api.github.com/graphql --input "${reqFile}"`)) as AutoPayload;
|
|
161
162
|
} catch (err) {
|
|
162
163
|
console.error("gh API failed fetching threads. Is gh authenticated?");
|
|
163
164
|
console.error((err as Error).message);
|
|
@@ -355,9 +356,30 @@ async function main(): Promise<void> {
|
|
|
355
356
|
const shouldTag = args.includes("--tag-agents");
|
|
356
357
|
const commentIdx = args.indexOf("--comment");
|
|
357
358
|
const customComment = commentIdx !== -1 ? args[commentIdx + 1] : null;
|
|
359
|
+
const threadFlags: string[] = [];
|
|
360
|
+
for (let i = 0; i < args.length; i++) {
|
|
361
|
+
if (args[i] === "--thread") {
|
|
362
|
+
const next = args[i + 1];
|
|
363
|
+
if (!next || next.startsWith("--")) {
|
|
364
|
+
console.error("--thread requires a thread ID. Usage: pr-prism resolve <PR> --thread <THREAD_ID>");
|
|
365
|
+
process.exit(1);
|
|
366
|
+
}
|
|
367
|
+
threadFlags.push(next);
|
|
368
|
+
i++;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
358
371
|
const prArg = args.find((a) => /^\d+$/.test(a));
|
|
359
372
|
const prNumber = prArg != null ? parseInt(prArg, 10) : null;
|
|
360
373
|
|
|
374
|
+
if (isAuto && threadFlags.length > 0) {
|
|
375
|
+
console.error("--auto and --thread are incompatible.");
|
|
376
|
+
process.exit(1);
|
|
377
|
+
}
|
|
378
|
+
if (threadFlags.length > 0 && prNumber === null) {
|
|
379
|
+
console.error("--thread requires a PR number. Usage: pnpm run pr-resolve -- <PR> --thread <THREAD_ID>");
|
|
380
|
+
process.exit(1);
|
|
381
|
+
}
|
|
382
|
+
|
|
361
383
|
if (isAuto) {
|
|
362
384
|
if (prNumber === null) {
|
|
363
385
|
console.error("--auto requires a PR number. Usage: pnpm run pr-resolve -- <PR> --auto");
|
|
@@ -385,20 +407,45 @@ async function main(): Promise<void> {
|
|
|
385
407
|
return;
|
|
386
408
|
}
|
|
387
409
|
|
|
388
|
-
let
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
410
|
+
let owner: string;
|
|
411
|
+
let repo: string;
|
|
412
|
+
let threadIds: string[];
|
|
413
|
+
let resolvedPr: number;
|
|
414
|
+
|
|
415
|
+
if (threadFlags.length > 0) {
|
|
416
|
+
const sidecarPath = findSidecar(prNumber);
|
|
417
|
+
if (sidecarPath) {
|
|
418
|
+
const sidecar = JSON.parse(readFileSync(sidecarPath, "utf-8")) as ThreadsSidecar;
|
|
419
|
+
owner = sidecar.owner;
|
|
420
|
+
repo = sidecar.repo;
|
|
421
|
+
resolvedPr = sidecar.prNumber;
|
|
422
|
+
} else {
|
|
423
|
+
const remote = run("git remote get-url origin");
|
|
424
|
+
const m = remote.match(/github\.com[:/]([^/]+)\/([^/\s.]+)/);
|
|
425
|
+
if (!m) { console.error("Could not detect GitHub repo."); process.exit(1); }
|
|
426
|
+
owner = m[1];
|
|
427
|
+
repo = m[2].replace(/\.git$/, "");
|
|
428
|
+
resolvedPr = prNumber ?? 0;
|
|
429
|
+
}
|
|
430
|
+
threadIds = threadFlags;
|
|
431
|
+
} else {
|
|
432
|
+
let sidecarPath = findSidecar(prNumber);
|
|
393
433
|
if (!sidecarPath) {
|
|
394
|
-
console.log("
|
|
395
|
-
|
|
434
|
+
console.log("\n⚡ No sidecar found — running pr-review to generate it…\n");
|
|
435
|
+
runScrape(prNumber);
|
|
436
|
+
sidecarPath = findSidecar(prNumber);
|
|
437
|
+
if (!sidecarPath) {
|
|
438
|
+
console.log("ℹ️ pr-review ran but found no inline review threads to resolve.");
|
|
439
|
+
process.exit(0);
|
|
440
|
+
}
|
|
396
441
|
}
|
|
442
|
+
const sidecar = JSON.parse(readFileSync(sidecarPath, "utf-8")) as ThreadsSidecar;
|
|
443
|
+
threadIds = sidecar.threadIds;
|
|
444
|
+
owner = sidecar.owner;
|
|
445
|
+
repo = sidecar.repo;
|
|
446
|
+
resolvedPr = sidecar.prNumber;
|
|
397
447
|
}
|
|
398
448
|
|
|
399
|
-
const sidecar = JSON.parse(readFileSync(sidecarPath, "utf-8")) as ThreadsSidecar;
|
|
400
|
-
const { threadIds, owner, repo } = sidecar;
|
|
401
|
-
const resolvedPr = sidecar.prNumber;
|
|
402
449
|
const action = isUnresolve ? "Unresolve" : "Resolve";
|
|
403
450
|
|
|
404
451
|
console.log(`\n${action} ${threadIds.length} thread(s) in ${owner}/${repo} #${resolvedPr}${isDryRun ? " [DRY RUN]" : ""}\n`);
|
|
@@ -21,8 +21,8 @@ import { writeFileSync, readFileSync, existsSync, mkdirSync, unlinkSync } from "
|
|
|
21
21
|
import { join } from "node:path";
|
|
22
22
|
import { tmpdir } from "node:os";
|
|
23
23
|
import { defineWizard, runWizard } from "grimoire-wizard";
|
|
24
|
+
import { isBot, stripNoise, renderSuggestions, formatTokenSummary } from "./lib/sanitize.js";
|
|
24
25
|
|
|
25
|
-
const KNOWN_BOTS = ["github-actions", "dependabot", "coderabbitai", "changeset-bot", "codeantai"];
|
|
26
26
|
const OUT_DIR = "pr-reviews";
|
|
27
27
|
const CACHE_FILE = join(OUT_DIR, ".scraped-ids.json");
|
|
28
28
|
|
|
@@ -62,9 +62,9 @@ function run(cmd: string): string {
|
|
|
62
62
|
function detectRepo(): { owner: string; repo: string } {
|
|
63
63
|
try {
|
|
64
64
|
const remote = run("git remote get-url origin");
|
|
65
|
-
const m = remote.match(/github\.com[:/]([^/]+)\/([^/\s
|
|
65
|
+
const m = remote.match(/github\.com[:/]([^/]+)\/([^/\s]+?)(?:\.git)?$/);
|
|
66
66
|
if (!m) throw new Error();
|
|
67
|
-
return { owner: m[1], repo: m[2]
|
|
67
|
+
return { owner: m[1], repo: m[2] };
|
|
68
68
|
} catch {
|
|
69
69
|
console.error("❌ Could not detect GitHub repo. Run from a GitHub repo or pass a URL.");
|
|
70
70
|
process.exit(1);
|
|
@@ -113,7 +113,7 @@ function fetchPr(owner: string, repo: string, prNumber: number): PrPayload {
|
|
|
113
113
|
const reqFile = join(tmpdir(), ".pr-review-req.json");
|
|
114
114
|
writeFileSync(reqFile, JSON.stringify({ query: GRAPHQL_QUERY, variables: { owner, repo, prNumber } }), "utf-8");
|
|
115
115
|
try {
|
|
116
|
-
return JSON.parse(run(`gh api https://api.github.com/graphql --input ${reqFile}`)) as PrPayload;
|
|
116
|
+
return JSON.parse(run(`gh api https://api.github.com/graphql --input "${reqFile}"`)) as PrPayload;
|
|
117
117
|
} catch (err) {
|
|
118
118
|
console.error("❌ gh API failed. Is gh authenticated? Run: gh auth login");
|
|
119
119
|
console.error((err as Error).message);
|
|
@@ -128,42 +128,20 @@ function loadCache(): Set<string> {
|
|
|
128
128
|
try { return new Set((JSON.parse(readFileSync(CACHE_FILE, "utf-8")) as IdCache).seen); } catch { return new Set(); }
|
|
129
129
|
}
|
|
130
130
|
function saveCache(seen: Set<string>): void { writeFileSync(CACHE_FILE, JSON.stringify({ seen: [...seen] }, null, 2), "utf-8"); }
|
|
131
|
-
function isBot(login: string): boolean { const l = login.toLowerCase(); return l.endsWith("[bot]") || KNOWN_BOTS.some((b) => l.includes(b)); }
|
|
132
|
-
|
|
133
|
-
const NOISE_DOMAINS = [
|
|
134
|
-
"twitter.com/intent", "x.com/intent",
|
|
135
|
-
"reddit.com/submit",
|
|
136
|
-
"linkedin.com/sharing",
|
|
137
|
-
"app.codeant.ai", "codeant.ai/feedback",
|
|
138
|
-
];
|
|
139
|
-
|
|
140
|
-
function stripNoise(body: string): string {
|
|
141
|
-
return body
|
|
142
|
-
.replace(/<a\s[^>]*href=['"]([^'"]+)['"][^>]*>[\s\S]*?<\/a>/gi, (match, url: string) =>
|
|
143
|
-
NOISE_DOMAINS.some((d) => url.includes(d)) ? "" : match,
|
|
144
|
-
)
|
|
145
|
-
.replace(/\[([^\]]*)\]\((https?:\/\/[^)]+)\)/g, (match, _text, url: string) =>
|
|
146
|
-
NOISE_DOMAINS.some((d) => url.includes(d)) ? "" : match,
|
|
147
|
-
)
|
|
148
|
-
.replace(/^[\s·|—\-]+$/gm, "")
|
|
149
|
-
.replace(/\n{3,}/g, "\n\n")
|
|
150
|
-
.trim();
|
|
151
|
-
}
|
|
152
131
|
|
|
153
|
-
function renderSuggestions(body: string): string {
|
|
154
|
-
return body.replace(/```suggestion\n([\s\S]*?)```/g, (_, code: string) => {
|
|
155
|
-
const lines = code.trimEnd().split("\n").map((l: string) => `+ ${l}`).join("\n");
|
|
156
|
-
return `\n**SUGGESTED CHANGE:**\n\`\`\`diff\n${lines}\n\`\`\`\n`;
|
|
157
|
-
});
|
|
158
|
-
}
|
|
159
132
|
|
|
160
|
-
|
|
133
|
+
|
|
134
|
+
interface AppendResult { text: string; rawChars: number; cleanedChars: number; }
|
|
135
|
+
|
|
136
|
+
function appendComment(out: string, c: GhComment, prefix: string, threadId?: string): AppendResult {
|
|
137
|
+
const rawChars = c.body.length;
|
|
161
138
|
const body = renderSuggestions(stripNoise(c.body)).trim();
|
|
162
|
-
if (!body) return out;
|
|
139
|
+
if (!body) return { text: out, rawChars, cleanedChars: 0 };
|
|
163
140
|
const meta = threadId
|
|
164
141
|
? `thread \`${threadId}\` · \`#${c.databaseId}\``
|
|
165
142
|
: `\`#${c.databaseId}\``;
|
|
166
|
-
|
|
143
|
+
const appended = prefix + `${c.author.login} ${meta}\n${body}\n\n---\n\n`;
|
|
144
|
+
return { text: out + appended, rawChars, cleanedChars: body.length };
|
|
167
145
|
}
|
|
168
146
|
|
|
169
147
|
function ensureGitignore(): void {
|
|
@@ -190,17 +168,28 @@ function ensureGitignore(): void {
|
|
|
190
168
|
console.log("📝 Appended pr-reviews output entries to .gitignore");
|
|
191
169
|
}
|
|
192
170
|
|
|
171
|
+
function parseRepoFlag(args: string[]): { owner: string; repo: string } | null {
|
|
172
|
+
const idx = args.indexOf("--repo");
|
|
173
|
+
if (idx === -1 || !args[idx + 1]) return null;
|
|
174
|
+
const parts = args[idx + 1].split("/");
|
|
175
|
+
if (parts.length !== 2) return null;
|
|
176
|
+
return { owner: parts[0], repo: parts[1] };
|
|
177
|
+
}
|
|
178
|
+
|
|
193
179
|
async function main(): Promise<void> {
|
|
194
|
-
const
|
|
180
|
+
const args = process.argv.slice(2);
|
|
181
|
+
const repoFlag = parseRepoFlag(args);
|
|
182
|
+
const positional = args.filter((a, i) => a !== "--repo" && args[i - 1] !== "--repo");
|
|
183
|
+
const arg = positional[0];
|
|
195
184
|
let owner: string, repo: string, prNumber: number;
|
|
196
185
|
|
|
197
186
|
if (arg?.startsWith("http")) {
|
|
198
187
|
({ owner, repo, prNumber } = parsePrUrl(arg));
|
|
199
188
|
} else if (arg && /^\d+$/.test(arg)) {
|
|
200
|
-
({ owner, repo } = detectRepo());
|
|
189
|
+
({ owner, repo } = repoFlag ?? detectRepo());
|
|
201
190
|
prNumber = parseInt(arg, 10);
|
|
202
191
|
} else {
|
|
203
|
-
({ owner, repo } = detectRepo());
|
|
192
|
+
({ owner, repo } = repoFlag ?? detectRepo());
|
|
204
193
|
const prs = listOpenPrs(owner, repo);
|
|
205
194
|
console.log(`\nFound ${prs.length} open PR(s) in ${owner}/${repo}\n`);
|
|
206
195
|
prNumber = await selectPr(prs);
|
|
@@ -212,8 +201,11 @@ async function main(): Promise<void> {
|
|
|
212
201
|
mkdirSync(OUT_DIR, { recursive: true });
|
|
213
202
|
ensureGitignore();
|
|
214
203
|
const cache = loadCache();
|
|
215
|
-
|
|
204
|
+
const header = `PR #${prNumber} -- ${owner}/${repo}\n\n`;
|
|
205
|
+
let body = "";
|
|
216
206
|
let count = 0;
|
|
207
|
+
let totalRawChars = 0;
|
|
208
|
+
let totalCleanedChars = 0;
|
|
217
209
|
const emittedThreadIds: string[] = [];
|
|
218
210
|
|
|
219
211
|
for (const thread of pr.reviewThreads.nodes) {
|
|
@@ -222,9 +214,13 @@ async function main(): Promise<void> {
|
|
|
222
214
|
const key = String(c.databaseId);
|
|
223
215
|
if (thread.isResolved || isBot(c.author.login)) { cache.add(key); continue; }
|
|
224
216
|
if (cache.has(key)) continue;
|
|
225
|
-
const filePrefix = c.path ?
|
|
226
|
-
const outdatedPrefix = thread.isOutdated ?
|
|
227
|
-
|
|
217
|
+
const filePrefix = c.path ? `[${c.path}] ` : "";
|
|
218
|
+
const outdatedPrefix = thread.isOutdated ? "[OUTDATED] " : "";
|
|
219
|
+
const result = appendComment(body, c, filePrefix + outdatedPrefix, firstInThread ? thread.id : undefined);
|
|
220
|
+
if (result.cleanedChars === 0) { cache.add(key); continue; }
|
|
221
|
+
body = result.text;
|
|
222
|
+
totalRawChars += result.rawChars;
|
|
223
|
+
totalCleanedChars += result.cleanedChars;
|
|
228
224
|
cache.add(key); count++;
|
|
229
225
|
if (firstInThread) { emittedThreadIds.push(thread.id); firstInThread = false; }
|
|
230
226
|
}
|
|
@@ -234,10 +230,19 @@ async function main(): Promise<void> {
|
|
|
234
230
|
const key = String(c.databaseId);
|
|
235
231
|
if (isBot(c.author.login) || !c.body.trim()) { cache.add(key); continue; }
|
|
236
232
|
if (cache.has(key)) continue;
|
|
237
|
-
|
|
233
|
+
const result = appendComment(body, c, "");
|
|
234
|
+
if (result.cleanedChars === 0) { cache.add(key); continue; }
|
|
235
|
+
body = result.text;
|
|
236
|
+
totalRawChars += result.rawChars;
|
|
237
|
+
totalCleanedChars += result.cleanedChars;
|
|
238
238
|
cache.add(key); count++;
|
|
239
239
|
}
|
|
240
240
|
|
|
241
|
+
const tokenSummary = count > 0
|
|
242
|
+
? formatTokenSummary(totalRawChars, totalCleanedChars) + "\n\n"
|
|
243
|
+
: "";
|
|
244
|
+
const output = header + tokenSummary + body;
|
|
245
|
+
|
|
241
246
|
saveCache(cache);
|
|
242
247
|
const outFile = join(OUT_DIR, `new-${new Date().toISOString().replace(/[:.]/g, "-")}.md`);
|
|
243
248
|
writeFileSync(outFile, output, "utf-8");
|
|
@@ -1,123 +0,0 @@
|
|
|
1
|
-
# Automated Release Workflow
|
|
2
|
-
# ─────────────────────────
|
|
3
|
-
# Bumps version, creates Git tag, and publishes a GitHub Release
|
|
4
|
-
# when a PR is merged into `main`.
|
|
5
|
-
#
|
|
6
|
-
# Version bump is determined by PR labels:
|
|
7
|
-
# - `major` → breaking changes (1.0.0 → 2.0.0)
|
|
8
|
-
# - `minor` → new features (1.0.0 → 1.1.0)
|
|
9
|
-
# - `patch` → bug fixes (1.0.0 → 1.0.1) [default]
|
|
10
|
-
# - `no-release` → skip entirely
|
|
11
|
-
#
|
|
12
|
-
# Template Note:
|
|
13
|
-
# - Ensure labels `major`, `minor`, `patch`, `no-release` exist in your repo
|
|
14
|
-
# - For npm publish: uncomment the publish step and add NPM_TOKEN secret
|
|
15
|
-
# - Works with dev → main merge flow out of the box
|
|
16
|
-
# - GITHUB_TOKEN won't trigger downstream workflows; use a PAT if needed
|
|
17
|
-
|
|
18
|
-
name: Release
|
|
19
|
-
|
|
20
|
-
on:
|
|
21
|
-
pull_request:
|
|
22
|
-
types: [closed]
|
|
23
|
-
branches: [main]
|
|
24
|
-
|
|
25
|
-
jobs:
|
|
26
|
-
release:
|
|
27
|
-
if: >
|
|
28
|
-
github.event.pull_request.merged == true &&
|
|
29
|
-
!contains(github.event.pull_request.labels.*.name, 'no-release')
|
|
30
|
-
runs-on: ubuntu-latest
|
|
31
|
-
permissions:
|
|
32
|
-
contents: write
|
|
33
|
-
|
|
34
|
-
steps:
|
|
35
|
-
- name: Checkout
|
|
36
|
-
uses: actions/checkout@v6
|
|
37
|
-
with:
|
|
38
|
-
ref: main
|
|
39
|
-
fetch-depth: 0
|
|
40
|
-
token: ${{ secrets.GITHUB_TOKEN }}
|
|
41
|
-
|
|
42
|
-
- name: Setup Node.js
|
|
43
|
-
uses: actions/setup-node@v6
|
|
44
|
-
with:
|
|
45
|
-
node-version: 22
|
|
46
|
-
|
|
47
|
-
- name: Configure Git
|
|
48
|
-
run: |
|
|
49
|
-
git config user.name "github-actions[bot]"
|
|
50
|
-
git config user.email "github-actions[bot]@users.noreply.github.com"
|
|
51
|
-
|
|
52
|
-
- name: Determine version bump
|
|
53
|
-
id: bump
|
|
54
|
-
env:
|
|
55
|
-
LABELS: ${{ toJson(github.event.pull_request.labels.*.name) }}
|
|
56
|
-
run: |
|
|
57
|
-
if echo "$LABELS" | grep -q '"major"'; then
|
|
58
|
-
echo "type=major" >> "$GITHUB_OUTPUT"
|
|
59
|
-
elif echo "$LABELS" | grep -q '"minor"'; then
|
|
60
|
-
echo "type=minor" >> "$GITHUB_OUTPUT"
|
|
61
|
-
else
|
|
62
|
-
echo "type=patch" >> "$GITHUB_OUTPUT"
|
|
63
|
-
fi
|
|
64
|
-
|
|
65
|
-
- name: Bump version in package.json
|
|
66
|
-
id: version
|
|
67
|
-
run: |
|
|
68
|
-
if ! node -e "const p = require('./package.json'); if (!p.version) process.exit(1);" 2>/dev/null; then
|
|
69
|
-
echo "No version found in package.json — initializing to 0.1.0"
|
|
70
|
-
npm pkg set version=0.1.0
|
|
71
|
-
fi
|
|
72
|
-
NEW_VERSION=$(npm version ${{ steps.bump.outputs.type }} --no-git-tag-version)
|
|
73
|
-
echo "version=$NEW_VERSION" >> "$GITHUB_OUTPUT"
|
|
74
|
-
echo "Bumped to $NEW_VERSION (${{ steps.bump.outputs.type }})"
|
|
75
|
-
|
|
76
|
-
- name: Build release notes from commits
|
|
77
|
-
id: notes
|
|
78
|
-
env:
|
|
79
|
-
PR_TITLE: ${{ github.event.pull_request.title }}
|
|
80
|
-
PR_NUMBER: ${{ github.event.pull_request.number }}
|
|
81
|
-
PR_URL: ${{ github.event.pull_request.html_url }}
|
|
82
|
-
BUMP_TYPE: ${{ steps.bump.outputs.type }}
|
|
83
|
-
run: |
|
|
84
|
-
PREV_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
|
|
85
|
-
if [ -n "$PREV_TAG" ]; then
|
|
86
|
-
COMMITS=$(git log "$PREV_TAG"..HEAD --pretty=format:"- %s (%h)" --no-merges)
|
|
87
|
-
else
|
|
88
|
-
COMMITS=$(git log --pretty=format:"- %s (%h)" --no-merges -20)
|
|
89
|
-
fi
|
|
90
|
-
|
|
91
|
-
{
|
|
92
|
-
echo "body<<RELEASE_EOF"
|
|
93
|
-
echo "## What Changed"
|
|
94
|
-
echo ""
|
|
95
|
-
echo "**PR:** [#${PR_NUMBER} — ${PR_TITLE}](${PR_URL})"
|
|
96
|
-
echo "**Bump:** \`${BUMP_TYPE}\`"
|
|
97
|
-
echo ""
|
|
98
|
-
echo "### Commits"
|
|
99
|
-
echo ""
|
|
100
|
-
echo "$COMMITS"
|
|
101
|
-
echo ""
|
|
102
|
-
echo "RELEASE_EOF"
|
|
103
|
-
} >> "$GITHUB_OUTPUT"
|
|
104
|
-
|
|
105
|
-
- name: Commit version bump and create tag
|
|
106
|
-
run: |
|
|
107
|
-
git add package.json package-lock.json 2>/dev/null || git add package.json
|
|
108
|
-
git commit -m "chore(release): ${{ steps.version.outputs.version }}"
|
|
109
|
-
git tag -a "${{ steps.version.outputs.version }}" \
|
|
110
|
-
-m "Release ${{ steps.version.outputs.version }} — ${{ github.event.pull_request.title }}"
|
|
111
|
-
git push origin main --follow-tags
|
|
112
|
-
|
|
113
|
-
- name: Create GitHub Release
|
|
114
|
-
env:
|
|
115
|
-
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
116
|
-
TAG: ${{ steps.version.outputs.version }}
|
|
117
|
-
run: |
|
|
118
|
-
gh release create "$TAG" \
|
|
119
|
-
--title "$TAG — ${{ github.event.pull_request.title }}" \
|
|
120
|
-
--notes "${{ steps.notes.outputs.body }}" \
|
|
121
|
-
--latest
|
|
122
|
-
|
|
123
|
-
# npm publish is handled by publish.yml (triggered by the tag created above)
|
package/pr-reviews/.gitkeep
DELETED
|
File without changes
|