gh-inbox-clean 0.0.1

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.
Files changed (3) hide show
  1. package/README.md +40 -0
  2. package/dist/cli.mjs +637 -0
  3. package/package.json +37 -0
package/README.md ADDED
@@ -0,0 +1,40 @@
1
+ # gh-inbox-clean
2
+
3
+ CLI to cleanup GitHub PR notifications you no longer need to review. Marks notifications as done for PRs that are closed or where you are no longer a requested reviewer.
4
+
5
+ ## Prerequisites
6
+
7
+ You must have the [GitHub CLI](https://cli.github.com/) installed and authenticated:
8
+
9
+ ```sh
10
+ gh auth login
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ ```sh
16
+ npx gh-inbox-clean
17
+ ```
18
+
19
+ ### Options
20
+
21
+ ```
22
+ -h, --help Show help message
23
+ -v, --version Show version number
24
+ -d, --dry-run Preview what would be cleared without making changes
25
+ -t, --teams Comma-separated team slugs to keep notifications for
26
+ ```
27
+
28
+ ### Examples
29
+
30
+ Preview what would be cleared:
31
+
32
+ ```sh
33
+ npx gh-inbox-clean --dry-run
34
+ ```
35
+
36
+ Keep notifications where specific teams are requested reviewers:
37
+
38
+ ```sh
39
+ npx gh-inbox-clean --teams my-team,other-team
40
+ ```
package/dist/cli.mjs ADDED
@@ -0,0 +1,637 @@
1
+ #!/usr/bin/env node
2
+ import { Octokit } from "@octokit/rest";
3
+ import { RequestError } from "@octokit/request-error";
4
+ import { execFile } from "node:child_process";
5
+ import { parseArgs, promisify, stripVTControlCharacters } from "node:util";
6
+ import process$1 from "node:process";
7
+ import tty from "node:tty";
8
+
9
+ //#region \0rolldown/runtime.js
10
+ var __defProp = Object.defineProperty;
11
+ var __exportAll = (all, no_symbols) => {
12
+ let target = {};
13
+ for (var name in all) {
14
+ __defProp(target, name, {
15
+ get: all[name],
16
+ enumerable: true
17
+ });
18
+ }
19
+ if (!no_symbols) {
20
+ __defProp(target, Symbol.toStringTag, { value: "Module" });
21
+ }
22
+ return target;
23
+ };
24
+
25
+ //#endregion
26
+ //#region node_modules/yoctocolors/base.js
27
+ var base_exports = /* @__PURE__ */ __exportAll({
28
+ bgBlack: () => bgBlack,
29
+ bgBlue: () => bgBlue,
30
+ bgBlueBright: () => bgBlueBright,
31
+ bgCyan: () => bgCyan,
32
+ bgCyanBright: () => bgCyanBright,
33
+ bgGray: () => bgGray,
34
+ bgGreen: () => bgGreen,
35
+ bgGreenBright: () => bgGreenBright,
36
+ bgMagenta: () => bgMagenta,
37
+ bgMagentaBright: () => bgMagentaBright,
38
+ bgRed: () => bgRed,
39
+ bgRedBright: () => bgRedBright,
40
+ bgWhite: () => bgWhite,
41
+ bgWhiteBright: () => bgWhiteBright,
42
+ bgYellow: () => bgYellow,
43
+ bgYellowBright: () => bgYellowBright,
44
+ black: () => black,
45
+ blue: () => blue,
46
+ blueBright: () => blueBright,
47
+ bold: () => bold,
48
+ cyan: () => cyan,
49
+ cyanBright: () => cyanBright,
50
+ dim: () => dim,
51
+ gray: () => gray,
52
+ green: () => green,
53
+ greenBright: () => greenBright,
54
+ hidden: () => hidden,
55
+ inverse: () => inverse,
56
+ italic: () => italic,
57
+ magenta: () => magenta,
58
+ magentaBright: () => magentaBright,
59
+ overline: () => overline,
60
+ red: () => red,
61
+ redBright: () => redBright,
62
+ reset: () => reset,
63
+ strikethrough: () => strikethrough,
64
+ underline: () => underline,
65
+ white: () => white,
66
+ whiteBright: () => whiteBright,
67
+ yellow: () => yellow,
68
+ yellowBright: () => yellowBright
69
+ });
70
+ const hasColors = tty?.WriteStream?.prototype?.hasColors?.() ?? false;
71
+ const format = (open, close) => {
72
+ if (!hasColors) return (input) => input;
73
+ const openCode = `\u001B[${open}m`;
74
+ const closeCode = `\u001B[${close}m`;
75
+ return (input) => {
76
+ const string = input + "";
77
+ let index = string.indexOf(closeCode);
78
+ if (index === -1) return openCode + string + closeCode;
79
+ let result = openCode;
80
+ let lastIndex = 0;
81
+ const replaceCode = (close === 22 ? closeCode : "") + openCode;
82
+ while (index !== -1) {
83
+ result += string.slice(lastIndex, index) + replaceCode;
84
+ lastIndex = index + closeCode.length;
85
+ index = string.indexOf(closeCode, lastIndex);
86
+ }
87
+ result += string.slice(lastIndex) + closeCode;
88
+ return result;
89
+ };
90
+ };
91
+ const reset = format(0, 0);
92
+ const bold = format(1, 22);
93
+ const dim = format(2, 22);
94
+ const italic = format(3, 23);
95
+ const underline = format(4, 24);
96
+ const overline = format(53, 55);
97
+ const inverse = format(7, 27);
98
+ const hidden = format(8, 28);
99
+ const strikethrough = format(9, 29);
100
+ const black = format(30, 39);
101
+ const red = format(31, 39);
102
+ const green = format(32, 39);
103
+ const yellow = format(33, 39);
104
+ const blue = format(34, 39);
105
+ const magenta = format(35, 39);
106
+ const cyan = format(36, 39);
107
+ const white = format(37, 39);
108
+ const gray = format(90, 39);
109
+ const bgBlack = format(40, 49);
110
+ const bgRed = format(41, 49);
111
+ const bgGreen = format(42, 49);
112
+ const bgYellow = format(43, 49);
113
+ const bgBlue = format(44, 49);
114
+ const bgMagenta = format(45, 49);
115
+ const bgCyan = format(46, 49);
116
+ const bgWhite = format(47, 49);
117
+ const bgGray = format(100, 49);
118
+ const redBright = format(91, 39);
119
+ const greenBright = format(92, 39);
120
+ const yellowBright = format(93, 39);
121
+ const blueBright = format(94, 39);
122
+ const magentaBright = format(95, 39);
123
+ const cyanBright = format(96, 39);
124
+ const whiteBright = format(97, 39);
125
+ const bgRedBright = format(101, 49);
126
+ const bgGreenBright = format(102, 49);
127
+ const bgYellowBright = format(103, 49);
128
+ const bgBlueBright = format(104, 49);
129
+ const bgMagentaBright = format(105, 49);
130
+ const bgCyanBright = format(106, 49);
131
+ const bgWhiteBright = format(107, 49);
132
+
133
+ //#endregion
134
+ //#region node_modules/yocto-spinner/index.js
135
+ const isUnicodeSupported = process$1.platform !== "win32" || Boolean(process$1.env.WT_SESSION) || process$1.env.TERM_PROGRAM === "vscode";
136
+ const isInteractive = (stream) => Boolean(stream.isTTY && process$1.env.TERM !== "dumb" && !("CI" in process$1.env));
137
+ const infoSymbol = blue(isUnicodeSupported ? "ℹ" : "i");
138
+ const successSymbol = green(isUnicodeSupported ? "✔" : "√");
139
+ const warningSymbol = yellow(isUnicodeSupported ? "⚠" : "‼");
140
+ const errorSymbol = red(isUnicodeSupported ? "✖" : "×");
141
+ const defaultSpinner = {
142
+ frames: isUnicodeSupported ? [
143
+ "⠋",
144
+ "⠙",
145
+ "⠹",
146
+ "⠸",
147
+ "⠼",
148
+ "⠴",
149
+ "⠦",
150
+ "⠧",
151
+ "⠇",
152
+ "⠏"
153
+ ] : [
154
+ "-",
155
+ "\\",
156
+ "|",
157
+ "/"
158
+ ],
159
+ interval: 80
160
+ };
161
+ const SYNCHRONIZED_OUTPUT_ENABLE = "\x1B[?2026h";
162
+ const SYNCHRONIZED_OUTPUT_DISABLE = "\x1B[?2026l";
163
+ const activeHooksPerStream = /* @__PURE__ */ new Set();
164
+ var YoctoSpinner = class {
165
+ #frames;
166
+ #interval;
167
+ #currentFrame = -1;
168
+ #timer;
169
+ #text;
170
+ #stream;
171
+ #color;
172
+ #lines = 0;
173
+ #exitHandlerBound;
174
+ #isInteractive;
175
+ #lastSpinnerFrameTime = 0;
176
+ #isSpinning = false;
177
+ #hookedStreams = /* @__PURE__ */ new Map();
178
+ #isInternalWrite = false;
179
+ #isDeferringRender = false;
180
+ constructor(options = {}) {
181
+ const spinner = options.spinner ?? defaultSpinner;
182
+ this.#frames = spinner.frames;
183
+ this.#interval = spinner.interval;
184
+ this.#text = options.text ?? "";
185
+ this.#stream = options.stream ?? process$1.stderr;
186
+ this.#color = options.color ?? "cyan";
187
+ this.#isInteractive = isInteractive(this.#stream);
188
+ this.#exitHandlerBound = this.#exitHandler.bind(this);
189
+ }
190
+ #internalWrite(action) {
191
+ this.#isInternalWrite = true;
192
+ try {
193
+ return action();
194
+ } finally {
195
+ this.#isInternalWrite = false;
196
+ }
197
+ }
198
+ #stringifyChunk(chunk, encoding) {
199
+ if (chunk === void 0 || chunk === null) return "";
200
+ if (typeof chunk === "string") return chunk;
201
+ if (Buffer.isBuffer(chunk) || ArrayBuffer.isView(chunk)) {
202
+ const normalizedEncoding = typeof encoding === "string" && encoding !== "" && encoding !== "buffer" ? encoding : "utf8";
203
+ return Buffer.from(chunk).toString(normalizedEncoding);
204
+ }
205
+ return String(chunk);
206
+ }
207
+ #withSynchronizedOutput(action) {
208
+ if (!this.#isInteractive) return action();
209
+ try {
210
+ this.#write(SYNCHRONIZED_OUTPUT_ENABLE);
211
+ return action();
212
+ } finally {
213
+ this.#write(SYNCHRONIZED_OUTPUT_DISABLE);
214
+ }
215
+ }
216
+ #hookStream(stream) {
217
+ if (!stream || this.#hookedStreams.has(stream) || typeof stream.write !== "function") return;
218
+ if (activeHooksPerStream.has(stream)) return;
219
+ const originalWrite = stream.write;
220
+ const hookedWrite = (...writeArguments) => this.#hookedWrite(stream, originalWrite, writeArguments);
221
+ this.#hookedStreams.set(stream, {
222
+ originalWrite,
223
+ hookedWrite
224
+ });
225
+ activeHooksPerStream.add(stream);
226
+ stream.write = hookedWrite;
227
+ }
228
+ #installHook() {
229
+ if (!this.#isInteractive || this.#hookedStreams.size > 0) return;
230
+ const streamsToHook = new Set([this.#stream]);
231
+ if (this.#stream === process$1.stdout || this.#stream === process$1.stderr) {
232
+ if (isInteractive(process$1.stdout)) streamsToHook.add(process$1.stdout);
233
+ if (isInteractive(process$1.stderr)) streamsToHook.add(process$1.stderr);
234
+ }
235
+ for (const stream of streamsToHook) this.#hookStream(stream);
236
+ }
237
+ #uninstallHook() {
238
+ for (const [stream, hookInfo] of this.#hookedStreams) {
239
+ if (stream.write === hookInfo.hookedWrite) stream.write = hookInfo.originalWrite;
240
+ activeHooksPerStream.delete(stream);
241
+ }
242
+ this.#hookedStreams.clear();
243
+ }
244
+ #hookedWrite(stream, originalWrite, writeArguments) {
245
+ const [chunk, encoding, callback] = writeArguments;
246
+ let resolvedEncoding = encoding;
247
+ let resolvedCallback = callback;
248
+ if (typeof resolvedEncoding === "function") {
249
+ resolvedCallback = resolvedEncoding;
250
+ resolvedEncoding = void 0;
251
+ }
252
+ if (this.#isInternalWrite || !this.isSpinning) return originalWrite.call(stream, chunk, resolvedEncoding, resolvedCallback);
253
+ if (this.#lines > 0) this.clear();
254
+ const chunkString = this.#stringifyChunk(chunk, resolvedEncoding);
255
+ const chunkTerminatesLine = chunkString.at(-1) === "\n";
256
+ const writeResult = originalWrite.call(stream, chunk, resolvedEncoding, resolvedCallback);
257
+ if (chunkTerminatesLine) this.#isDeferringRender = false;
258
+ else if (chunkString !== "") this.#isDeferringRender = true;
259
+ if (this.isSpinning && !this.#isDeferringRender) this.#render();
260
+ return writeResult;
261
+ }
262
+ start(text) {
263
+ if (text) this.#text = text;
264
+ if (this.isSpinning) return this;
265
+ this.#isSpinning = true;
266
+ this.#hideCursor();
267
+ this.#installHook();
268
+ this.#render();
269
+ this.#subscribeToProcessEvents();
270
+ if (this.#isInteractive) this.#timer = setInterval(() => {
271
+ this.#render();
272
+ }, this.#interval);
273
+ return this;
274
+ }
275
+ stop(finalText) {
276
+ if (!this.isSpinning) return this;
277
+ const shouldWriteNewline = this.#isDeferringRender;
278
+ this.#isSpinning = false;
279
+ if (this.#timer) {
280
+ clearInterval(this.#timer);
281
+ this.#timer = void 0;
282
+ }
283
+ this.#isDeferringRender = false;
284
+ this.#uninstallHook();
285
+ this.#showCursor();
286
+ this.clear();
287
+ this.#unsubscribeFromProcessEvents();
288
+ if (finalText) {
289
+ const prefix = shouldWriteNewline ? "\n" : "";
290
+ this.#stream.write(`${prefix}${finalText}\n`);
291
+ }
292
+ return this;
293
+ }
294
+ #symbolStop(symbol, text) {
295
+ return this.stop(`${symbol} ${text ?? this.#text}`);
296
+ }
297
+ success(text) {
298
+ return this.#symbolStop(successSymbol, text);
299
+ }
300
+ error(text) {
301
+ return this.#symbolStop(errorSymbol, text);
302
+ }
303
+ warning(text) {
304
+ return this.#symbolStop(warningSymbol, text);
305
+ }
306
+ info(text) {
307
+ return this.#symbolStop(infoSymbol, text);
308
+ }
309
+ get isSpinning() {
310
+ return this.#isSpinning;
311
+ }
312
+ get text() {
313
+ return this.#text;
314
+ }
315
+ set text(value) {
316
+ this.#text = value ?? "";
317
+ this.#render();
318
+ }
319
+ get color() {
320
+ return this.#color;
321
+ }
322
+ set color(value) {
323
+ this.#color = value;
324
+ this.#render();
325
+ }
326
+ clear() {
327
+ if (!this.#isInteractive) return this;
328
+ if (this.#lines === 0) return this;
329
+ this.#internalWrite(() => {
330
+ this.#stream.cursorTo(0);
331
+ for (let index = 0; index < this.#lines; index++) {
332
+ if (index > 0) this.#stream.moveCursor(0, -1);
333
+ this.#stream.clearLine(1);
334
+ }
335
+ });
336
+ this.#lines = 0;
337
+ return this;
338
+ }
339
+ #render() {
340
+ if (this.#isDeferringRender) return;
341
+ const useSynchronizedOutput = this.#isInteractive;
342
+ const now = Date.now();
343
+ if (this.#currentFrame === -1 || now - this.#lastSpinnerFrameTime >= this.#interval) {
344
+ this.#currentFrame = ++this.#currentFrame % this.#frames.length;
345
+ this.#lastSpinnerFrameTime = now;
346
+ }
347
+ const applyColor = base_exports[this.#color] ?? cyan;
348
+ const frame = this.#frames[this.#currentFrame];
349
+ let string = `${applyColor(frame)} ${this.#text}`;
350
+ if (!this.#isInteractive) string += "\n";
351
+ if (useSynchronizedOutput) this.#withSynchronizedOutput(() => {
352
+ this.clear();
353
+ this.#write(string);
354
+ });
355
+ else this.#write(string);
356
+ if (this.#isInteractive) this.#lines = this.#lineCount(string);
357
+ }
358
+ #write(text) {
359
+ this.#internalWrite(() => {
360
+ this.#stream.write(text);
361
+ });
362
+ }
363
+ #lineCount(text) {
364
+ const width = this.#stream.columns ?? 80;
365
+ const lines = stripVTControlCharacters(text).split("\n");
366
+ let lineCount = 0;
367
+ for (const line of lines) lineCount += Math.max(1, Math.ceil(line.length / width));
368
+ return lineCount;
369
+ }
370
+ #hideCursor() {
371
+ if (this.#isInteractive) this.#write("\x1B[?25l");
372
+ }
373
+ #showCursor() {
374
+ if (this.#isInteractive) this.#write("\x1B[?25h");
375
+ }
376
+ #subscribeToProcessEvents() {
377
+ process$1.once("SIGINT", this.#exitHandlerBound);
378
+ process$1.once("SIGTERM", this.#exitHandlerBound);
379
+ }
380
+ #unsubscribeFromProcessEvents() {
381
+ process$1.off("SIGINT", this.#exitHandlerBound);
382
+ process$1.off("SIGTERM", this.#exitHandlerBound);
383
+ }
384
+ #exitHandler(signal) {
385
+ if (this.isSpinning) this.stop();
386
+ const exitCode = signal === "SIGINT" ? 130 : signal === "SIGTERM" ? 143 : 1;
387
+ process$1.exit(exitCode);
388
+ }
389
+ };
390
+ function yoctoSpinner(options) {
391
+ return new YoctoSpinner(options);
392
+ }
393
+
394
+ //#endregion
395
+ //#region ui.ts
396
+ function createSpinner() {
397
+ const instance = yoctoSpinner({ color: "cyan" });
398
+ return {
399
+ get isSpinning() {
400
+ return instance.isSpinning;
401
+ },
402
+ start(text) {
403
+ instance.start(text);
404
+ },
405
+ update(text) {
406
+ instance.text = text;
407
+ },
408
+ success(text) {
409
+ instance.success(text);
410
+ },
411
+ error(text) {
412
+ instance.error(text);
413
+ },
414
+ warning(text) {
415
+ instance.warning(text);
416
+ },
417
+ info(text) {
418
+ instance.info(text);
419
+ },
420
+ stop() {
421
+ if (instance.isSpinning) instance.stop();
422
+ }
423
+ };
424
+ }
425
+ function createLogger(spinner) {
426
+ return {
427
+ info(msg) {
428
+ spinner.stop();
429
+ process.stdout.write(`${msg}\n`);
430
+ },
431
+ error(msg) {
432
+ spinner.stop();
433
+ process.stderr.write(`${red(bold("error"))} ${msg}\n`);
434
+ }
435
+ };
436
+ }
437
+ function createUI() {
438
+ const spinner = createSpinner();
439
+ return {
440
+ spinner,
441
+ log: createLogger(spinner)
442
+ };
443
+ }
444
+
445
+ //#endregion
446
+ //#region index.ts
447
+ const VERSION = "1.0.0";
448
+ const execFile$1 = promisify(execFile);
449
+ function printHelp() {
450
+ process.stdout.write(`gh-inbox-clean v${VERSION}
451
+
452
+ Cleanup GitHub PR notifications you no longer need to review.
453
+ Marks notifications as done for PRs that are closed or where
454
+ you are no longer a requested reviewer.
455
+
456
+ Requires the GitHub CLI (gh) to be installed and authenticated.
457
+
458
+ Usage:
459
+ npx gh-inbox-clean [options]
460
+
461
+ Options:
462
+ -h, --help Show this help message
463
+ -v, --version Show version number
464
+ -d, --dry-run Preview what would be cleared without making changes
465
+ -t, --teams Comma-separated team slugs to keep notifications for
466
+ `);
467
+ }
468
+ function parseCliArgs() {
469
+ const { values } = parseArgs({
470
+ options: {
471
+ help: {
472
+ type: "boolean",
473
+ short: "h",
474
+ default: false
475
+ },
476
+ version: {
477
+ type: "boolean",
478
+ short: "v",
479
+ default: false
480
+ },
481
+ "dry-run": {
482
+ type: "boolean",
483
+ short: "d",
484
+ default: false
485
+ },
486
+ teams: {
487
+ type: "string",
488
+ short: "t",
489
+ default: ""
490
+ }
491
+ },
492
+ strict: true
493
+ });
494
+ const teamsRaw = values.teams ?? "";
495
+ return {
496
+ help: values.help ?? false,
497
+ version: values.version ?? false,
498
+ dryRun: values["dry-run"] ?? false,
499
+ teams: teamsRaw.split(",").map((s) => s.trim()).filter(Boolean)
500
+ };
501
+ }
502
+ function isRateLimitError(error) {
503
+ if (!(error instanceof RequestError)) return false;
504
+ if (error.status === 429) return true;
505
+ if (error.status === 403) return error.response?.headers?.["x-ratelimit-remaining"] === "0";
506
+ return false;
507
+ }
508
+ async function withRateLimitRetry(fn, onRetry, maxRetries = 3, baseDelay = 1e3) {
509
+ for (let attempt = 0; attempt <= maxRetries; attempt++) try {
510
+ return await fn();
511
+ } catch (error) {
512
+ if (attempt === maxRetries || !isRateLimitError(error)) throw error;
513
+ const retryAfter = Number(error.response?.headers?.["retry-after"]);
514
+ const delay = !Number.isNaN(retryAfter) && retryAfter > 0 ? retryAfter * 1e3 : baseDelay * 2 ** attempt;
515
+ const msg = `Rate limited, retrying in ${delay}ms (attempt ${attempt + 1}/${maxRetries})...`;
516
+ if (onRetry) onRetry(msg);
517
+ await new Promise((resolve) => setTimeout(resolve, delay));
518
+ }
519
+ throw new Error("Exceeded maximum retries");
520
+ }
521
+ async function getGithubToken() {
522
+ try {
523
+ const { stdout } = await execFile$1("gh", ["auth", "token"]);
524
+ return {
525
+ succeeded: true,
526
+ token: stdout.trim()
527
+ };
528
+ } catch {
529
+ try {
530
+ await execFile$1("gh", ["help"]);
531
+ return {
532
+ succeeded: false,
533
+ error: "GitHub CLI is installed but not authenticated. Run `gh auth login` first."
534
+ };
535
+ } catch (error) {
536
+ return {
537
+ succeeded: false,
538
+ error: `Could not run \`gh\`: ${error instanceof Error ? error.message : String(error)}`
539
+ };
540
+ }
541
+ }
542
+ }
543
+ function parsePullRequestUrl(url) {
544
+ const { pathname } = new URL(url);
545
+ const parts = pathname.split("/");
546
+ const owner = parts[2];
547
+ const repo = parts[3];
548
+ const pull_number = Number(parts[5]);
549
+ if (!owner || !repo || Number.isNaN(pull_number)) throw new Error(`Could not parse PR URL: ${url}`);
550
+ return {
551
+ owner,
552
+ repo,
553
+ pull_number
554
+ };
555
+ }
556
+ async function getAuthenticatedLogin(github) {
557
+ try {
558
+ const { data } = await github.users.getAuthenticated();
559
+ return data.login;
560
+ } catch (error) {
561
+ const message = error instanceof Error ? error.message : String(error);
562
+ throw new Error(`Failed to authenticate with GitHub. Is your token valid? Run \`gh auth login\` to re-authenticate. (${message})`);
563
+ }
564
+ }
565
+ async function main() {
566
+ const options = parseCliArgs();
567
+ if (options.help) {
568
+ printHelp();
569
+ return;
570
+ }
571
+ if (options.version) {
572
+ process.stdout.write(`${VERSION}\n`);
573
+ return;
574
+ }
575
+ const { spinner, log } = createUI();
576
+ try {
577
+ const onRetry = (msg) => spinner.update(yellow(msg));
578
+ spinner.start("Authenticating with GitHub...");
579
+ const tokenResult = await getGithubToken();
580
+ if (!tokenResult.succeeded) {
581
+ spinner.error(tokenResult.error ?? "Failed to get GitHub token");
582
+ process.exitCode = 1;
583
+ return;
584
+ }
585
+ const github = new Octokit({ auth: tokenResult.token });
586
+ const login = await withRateLimitRetry(() => getAuthenticatedLogin(github), onRetry);
587
+ spinner.success(`Authenticated as ${bold(login)}`);
588
+ if (options.dryRun) spinner.warning("Dry run — no notifications will be marked as done");
589
+ spinner.start("Fetching notifications...");
590
+ const cleared = [];
591
+ let checked = 0;
592
+ for await (const { data: notifications } of github.paginate.iterator("GET /notifications", { all: true })) for (const notification of notifications) try {
593
+ if (notification.subject.type !== "PullRequest") continue;
594
+ const subjectUrl = notification.subject.url;
595
+ if (!subjectUrl) continue;
596
+ checked++;
597
+ const { owner, repo, pull_number } = parsePullRequestUrl(subjectUrl);
598
+ const prLabel = `${owner}/${repo}#${pull_number}`;
599
+ spinner.update(`Checking PR ${dim(String(checked))} ${cyan(prLabel)}`);
600
+ const { data: pr } = await withRateLimitRetry(() => github.pulls.get({
601
+ owner,
602
+ repo,
603
+ pull_number
604
+ }), onRetry);
605
+ const isReviewer = pr.requested_reviewers?.some((r) => r.login === login);
606
+ const isTeamReviewer = options.teams.length > 0 && pr.requested_teams?.some((t) => options.teams.includes(t.slug));
607
+ if (pr.state !== "closed" && (isReviewer || isTeamReviewer)) continue;
608
+ const reason = pr.merged ? "merged" : pr.state === "closed" ? "closed" : "not a reviewer";
609
+ if (!options.dryRun) {
610
+ spinner.update(`Clearing ${cyan(prLabel)} ${dim(`(${reason})`)}`);
611
+ await withRateLimitRetry(() => github.activity.markThreadAsDone({ thread_id: Number(notification.id) }), onRetry);
612
+ }
613
+ cleared.push(`${pr.html_url} ${dim(`(${reason})`)}`);
614
+ } catch (error) {
615
+ const message = error instanceof Error ? error.message : String(error);
616
+ spinner.error(`Failed to process notification ${notification.id}: ${message}`);
617
+ spinner.start("Continuing...");
618
+ }
619
+ const suffix = options.dryRun ? dim(" (dry run)") : "";
620
+ if (cleared.length > 0) {
621
+ spinner.success(`${bold(String(cleared.length))} PR notifications cleared!${suffix}`);
622
+ for (const url of cleared) log.info(` ${green("+")} ${dim(url)}`);
623
+ } else spinner.info(`No PR notifications to clear.${suffix}`);
624
+ } catch (error) {
625
+ const message = error instanceof Error ? error.message : String(error);
626
+ spinner.error(message);
627
+ process.exitCode = 1;
628
+ }
629
+ }
630
+ main().catch((error) => {
631
+ const message = error instanceof Error ? error.message : String(error);
632
+ process.stderr.write(`${red(bold("error"))} ${message}\n`);
633
+ process.exitCode = 1;
634
+ });
635
+
636
+ //#endregion
637
+ export { };
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "gh-inbox-clean",
3
+ "version": "0.0.1",
4
+ "type": "module",
5
+ "bin": {
6
+ "gh-inbox-clean": "dist/cli.mjs"
7
+ },
8
+ "files": [
9
+ "dist"
10
+ ],
11
+ "scripts": {
12
+ "start": "node index.ts",
13
+ "build": "rolldown -c rolldown.config.ts",
14
+ "prepublishOnly": "npm run build",
15
+ "typecheck": "tsc",
16
+ "test": "echo \"Error: no test specified\" && exit 1"
17
+ },
18
+ "author": "Jack Westbrook <jack.westbrook@gmail.com>",
19
+ "license": "MIT",
20
+ "description": "CLI to cleanup GitHub PR notifications you no longer need to review",
21
+ "dependencies": {
22
+ "@octokit/request-error": "^7.0.0",
23
+ "@octokit/rest": "^22.0.0",
24
+ "yocto-spinner": "^1.1.0",
25
+ "yoctocolors": "^2.1.2"
26
+ },
27
+ "engines": {
28
+ "node": ">=20.0.0"
29
+ },
30
+ "devDependencies": {
31
+ "@tsconfig/node24": "^24.0.4",
32
+ "@tsconfig/recommended": "^1.0.13",
33
+ "@types/node": "^24.10.13",
34
+ "rolldown": "1.0.0-rc.5",
35
+ "typescript": "^5.9.3"
36
+ }
37
+ }