watcheroni 0.0.0-stage → 0.2.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.
@@ -0,0 +1,1838 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/main.ts
4
+ import { spawn as spawn3 } from "node:child_process";
5
+ import { parseArgs } from "node:util";
6
+
7
+ // package.json
8
+ var package_default = {
9
+ name: "watcheroni",
10
+ version: "0.2.1",
11
+ description: "Wait for, stream, or act on what happens to GitHub pull requests",
12
+ license: "MIT",
13
+ author: "paul cannon",
14
+ repository: {
15
+ type: "git",
16
+ url: "git+https://github.com/thepaul/watcheroni.git",
17
+ directory: "packages/cli"
18
+ },
19
+ homepage: "https://github.com/thepaul/watcheroni/tree/main/packages/cli#readme",
20
+ bugs: "https://github.com/thepaul/watcheroni/issues",
21
+ keywords: [
22
+ "github",
23
+ "pull-request",
24
+ "pr",
25
+ "ci",
26
+ "webhooks",
27
+ "notifications",
28
+ "cli",
29
+ "claude-code"
30
+ ],
31
+ type: "module",
32
+ bin: {
33
+ watcheroni: "dist/watcheroni.mjs"
34
+ },
35
+ files: [
36
+ "dist"
37
+ ],
38
+ engines: {
39
+ node: ">=22"
40
+ },
41
+ scripts: {
42
+ build: "esbuild src/main.ts --bundle --platform=node --format=esm --target=node22 --outfile=dist/watcheroni.mjs --banner:js='#!/usr/bin/env node'",
43
+ dev: "node src/main.ts",
44
+ typecheck: "tsc --noEmit",
45
+ test: "vitest run",
46
+ prepublishOnly: "npm run build"
47
+ },
48
+ devDependencies: {
49
+ "@types/node": "^24.19.2",
50
+ "@watcheroni/protocol": "*",
51
+ esbuild: "^0.28.2",
52
+ typescript: "^5.9.3",
53
+ vitest: "^4.1.12"
54
+ }
55
+ };
56
+
57
+ // src/config.ts
58
+ import { readFileSync as readFileSync2 } from "node:fs";
59
+ import { homedir } from "node:os";
60
+ import { join as join2 } from "node:path";
61
+
62
+ // src/github.ts
63
+ import { execFileSync } from "node:child_process";
64
+ var LoginNeeded = class extends Error {
65
+ };
66
+ var GitHubError = class extends Error {
67
+ status;
68
+ constructor(message, status) {
69
+ super(message);
70
+ this.status = status;
71
+ }
72
+ };
73
+ var cached;
74
+ function githubToken() {
75
+ if (cached) return cached;
76
+ const fromEnv = process.env.GH_TOKEN || process.env.GITHUB_TOKEN;
77
+ if (fromEnv) return cached = fromEnv;
78
+ try {
79
+ const out = execFileSync("gh", ["auth", "token"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
80
+ if (out) return cached = out;
81
+ } catch {
82
+ }
83
+ throw new LoginNeeded("No GitHub credentials: run `gh auth login`, or set GH_TOKEN or GITHUB_TOKEN.");
84
+ }
85
+ async function graphql(query, variables) {
86
+ const res = await fetch("https://api.github.com/graphql", {
87
+ method: "POST",
88
+ headers: {
89
+ Authorization: `Bearer ${githubToken()}`,
90
+ "content-type": "application/json",
91
+ "user-agent": "watcheroni-cli"
92
+ },
93
+ body: JSON.stringify({ query, variables }),
94
+ signal: AbortSignal.timeout(3e4)
95
+ });
96
+ if (res.status === 401) throw new LoginNeeded("GitHub rejected the token: run `gh auth login`, or check GH_TOKEN.");
97
+ if (!res.ok) throw new GitHubError(`GitHub GraphQL: ${res.status} ${await res.text()}`, res.status);
98
+ const body = await res.json();
99
+ if (body.errors?.length) {
100
+ const notFound = body.errors.some((e) => e.type === "NOT_FOUND");
101
+ throw new GitHubError(body.errors.map((e) => e.message).join("; "), notFound ? 404 : 502);
102
+ }
103
+ return body.data;
104
+ }
105
+
106
+ // src/login.ts
107
+ import { spawn } from "node:child_process";
108
+ import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
109
+ import { hostname } from "node:os";
110
+ import { dirname, join } from "node:path";
111
+ var DEFAULT_SERVER = "https://watcheroni.dev";
112
+ var defaultDeps = {
113
+ fetch: (...args) => fetch(...args),
114
+ sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
115
+ print: (line) => process.stderr.write(`${line}
116
+ `),
117
+ now: () => Date.now(),
118
+ openUrl
119
+ };
120
+ function openUrl(url) {
121
+ if (!process.stderr.isTTY || process.env.SSH_CONNECTION || process.env.SSH_TTY || process.env.WATCHERONI_NO_BROWSER) return false;
122
+ const [cmd, ...args] = process.platform === "darwin" ? ["open", url] : process.platform === "win32" ? ["cmd", "/c", "start", "", url] : process.env.DISPLAY || process.env.WAYLAND_DISPLAY ? ["xdg-open", url] : [];
123
+ if (!cmd) return false;
124
+ try {
125
+ const child = spawn(cmd, args, { stdio: "ignore", detached: true });
126
+ child.on("error", () => {
127
+ });
128
+ child.unref();
129
+ return true;
130
+ } catch {
131
+ return false;
132
+ }
133
+ }
134
+ function serverUrl() {
135
+ return (serverSetting() || DEFAULT_SERVER).replace(/\/+$/, "");
136
+ }
137
+ function credentialsPath() {
138
+ if (process.env.WATCHERONI_CREDENTIALS) return process.env.WATCHERONI_CREDENTIALS;
139
+ return join(configDir(), "credentials.json");
140
+ }
141
+ function readCredentials(path = credentialsPath()) {
142
+ try {
143
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
144
+ return { servers: parsed.servers ?? {} };
145
+ } catch {
146
+ return { servers: {} };
147
+ }
148
+ }
149
+ function writeCredentials(creds, path = credentialsPath()) {
150
+ mkdirSync(dirname(path), { recursive: true, mode: 448 });
151
+ const tmp = `${path}.${process.pid}.tmp`;
152
+ writeFileSync(tmp, JSON.stringify(creds, null, 2), { mode: 384 });
153
+ chmodSync(tmp, 384);
154
+ renameSync(tmp, path);
155
+ }
156
+ var LoginFailed = class extends Error {
157
+ };
158
+ async function login(server = serverUrl(), deps = defaultDeps) {
159
+ const { fetch: fetch2, print } = deps;
160
+ const metaRes = await fetch2(`${server}/v1/meta`);
161
+ if (!metaRes.ok) throw new LoginFailed(`${server}/v1/meta answered ${metaRes.status}`);
162
+ const meta = await metaRes.json();
163
+ if (!meta.github?.client_id) throw new LoginFailed(`${server} doesn't offer sign-in yet`);
164
+ const { client_id, web_url } = meta.github;
165
+ const token = await deviceFlow(web_url, client_id, deps);
166
+ const res = await fetch2(`${server}/v1/login`, {
167
+ method: "POST",
168
+ headers: { "content-type": "application/json" },
169
+ body: JSON.stringify({ github_token: token.access_token, label: `watcheroni CLI on ${hostname()}` })
170
+ });
171
+ if (!res.ok) {
172
+ const problem = await res.json().catch(() => ({}));
173
+ throw new LoginFailed(`the server refused the login (${res.status}${problem.title ? `: ${problem.title}` : ""})`);
174
+ }
175
+ const body = await res.json();
176
+ const now = deps.now();
177
+ const entry = {
178
+ api_key: body.api_key,
179
+ key_id: body.key_id,
180
+ account: body.account,
181
+ github: {
182
+ access_token: token.access_token,
183
+ refresh_token: token.refresh_token ?? null,
184
+ expires_at: token.expires_in ? new Date(now + token.expires_in * 1e3).toISOString() : null,
185
+ refresh_token_expires_at: token.refresh_token_expires_in ? new Date(now + token.refresh_token_expires_in * 1e3).toISOString() : null
186
+ }
187
+ };
188
+ const creds = readCredentials();
189
+ creds.servers[server] = entry;
190
+ writeCredentials(creds);
191
+ print(`Signed in to ${server} as ${body.account.login}.`);
192
+ return entry;
193
+ }
194
+ async function logout(server = serverUrl(), deps = defaultDeps) {
195
+ const creds = readCredentials();
196
+ const entry = creds.servers[server];
197
+ if (!entry) {
198
+ deps.print(`Not signed in to ${server}.`);
199
+ return false;
200
+ }
201
+ try {
202
+ const res = await deps.fetch(`${server}/v1/keys/current`, {
203
+ method: "DELETE",
204
+ headers: { Authorization: `Bearer ${entry.api_key}` }
205
+ });
206
+ if (!res.ok && res.status !== 401) deps.print(`The server didn't revoke the key (${res.status}); forgetting it anyway.`);
207
+ } catch (e) {
208
+ deps.print(`Couldn't reach ${server} to revoke the key (${e.message}); forgetting it anyway.`);
209
+ }
210
+ delete creds.servers[server];
211
+ writeCredentials(creds);
212
+ deps.print(`Signed out of ${server}.`);
213
+ return true;
214
+ }
215
+ async function githubTokenFor(server, deps = defaultDeps) {
216
+ const github = readCredentials().servers[server]?.github;
217
+ if (!github?.access_token) return null;
218
+ const now = deps.now();
219
+ const expiresAt = github.expires_at ? Date.parse(github.expires_at) : Infinity;
220
+ if (expiresAt - now > TOKEN_MARGIN_MS) return github.access_token;
221
+ const refreshExpiresAt = github.refresh_token_expires_at ? Date.parse(github.refresh_token_expires_at) : Infinity;
222
+ if (!github.refresh_token || refreshExpiresAt <= now) return null;
223
+ let token;
224
+ try {
225
+ const res = await deps.fetch(`${server}/v1/github/refresh`, {
226
+ method: "POST",
227
+ headers: { "content-type": "application/json" },
228
+ body: JSON.stringify({ refresh_token: github.refresh_token })
229
+ });
230
+ token = await res.json();
231
+ } catch {
232
+ return null;
233
+ }
234
+ if (typeof token.access_token !== "string") return null;
235
+ const creds = readCredentials();
236
+ const entry = creds.servers[server];
237
+ if (!entry) return token.access_token;
238
+ entry.github = {
239
+ access_token: token.access_token,
240
+ // GitHub rotates the refresh token on every use.
241
+ refresh_token: token.refresh_token ?? null,
242
+ expires_at: token.expires_in ? new Date(now + token.expires_in * 1e3).toISOString() : null,
243
+ refresh_token_expires_at: token.refresh_token_expires_in ? new Date(now + token.refresh_token_expires_in * 1e3).toISOString() : null
244
+ };
245
+ writeCredentials(creds);
246
+ return token.access_token;
247
+ }
248
+ var TOKEN_MARGIN_MS = 5 * 6e4;
249
+ async function deviceFlow(webUrl, clientId, deps) {
250
+ const post = async (path, body) => {
251
+ const res = await deps.fetch(`${webUrl}${path}`, {
252
+ method: "POST",
253
+ headers: { Accept: "application/json", "content-type": "application/json" },
254
+ body: JSON.stringify(body)
255
+ });
256
+ if (!res.ok) throw new LoginFailed(`GitHub answered ${res.status} to ${path}`);
257
+ return res.json();
258
+ };
259
+ const code = await post("/login/device/code", { client_id: clientId });
260
+ if (typeof code.device_code !== "string") throw new LoginFailed(`GitHub didn't start the device flow: ${JSON.stringify(code)}`);
261
+ const uri = String(code.verification_uri);
262
+ deps.print(`Your one-time code: ${code.user_code}`);
263
+ deps.print(deps.openUrl(uri) ? `Opening ${uri} in your browser; enter the code there. (If it doesn't open, go there yourself.)` : `Open ${uri} and enter the code there.`);
264
+ deps.print("");
265
+ deps.print(`GitHub will ask whether watcheroni may "act on your behalf". That's its wording for`);
266
+ deps.print("every app sign-in. watcheroni can only read pull requests, reviews, checks, issues and");
267
+ deps.print("Actions runs, on repos it's installed on that you can see. It can't change anything,");
268
+ deps.print("and the server doesn't keep the token.");
269
+ deps.print("");
270
+ let interval = Number(code.interval ?? 5) * 1e3;
271
+ const deadline = deps.now() + Number(code.expires_in ?? 900) * 1e3;
272
+ while (deps.now() < deadline) {
273
+ await deps.sleep(interval);
274
+ const answer = await post("/login/oauth/access_token", {
275
+ client_id: clientId,
276
+ device_code: code.device_code,
277
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code"
278
+ });
279
+ if (typeof answer.access_token === "string") return answer;
280
+ switch (answer.error) {
281
+ case "authorization_pending":
282
+ continue;
283
+ case "slow_down":
284
+ interval = Number(answer.interval ?? interval / 1e3 + 5) * 1e3;
285
+ continue;
286
+ case "access_denied":
287
+ throw new LoginFailed("sign-in was cancelled on GitHub");
288
+ case "expired_token":
289
+ throw new LoginFailed("the code expired; run `watcheroni login` again");
290
+ default:
291
+ throw new LoginFailed(`GitHub: ${answer.error_description ?? answer.error ?? "unexpected answer"}`);
292
+ }
293
+ }
294
+ throw new LoginFailed("the code expired; run `watcheroni login` again");
295
+ }
296
+
297
+ // src/targets.ts
298
+ import { execFileSync as execFileSync2 } from "node:child_process";
299
+ var targetKey = (t) => `${t.owner}/${t.name}#${t.number}`;
300
+ var repoKey = (t) => {
301
+ const kinds = [...new Set(t.kinds)].sort().join(",");
302
+ return `${t.owner}/${t.name} (${[kinds, t.base && `base ${t.base}`, t.author && `author ${t.author.toLowerCase()}`].filter(Boolean).join("; ")})`;
303
+ };
304
+ var isPr = (t) => "number" in t;
305
+ var anyKey = (t) => isPr(t) ? targetKey(t) : repoKey(t);
306
+ var UsageError = class extends Error {
307
+ };
308
+ function parseTarget(arg) {
309
+ const short = arg.match(/^([\w.-]+)\/([\w.-]+)#(\d+)$/);
310
+ const url = arg.match(/^https?:\/\/github\.com\/([\w.-]+)\/([\w.-]+)\/pull\/(\d+)(?:[/?#].*)?$/);
311
+ const m = short ?? url;
312
+ if (m) return { owner: m[1], name: m[2], number: Number(m[3]) };
313
+ const repo = arg.match(/^([\w.-]+)\/([\w.-]+)$/) ?? arg.match(/^https?:\/\/github\.com\/([\w.-]+)\/([\w.-]+?)\/?$/);
314
+ if (repo) return { owner: repo[1], name: repo[2] };
315
+ throw new UsageError(`${arg}: expected owner/repo#123, a pull request URL, or owner/repo`);
316
+ }
317
+ function remoteRepos(cwd = process.cwd()) {
318
+ let out;
319
+ try {
320
+ out = execFileSync2("git", ["remote", "-v"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
321
+ } catch {
322
+ return [];
323
+ }
324
+ const repos = /* @__PURE__ */ new Map();
325
+ for (const line of out.split("\n")) {
326
+ const m = line.match(/^(\S+)\s+(?:git@github\.com:|ssh:\/\/git@github\.com\/|https:\/\/github\.com\/)([\w.-]+)\/([\w.-]+?)(?:\.git)?\/?\s/);
327
+ if (m && !repos.has(`${m[2]}/${m[3]}`)) repos.set(`${m[2]}/${m[3]}`, { remote: m[1], owner: m[2], name: m[3] });
328
+ }
329
+ const rank = (r) => r === "upstream" ? 0 : r === "origin" ? 1 : 2;
330
+ return [...repos.values()].sort((a, b) => rank(a.remote) - rank(b.remote)).map(({ owner, name }) => ({ owner, name }));
331
+ }
332
+ async function currentBranchPr(cwd = process.cwd()) {
333
+ let branch = "";
334
+ try {
335
+ branch = execFileSync2("git", ["branch", "--show-current"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
336
+ } catch {
337
+ }
338
+ if (!branch) throw new UsageError("Not on a git branch; name a PR (owner/repo#123)");
339
+ const repos = remoteRepos(cwd);
340
+ if (!repos.length) throw new UsageError("No GitHub remote in this checkout; name a PR (owner/repo#123)");
341
+ for (const repo of repos) {
342
+ const data = await graphql(
343
+ `query($owner: String!, $name: String!, $branch: String!) {
344
+ repository(owner: $owner, name: $name) {
345
+ pullRequests(headRefName: $branch, states: OPEN, first: 1, orderBy: { field: CREATED_AT, direction: DESC }) { nodes { number } }
346
+ }
347
+ }`,
348
+ { ...repo, branch }
349
+ ).catch((e) => {
350
+ if (e instanceof LoginNeeded) throw e;
351
+ return null;
352
+ });
353
+ const number = data?.repository?.pullRequests.nodes[0]?.number;
354
+ if (number) return { ...repo, number };
355
+ }
356
+ throw new UsageError(`No open PR for branch ${branch} in ${repos.map((r) => `${r.owner}/${r.name}`).join(" or ")}`);
357
+ }
358
+
359
+ // src/config.ts
360
+ function configDir() {
361
+ if (process.env.WATCHERONI_CONFIG_DIR) return process.env.WATCHERONI_CONFIG_DIR;
362
+ return join2(process.env.XDG_CONFIG_HOME || join2(homedir(), ".config"), "watcheroni");
363
+ }
364
+ function serverSetting() {
365
+ return process.env.WATCHERONI_SERVER ?? readJson(join2(configDir(), "config.json"))?.server;
366
+ }
367
+ function loadServer() {
368
+ const credentials = readCredentials();
369
+ const raw = serverSetting() ?? (credentials.servers[DEFAULT_SERVER] ? DEFAULT_SERVER : "");
370
+ if (!raw) return null;
371
+ let parsed;
372
+ try {
373
+ parsed = new URL(raw);
374
+ } catch {
375
+ throw new UsageError(`${raw}: the server must be an http or https URL`);
376
+ }
377
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
378
+ throw new UsageError(`${raw}: the server must be an http or https URL`);
379
+ }
380
+ const url = raw.replace(/\/+$/, "");
381
+ const apiKey = process.env.WATCHERONI_API_KEY || credentials.servers[url]?.api_key;
382
+ if (!apiKey) {
383
+ throw new LoginNeeded(`No API key for ${url}: run \`watcheroni login\`, or set WATCHERONI_API_KEY`);
384
+ }
385
+ return { url, apiKey };
386
+ }
387
+ function readJson(file) {
388
+ let text;
389
+ try {
390
+ text = readFileSync2(file, "utf8");
391
+ } catch {
392
+ return null;
393
+ }
394
+ try {
395
+ return JSON.parse(text);
396
+ } catch {
397
+ throw new UsageError(`${file} isn't valid JSON`);
398
+ }
399
+ }
400
+
401
+ // ../protocol/src/index.ts
402
+ var INBOX_NAME = /^[A-Za-z0-9._-]{1,64}$/;
403
+ function matchesKind(patterns, kind) {
404
+ if (!patterns.length || kind.startsWith("watch.")) return true;
405
+ return patterns.some((p) => p === "*" || p === kind || p.endsWith(".*") && kind.startsWith(p.slice(0, -1)));
406
+ }
407
+ var DEFAULT_TTL_SECONDS = 7 * 24 * 3600;
408
+ var MAX_TTL_SECONDS = 30 * 24 * 3600;
409
+ var PRIVATE_VERIFY_SECONDS = 7 * 24 * 3600;
410
+
411
+ // src/diff.ts
412
+ function initialMemory(s) {
413
+ const rollup = rollupOf(s);
414
+ return { rollup: rollup ? { head: s.head, result: rollup.result, data: JSON.stringify(rollup) } : null, conflict: s.mergeable === "CONFLICTING" };
415
+ }
416
+ var FAILED = /* @__PURE__ */ new Set(["failure", "timed_out", "action_required", "startup_failure"]);
417
+ function diff(prev, next, memory) {
418
+ const events = [];
419
+ const full = next.repo.full_name;
420
+ const api = `https://api.github.com/repos/${full}`;
421
+ const emit = (kind, discriminator, data2, opts = {}) => {
422
+ const links = {};
423
+ if (opts.html) links.html = opts.html;
424
+ if (opts.api) links.api = opts.api;
425
+ events.push({
426
+ v: 1,
427
+ id: `local:${full}#${next.number}:${kind}:${discriminator}`,
428
+ sub: null,
429
+ kind,
430
+ at: opts.at ?? next.fetchedAt,
431
+ received_at: next.fetchedAt,
432
+ repo: next.repo,
433
+ pr: next.number,
434
+ actor: opts.actor ?? null,
435
+ data: data2,
436
+ links
437
+ });
438
+ };
439
+ const prLinks = { html: next.url, api: `${api}/pulls/${next.number}` };
440
+ if (next.head !== prev.head) emit("pr.pushed", next.head, { before: prev.head, after: next.head }, prLinks);
441
+ if (next.base !== prev.base) emit("pr.base_changed", `${next.base}:${next.fetchedAt}`, { from: prev.base, to: next.base }, prLinks);
442
+ if (prev.draft && !next.draft) emit("pr.ready_for_review", next.fetchedAt, {}, prLinks);
443
+ if (!prev.draft && next.draft) emit("pr.converted_to_draft", next.fetchedAt, {}, prLinks);
444
+ if (prev.state !== "OPEN" && next.state === "OPEN") emit("pr.reopened", next.fetchedAt, {}, prLinks);
445
+ for (const reviewer of next.requested) {
446
+ if (!prev.requested.includes(reviewer)) emit("pr.review_requested", `${reviewer}:${next.fetchedAt}`, { reviewer }, prLinks);
447
+ }
448
+ const prevReviews = new Map(prev.reviews.map((r) => [r.id, r]));
449
+ for (const r of next.reviews) {
450
+ const before = prevReviews.get(r.id);
451
+ const links = { actor: r.author, at: r.at, html: r.url, api: `${api}/pulls/${next.number}/reviews/${r.id}` };
452
+ if (!before && r.state !== "DISMISSED") {
453
+ emit("review.submitted", String(r.id), { review_id: r.id, state: r.state.toLowerCase(), has_body: !r.empty }, links);
454
+ } else if (before && before.state !== "DISMISSED" && r.state === "DISMISSED") {
455
+ emit("review.dismissed", String(r.id), { review_id: r.id }, { ...links, actor: null, at: null });
456
+ }
457
+ }
458
+ const prevComments = new Set(prev.reviewComments.map((c) => c.id));
459
+ for (const c of next.reviewComments) {
460
+ if (prevComments.has(c.id)) continue;
461
+ emit("review_comment.created", String(c.id), {
462
+ comment_id: c.id,
463
+ review_id: c.reviewId,
464
+ path: c.path,
465
+ line: c.line,
466
+ in_reply_to: c.inReplyTo
467
+ }, { actor: c.author, at: c.at, html: c.url, api: `${api}/pulls/comments/${c.id}` });
468
+ }
469
+ const prevThreads = new Map(prev.threads.map((t) => [t.id, t.resolved]));
470
+ for (const t of next.threads) {
471
+ const was = prevThreads.get(t.id);
472
+ if (was === void 0 || was === t.resolved) continue;
473
+ const kind = t.resolved ? "thread.resolved" : "thread.unresolved";
474
+ emit(kind, `${t.id}:${next.fetchedAt}`, {
475
+ thread_node_id: t.id,
476
+ path: t.path,
477
+ line: t.line,
478
+ comment_id: t.commentId
479
+ }, { html: next.url, api: t.commentId !== null ? `${api}/pulls/comments/${t.commentId}` : void 0 });
480
+ }
481
+ const prevIssueComments = new Set(prev.comments.map((c) => c.id));
482
+ for (const c of next.comments) {
483
+ if (prevIssueComments.has(c.id)) continue;
484
+ emit("comment.created", String(c.id), { comment_id: c.id }, { actor: c.author, at: c.at, html: c.url, api: `${api}/issues/comments/${c.id}` });
485
+ }
486
+ const prevChecks = new Map(next.head === prev.head ? prev.checks.map((c) => [c.key, c]) : []);
487
+ for (const c of next.checks) {
488
+ const before = prevChecks.get(c.key);
489
+ if (!isFailed(c) || before && isFailed(before) && before.conclusion === c.conclusion) continue;
490
+ const data2 = { head_sha: next.head, name: c.name, conclusion: c.conclusion };
491
+ if (c.checkRunId !== null) data2.check_run_id = c.checkRunId;
492
+ else data2.status_context = c.name;
493
+ if (c.workflowRunId !== null) data2.workflow_run_id = c.workflowRunId;
494
+ emit("check.failed", `${next.head}:${c.key}:${c.conclusion}`, data2, {
495
+ at: c.at,
496
+ html: c.url,
497
+ api: c.checkRunId !== null ? `${api}/check-runs/${c.checkRunId}` : void 0
498
+ });
499
+ }
500
+ for (const [runId, run2] of workflowRuns(next.checks)) {
501
+ const before = workflowRuns([...prevChecks.values()]).get(runId);
502
+ if (!run2.done || before?.done) continue;
503
+ emit("workflow.completed", String(runId), {
504
+ head_sha: next.head,
505
+ run_id: runId,
506
+ workflow: run2.workflow,
507
+ conclusion: run2.conclusion
508
+ }, { html: `https://github.com/${full}/actions/runs/${runId}`, api: `${api}/actions/runs/${runId}` });
509
+ }
510
+ let { rollup } = memory;
511
+ const now = rollupOf(next);
512
+ const data = now && JSON.stringify(now);
513
+ if (now && (rollup?.head !== next.head || rollup.result !== now.result || rollup.data !== void 0 && rollup.data !== data)) {
514
+ emit("checks.completed", `${next.head}:${now.result}:${next.fetchedAt}`, { head_sha: next.head, ...now }, prLinks);
515
+ rollup = { head: next.head, result: now.result, data };
516
+ }
517
+ let { conflict } = memory;
518
+ if (next.state === "OPEN" && next.mergeable === "CONFLICTING" && !conflict) {
519
+ emit("merge.conflict", `${next.head}:${next.baseSha}`, { head_sha: next.head, base_sha: next.baseSha }, prLinks);
520
+ conflict = true;
521
+ } else if (next.mergeable === "MERGEABLE" && conflict) {
522
+ emit("merge.clean", `${next.head}:${next.baseSha}`, { head_sha: next.head, base_sha: next.baseSha }, prLinks);
523
+ conflict = false;
524
+ }
525
+ if (prev.state === "OPEN" && next.state === "MERGED") {
526
+ emit("pr.merged", next.mergeSha ?? next.head, { merge_sha: next.mergeSha }, prLinks);
527
+ emit("watch.ended", "merged", { reason: "merged" }, prLinks);
528
+ } else if (prev.state === "OPEN" && next.state === "CLOSED") {
529
+ emit("pr.closed", next.fetchedAt, {}, prLinks);
530
+ emit("watch.ended", `closed:${next.fetchedAt}`, { reason: "closed" }, prLinks);
531
+ }
532
+ return { events, memory: { rollup, conflict } };
533
+ }
534
+ function isFailed(c) {
535
+ return c.done && c.conclusion !== null && FAILED.has(c.conclusion);
536
+ }
537
+ function rollupOf(s) {
538
+ if (!s.checks.length || s.checks.some((c) => !c.done)) return null;
539
+ const counts = { success: 0, failure: 0, cancelled: 0, skipped: 0, neutral: 0 };
540
+ const failed = [];
541
+ for (const c of s.checks) {
542
+ if (isFailed(c)) {
543
+ counts.failure++;
544
+ failed.push(c.name);
545
+ } else if (c.conclusion === "cancelled") counts.cancelled++;
546
+ else if (c.conclusion === "skipped" || c.conclusion === "stale") counts.skipped++;
547
+ else if (c.conclusion === "neutral") counts.neutral++;
548
+ else counts.success++;
549
+ }
550
+ const result = counts.failure ? "failure" : counts.cancelled ? "cancelled" : "success";
551
+ return { result, failed: failed.slice(0, 20), counts };
552
+ }
553
+ function workflowRuns(checks) {
554
+ const runs = /* @__PURE__ */ new Map();
555
+ for (const c of checks) {
556
+ if (c.workflowRunId === null) continue;
557
+ const run2 = runs.get(c.workflowRunId) ?? { workflow: c.workflow, done: true, conclusion: "success" };
558
+ run2.done &&= c.done;
559
+ if (isFailed(c)) run2.conclusion = "failure";
560
+ else if (c.conclusion === "cancelled" && run2.conclusion === "success") run2.conclusion = "cancelled";
561
+ runs.set(c.workflowRunId, run2);
562
+ }
563
+ return runs;
564
+ }
565
+
566
+ // src/snapshot.ts
567
+ var QUERY = `
568
+ query($owner: String!, $name: String!, $number: Int!) {
569
+ repository(owner: $owner, name: $name) {
570
+ databaseId
571
+ nameWithOwner
572
+ pullRequest(number: $number) {
573
+ number url state isDraft mergeable
574
+ headRefOid baseRefName baseRefOid
575
+ mergeCommit { oid }
576
+ reviewRequests(first: 50) { nodes { requestedReviewer { __typename ... on User { login } ... on Bot { login } ... on Team { slug } } } }
577
+ reviews(last: 100) { nodes { databaseId state body submittedAt url author { ...actor } } }
578
+ reviewThreads(last: 100) { nodes { id isResolved path line originalLine first: comments(first: 1) { nodes { databaseId } } comments(last: 100) { nodes {
579
+ databaseId path line originalLine createdAt url replyTo { databaseId } pullRequestReview { databaseId state } author { ...actor }
580
+ } } } }
581
+ comments(last: 100) { nodes { databaseId createdAt url author { ...actor } } }
582
+ commits(last: 1) { nodes { commit { oid statusCheckRollup { contexts(first: 100) { nodes {
583
+ __typename
584
+ ... on CheckRun { databaseId name status conclusion detailsUrl completedAt checkSuite { workflowRun { databaseId workflow { name } } } }
585
+ ... on StatusContext { context state targetUrl createdAt }
586
+ } } } } } }
587
+ }
588
+ }
589
+ }
590
+ fragment actor on Actor { __typename login ... on User { databaseId } ... on Bot { databaseId } }
591
+ `;
592
+ async function fetchSnapshot(target) {
593
+ const data = await graphql(QUERY, { owner: target.owner, name: target.name, number: target.number });
594
+ const repo = data.repository;
595
+ if (!repo?.pullRequest) throw new Error(`${target.owner}/${target.name}#${target.number}: no such pull request`);
596
+ return toSnapshot(repo.databaseId, repo.nameWithOwner, repo.pullRequest, (/* @__PURE__ */ new Date()).toISOString());
597
+ }
598
+ function toSnapshot(repoId, fullName, pr, fetchedAt) {
599
+ const commit = pr.commits.nodes[0]?.commit;
600
+ const contexts = commit?.oid === pr.headRefOid ? commit.statusCheckRollup?.contexts.nodes ?? [] : [];
601
+ return {
602
+ fetchedAt,
603
+ repo: { id: repoId, full_name: fullName },
604
+ number: pr.number,
605
+ url: pr.url,
606
+ state: pr.state,
607
+ draft: pr.isDraft,
608
+ mergeable: pr.mergeable,
609
+ head: pr.headRefOid,
610
+ base: pr.baseRefName,
611
+ baseSha: pr.baseRefOid,
612
+ mergeSha: pr.mergeCommit?.oid ?? null,
613
+ requested: pr.reviewRequests.nodes.flatMap(({ requestedReviewer: r }) => !r ? [] : r.__typename === "Team" ? [`team:${r.slug}`] : r.login ? [r.login] : []),
614
+ reviews: pr.reviews.nodes.filter((r) => r.state !== "PENDING").map((r) => ({
615
+ id: r.databaseId,
616
+ state: r.state,
617
+ empty: !r.body.trim(),
618
+ at: r.submittedAt,
619
+ url: r.url,
620
+ author: actor(r.author)
621
+ })),
622
+ reviewComments: pr.reviewThreads.nodes.flatMap((t) => t.comments.nodes).filter((c) => c.pullRequestReview?.state !== "PENDING").map((c) => ({
623
+ id: c.databaseId,
624
+ reviewId: c.pullRequestReview?.databaseId ?? null,
625
+ inReplyTo: c.replyTo?.databaseId ?? null,
626
+ path: c.path,
627
+ line: c.line ?? c.originalLine,
628
+ at: c.createdAt,
629
+ url: c.url,
630
+ author: actor(c.author)
631
+ })),
632
+ threads: pr.reviewThreads.nodes.map((t) => ({
633
+ id: t.id,
634
+ resolved: t.isResolved,
635
+ path: t.path,
636
+ line: t.line ?? t.originalLine,
637
+ commentId: t.first.nodes[0]?.databaseId ?? null
638
+ })),
639
+ comments: pr.comments.nodes.map((c) => ({ id: c.databaseId, at: c.createdAt, url: c.url, author: actor(c.author) })),
640
+ checks: contexts.map(toCheck)
641
+ };
642
+ }
643
+ function actor(a) {
644
+ return a ? { id: a.databaseId ?? 0, login: a.login, bot: a.__typename === "Bot" } : null;
645
+ }
646
+ function toCheck(c) {
647
+ if (c.__typename === "CheckRun") {
648
+ return {
649
+ key: `check_run:${c.databaseId}`,
650
+ name: c.name ?? "",
651
+ type: "check_run",
652
+ checkRunId: c.databaseId ?? null,
653
+ workflowRunId: c.checkSuite?.workflowRun?.databaseId ?? null,
654
+ workflow: c.checkSuite?.workflowRun?.workflow.name ?? null,
655
+ done: c.status === "COMPLETED",
656
+ conclusion: c.conclusion?.toLowerCase() ?? null,
657
+ url: c.detailsUrl ?? null,
658
+ at: c.completedAt ?? null
659
+ };
660
+ }
661
+ const state = c.state?.toLowerCase() ?? "pending";
662
+ const done = state !== "pending" && state !== "expected";
663
+ return {
664
+ key: `status:${c.context}`,
665
+ name: c.context ?? "",
666
+ type: "status",
667
+ checkRunId: null,
668
+ workflowRunId: null,
669
+ workflow: null,
670
+ done,
671
+ conclusion: done ? state === "success" ? "success" : "failure" : null,
672
+ url: c.targetUrl ?? null,
673
+ at: c.createdAt ?? null
674
+ };
675
+ }
676
+
677
+ // src/poll.ts
678
+ async function pollOnce(inbox, log2, fetch2 = fetchSnapshot, all = true) {
679
+ const added = [];
680
+ for (const w of inbox.watched()) {
681
+ if (w.sub && !all) continue;
682
+ const key = `${w.target.owner}/${w.target.name}#${w.target.number}`;
683
+ let next;
684
+ try {
685
+ next = await fetch2(w.target);
686
+ } catch (e) {
687
+ if (e instanceof LoginNeeded) throw e;
688
+ if (e instanceof GitHubError && e.status === 404) {
689
+ log2(`${key}: no such pull request, or your GitHub login can't see it; no longer watching it`);
690
+ inbox.unwatch(key);
691
+ continue;
692
+ }
693
+ log2(`${key}: ${e.message}; trying again next time`);
694
+ continue;
695
+ }
696
+ if (!w.snapshot || !w.memory) {
697
+ inbox.update(key, next, initialMemory(next));
698
+ if (next.state !== "OPEN") {
699
+ const reason = next.state === "MERGED" ? "merged" : "closed";
700
+ added.push(...inbox.append([{
701
+ v: 1,
702
+ id: `local:${key}:watch.ended:${reason}`,
703
+ sub: null,
704
+ kind: "watch.ended",
705
+ at: next.fetchedAt,
706
+ received_at: next.fetchedAt,
707
+ repo: next.repo,
708
+ pr: next.number,
709
+ actor: null,
710
+ data: { reason },
711
+ links: { html: next.url }
712
+ }]));
713
+ inbox.unwatch(key);
714
+ } else {
715
+ inbox.save();
716
+ }
717
+ continue;
718
+ }
719
+ const { events, memory } = diff(w.snapshot, next, w.memory);
720
+ inbox.update(key, next, memory);
721
+ added.push(...inbox.append(events));
722
+ if (events.some((e) => e.kind === "watch.ended")) inbox.unwatch(key);
723
+ }
724
+ return added;
725
+ }
726
+
727
+ // src/feed.ts
728
+ var Feed = class {
729
+ inbox;
730
+ server;
731
+ o;
732
+ hadTargets;
733
+ nextPoll = 0;
734
+ // Whether the server ended a repository-wide watch, for the footer.
735
+ repoEnded = false;
736
+ constructor(inbox, server, o) {
737
+ this.inbox = inbox;
738
+ this.server = server;
739
+ this.o = o;
740
+ this.hadTargets = inbox.watched().length + inbox.repoWatches().length > 0;
741
+ }
742
+ // Pending events that match, acknowledging the rest so they don't wait
743
+ // for ever.
744
+ wanted(kinds) {
745
+ this.check();
746
+ const items = [];
747
+ for (const source of this.server ? [this.server, this.inbox] : [this.inbox]) {
748
+ const pending = source.pending();
749
+ if (source === this.server) {
750
+ for (const e of pending) {
751
+ if (e.sub && e.kind.startsWith("watch.") && this.inbox.subscriptionEnded(e.sub, e.kind) === "repo") this.repoEnded = true;
752
+ }
753
+ }
754
+ const keep = pending.filter((e) => matchesKind(kinds, e.kind));
755
+ const skip = pending.filter((e) => !matchesKind(kinds, e.kind));
756
+ const firstKept = keep[0]?.seq ?? Infinity;
757
+ const skippable = skip.filter((e) => e.seq < firstKept).map((e) => e.seq);
758
+ if (skippable.length) source.ack(Math.max(...skippable));
759
+ if (!keep.length && skip.length) source.ack(Math.max(...skip.map((e) => e.seq)));
760
+ items.push(...keep.map((event) => ({ event, source })));
761
+ }
762
+ return items;
763
+ }
764
+ ack(items) {
765
+ const last = /* @__PURE__ */ new Map();
766
+ for (const { event, source } of items) last.set(source, Math.max(last.get(source) ?? 0, event.seq));
767
+ for (const [source, seq] of last) source.ack(seq);
768
+ }
769
+ // Polls GitHub for the targets watched from this machine. While the
770
+ // server is unreachable that includes those it was watching.
771
+ async poll() {
772
+ this.check();
773
+ await pollOnce(this.inbox, this.o.log, this.o.fetch, !this.server?.connected);
774
+ this.nextPoll = Date.now() + this.o.interval;
775
+ }
776
+ // Waits `ms`, collecting whatever arrives, then polls.
777
+ async settle(ms) {
778
+ await sleep(ms);
779
+ await this.poll();
780
+ }
781
+ // Waits up to `ms` for something new: returns early when the server
782
+ // sends events, and polls GitHub when a poll is due or the wait is over.
783
+ async more(ms) {
784
+ if (!this.server) {
785
+ await sleep(ms);
786
+ return this.poll();
787
+ }
788
+ const woke = await this.server.changed(Math.min(ms, Math.max(this.nextPoll - Date.now(), 0)));
789
+ this.check();
790
+ if (!woke || Date.now() >= this.nextPoll) await this.poll();
791
+ }
792
+ watching() {
793
+ return [...this.inbox.watched().map((w) => targetKey(w.target)), ...this.inbox.repoWatches().map((r) => repoKey(r.target))];
794
+ }
795
+ // Whether everything watched has ended. Without a server, that's when
796
+ // no PR is left. A server inbox may also be fed by subscriptions made
797
+ // elsewhere, which can't be listed yet, so it ends only when this
798
+ // command was watching PRs and they have all ended. A repository-wide
799
+ // watch has no end of its own, but only the server feeds it: without
800
+ // one it leaves nothing to wait for.
801
+ ended() {
802
+ if (this.inbox.watched().length || this.server && this.inbox.repoWatches().length) return false;
803
+ return this.server ? this.hadTargets : true;
804
+ }
805
+ check() {
806
+ if (this.server?.failure) throw this.server.failure;
807
+ }
808
+ };
809
+ var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
810
+
811
+ // src/format.ts
812
+ var BODY = "--jq .body";
813
+ function formatText(events, stillWatching, reading, repoEnded = false) {
814
+ const groups = /* @__PURE__ */ new Map();
815
+ for (const e of events) {
816
+ const key = e.repo ? e.pr !== null ? `${e.repo.full_name}#${e.pr}` : e.repo.full_name : "other";
817
+ groups.set(key, [...groups.get(key) ?? [], e]);
818
+ }
819
+ const lines = [];
820
+ for (const [key, list] of groups) {
821
+ lines.push(`${key}: ${list.length} new event${list.length === 1 ? "" : "s"}`);
822
+ lines.push(...describeAll(list));
823
+ lines.push("");
824
+ }
825
+ lines.push(footer(stillWatching, reading, repoEnded));
826
+ return lines.join("\n");
827
+ }
828
+ function footer(stillWatching, reading, repoEnded = false) {
829
+ if (!stillWatching.length && reading) return `Still reading inbox ${reading}. Run \`watcheroni wait\` again to keep watching.`;
830
+ if (!stillWatching.length && repoEnded) return "Nothing is left to watch: the server's watch on the repository has ended.";
831
+ if (!stillWatching.length) return "Nothing is left to watch: every watched PR has been merged or closed.";
832
+ const what = stillWatching.length === 1 && stillWatching[0].includes("#") ? `${stillWatching[0]} is still open` : `Still watching ${stillWatching.join(", ")}`;
833
+ return `${what}. Run \`watcheroni wait\` again to keep watching.`;
834
+ }
835
+ function describeAll(events) {
836
+ const lines = [];
837
+ const byReview = /* @__PURE__ */ new Map();
838
+ for (const e of events) {
839
+ const reviewId = e.data.review_id;
840
+ if (e.kind === "review_comment.created" && typeof reviewId === "number") {
841
+ byReview.set(reviewId, [...byReview.get(reviewId) ?? [], e]);
842
+ }
843
+ }
844
+ const shownReviews = /* @__PURE__ */ new Set();
845
+ for (const e of events) {
846
+ const reviewId = typeof e.data.review_id === "number" ? e.data.review_id : null;
847
+ if (e.kind === "review_comment.created" && reviewId !== null && (byReview.get(reviewId)?.length ?? 0) > 1) {
848
+ if (shownReviews.has(reviewId)) continue;
849
+ shownReviews.add(reviewId);
850
+ const comments = byReview.get(reviewId);
851
+ const where = comments.map((c) => location(c.data) || "?");
852
+ lines.push(`- ${comments.length} review comments${by(e)} on ${where.join(", ")}`);
853
+ lines.push(` gh api ${repoPath(e)}/pulls/${e.pr}/comments --paginate --jq '.[] | select(.pull_request_review_id == ${reviewId}) | "\\(.path)\\(.line // .original_line | if . then ":\\(.)" else "" end) \\(.body)"'`);
854
+ continue;
855
+ }
856
+ if (e.kind === "review.submitted" && reviewId !== null && e.data.state === "commented" && e.data.has_body === false) {
857
+ const comments = byReview.get(reviewId) ?? [];
858
+ if (comments.length && comments.every((c) => c.data.in_reply_to)) continue;
859
+ }
860
+ const [text, command] = describe(e);
861
+ lines.push(`- ${text}`);
862
+ if (command) lines.push(` ${command}`);
863
+ }
864
+ return lines;
865
+ }
866
+ function describe(e) {
867
+ const d = e.data;
868
+ const repo = repoPath(e);
869
+ const short = (sha) => typeof sha === "string" ? sha.slice(0, 7) : "?";
870
+ switch (e.kind) {
871
+ case "review.submitted":
872
+ return [`review.submitted${by(e)}: ${d.state}`, `gh api ${repo}/pulls/${e.pr}/reviews/${d.review_id} --jq '.state, .body'`];
873
+ case "review.dismissed":
874
+ return [`review.dismissed: review ${d.review_id}`, null];
875
+ case "review_comment.created": {
876
+ const where = d.path ? ` on ${location(d)}` : "";
877
+ const reply = d.in_reply_to ? " (a reply)" : "";
878
+ return [`review_comment.created${by(e)}${where}${reply}`, `gh api ${repo}/pulls/comments/${d.comment_id} ${BODY}`];
879
+ }
880
+ case "comment.created":
881
+ return [`comment.created${by(e)}`, `gh api ${repo}/issues/comments/${d.comment_id} ${BODY}`];
882
+ case "thread.resolved":
883
+ case "thread.unresolved": {
884
+ const where = d.path ? ` on ${location(d)}` : "";
885
+ return [`${e.kind}${by(e)}${where}`, typeof d.comment_id === "number" ? `gh api ${repo}/pulls/comments/${d.comment_id} ${BODY}` : null];
886
+ }
887
+ case "check.failed": {
888
+ const command = typeof d.workflow_run_id === "number" ? `gh run view ${d.workflow_run_id} -R ${e.repo?.full_name} --log-failed` : typeof d.check_run_id === "number" ? `gh api ${repo}/check-runs/${d.check_run_id}` : e.links.html ?? null;
889
+ return [`check.failed: "${d.name}" (${d.conclusion}) on ${short(d.head_sha)}`, command];
890
+ }
891
+ case "checks.completed": {
892
+ const failed = Array.isArray(d.failed) && d.failed.length ? `; failed: ${d.failed.join(", ")}` : "";
893
+ return [`checks.completed on ${short(d.head_sha)}: ${d.result}${failed}`, failed ? `gh pr checks ${e.pr} -R ${e.repo?.full_name}` : null];
894
+ }
895
+ case "workflow.completed":
896
+ return [`workflow.completed: "${d.workflow}" ${d.conclusion}`, d.conclusion === "success" ? null : `gh run view ${d.run_id} -R ${e.repo?.full_name} --log-failed`];
897
+ case "merge.conflict":
898
+ return [`merge.conflict with the base branch (head ${short(d.head_sha)}, base ${short(d.base_sha)})`, null];
899
+ case "merge.clean":
900
+ return ["merge.clean: the conflict is gone", null];
901
+ case "pr.pushed":
902
+ return [`pr.pushed: ${short(d.before)} -> ${short(d.after)}`, null];
903
+ case "pr.base_changed":
904
+ return [`pr.base_changed: ${d.from} -> ${d.to}`, null];
905
+ case "pr.review_requested":
906
+ return [`pr.review_requested: ${d.reviewer}`, null];
907
+ case "pr.merged":
908
+ return [`pr.merged as ${short(d.merge_sha)}`, null];
909
+ case "watch.ended":
910
+ return [`watch.ended: the PR was ${d.reason}`, null];
911
+ // A repository-wide watch (no PR) isn't polled from here when it ends.
912
+ case "watch.expired":
913
+ return [e.pr === null ? "watch.expired: the server stopped watching after a week with nobody reading the inbox; name the repository again to keep watching" : "watch.expired: the server stopped watching after a week with nobody reading the inbox; watching it from this machine now", null];
914
+ case "watch.unverified":
915
+ return [d.reason === "access_denied" ? "watch.unverified: your GitHub login can no longer read this repo" : `watch.unverified: the server's access check lapsed; run \`watcheroni login\`, then name the ${e.pr === null ? "repository" : "PR"} again`, null];
916
+ case "watch.revoked":
917
+ return ["watch.revoked: the watcheroni App lost access to this repo", null];
918
+ case "inbox.gap":
919
+ return [`inbox.gap: events after ${d.missed_after} were dropped before they were read; check the watched PRs on GitHub`, null];
920
+ default:
921
+ return [`${e.kind}${by(e)}`, e.links.api ? `gh api ${e.links.api.replace("https://api.github.com/", "")}` : null];
922
+ }
923
+ }
924
+ function location(d) {
925
+ return d.path ? `${d.path}${d.line ? `:${d.line}` : ""}` : "";
926
+ }
927
+ function by(e) {
928
+ return e.actor ? ` by ${e.actor.login}` : "";
929
+ }
930
+ function repoPath(e) {
931
+ return `repos/${e.repo?.full_name ?? "?"}`;
932
+ }
933
+
934
+ // src/inbox.ts
935
+ import { closeSync, mkdirSync as mkdirSync2, openSync, readFileSync as readFileSync3, renameSync as renameSync2, unlinkSync, writeFileSync as writeFileSync2 } from "node:fs";
936
+ import { homedir as homedir2 } from "node:os";
937
+ import { join as join3 } from "node:path";
938
+ var SEEN_KEPT = 2e3;
939
+ var InboxBusy = class extends Error {
940
+ };
941
+ function stateDir() {
942
+ if (process.env.WATCHERONI_STATE_DIR) return process.env.WATCHERONI_STATE_DIR;
943
+ return join3(process.env.XDG_STATE_HOME || join3(homedir2(), ".local", "state"), "watcheroni");
944
+ }
945
+ var Inbox = class _Inbox {
946
+ name;
947
+ file;
948
+ lockFile;
949
+ state;
950
+ constructor(name, dir) {
951
+ this.name = name;
952
+ this.file = join3(dir, `${name}.json`);
953
+ this.lockFile = join3(dir, `${name}.lock`);
954
+ this.state = this.load();
955
+ }
956
+ // Opens the inbox, holding its lock until close() or process exit.
957
+ static open(name) {
958
+ if (!INBOX_NAME.test(name)) throw new UsageError(`${name}: inbox names are 1-64 letters, digits, '.', '_' or '-'`);
959
+ const dir = join3(stateDir(), "inboxes");
960
+ mkdirSync2(dir, { recursive: true });
961
+ lock(join3(dir, `${name}.lock`), name);
962
+ const inbox = new _Inbox(name, dir);
963
+ process.once("exit", () => inbox.close());
964
+ return inbox;
965
+ }
966
+ // Reads an inbox without locking it, for `ls`.
967
+ static peek(name) {
968
+ const inbox = new _Inbox(name, join3(stateDir(), "inboxes"));
969
+ return { watched: inbox.watched(), repos: inbox.repoWatches(), pending: inbox.state.events.length };
970
+ }
971
+ close() {
972
+ try {
973
+ unlinkSync(this.lockFile);
974
+ } catch {
975
+ }
976
+ }
977
+ watched() {
978
+ return Object.values(this.state.watched);
979
+ }
980
+ repoWatches() {
981
+ return Object.values(this.state.repos ?? {});
982
+ }
983
+ // Records (or updates) a repository-wide watch. False if it's new.
984
+ watchRepo(key, target, sub) {
985
+ const repos = this.state.repos ??= {};
986
+ const known = !!repos[key];
987
+ repos[key] = { target, addedAt: repos[key]?.addedAt ?? (/* @__PURE__ */ new Date()).toISOString(), sub };
988
+ this.save();
989
+ return known;
990
+ }
991
+ // Forgets every repository-wide watch on `owner/name`, returning them.
992
+ unwatchRepo(owner, name) {
993
+ const gone = Object.entries(this.state.repos ?? {}).filter(([, r]) => r.target.owner === owner && r.target.name === name);
994
+ for (const [key] of gone) delete this.state.repos[key];
995
+ if (gone.length) this.save();
996
+ return gone.map(([, r]) => r);
997
+ }
998
+ watch(key, target, sub = null) {
999
+ if (this.state.watched[key]) return false;
1000
+ this.state.watched[key] = { target, addedAt: (/* @__PURE__ */ new Date()).toISOString(), snapshot: null, memory: null, ...sub ? { sub } : {} };
1001
+ this.save();
1002
+ return true;
1003
+ }
1004
+ unwatch(key) {
1005
+ if (!this.state.watched[key]) return false;
1006
+ delete this.state.watched[key];
1007
+ this.save();
1008
+ return true;
1009
+ }
1010
+ // Records the server's subscription, or that it declined (null).
1011
+ setSubscription(key, sub) {
1012
+ const w = this.state.watched[key];
1013
+ if (!w) return;
1014
+ if (sub) {
1015
+ w.sub = sub;
1016
+ delete w.subTriedAt;
1017
+ } else {
1018
+ w.subTriedAt = (/* @__PURE__ */ new Date()).toISOString();
1019
+ }
1020
+ this.save();
1021
+ }
1022
+ // The server ended a subscription with a `watch.*` event. A PR that
1023
+ // merged, closed or expired is no longer watched; one whose watch was
1024
+ // revoked (the App uninstalled, say) or not re-verified is polled from
1025
+ // here instead. Returns "repo" when it was a repository-wide watch.
1026
+ subscriptionEnded(sub, kind) {
1027
+ const repo = Object.entries(this.state.repos ?? {}).find(([, r]) => r.sub === sub);
1028
+ if (repo) {
1029
+ delete this.state.repos[repo[0]];
1030
+ this.save();
1031
+ return "repo";
1032
+ }
1033
+ const entry = Object.entries(this.state.watched).find(([, w2]) => w2.sub === sub);
1034
+ if (!entry) return null;
1035
+ const [key, w] = entry;
1036
+ if (kind === "watch.revoked" || kind === "watch.unverified" || kind === "watch.expired") delete w.sub;
1037
+ else delete this.state.watched[key];
1038
+ this.save();
1039
+ return "pr";
1040
+ }
1041
+ update(key, snapshot, memory) {
1042
+ const w = this.state.watched[key];
1043
+ if (w) Object.assign(w, { snapshot, memory });
1044
+ }
1045
+ // Numbers and saves new events, dropping any seen before.
1046
+ append(events) {
1047
+ const added = [];
1048
+ const seen = new Set(this.state.seen);
1049
+ for (const e of events) {
1050
+ if (seen.has(e.id)) continue;
1051
+ seen.add(e.id);
1052
+ this.state.seen.push(e.id);
1053
+ const event = { ...e, seq: ++this.state.seq };
1054
+ this.state.events.push(event);
1055
+ added.push(event);
1056
+ }
1057
+ this.state.seen = this.state.seen.slice(-SEEN_KEPT);
1058
+ this.save();
1059
+ return added;
1060
+ }
1061
+ pending() {
1062
+ return [...this.state.events];
1063
+ }
1064
+ ack(seq) {
1065
+ this.state.events = this.state.events.filter((e) => e.seq > seq);
1066
+ this.state.acked = Math.max(this.state.acked, seq);
1067
+ this.save();
1068
+ }
1069
+ save() {
1070
+ const tmp = `${this.file}.${process.pid}.tmp`;
1071
+ writeFileSync2(tmp, JSON.stringify(this.state, null, 1));
1072
+ renameSync2(tmp, this.file);
1073
+ }
1074
+ load() {
1075
+ try {
1076
+ return JSON.parse(readFileSync3(this.file, "utf8"));
1077
+ } catch {
1078
+ return { version: 1, seq: 0, acked: 0, events: [], seen: [], watched: {} };
1079
+ }
1080
+ }
1081
+ };
1082
+ function lock(file, name) {
1083
+ for (let attempt = 0; attempt < 2; attempt++) {
1084
+ try {
1085
+ const fd = openSync(file, "wx");
1086
+ writeFileSync2(fd, String(process.pid));
1087
+ closeSync(fd);
1088
+ return;
1089
+ } catch (e) {
1090
+ if (e.code !== "EEXIST") throw e;
1091
+ }
1092
+ const pid = Number(readFileSync3(file, "utf8"));
1093
+ if (pid && alive(pid)) {
1094
+ throw new InboxBusy(`Inbox ${name} is in use by process ${pid}, another wait, stream or exec; stop it, or use another inbox with --inbox`);
1095
+ }
1096
+ unlinkSync(file);
1097
+ }
1098
+ throw new InboxBusy(`Couldn't lock inbox ${name}`);
1099
+ }
1100
+ function alive(pid) {
1101
+ try {
1102
+ process.kill(pid, 0);
1103
+ return true;
1104
+ } catch (e) {
1105
+ return e.code === "EPERM";
1106
+ }
1107
+ }
1108
+
1109
+ // src/selfhost.ts
1110
+ import { spawn as spawn2 } from "node:child_process";
1111
+ import { existsSync } from "node:fs";
1112
+ import { dirname as dirname2, join as join4 } from "node:path";
1113
+ var SCRIPT = join4("packages", "server", "selfhost", "run.mjs");
1114
+ function findSelfhost(from) {
1115
+ for (let dir = from; ; dir = dirname2(dir)) {
1116
+ if (existsSync(join4(dir, SCRIPT))) return join4(dir, SCRIPT);
1117
+ if (dirname2(dir) === dir) return null;
1118
+ }
1119
+ }
1120
+ function selfhost(args, cwd = process.cwd()) {
1121
+ const script = findSelfhost(cwd);
1122
+ if (!script) {
1123
+ throw new UsageError(
1124
+ "selfhost runs from a clone of watcheroni, which has the server to deploy:\n git clone https://github.com/thepaul/watcheroni && cd watcheroni && npm install\n npm run selfhost"
1125
+ );
1126
+ }
1127
+ return new Promise((resolve) => {
1128
+ const child = spawn2(process.execPath, [script, ...args], { stdio: "inherit" });
1129
+ child.on("error", () => resolve(1));
1130
+ child.on("close", (code, signal) => resolve(code ?? (signal ? 128 : 1)));
1131
+ });
1132
+ }
1133
+
1134
+ // src/server.ts
1135
+ var Unreachable = class extends Error {
1136
+ };
1137
+ var ServerError = class extends Error {
1138
+ status;
1139
+ code;
1140
+ // The whole problem document, for fields such as `install_url`.
1141
+ problem;
1142
+ constructor(message, status, code, problem = {}) {
1143
+ super(message);
1144
+ this.status = status;
1145
+ this.code = code;
1146
+ this.problem = problem;
1147
+ }
1148
+ };
1149
+ var GITHUB_TOKEN_CODES = /* @__PURE__ */ new Set(["github_token_required", "github_token_invalid"]);
1150
+ var SUBPROTOCOL = "watcheroni.v1";
1151
+ var WAIT_SECONDS = 300;
1152
+ var ServerClient = class {
1153
+ url;
1154
+ apiKey;
1155
+ constructor(config) {
1156
+ this.url = config.url;
1157
+ this.apiKey = config.apiKey;
1158
+ }
1159
+ async request(method, path, o = {}) {
1160
+ const timeout = AbortSignal.timeout(o.timeoutMs ?? 3e4);
1161
+ let res;
1162
+ try {
1163
+ res = await fetch(this.url + path, {
1164
+ method,
1165
+ headers: {
1166
+ Authorization: `Bearer ${this.apiKey}`,
1167
+ "user-agent": `watcheroni-cli/${package_default.version}`,
1168
+ ...o.body === void 0 ? {} : { "content-type": "application/json" },
1169
+ ...o.headers
1170
+ },
1171
+ body: o.body === void 0 ? void 0 : JSON.stringify(o.body),
1172
+ signal: o.signal ? AbortSignal.any([o.signal, timeout]) : timeout
1173
+ });
1174
+ } catch (e) {
1175
+ if (o.signal?.aborted) throw e;
1176
+ const cause = e.cause;
1177
+ throw new Unreachable(`can't reach ${this.url}: ${cause?.message ?? e.message}`);
1178
+ }
1179
+ if (res.status === 204) return void 0;
1180
+ if (res.ok) return await res.json();
1181
+ const problem = await res.json().catch(() => ({}));
1182
+ const code = problem.code ?? String(res.status);
1183
+ if (res.status === 401 && !GITHUB_TOKEN_CODES.has(code)) {
1184
+ throw new LoginNeeded(`${this.url} rejected the API key (${code}); check WATCHERONI_API_KEY`);
1185
+ }
1186
+ if (res.status >= 500 || res.status === 429) throw new Unreachable(`${this.url} answered ${res.status} (${code})`);
1187
+ throw new ServerError(`${this.url}: ${method} ${path}: ${res.status} ${problem.detail ?? problem.title ?? code}`, res.status, code, problem);
1188
+ }
1189
+ ensureInbox(name) {
1190
+ return this.request("PUT", `/v1/inboxes/${name}`);
1191
+ }
1192
+ events(name, after, wait, signal) {
1193
+ const query = new URLSearchParams({ wait: String(wait) });
1194
+ if (after !== null) query.set("after", String(after));
1195
+ return this.request("GET", `/v1/inboxes/${name}/events?${query}`, { signal, timeoutMs: (wait + 30) * 1e3 });
1196
+ }
1197
+ ack(name, seq) {
1198
+ return this.request("POST", `/v1/inboxes/${name}/ack`, { body: { seq } });
1199
+ }
1200
+ // Where and how to open the inbox's WebSocket. The API key goes in the
1201
+ // handshake's Authorization header, which the protocol allows for
1202
+ // clients that can set headers. If that ever has to change, this is
1203
+ // where to get a one-use ticket from POST /v1/inboxes/{name}/ticket and
1204
+ // connect to the URL it returns instead.
1205
+ async socket(name) {
1206
+ const url = new URL(`${this.url}/v1/inboxes/${name}/ws`);
1207
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
1208
+ return { url: url.href, headers: { Authorization: `Bearer ${this.apiKey}` } };
1209
+ }
1210
+ };
1211
+ async function subscribe(client, inbox, target, o) {
1212
+ const repo = `${target.owner}/${target.name}`;
1213
+ const path = `/v1/inboxes/${inbox}/subscriptions`;
1214
+ const body = isPr(target) ? { repo, pr: target.number } : { repo, kinds: target.kinds, ...target.base ? { base: target.base } : {}, ...target.author ? { author: target.author } : {} };
1215
+ try {
1216
+ try {
1217
+ return (await client.request("POST", path, { body })).id;
1218
+ } catch (e) {
1219
+ if (!(e instanceof ServerError && e.code === "github_token_required")) throw e;
1220
+ const token = await o.githubToken?.();
1221
+ if (!token) {
1222
+ o.log(`${repo} is private and there's no GitHub token for ${client.url} on this machine; run \`watcheroni login\` to have the server watch it`);
1223
+ return null;
1224
+ }
1225
+ return (await client.request("POST", path, { body, headers: { "X-GitHub-Token": token } })).id;
1226
+ }
1227
+ } catch (e) {
1228
+ if (e instanceof ServerError) {
1229
+ if (e.code === "app_not_installed") {
1230
+ const url = typeof e.problem.install_url === "string" ? `; it can be installed from ${e.problem.install_url}` : "";
1231
+ o.log(`the watcheroni App isn't installed on ${repo}${url}`);
1232
+ } else if (e.code === "github_token_invalid") {
1233
+ o.log(`${client.url} didn't accept this machine's GitHub token for ${repo}; run \`watcheroni login\` again`);
1234
+ } else {
1235
+ o.log(`${client.url} won't watch ${anyKey(target)}: ${e.problem.detail ?? e.problem.title ?? e.code}`);
1236
+ }
1237
+ return null;
1238
+ }
1239
+ if (e instanceof Unreachable) {
1240
+ o.log(`couldn't subscribe to ${anyKey(target)} (${e.message})`);
1241
+ return null;
1242
+ }
1243
+ throw e;
1244
+ }
1245
+ }
1246
+ var RENEW_BEFORE_MS = 2 * 24 * 36e5;
1247
+ var RENEW_EVERY_MS = 6 * 36e5;
1248
+ async function renewSubscriptions(client, inbox, o, now = Date.now()) {
1249
+ let subs;
1250
+ try {
1251
+ subs = (await client.request("GET", `/v1/inboxes/${inbox}/subscriptions`)).subscriptions;
1252
+ } catch (e) {
1253
+ if (e instanceof ServerError || e instanceof Unreachable || e instanceof LoginNeeded) return;
1254
+ throw e;
1255
+ }
1256
+ const due = subs.filter((s) => s.repo.private && s.verified_until && Date.parse(s.verified_until) - now < RENEW_BEFORE_MS);
1257
+ if (!due.length) return;
1258
+ const token = await o.githubToken?.();
1259
+ if (!token) {
1260
+ o.log(`the server needs a fresh GitHub token to keep watching ${due.map((s) => s.repo.full_name).join(", ")}; run \`watcheroni login\``);
1261
+ return;
1262
+ }
1263
+ for (const s of due) {
1264
+ try {
1265
+ await client.request("POST", `/v1/inboxes/${inbox}/subscriptions/${s.id}/verify`, { headers: { "X-GitHub-Token": token } });
1266
+ } catch (e) {
1267
+ if (e instanceof ServerError) {
1268
+ o.log(e.code === "repo_access_denied" ? `you can no longer read ${s.repo.full_name}, so the server has stopped watching it` : `couldn't renew the server's watch on ${s.repo.full_name}: ${e.problem.detail ?? e.problem.title ?? e.code}`);
1269
+ continue;
1270
+ }
1271
+ if (e instanceof Unreachable || e instanceof LoginNeeded) return;
1272
+ throw e;
1273
+ }
1274
+ }
1275
+ }
1276
+ var ServerReader = class {
1277
+ client;
1278
+ name;
1279
+ // True while connected and reading.
1280
+ connected = false;
1281
+ // Set when reading has stopped for good; the next wait throws it.
1282
+ failure = null;
1283
+ pos = null;
1284
+ queue = [];
1285
+ seen = /* @__PURE__ */ new Set();
1286
+ fresh = false;
1287
+ waiters = /* @__PURE__ */ new Set();
1288
+ ws = null;
1289
+ mode = "ws";
1290
+ stopped = false;
1291
+ abort = new AbortController();
1292
+ acks = /* @__PURE__ */ new Set();
1293
+ firstOutcome = null;
1294
+ o;
1295
+ constructor(client, name, o) {
1296
+ this.client = client;
1297
+ this.name = name;
1298
+ this.o = { backoff: [1e3, 6e4], pingMs: 3e4, connectTimeoutMs: 15e3, ...o };
1299
+ }
1300
+ // Starts reading. Resolves true once connected, or false if the first
1301
+ // attempt failed (reading goes on retrying in the background). Throws
1302
+ // if the server refused for good.
1303
+ start() {
1304
+ const first = new Promise((resolve) => this.firstOutcome = resolve);
1305
+ void this.run();
1306
+ return first.then((ok) => {
1307
+ if (this.failure) throw this.failure;
1308
+ return ok;
1309
+ });
1310
+ }
1311
+ // Events received and not yet acknowledged, oldest first.
1312
+ pending() {
1313
+ this.fresh = false;
1314
+ return [...this.queue];
1315
+ }
1316
+ // Waits up to `ms` for events that pending() hasn't returned yet, or for
1317
+ // reading to fail. True if something happened.
1318
+ changed(ms) {
1319
+ if (this.fresh || this.failure) return Promise.resolve(true);
1320
+ return new Promise((resolve) => {
1321
+ const done = (woke) => {
1322
+ clearTimeout(timer);
1323
+ this.waiters.delete(wake);
1324
+ resolve(woke);
1325
+ };
1326
+ const wake = () => done(true);
1327
+ const timer = setTimeout(() => done(false), ms);
1328
+ this.waiters.add(wake);
1329
+ });
1330
+ }
1331
+ // Moves the server's acknowledged position, over the socket if there is
1332
+ // one, else over HTTP.
1333
+ ack(seq) {
1334
+ this.queue = this.queue.filter((e) => e.seq > seq);
1335
+ if (this.ws?.readyState === WebSocket.OPEN) {
1336
+ this.ws.send(JSON.stringify({ type: "ack", seq }));
1337
+ return;
1338
+ }
1339
+ const sent = this.client.ack(this.name, seq).then(
1340
+ () => {
1341
+ },
1342
+ (e) => this.o.log(`couldn't acknowledge event ${seq} (${e.message}); it may be shown again`)
1343
+ ).finally(() => this.acks.delete(sent));
1344
+ this.acks.add(sent);
1345
+ }
1346
+ // Stops reading, after sending any acknowledgements still on their way.
1347
+ async close() {
1348
+ this.stopped = true;
1349
+ this.abort.abort();
1350
+ const ws = this.ws;
1351
+ if (ws && ws.readyState !== WebSocket.CLOSED) {
1352
+ await new Promise((resolve) => {
1353
+ const timer = setTimeout(resolve, 2e3);
1354
+ ws.addEventListener("close", () => {
1355
+ clearTimeout(timer);
1356
+ resolve();
1357
+ });
1358
+ ws.close(1e3, "done");
1359
+ });
1360
+ }
1361
+ await Promise.all(this.acks);
1362
+ this.wake();
1363
+ }
1364
+ async run() {
1365
+ let delay = this.o.backoff[0];
1366
+ while (!this.stopped) {
1367
+ try {
1368
+ if (this.mode === "ws") {
1369
+ const { opened, code, reason } = await this.openSocket();
1370
+ if (this.stopped) break;
1371
+ if (!opened) {
1372
+ await this.client.ensureInbox(this.name);
1373
+ this.o.log(`couldn't open a WebSocket to ${this.client.url}; long-polling instead`);
1374
+ this.mode = "longpoll";
1375
+ continue;
1376
+ }
1377
+ if (code === 4001) throw new LoginNeeded(`${this.client.url} rejected the API key (${reason || "4001"}); check WATCHERONI_API_KEY`);
1378
+ if (code === 4004) throw new ServerError(`inbox ${this.name} was deleted on ${this.client.url}`, 404, "not_found");
1379
+ if (code === 4029) {
1380
+ this.o.log(`${this.client.url} has too many connections for this account; long-polling instead`);
1381
+ this.mode = "longpoll";
1382
+ continue;
1383
+ }
1384
+ if (this.connected) delay = this.o.backoff[0];
1385
+ this.connected = false;
1386
+ throw new Unreachable(`lost the connection to ${this.client.url} (${code}${reason ? ` ${reason}` : ""})`);
1387
+ } else {
1388
+ const page = await this.client.events(this.name, this.pos, this.connected ? WAIT_SECONDS : 0, this.abort.signal);
1389
+ this.markConnected();
1390
+ if (this.pos === null) this.pos = page.acked;
1391
+ this.add(page.events);
1392
+ this.pos = Math.max(this.pos ?? 0, page.next);
1393
+ delay = this.o.backoff[0];
1394
+ }
1395
+ } catch (e) {
1396
+ if (this.stopped) break;
1397
+ if (!(e instanceof Unreachable)) {
1398
+ this.fail(e);
1399
+ break;
1400
+ }
1401
+ this.connected = false;
1402
+ this.settleFirst(false);
1403
+ if (!this.outage) {
1404
+ this.outage = true;
1405
+ if (this.wasConnected) this.o.log(`${e.message}; reconnecting`);
1406
+ }
1407
+ const jittered = delay * (0.5 + Math.random() / 2);
1408
+ await new Promise((r) => setTimeout(r, jittered));
1409
+ delay = Math.min(delay * 2, this.o.backoff[1]);
1410
+ }
1411
+ }
1412
+ }
1413
+ wasConnected = false;
1414
+ outage = false;
1415
+ markConnected() {
1416
+ if (this.outage) this.o.log(`${this.wasConnected ? "reconnected" : "connected"} to ${this.client.url}`);
1417
+ this.outage = false;
1418
+ this.connected = true;
1419
+ this.wasConnected = true;
1420
+ this.settleFirst(true);
1421
+ }
1422
+ settleFirst(connected) {
1423
+ this.firstOutcome?.(connected);
1424
+ this.firstOutcome = null;
1425
+ }
1426
+ fail(e) {
1427
+ this.failure = e;
1428
+ this.connected = false;
1429
+ this.settleFirst(false);
1430
+ this.wake();
1431
+ }
1432
+ // One WebSocket connection, from opening to closing.
1433
+ async openSocket() {
1434
+ const { url, headers } = await this.client.socket(this.name);
1435
+ return new Promise((resolve) => {
1436
+ const ws = new WebSocket(url, { protocols: [SUBPROTOCOL], headers });
1437
+ let opened = false;
1438
+ let heard = Date.now();
1439
+ const connectTimer = setTimeout(() => ws.close(), this.o.connectTimeoutMs);
1440
+ const pinger = setInterval(() => {
1441
+ if (Date.now() - heard > 2.5 * this.o.pingMs) ws.close(4e3, "silent");
1442
+ else if (ws.readyState === WebSocket.OPEN) ws.send("ping");
1443
+ }, this.o.pingMs);
1444
+ ws.addEventListener("open", () => {
1445
+ opened = true;
1446
+ clearTimeout(connectTimer);
1447
+ ws.send(JSON.stringify(this.pos === null ? { type: "hello" } : { type: "hello", after: this.pos }));
1448
+ });
1449
+ ws.addEventListener("message", (m) => {
1450
+ heard = Date.now();
1451
+ if (m.data === "pong" || typeof m.data !== "string") return;
1452
+ let msg;
1453
+ try {
1454
+ msg = JSON.parse(m.data);
1455
+ } catch {
1456
+ return;
1457
+ }
1458
+ if (msg.type === "ready") {
1459
+ this.ws = ws;
1460
+ if (this.pos === null) this.pos = msg.acked;
1461
+ this.markConnected();
1462
+ } else if (msg.type === "events") {
1463
+ this.add(msg.events);
1464
+ } else if (msg.type === "error") {
1465
+ this.o.log(`${this.client.url}: ${msg.code}${msg.detail ? `: ${msg.detail}` : ""}`);
1466
+ }
1467
+ });
1468
+ const onAbort = () => {
1469
+ if (ws.readyState === WebSocket.CONNECTING) ws.close();
1470
+ };
1471
+ const finish = (code, reason) => {
1472
+ clearTimeout(connectTimer);
1473
+ clearInterval(pinger);
1474
+ this.abort.signal.removeEventListener("abort", onAbort);
1475
+ if (this.ws === ws) this.ws = null;
1476
+ resolve({ opened, code, reason });
1477
+ };
1478
+ ws.addEventListener("close", (ev) => finish(ev.code, ev.reason));
1479
+ ws.addEventListener("error", () => {
1480
+ if (!opened) finish(1006, "");
1481
+ });
1482
+ this.abort.signal.addEventListener("abort", onAbort, { once: true });
1483
+ });
1484
+ }
1485
+ // Keeps new events, dropping ones already seen (delivery is at least once).
1486
+ add(events) {
1487
+ let added = false;
1488
+ for (const e of events) {
1489
+ if (this.pos !== null && e.seq <= this.pos) continue;
1490
+ this.pos = e.seq;
1491
+ if (this.seen.has(e.id)) continue;
1492
+ this.seen.add(e.id);
1493
+ this.queue.push(e);
1494
+ added = true;
1495
+ }
1496
+ if (this.seen.size > 2e3) this.seen = new Set([...this.seen].slice(-1e3));
1497
+ if (added) {
1498
+ this.fresh = true;
1499
+ this.wake();
1500
+ }
1501
+ }
1502
+ wake() {
1503
+ for (const w of [...this.waiters]) w();
1504
+ }
1505
+ };
1506
+
1507
+ // src/main.ts
1508
+ var EXIT = { events: 0, error: 1, timeout: 2, ended: 3, login: 4 };
1509
+ var HELP = `watcheroni ${package_default.version}: wait for, stream, or act on what happens to GitHub pull requests
1510
+
1511
+ Usage:
1512
+ watcheroni wait [target...] [--timeout 1h] [--settle 20s] [--format text|json]
1513
+ watcheroni stream [target...]
1514
+ watcheroni exec [target...] [--on kinds] [--batch] -- command [args...]
1515
+ watcheroni watch [target...]
1516
+ watcheroni unwatch <target...>
1517
+ watcheroni ls
1518
+ watcheroni login | logout sign in to the server ($WATCHERONI_SERVER)
1519
+ watcheroni selfhost [--help] run your own server (from a watcheroni clone)
1520
+
1521
+ Targets are owner/repo#123 or a pull request URL. With none, the open PR
1522
+ for the current git branch. owner/repo watches the whole repository, with
1523
+ the server only, and needs --kinds.
1524
+
1525
+ Options:
1526
+ --inbox NAME which inbox to use (default: $WATCHERONI_INBOX, or
1527
+ cc-$CLAUDE_CODE_SESSION_ID in Claude Code, or "default")
1528
+ --kinds LIST only these kinds, e.g. check.*,review.submitted
1529
+ --base BRANCH owner/repo targets: only PRs against this branch
1530
+ --author LOGIN owner/repo targets: only PRs opened by this GitHub user
1531
+ --interval TIME how often to poll GitHub (default 2m, at least 10s)
1532
+
1533
+ wait exits 0 after printing events, 2 on timeout, 3 when every watched PR
1534
+ has ended (never while a whole repository is watched), 4 when GitHub
1535
+ credentials are missing, 1 on other errors.
1536
+
1537
+ With a server ($WATCHERONI_SERVER and $WATCHERONI_API_KEY, or
1538
+ ~/.config/watcheroni), wait, stream and exec read the inbox on the server.
1539
+ Without one, or when it can't be reached, they poll GitHub from this
1540
+ machine with your gh login (or GH_TOKEN). That token is never sent to the
1541
+ server.
1542
+ `;
1543
+ var log = (msg) => process.stderr.write(`watcheroni: ${msg}
1544
+ `);
1545
+ function parseDuration(s) {
1546
+ const m = s.match(/^(\d+(?:\.\d+)?)(ms|s|m|h|d)?$/);
1547
+ if (!m) throw new UsageError(`${s}: expected a duration like 30s, 2m or 1h`);
1548
+ const unit = { ms: 1, s: 1e3, m: 6e4, h: 36e5, d: 864e5 }[m[2] ?? "s"];
1549
+ return Number(m[1]) * unit;
1550
+ }
1551
+ async function main(argv) {
1552
+ if (argv[0] === "selfhost") return selfhost(argv.slice(1));
1553
+ const split = argv.indexOf("--");
1554
+ const command = split === -1 ? [] : argv.slice(split + 1);
1555
+ const { values: opts, positionals } = parseArgs({
1556
+ args: split === -1 ? argv : argv.slice(0, split),
1557
+ allowPositionals: true,
1558
+ options: {
1559
+ inbox: { type: "string" },
1560
+ kinds: { type: "string" },
1561
+ base: { type: "string" },
1562
+ author: { type: "string" },
1563
+ on: { type: "string" },
1564
+ interval: { type: "string", default: "2m" },
1565
+ timeout: { type: "string", default: "1h" },
1566
+ settle: { type: "string", default: "20s" },
1567
+ format: { type: "string", default: "text" },
1568
+ batch: { type: "boolean", default: false },
1569
+ help: { type: "boolean", short: "h", default: false },
1570
+ version: { type: "boolean", short: "v", default: false }
1571
+ }
1572
+ });
1573
+ const [verb, ...args] = positionals;
1574
+ if (opts.version) {
1575
+ process.stdout.write(`${package_default.version}
1576
+ `);
1577
+ return 0;
1578
+ }
1579
+ if (opts.help || !verb || verb === "help") {
1580
+ process.stdout.write(HELP);
1581
+ return verb || opts.help ? 0 : EXIT.error;
1582
+ }
1583
+ const inboxName = opts.inbox ?? process.env.WATCHERONI_INBOX ?? (process.env.CLAUDE_CODE_SESSION_ID ? `cc-${process.env.CLAUDE_CODE_SESSION_ID}` : "default");
1584
+ if (verb === "login") return await login(), 0;
1585
+ if (verb === "logout") return await logout(), 0;
1586
+ if (verb === "ls") {
1587
+ const { watched, repos, pending } = Inbox.peek(inboxName);
1588
+ if (!watched.length && !repos.length) process.stdout.write(`Inbox ${inboxName} is watching nothing.
1589
+ `);
1590
+ for (const w of watched) process.stdout.write(`${targetKey(w.target)} since ${w.addedAt}
1591
+ `);
1592
+ for (const r of repos) process.stdout.write(`${repoKey(r.target)} since ${r.addedAt}
1593
+ `);
1594
+ if (pending) process.stdout.write(`${pending} event(s) not yet read.
1595
+ `);
1596
+ return 0;
1597
+ }
1598
+ if (!["wait", "stream", "exec", "watch", "unwatch"].includes(verb)) {
1599
+ throw new UsageError(`unknown command ${verb}; see watcheroni --help`);
1600
+ }
1601
+ const kinds = (opts.on ?? opts.kinds)?.split(",").map((k) => k.trim()).filter(Boolean) ?? [];
1602
+ const interval = Math.max(parseDuration(opts.interval), 1e4);
1603
+ if (verb === "exec" && !command.length) throw new UsageError("exec needs a command after --");
1604
+ if (opts.format !== "text" && opts.format !== "json") throw new UsageError("--format is text or json");
1605
+ const config = loadServer();
1606
+ const parsed = args.map(parseTarget);
1607
+ const targets = parsed.filter(isPr);
1608
+ const repoTargets = parsed.filter((t) => !isPr(t)).map((t) => ({
1609
+ ...t,
1610
+ kinds,
1611
+ ...opts.base ? { base: opts.base } : {},
1612
+ ...opts.author ? { author: opts.author } : {}
1613
+ }));
1614
+ if ((opts.base || opts.author) && !repoTargets.length) throw new UsageError("--base and --author are for whole-repository targets (owner/repo)");
1615
+ if (verb !== "unwatch") {
1616
+ for (const t of repoTargets) {
1617
+ const repo = `${t.owner}/${t.name}`;
1618
+ if (!config) {
1619
+ throw new UsageError(`${repo}: watching a whole repository needs the watcheroni server (see \`watcheroni login\`); from this machine it would mean polling every PR in it. Name a PR instead (${repo}#123)`);
1620
+ }
1621
+ if (!kinds.length) throw new UsageError(`${repo}: watching a whole repository needs --kinds, e.g. --kinds pr.opened`);
1622
+ }
1623
+ }
1624
+ const inbox = Inbox.open(inboxName);
1625
+ const client = config && new ServerClient(config);
1626
+ if (verb === "unwatch") {
1627
+ if (!parsed.length) throw new UsageError("unwatch needs a target");
1628
+ for (const t of targets) {
1629
+ if (!inbox.unwatch(targetKey(t))) log(`${targetKey(t)} wasn't being watched in inbox ${inboxName}`);
1630
+ }
1631
+ for (const t of repoTargets) {
1632
+ const gone = inbox.unwatchRepo(t.owner, t.name);
1633
+ if (!gone.length) log(`${t.owner}/${t.name} wasn't being watched as a whole in inbox ${inboxName}`);
1634
+ if (!client) {
1635
+ for (const r of gone) {
1636
+ log(`stopped watching ${repoKey(r.target)} here, but with no server configured its subscription there wasn't ended; it ends after a week nobody reads inbox ${inboxName}`);
1637
+ }
1638
+ continue;
1639
+ }
1640
+ for (const r of gone) {
1641
+ await client.request("DELETE", `/v1/inboxes/${inboxName}/subscriptions/${r.sub}`).catch((e) => {
1642
+ if (!(e instanceof ServerError && e.status === 404)) log(`couldn't end the server's watch on ${repoKey(r.target)}: ${e.message}`);
1643
+ });
1644
+ }
1645
+ }
1646
+ return 0;
1647
+ }
1648
+ let reachable = false;
1649
+ if (client) {
1650
+ try {
1651
+ await client.ensureInbox(inboxName);
1652
+ reachable = true;
1653
+ } catch (e) {
1654
+ if (!(e instanceof Unreachable)) throw e;
1655
+ if (repoTargets.length) throw new Unreachable(`${e.message}; a whole repository can only be watched by the server`);
1656
+ log(`${e.message}; polling GitHub from this machine instead`);
1657
+ }
1658
+ }
1659
+ for (const t of client && reachable ? repoTargets : []) {
1660
+ const key = repoKey(t);
1661
+ const known = inbox.repoWatches().find((r) => repoKey(r.target) === key);
1662
+ if (known) continue;
1663
+ const sub = await subscribe(client, inboxName, t, { log, githubToken: () => githubTokenFor(client.url) });
1664
+ if (!sub) throw new ServerError(`can't watch ${key}: the server won't, and a whole repository can't be watched from this machine`, 0, "refused");
1665
+ inbox.watchRepo(key, t, sub);
1666
+ log(`watching ${key} in inbox ${inboxName}`);
1667
+ }
1668
+ if (!targets.length && !repoTargets.length) {
1669
+ try {
1670
+ targets.push(await currentBranchPr());
1671
+ } catch (e) {
1672
+ if (!(e instanceof UsageError || client && e instanceof LoginNeeded)) throw e;
1673
+ if (!inbox.watched().length && !client) {
1674
+ if (verb === "watch" || !inbox.pending().length) throw e;
1675
+ log(`${e.message}; reading the events left in inbox ${inboxName}`);
1676
+ }
1677
+ }
1678
+ }
1679
+ for (const t of targets) {
1680
+ const key = targetKey(t);
1681
+ const known = inbox.watched().find((w) => targetKey(w.target) === key);
1682
+ const tried = known?.subTriedAt ? Date.parse(known.subTriedAt) : 0;
1683
+ const throttled = !args.length && Date.now() - tried < RESUBSCRIBE_MS;
1684
+ if (known && (known.sub || !reachable || throttled)) continue;
1685
+ const sub = client && reachable ? await subscribe(client, inboxName, t, { log, githubToken: () => githubTokenFor(client.url) }) : null;
1686
+ if (known) {
1687
+ inbox.setSubscription(key, sub);
1688
+ continue;
1689
+ }
1690
+ inbox.watch(key, t, sub);
1691
+ if (client && reachable && !sub) inbox.setSubscription(key, null);
1692
+ log(`watching ${key} in inbox ${inboxName}${client && !sub ? ", polling GitHub from this machine" : ""}`);
1693
+ }
1694
+ const renew = () => client ? renewSubscriptions(client, inboxName, { log, githubToken: () => githubTokenFor(client.url) }) : Promise.resolve();
1695
+ if (reachable) await renew();
1696
+ const renewTimer = client ? setInterval(() => void renew().catch((e) => log(`renewing subscriptions: ${e.message}`)), RENEW_EVERY_MS) : null;
1697
+ renewTimer?.unref();
1698
+ if (verb === "watch") {
1699
+ clearInterval(renewTimer ?? void 0);
1700
+ await pollOnce(inbox, log, void 0, !reachable);
1701
+ return 0;
1702
+ }
1703
+ let server = null;
1704
+ if (client) {
1705
+ server = new ServerReader(client, inboxName, { log });
1706
+ const connected = await server.start();
1707
+ if (!connected && reachable) log(`can't read inbox ${inboxName} on ${client.url}; polling GitHub from this machine meanwhile`);
1708
+ if (!connected && verb === "wait" && !inbox.watched().length) {
1709
+ await server.close();
1710
+ throw new Unreachable(`can't reach ${client.url}, and inbox ${inboxName} watches no PR to poll from here instead`);
1711
+ }
1712
+ }
1713
+ const feed = new Feed(inbox, server, { interval, log });
1714
+ try {
1715
+ switch (verb) {
1716
+ case "wait":
1717
+ return await waitCommand(feed, { kinds, interval, timeout: parseDuration(opts.timeout), settle: parseDuration(opts.settle), json: opts.format === "json", inbox: inboxName });
1718
+ case "stream":
1719
+ return await streamCommand(feed, { kinds, interval });
1720
+ case "exec":
1721
+ return await execCommand(feed, { kinds, interval, batch: opts.batch, settle: parseDuration(opts.settle), command });
1722
+ }
1723
+ } finally {
1724
+ clearInterval(renewTimer ?? void 0);
1725
+ await server?.close();
1726
+ }
1727
+ return EXIT.error;
1728
+ }
1729
+ var RESUBSCRIBE_MS = 60 * 6e4;
1730
+ async function waitCommand(feed, o) {
1731
+ const deadline = Date.now() + o.timeout;
1732
+ const reading = () => feed.ended() ? void 0 : o.inbox;
1733
+ if (!feed.wanted(o.kinds).length) await feed.poll();
1734
+ for (; ; ) {
1735
+ let items = feed.wanted(o.kinds);
1736
+ if (items.length) {
1737
+ if (o.settle > 0 && !feed.ended()) {
1738
+ await feed.settle(Math.min(o.settle, Math.max(deadline - Date.now(), 0)));
1739
+ items = feed.wanted(o.kinds);
1740
+ }
1741
+ const events = items.map((i) => i.event);
1742
+ const still = feed.watching();
1743
+ process.stdout.write(o.json ? `${JSON.stringify({ events, watching: still })}
1744
+ ` : `${formatText(events, still, reading(), feed.repoEnded)}
1745
+ `);
1746
+ feed.ack(items);
1747
+ return feed.ended() ? EXIT.ended : EXIT.events;
1748
+ }
1749
+ if (feed.ended()) {
1750
+ process.stdout.write(o.json ? `${JSON.stringify({ events: [], watching: [] })}
1751
+ ` : `${footer([], void 0, feed.repoEnded)}
1752
+ `);
1753
+ return EXIT.ended;
1754
+ }
1755
+ const left = deadline - Date.now();
1756
+ if (left <= 0) {
1757
+ const still = feed.watching();
1758
+ process.stdout.write(o.json ? `${JSON.stringify({ events: [], watching: still, timeout: true })}
1759
+ ` : `No new events. ${footer(still, reading())}
1760
+ `);
1761
+ return EXIT.timeout;
1762
+ }
1763
+ await feed.more(Math.min(o.interval, left));
1764
+ }
1765
+ }
1766
+ async function streamCommand(feed, o) {
1767
+ for (; ; ) {
1768
+ for (const item of feed.wanted(o.kinds)) {
1769
+ process.stdout.write(`${JSON.stringify(item.event)}
1770
+ `);
1771
+ feed.ack([item]);
1772
+ }
1773
+ if (feed.ended()) return EXIT.ended;
1774
+ await feed.more(o.interval);
1775
+ }
1776
+ }
1777
+ async function execCommand(feed, o) {
1778
+ await feed.poll();
1779
+ for (; ; ) {
1780
+ const items = feed.wanted(o.kinds);
1781
+ if (items.length && o.batch) {
1782
+ if (o.settle > 0) await feed.settle(o.settle);
1783
+ const batch = feed.wanted(o.kinds);
1784
+ const code = await run(o.command, JSON.stringify(batch.map((i) => i.event)), { WATCHERONI_COUNT: String(batch.length) });
1785
+ if (code === 0) feed.ack(batch);
1786
+ else log(`command exited ${code}; the ${batch.length} event(s) will be offered again`);
1787
+ } else {
1788
+ for (const item of items) {
1789
+ const e = item.event;
1790
+ const code = await run(o.command, JSON.stringify(e), {
1791
+ WATCHERONI_KIND: e.kind,
1792
+ WATCHERONI_REPO: e.repo?.full_name ?? "",
1793
+ WATCHERONI_PR: e.pr === null ? "" : String(e.pr),
1794
+ WATCHERONI_URL: e.links.html ?? "",
1795
+ WATCHERONI_EVENT_ID: e.id
1796
+ });
1797
+ if (code !== 0) {
1798
+ log(`command exited ${code} for ${e.kind} (${e.id}); it will be offered again`);
1799
+ break;
1800
+ }
1801
+ feed.ack([item]);
1802
+ }
1803
+ }
1804
+ if (feed.ended() && !feed.wanted(o.kinds).length) return EXIT.ended;
1805
+ await feed.more(o.interval);
1806
+ }
1807
+ }
1808
+ function run(command, stdin, env) {
1809
+ return new Promise((resolve) => {
1810
+ const child = spawn3(command[0], command.slice(1), { stdio: ["pipe", "inherit", "inherit"], env: { ...process.env, ...env } });
1811
+ child.on("error", (e) => {
1812
+ log(`couldn't run ${command[0]}: ${e.message}`);
1813
+ resolve(127);
1814
+ });
1815
+ child.on("close", (code, signal) => resolve(code ?? (signal ? 128 : 1)));
1816
+ child.stdin.on("error", () => {
1817
+ });
1818
+ child.stdin.end(stdin);
1819
+ });
1820
+ }
1821
+ process.on("SIGINT", () => process.exit(130));
1822
+ process.on("SIGTERM", () => process.exit(143));
1823
+ delete process.env.WATCHERONI_WRAPPER;
1824
+ main(process.argv.slice(2)).then(
1825
+ (code) => process.exit(code),
1826
+ (e) => {
1827
+ if (e instanceof LoginNeeded) {
1828
+ log(e.message);
1829
+ process.exit(EXIT.login);
1830
+ }
1831
+ if (e instanceof UsageError || e instanceof InboxBusy || e instanceof LoginFailed || e instanceof Unreachable || e instanceof ServerError) log(e.message);
1832
+ else log(e instanceof Error ? e.stack ?? e.message : String(e));
1833
+ process.exit(EXIT.error);
1834
+ }
1835
+ );
1836
+ export {
1837
+ parseDuration
1838
+ };