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.
- package/README.md +40 -0
- package/dist/cli.mjs +637 -0
- 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
|
+
}
|