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.
@@ -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 sidecarPath = findSidecar(prNumber);
389
- if (!sidecarPath) {
390
- console.log("\n⚡ No sidecar found — running pr-review to generate it…\n");
391
- runScrape(prNumber);
392
- sidecarPath = findSidecar(prNumber);
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("ℹ️ pr-review ran but found no inline review threads to resolve.");
395
- process.exit(0);
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].replace(/\.git$/, "") };
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
- function appendComment(out: string, c: GhComment, prefix: string, threadId?: string): string {
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
- return out + prefix + `## 💬 **${c.author.login}** · ${meta}\n\n${body}\n\n---\n\n`;
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 arg = process.argv[2];
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
- let output = `# PR Review ${owner}/${repo} #${prNumber}\n\n`;
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 ? `### 📄 File: \`${c.path}\`\n\n` : "";
226
- const outdatedPrefix = thread.isOutdated ? `### ⚠️ OUTDATED / SUPERSEDED\n\n` : "";
227
- output = appendComment(output, c, filePrefix + outdatedPrefix, firstInThread ? thread.id : undefined);
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
- output = appendComment(output, c, "");
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)
File without changes