co-maintainer 0.5.2 → 0.5.3

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/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "co-maintainer",
3
- "version": "0.5.2",
3
+ "version": "0.5.3",
4
4
  "description": "An imitator of yours, A PR review tool like co-maintainer.",
5
5
  "keywords": [
6
6
  "ai",
@@ -59,11 +59,9 @@
59
59
  "dx:scenario": "node scripts/dx_scenario.ts",
60
60
  "dashboard:smoke": "node scripts/dashboard_smoke.ts"
61
61
  },
62
- "dependencies": {
63
- "marked": "^15.0.7"
64
- },
65
62
  "devDependencies": {
66
63
  "@types/node": "^24.0.0",
64
+ "marked": "^15.0.7",
67
65
  "oxfmt": "^0.68.0",
68
66
  "oxlint": "^1.83.0",
69
67
  "playwright": "^1.63.0",
@@ -0,0 +1 @@
1
+ export declare function runClear(args: string[]): Promise<void>;
@@ -0,0 +1,152 @@
1
+ /** `co-maintainer clear`: delete what `init` produced.
2
+ *
3
+ * A repository goes back to "never inited": guides, the knowledge stamp, the
4
+ * skill state and the settings `init` remembered are gone, so the next build
5
+ * is `init`, not `sync`. `--include-cache` reaches further and takes the
6
+ * fetched evidence too, which makes that next init a cold build.
7
+ *
8
+ * `all` touches every repository on the machine, so it asks first. Pending
9
+ * jobs are asked about separately because they would otherwise rebuild the
10
+ * knowledge after it was cleared. */
11
+ import { CliError, EXIT_USAGE } from "../error.js";
12
+ import { askConfirm } from "../prompt.js";
13
+ import { canPrompt } from "../../tools/codegraph.js";
14
+ import { liveLockPid } from "../../store/app_db.js";
15
+ import { unknownOptionMessage } from "./registry.js";
16
+ import { cancelOrphanedJobs, cancelQueuedJobs, clearRepos, clearRepo, isSafeRepoName, listStoredRepos, repoJobs, withAppDbIfPresent, } from "../../services/clear.js";
17
+ /** "3 guides, 1 cache row, saved settings" without a trailing separator. */
18
+ function joinParts(parts) {
19
+ return parts.join(", ");
20
+ }
21
+ function repoParts(result) {
22
+ const parts = [];
23
+ if (result.guides > 0)
24
+ parts.push(`${result.guides} guide file(s)`);
25
+ if (result.cacheRows > 0)
26
+ parts.push(`${result.cacheRows} cache row(s)`);
27
+ if (result.clones)
28
+ parts.push("the clone");
29
+ if (result.worktrees)
30
+ parts.push("the worktrees");
31
+ if (result.settings)
32
+ parts.push("saved settings");
33
+ return parts;
34
+ }
35
+ function printRepo(result) {
36
+ const parts = repoParts(result);
37
+ if (parts.length === 0) {
38
+ console.log(`Nothing to clear for ${result.repo}.`);
39
+ return;
40
+ }
41
+ console.log(`Cleared ${result.repo}: ${joinParts(parts)}.`);
42
+ }
43
+ function printAll(result) {
44
+ if (result.repos.length === 0) {
45
+ console.log("Nothing to clear.");
46
+ return;
47
+ }
48
+ for (const repo of result.repos) {
49
+ const parts = repoParts(repo);
50
+ if (parts.length > 0) {
51
+ console.log(`Cleared ${repo.repo}: ${joinParts(parts)}.`);
52
+ }
53
+ }
54
+ const extra = [];
55
+ if (result.cacheFile)
56
+ extra.push("cache.db");
57
+ if (result.clones)
58
+ extra.push("every clone");
59
+ if (result.worktrees)
60
+ extra.push("every worktree");
61
+ if (result.settings)
62
+ extra.push("saved settings");
63
+ const tail = extra.length > 0 ? ` Also cleared ${joinParts(extra)}.` : "";
64
+ console.log(`Cleared ${result.repos.length} repositories.${tail}`);
65
+ }
66
+ /** What the pending-job question should say. */
67
+ function jobSummary(repos) {
68
+ const jobs = repoJobs(repos);
69
+ const parts = [];
70
+ if (jobs.queued.length > 0)
71
+ parts.push(`${jobs.queued.length} queued`);
72
+ if (jobs.running.length > 0)
73
+ parts.push(`${jobs.running.length} unfinished`);
74
+ if (parts.length === 0)
75
+ return undefined;
76
+ return `This target has ${joinParts(parts)} job(s).`;
77
+ }
78
+ /** Pending jobs would rebuild the knowledge this command is deleting. The
79
+ * question is asked once for the whole target. Without a terminal (or with
80
+ * `--yes`) they are canceled: keeping them would contradict the clear. */
81
+ async function handlePendingJobs(repos, assumeYes) {
82
+ await withAppDbIfPresent(async () => {
83
+ const summary = jobSummary(repos);
84
+ if (summary === undefined)
85
+ return;
86
+ const cancelThem = !canPrompt()
87
+ ? true
88
+ : assumeYes || (await askConfirm(`${summary} Cancel them?`));
89
+ if (!cancelThem) {
90
+ console.log("Pending jobs were kept. They can rebuild the knowledge later.");
91
+ return;
92
+ }
93
+ const canceled = cancelQueuedJobs(repos) + cancelOrphanedJobs(repos);
94
+ if (canceled > 0)
95
+ console.log(`Canceled ${canceled} job(s).`);
96
+ });
97
+ }
98
+ export async function runClear(args) {
99
+ let includeCache = false;
100
+ let yes = false;
101
+ const positionals = [];
102
+ for (const arg of args) {
103
+ if (arg === "--include-cache") {
104
+ includeCache = true;
105
+ continue;
106
+ }
107
+ if (arg === "--yes") {
108
+ yes = true;
109
+ continue;
110
+ }
111
+ if (arg.startsWith("-")) {
112
+ throw new CliError("usage", unknownOptionMessage(arg, "clear"));
113
+ }
114
+ positionals.push(arg);
115
+ }
116
+ const target = positionals[0];
117
+ if (positionals.length !== 1 || !target) {
118
+ throw new CliError("usage", "clear needs one target: owner/repo or all.", "Usage: co-maintainer clear <owner/repo|all> [--include-cache] [--yes]");
119
+ }
120
+ const all = target === "all";
121
+ if (!all && !isSafeRepoName(target)) {
122
+ throw new CliError("usage", "Repository must look like owner/repo");
123
+ }
124
+ // A live serve owns guides and caches: clearing underneath it would confuse
125
+ // webhooks and remote clients mid-flight. The same guard rollback uses.
126
+ const pid = await liveLockPid();
127
+ if (pid !== undefined) {
128
+ throw new CliError("serve_already_running", `A co-maintainer serve process (pid ${pid}) is using this data directory.`, "Stop it, then run clear again.", EXIT_USAGE);
129
+ }
130
+ const repos = all ? await listStoredRepos() : [target];
131
+ if (all && !yes) {
132
+ if (!canPrompt()) {
133
+ throw new CliError("confirmation_required", "clear all needs your confirmation.", "Run it in a terminal, or pass --yes.", EXIT_USAGE);
134
+ }
135
+ const scope = includeCache
136
+ ? "guides, settings, cached evidence, clones and worktrees"
137
+ : "guides and saved settings";
138
+ console.log(`This deletes the ${scope} of ${repos.length} repositories on this machine.`);
139
+ if (!(await askConfirm("Clear them?"))) {
140
+ console.log("Nothing changed.");
141
+ return;
142
+ }
143
+ }
144
+ await handlePendingJobs(repos, yes);
145
+ // The snapshot the jobs were negotiated against is the one that gets
146
+ // cleared, so the printed count matches what actually went.
147
+ if (all)
148
+ printAll(await clearRepos(repos, { includeCache }));
149
+ else
150
+ printRepo(await clearRepo(target, { includeCache }));
151
+ console.log(`Run co-maintainer init ${all ? "owner/repo" : target} to build the guides again.`);
152
+ }
@@ -30,6 +30,10 @@ export declare function findCommand(name: string): CommandSpec | undefined;
30
30
  /** Commands listed in the global help. A hidden alias or command is resolvable
31
31
  * but invisible. */
32
32
  export declare function visibleCommands(): CommandSpec[];
33
+ /** The visible command names in help order. Tests and scripts iterate this
34
+ * instead of repeating the list, so a new command cannot be forgotten in one
35
+ * place and present in another. */
36
+ export declare function visibleCommandNames(): string[];
33
37
  /** Levenshtein distance, iterative and allocation-light: the help path runs it
34
38
  * on every unknown token, and the strings are short. */
35
39
  export declare function levenshtein(a: string, b: string): number;
@@ -430,6 +430,48 @@ export const COMMANDS = [
430
430
  "co-maintainer view owner/repo --remote",
431
431
  ],
432
432
  },
433
+ {
434
+ name: "clear",
435
+ summary: "Delete the generated guides and saved settings for a repository, or for every repository.",
436
+ usage: ["co-maintainer clear <owner/repo|all> [options]"],
437
+ groups: [
438
+ {
439
+ title: "Clear",
440
+ flags: [
441
+ {
442
+ name: "include-cache",
443
+ description: "Also delete the fetched evidence: pull request listings, AI job cache, costs, local review carry-over, clones and worktrees. The next init is then a cold build.",
444
+ },
445
+ {
446
+ name: "yes",
447
+ description: "Skip the confirmation `all` asks. Needed when there is no terminal.",
448
+ },
449
+ ],
450
+ },
451
+ ],
452
+ examples: [
453
+ "co-maintainer clear owner/repo",
454
+ "co-maintainer clear owner/repo --include-cache",
455
+ "co-maintainer clear all --include-cache --yes",
456
+ ],
457
+ },
458
+ {
459
+ name: "uninstall",
460
+ summary: "Delete every co-maintainer data file on this machine.",
461
+ usage: ["co-maintainer uninstall [--yes]"],
462
+ groups: [
463
+ {
464
+ title: "Uninstall",
465
+ flags: [
466
+ {
467
+ name: "yes",
468
+ description: "Skip the confirmation. Needed when there is no terminal.",
469
+ },
470
+ ],
471
+ },
472
+ ],
473
+ examples: ["co-maintainer uninstall", "co-maintainer uninstall --yes"],
474
+ },
433
475
  {
434
476
  name: "set",
435
477
  summary: "Persist defaults and secrets to the user config file.",
@@ -644,6 +686,12 @@ export function findCommand(name) {
644
686
  export function visibleCommands() {
645
687
  return COMMANDS.filter((command) => !command.hidden);
646
688
  }
689
+ /** The visible command names in help order. Tests and scripts iterate this
690
+ * instead of repeating the list, so a new command cannot be forgotten in one
691
+ * place and present in another. */
692
+ export function visibleCommandNames() {
693
+ return visibleCommands().map((command) => command.name);
694
+ }
647
695
  /** Levenshtein distance, iterative and allocation-light: the help path runs it
648
696
  * on every unknown token, and the strings are short. */
649
697
  export function levenshtein(a, b) {
@@ -0,0 +1 @@
1
+ export declare function runUninstall(args: string[]): Promise<void>;
@@ -0,0 +1,43 @@
1
+ /** `co-maintainer uninstall`: delete every data file this machine holds.
2
+ *
3
+ * The npm package cannot remove itself while the CLI runs from it, so the
4
+ * command deletes the config, databases, guides, clones, worktrees and tools
5
+ * and then prints the npm command that finishes the job. */
6
+ import { CliError, EXIT_USAGE } from "../error.js";
7
+ import { askConfirm } from "../prompt.js";
8
+ import { canPrompt } from "../../tools/codegraph.js";
9
+ import { liveLockPid } from "../../store/app_db.js";
10
+ import { unknownOptionMessage } from "./registry.js";
11
+ import { uninstallData } from "../../services/clear.js";
12
+ export async function runUninstall(args) {
13
+ for (const arg of args) {
14
+ if (arg !== "--yes") {
15
+ throw new CliError("usage", unknownOptionMessage(arg, "uninstall"));
16
+ }
17
+ }
18
+ const pid = await liveLockPid();
19
+ if (pid !== undefined) {
20
+ throw new CliError("serve_already_running", `A co-maintainer serve process (pid ${pid}) is using this data directory.`, "Stop it, then run uninstall again.", EXIT_USAGE);
21
+ }
22
+ if (!args.includes("--yes")) {
23
+ if (!canPrompt()) {
24
+ throw new CliError("confirmation_required", "Uninstall needs your confirmation.", "Run it in a terminal, or pass --yes.", EXIT_USAGE);
25
+ }
26
+ console.log("This deletes every co-maintainer data file: config.json, the databases, guides, clones, worktrees and tools.");
27
+ if (!(await askConfirm("Uninstall?"))) {
28
+ console.log("Nothing changed.");
29
+ return;
30
+ }
31
+ }
32
+ const removed = await uninstallData();
33
+ if (removed.paths.length === 0) {
34
+ console.log("Nothing to remove.");
35
+ }
36
+ else {
37
+ console.log("Removed:");
38
+ for (const path of removed.paths)
39
+ console.log(` ${path}`);
40
+ }
41
+ console.log("The CLI itself is still installed. Remove it with:");
42
+ console.log(" npm uninstall -g co-maintainer");
43
+ }
@@ -6,6 +6,8 @@ import { runRollback } from "./commands/rollback.js";
6
6
  import { runSet } from "./commands/set.js";
7
7
  import { runConfig } from "./commands/config.js";
8
8
  import { runView } from "./commands/view.js";
9
+ import { runClear } from "./commands/clear.js";
10
+ import { runUninstall } from "./commands/uninstall.js";
9
11
  import { runProbe } from "./commands/probe.js";
10
12
  import { runReviewFromCli } from "./commands/review.js";
11
13
  import { runInitOrRemake } from "../services/setup.js";
@@ -68,6 +70,14 @@ export async function run(args) {
68
70
  await runView(args.slice(1));
69
71
  return;
70
72
  }
73
+ if (args[0] === "clear") {
74
+ await runClear(args.slice(1));
75
+ return;
76
+ }
77
+ if (args[0] === "uninstall") {
78
+ await runUninstall(args.slice(1));
79
+ return;
80
+ }
71
81
  if (args[0] === "serve") {
72
82
  await runServe(args.slice(1));
73
83
  return;
@@ -142,8 +142,16 @@ export declare function repoSlug(repo: string): string;
142
142
  * Windows is the roaming profile and gets synced across machines. */
143
143
  export declare function clonesDir(): string;
144
144
  export declare function cloneDir(repo: string): string;
145
+ /** The directory holding one repository's PR worktrees. `clear` removes it
146
+ * whole, so the layout lives in one place instead of at each call site. */
147
+ export declare function repoWorktreesDir(repo: string): string;
148
+ /** Every repository's worktrees, for `clear all` and `uninstall`. */
149
+ export declare function worktreesDir(): string;
145
150
  export declare function worktreeDir(repo: string, pr: number): string;
146
151
  export declare function cacheDbPath(): string;
152
+ /** Transient pid files that keep one local review per repository root.
153
+ * `uninstall` still takes them: they are co-maintainer data too. */
154
+ export declare function locksDir(): string;
147
155
  export declare function loadEnvFile(path: string): void;
148
156
  export declare function readConfig(): UserConfig;
149
157
  /** The App private key to sign with. An inline `githubAppPrivateKey` wins,
@@ -158,6 +166,12 @@ export declare function appPrivateKeyFileMissing(config: Pick<UserConfig, "githu
158
166
  /** Merges `patch` into `repos[repo]` in config.json, dropping undefined
159
167
  * fields. Never call this with a token or other secret. */
160
168
  export declare function writeRepoConfig(repo: string, patch: RepoConfig): Promise<void>;
169
+ /** Drops `repos[repo]` from config.json. `clear` uses this so the repository
170
+ * goes back to "never inited": the next run resolves its flags again. Returns
171
+ * whether there was an entry to drop. */
172
+ export declare function removeRepoConfig(repo: string): Promise<boolean>;
173
+ /** Drops every per-repo entry. `clear all` forgets every repository it knows. */
174
+ export declare function removeAllRepoConfig(): Promise<boolean>;
161
175
  /** Merges `patch` into the top-level (global, cross-repo) config —
162
176
  * used by `co-maintainer set`. This is the only path allowed to persist
163
177
  * a token. */
@@ -117,12 +117,26 @@ export function clonesDir() {
117
117
  export function cloneDir(repo) {
118
118
  return `${clonesDir()}/${repoSlug(repo)}`;
119
119
  }
120
+ /** The directory holding one repository's PR worktrees. `clear` removes it
121
+ * whole, so the layout lives in one place instead of at each call site. */
122
+ export function repoWorktreesDir(repo) {
123
+ return `${getCacheDir()}/co-maintainer/wt/${repoSlug(repo)}`;
124
+ }
125
+ /** Every repository's worktrees, for `clear all` and `uninstall`. */
126
+ export function worktreesDir() {
127
+ return `${getCacheDir()}/co-maintainer/wt`;
128
+ }
120
129
  export function worktreeDir(repo, pr) {
121
- return `${getCacheDir()}/co-maintainer/wt/${repoSlug(repo)}/${pr}`;
130
+ return `${repoWorktreesDir(repo)}/${pr}`;
122
131
  }
123
132
  export function cacheDbPath() {
124
133
  return `${getCacheDir()}/co-maintainer/cache.db`;
125
134
  }
135
+ /** Transient pid files that keep one local review per repository root.
136
+ * `uninstall` still takes them: they are co-maintainer data too. */
137
+ export function locksDir() {
138
+ return `${getCacheDir()}/co-maintainer/locks`;
139
+ }
126
140
  export function loadEnvFile(path) {
127
141
  const contents = readTextFileSync(path);
128
142
  for (const line of contents.split(/\r?\n/)) {
@@ -200,6 +214,29 @@ export async function writeRepoConfig(repo, patch) {
200
214
  repos[repo] = Object.fromEntries(Object.entries({ ...repos[repo], ...patch }).filter(([, value]) => value !== undefined));
201
215
  await writeConfig({ ...config, repos });
202
216
  }
217
+ /** Drops `repos[repo]` from config.json. `clear` uses this so the repository
218
+ * goes back to "never inited": the next run resolves its flags again. Returns
219
+ * whether there was an entry to drop. */
220
+ export async function removeRepoConfig(repo) {
221
+ const config = readConfig();
222
+ if (!config.repos || !(repo in config.repos))
223
+ return false;
224
+ const repos = { ...config.repos };
225
+ delete repos[repo];
226
+ await writeConfig({
227
+ ...config,
228
+ repos: Object.keys(repos).length > 0 ? repos : undefined,
229
+ });
230
+ return true;
231
+ }
232
+ /** Drops every per-repo entry. `clear all` forgets every repository it knows. */
233
+ export async function removeAllRepoConfig() {
234
+ const config = readConfig();
235
+ if (!config.repos || Object.keys(config.repos).length === 0)
236
+ return false;
237
+ await writeConfig({ ...config, repos: undefined });
238
+ return true;
239
+ }
203
240
  /** Merges `patch` into the top-level (global, cross-repo) config —
204
241
  * used by `co-maintainer set`. This is the only path allowed to persist
205
242
  * a token. */
@@ -1,4 +1,4 @@
1
- import { getCacheDir } from "../config.js";
1
+ import { locksDir } from "../config.js";
2
2
  import { ReviewCliError } from "./git_ops.js";
3
3
  import { isNotFound, isProcessAlive, mkdir, readTextFile, remove, removeSync, writeTextFile, } from "../util/runtime.js";
4
4
  async function lockPath(gitRoot) {
@@ -7,7 +7,7 @@ async function lockPath(gitRoot) {
7
7
  .map((b) => b.toString(16).padStart(2, "0"))
8
8
  .join("")
9
9
  .slice(0, 16);
10
- const dir = `${getCacheDir()}/co-maintainer/locks`;
10
+ const dir = locksDir();
11
11
  await mkdir(dir, { recursive: true });
12
12
  return `${dir}/${hex}.lock`;
13
13
  }
@@ -0,0 +1,7 @@
1
+ /** `undefined` means the clear may go ahead. A 409 carries enough detail for
2
+ * the page to ask the right question: whether to abort reviews and queued
3
+ * jobs, or to wait out a sync. */
4
+ export declare function negotiateJobs(repos: string[], abort: boolean): Response | undefined;
5
+ export declare function handleKnowledgeRoute(request: Request, url: URL): Promise<Response>;
6
+ /** Shared by the per-repo route, which knows exactly one repository. */
7
+ export declare function clearOneRepo(fullName: string, url: URL): Promise<Response>;
@@ -0,0 +1,68 @@
1
+ /** The dashboard's clear routes.
2
+ *
3
+ * `DELETE /api/repos/:owner/:repo/knowledge` forgets one repository,
4
+ * `DELETE /api/knowledge` forgets every stored repository. Both share the job
5
+ * negotiation here: a queued job can be canceled and a running review can be
6
+ * aborted (`?onRunning=abort`), but a running `init`/`remake` cannot be
7
+ * stopped, so the route answers 409 and the page waits for it instead. */
8
+ import { errorResponse } from "../errors.js";
9
+ import { abortRunningReviews, cancelQueuedJobs, clearRepos, clearRepo, listStoredRepos, repoJobs, } from "../../services/clear.js";
10
+ function readClearParams(url) {
11
+ return {
12
+ includeCache: url.searchParams.get("includeCache") === "1",
13
+ abort: url.searchParams.get("onRunning") === "abort",
14
+ };
15
+ }
16
+ /** A running setup job ignores the abort signal and would write the guides
17
+ * again after the clear, so it is the one thing the caller cannot skip. */
18
+ function isSetupType(type) {
19
+ return type !== "review" && type !== "remote_review";
20
+ }
21
+ /** `undefined` means the clear may go ahead. A 409 carries enough detail for
22
+ * the page to ask the right question: whether to abort reviews and queued
23
+ * jobs, or to wait out a sync. */
24
+ export function negotiateJobs(repos, abort) {
25
+ const jobs = repoJobs(repos);
26
+ if (jobs.queued.length === 0 && jobs.running.length === 0)
27
+ return undefined;
28
+ if (!abort) {
29
+ return errorResponse(409, "jobs_running", "Jobs are pending for this target.", {
30
+ queued: jobs.queued.length,
31
+ running: jobs.running.map((job) => ({
32
+ id: job.id,
33
+ type: job.type,
34
+ abortable: !isSetupType(job.type),
35
+ })),
36
+ });
37
+ }
38
+ const setup = jobs.running.find((job) => isSetupType(job.type));
39
+ if (setup) {
40
+ return errorResponse(409, "job_not_abortable", "A sync is running and cannot be stopped.", { id: setup.id, type: setup.type });
41
+ }
42
+ cancelQueuedJobs(repos);
43
+ abortRunningReviews(repos);
44
+ return undefined;
45
+ }
46
+ export async function handleKnowledgeRoute(request, url) {
47
+ if (url.pathname !== "/api/knowledge" || request.method !== "DELETE") {
48
+ return errorResponse(404, "not_found", `no route for ${url.pathname}`);
49
+ }
50
+ const { includeCache, abort } = readClearParams(url);
51
+ // The same snapshot is negotiated and cleared, so a repository added in
52
+ // between cannot slip past the job check.
53
+ const repos = await listStoredRepos();
54
+ const conflict = negotiateJobs(repos, abort);
55
+ if (conflict)
56
+ return conflict;
57
+ const result = await clearRepos(repos, { includeCache });
58
+ return Response.json({ ok: true, ...result });
59
+ }
60
+ /** Shared by the per-repo route, which knows exactly one repository. */
61
+ export async function clearOneRepo(fullName, url) {
62
+ const { includeCache, abort } = readClearParams(url);
63
+ const conflict = negotiateJobs([fullName], abort);
64
+ if (conflict)
65
+ return conflict;
66
+ const result = await clearRepo(fullName, { includeCache });
67
+ return Response.json({ ok: true, ...result });
68
+ }
@@ -10,6 +10,7 @@ import { probePlan, recommendationPatch } from "../../services/probe.js";
10
10
  import { estimateInit, readJobHistory } from "../../ai/estimate.js";
11
11
  import { loadPrices } from "../../ai/pricing.js";
12
12
  import { prDetail, repoKnowledge, RepoNotFound, repoOverview, repoPulls, requireActiveRepo, } from "../../services/dashboard.js";
13
+ import { clearOneRepo } from "./knowledge.js";
13
14
  const REPO = /^\/api\/repos\/([^/]+)\/([^/]+)(?:\/(remake|knowledge|pulls)(?:\/(\d+)(?:\/(review))?)?)?$/;
14
15
  export async function handleReposRoute(request, url, githubApp) {
15
16
  if (url.pathname === "/api/repos" && request.method === "GET") {
@@ -264,6 +265,10 @@ export async function handleReposRoute(request, url, githubApp) {
264
265
  if (sub === "knowledge" && request.method === "GET") {
265
266
  return Response.json(await repoKnowledge(fullName));
266
267
  }
268
+ if (sub === "knowledge" && request.method === "DELETE") {
269
+ requireActiveRepo(fullName);
270
+ return await clearOneRepo(fullName, url);
271
+ }
267
272
  if (sub === "pulls" &&
268
273
  pr &&
269
274
  action === "review" &&
@@ -9,6 +9,7 @@ import { aiConfigured, readConfig, resolveAppPrivateKey } from "../config.js";
9
9
  import { listJobs } from "../store/jobs.js";
10
10
  import { handleJobsRoute } from "./api/jobs.js";
11
11
  import { handleReposRoute } from "./api/repos.js";
12
+ import { handleKnowledgeRoute } from "./api/knowledge.js";
12
13
  import { handleInstallationsRoute } from "./api/installations.js";
13
14
  import { handleSettingsRoute } from "./api/settings.js";
14
15
  import { handleActivityRoute } from "./api/activity.js";
@@ -154,6 +155,9 @@ export function createApp(deps) {
154
155
  if (url.pathname.startsWith("/api/repos")) {
155
156
  return await handleReposRoute(request, url, githubApp);
156
157
  }
158
+ if (url.pathname === "/api/knowledge") {
159
+ return await handleKnowledgeRoute(request, url);
160
+ }
157
161
  if (url.pathname === "/api/installations") {
158
162
  return await handleInstallationsRoute(request, githubApp);
159
163
  }
@@ -1,2 +1,2 @@
1
1
  /** Generated from src/server/pages/client.js by scripts/embed_assets.mjs — do not edit. */
2
- export declare const client = "function toast(message) {\n const host = document.getElementById(\"toasts\");\n if (!host) return;\n const el = document.createElement(\"div\");\n el.className = \"toast\";\n el.setAttribute(\"role\", \"status\");\n el.textContent = message;\n host.appendChild(el);\n setTimeout(function () {\n el.remove();\n }, 5000);\n}\n\nfunction fail(card, message, retry) {\n toast(message);\n if (!card) return;\n let box = card.querySelector(\"[data-fail]\");\n if (!box) {\n box = document.createElement(\"div\");\n box.setAttribute(\"data-fail\", \"1\");\n box.className = \"fail\";\n card.appendChild(box);\n }\n box.replaceChildren();\n const p = document.createElement(\"p\");\n p.textContent = message;\n const btn = document.createElement(\"button\");\n btn.className = \"btn sm\";\n btn.type = \"button\";\n btn.textContent = \"Retry\";\n btn.addEventListener(\"click\", retry);\n box.appendChild(p);\n box.appendChild(btn);\n}\n\nfunction clearFail(card) {\n const box = card && card.querySelector(\"[data-fail]\");\n if (box) box.remove();\n}\n\nfunction skeleton(card, on) {\n if (!card) return;\n card.classList.toggle(\"busy\", on);\n}\n\nasync function api(method, url, body) {\n const res = await fetch(url, {\n method: method,\n headers: {\n \"content-type\": \"application/json\",\n \"x-requested-with\": \"co-maintainer\",\n },\n body: body === undefined ? undefined : JSON.stringify(body),\n });\n if (!res.ok) {\n let message = \"Request failed\";\n try {\n const data = await res.json();\n if (data.error && data.error.message) message = data.error.message;\n } catch {\n // body was not JSON; keep the generic message\n }\n throw new Error(message);\n }\n if (res.status === 204) return null;\n return await res.json();\n}\n\nasync function run(btn, card, fn) {\n if (btn) btn.disabled = true;\n skeleton(card, true);\n clearFail(card);\n try {\n await fn();\n } catch (err) {\n fail(card, err.message, function () {\n run(btn, card, fn);\n });\n } finally {\n if (btn) btn.disabled = false;\n skeleton(card, false);\n }\n}\n\nasync function postAndGo(url, body, next, btn) {\n const card = btn && btn.closest(\"[data-async]\");\n await run(btn, card, async function () {\n await api(\"POST\", url, body);\n location.href = next;\n });\n}\n\n// Called from the inline scripts on the repo list and repo pages, which deno lint\n// cannot see, so it looks unused from inside this file.\n// deno-lint-ignore no-unused-vars\nfunction bindToggle(btn, url, field) {\n btn.addEventListener(\"click\", async function () {\n if (btn.disabled) return;\n const on = !btn.classList.contains(\"on\");\n btn.classList.toggle(\"on\", on);\n btn.setAttribute(\"data-on\", on ? \"1\" : \"0\");\n btn.disabled = true;\n const card = btn.closest(\"[data-async]\");\n try {\n const payload = {};\n payload[field] = on;\n await api(\"PATCH\", url, payload);\n } catch (err) {\n btn.classList.toggle(\"on\", !on);\n btn.setAttribute(\"data-on\", on ? \"0\" : \"1\");\n fail(card, err.message, function () {\n btn.click();\n });\n } finally {\n btn.disabled = false;\n }\n });\n}\n\n// The helper is inlined into `<head>` by `layout.ts`, before any page script\n// runs, so this is the one place that can close over both `toast()` and\n// `bindToggle` without depending on point-in-time globals.\ndocument.addEventListener(\"DOMContentLoaded\", function () {\n // Client errors bubble to `window` from `document`.\n addEventListener(\"error\", function (ev) {\n toast((ev.error && ev.error.message) || \"Something broke\");\n });\n addEventListener(\"unhandledrejection\", function (ev) {\n toast((ev.reason && ev.reason.message) || \"Something broke\");\n });\n\n document.addEventListener(\"click\", function (ev) {\n const btn =\n ev.target && ev.target.closest\n ? ev.target.closest(\"[data-cancel], [data-post]\")\n : null;\n if (!btn) return;\n ev.preventDefault();\n if (btn.hasAttribute(\"data-cancel\")) {\n postAndGo(\n \"/api/jobs/\" + btn.getAttribute(\"data-cancel\") + \"/cancel\",\n {},\n \"/activity\",\n btn,\n );\n return;\n }\n const body = btn.getAttribute(\"data-body\");\n postAndGo(\n btn.getAttribute(\"data-post\"),\n body ? JSON.parse(body) : {},\n \"/activity\",\n btn,\n );\n });\n\n // Reads `activity-root` at call time, so it has to wait for the body even\n // though this file itself is already parsed.\n if (!document.getElementById(\"activity-root\")) return;\n setInterval(async function () {\n if (document.hidden) return;\n if (document.querySelector(\"[data-async].busy\")) return;\n const root = document.getElementById(\"activity-root\");\n if (!root) return;\n try {\n const res = await fetch(location.pathname + location.search, {\n headers: { accept: \"text/html\" },\n });\n if (!res.ok) return;\n const doc = new DOMParser().parseFromString(\n await res.text(),\n \"text/html\",\n );\n const next = doc.getElementById(\"activity-root\");\n if (!next || next.innerHTML === root.innerHTML) return;\n root.replaceWith(next);\n } catch {\n // a failed poll just waits for the next tick\n }\n }, 5000);\n});\n";
2
+ export declare const client = "function toast(message) {\n const host = document.getElementById(\"toasts\");\n if (!host) return;\n const el = document.createElement(\"div\");\n el.className = \"toast\";\n el.setAttribute(\"role\", \"status\");\n el.textContent = message;\n host.appendChild(el);\n setTimeout(function () {\n el.remove();\n }, 5000);\n}\n\nfunction fail(card, message, retry) {\n toast(message);\n if (!card) return;\n let box = card.querySelector(\"[data-fail]\");\n if (!box) {\n box = document.createElement(\"div\");\n box.setAttribute(\"data-fail\", \"1\");\n box.className = \"fail\";\n card.appendChild(box);\n }\n box.replaceChildren();\n const p = document.createElement(\"p\");\n p.textContent = message;\n const btn = document.createElement(\"button\");\n btn.className = \"btn sm\";\n btn.type = \"button\";\n btn.textContent = \"Retry\";\n btn.addEventListener(\"click\", retry);\n box.appendChild(p);\n box.appendChild(btn);\n}\n\nfunction clearFail(card) {\n const box = card && card.querySelector(\"[data-fail]\");\n if (box) box.remove();\n}\n\nfunction skeleton(card, on) {\n if (!card) return;\n card.classList.toggle(\"busy\", on);\n}\n\nasync function api(method, url, body) {\n const res = await fetch(url, {\n method: method,\n headers: {\n \"content-type\": \"application/json\",\n \"x-requested-with\": \"co-maintainer\",\n },\n body: body === undefined ? undefined : JSON.stringify(body),\n });\n if (!res.ok) {\n let message = \"Request failed\";\n let code = \"\";\n let detail;\n try {\n const data = await res.json();\n if (data.error && data.error.message) message = data.error.message;\n if (data.error && data.error.code) code = data.error.code;\n if (data.error) detail = data.error.detail;\n } catch {\n // body was not JSON; keep the generic message\n }\n const error = new Error(message);\n if (code) error.code = code;\n error.detail = detail;\n throw error;\n }\n if (res.status === 204) return null;\n return await res.json();\n}\n\nasync function run(btn, card, fn) {\n if (btn) btn.disabled = true;\n skeleton(card, true);\n clearFail(card);\n try {\n await fn();\n } catch (err) {\n fail(card, err.message, function () {\n run(btn, card, fn);\n });\n } finally {\n if (btn) btn.disabled = false;\n skeleton(card, false);\n }\n}\n\n/** Polls one job until it reaches a terminal status. A running `init`/`remake`\n * ignores the abort signal, so \"wait for the sync\" is the only honest option\n * the clear pages can offer for it. */\nfunction waitForJob(id) {\n return new Promise(function (resolve, reject) {\n let attempts = 0;\n async function tick() {\n attempts++;\n try {\n const res = await fetch(\"/api/jobs/\" + encodeURIComponent(id), {\n headers: { \"x-requested-with\": \"co-maintainer\" },\n });\n if (!res.ok) throw new Error(\"Could not check the running job.\");\n const job = await res.json();\n if (job.status !== \"running\" && job.status !== \"queued\") {\n resolve();\n return;\n }\n if (attempts > 1200) {\n reject(new Error(\"The sync did not finish.\"));\n return;\n }\n setTimeout(tick, 3000);\n } catch (err) {\n reject(err);\n }\n }\n tick();\n });\n}\n\n/** Clears knowledge, asking about pending jobs when the server refuses.\n * Returns false when the user backed out, so the caller does not reload.\n * A running review is abortable and a running setup is not, which is the\n * difference between the two questions. */\nasync function clearKnowledge(url, includeCache) {\n const target = includeCache ? url + \"?includeCache=1\" : url;\n const retry = target.indexOf(\"?\") >= 0 ? \"&\" : \"?\";\n try {\n await api(\"DELETE\", target);\n } catch (err) {\n if (err.code !== \"jobs_running\") throw err;\n const detail = err.detail || { queued: 0, running: [] };\n const running = detail.running || [];\n const setup = running.find(function (job) {\n return job.abortable === false;\n });\n if (setup) {\n if (\n !confirm(\n \"A sync is running and cannot be stopped. Wait for it to finish and then clear?\",\n )\n ) {\n return false;\n }\n await waitForJob(setup.id);\n await api(\"DELETE\", target);\n return;\n }\n const parts = [];\n if (running.length) parts.push(running.length + \" running review(s)\");\n if (detail.queued) parts.push(detail.queued + \" queued job(s)\");\n if (!confirm(\"Stop \" + parts.join(\" and \") + \" and clear?\")) return false;\n try {\n await api(\"DELETE\", target + retry + \"onRunning=abort\");\n } catch (retryError) {\n // A setup job was claimed between the first 409 and this retry. It\n // cannot be aborted, so wait it out like the branch above.\n if (retryError.code !== \"job_not_abortable\") throw retryError;\n const jobId = retryError.detail && retryError.detail.id;\n if (jobId) await waitForJob(jobId);\n await api(\"DELETE\", target);\n }\n }\n}\n\nasync function postAndGo(url, body, next, btn) {\n const card = btn && btn.closest(\"[data-async]\");\n await run(btn, card, async function () {\n await api(\"POST\", url, body);\n location.href = next;\n });\n}\n\n// Called from the inline scripts on the repo list and repo pages, which deno lint\n// cannot see, so it looks unused from inside this file.\n// deno-lint-ignore no-unused-vars\nfunction bindToggle(btn, url, field) {\n btn.addEventListener(\"click\", async function () {\n if (btn.disabled) return;\n const on = !btn.classList.contains(\"on\");\n btn.classList.toggle(\"on\", on);\n btn.setAttribute(\"data-on\", on ? \"1\" : \"0\");\n btn.disabled = true;\n const card = btn.closest(\"[data-async]\");\n try {\n const payload = {};\n payload[field] = on;\n await api(\"PATCH\", url, payload);\n } catch (err) {\n btn.classList.toggle(\"on\", !on);\n btn.setAttribute(\"data-on\", on ? \"0\" : \"1\");\n fail(card, err.message, function () {\n btn.click();\n });\n } finally {\n btn.disabled = false;\n }\n });\n}\n\n// The helper is inlined into `<head>` by `layout.ts`, before any page script\n// runs, so this is the one place that can close over both `toast()` and\n// `bindToggle` without depending on point-in-time globals.\ndocument.addEventListener(\"DOMContentLoaded\", function () {\n // Client errors bubble to `window` from `document`.\n addEventListener(\"error\", function (ev) {\n toast((ev.error && ev.error.message) || \"Something broke\");\n });\n addEventListener(\"unhandledrejection\", function (ev) {\n toast((ev.reason && ev.reason.message) || \"Something broke\");\n });\n\n document.addEventListener(\"click\", function (ev) {\n const btn =\n ev.target && ev.target.closest\n ? ev.target.closest(\"[data-cancel], [data-post]\")\n : null;\n if (!btn) return;\n ev.preventDefault();\n if (btn.hasAttribute(\"data-cancel\")) {\n postAndGo(\n \"/api/jobs/\" + btn.getAttribute(\"data-cancel\") + \"/cancel\",\n {},\n \"/activity\",\n btn,\n );\n return;\n }\n const body = btn.getAttribute(\"data-body\");\n postAndGo(\n btn.getAttribute(\"data-post\"),\n body ? JSON.parse(body) : {},\n \"/activity\",\n btn,\n );\n });\n\n // Reads `activity-root` at call time, so it has to wait for the body even\n // though this file itself is already parsed.\n if (!document.getElementById(\"activity-root\")) return;\n setInterval(async function () {\n if (document.hidden) return;\n if (document.querySelector(\"[data-async].busy\")) return;\n const root = document.getElementById(\"activity-root\");\n if (!root) return;\n try {\n const res = await fetch(location.pathname + location.search, {\n headers: { accept: \"text/html\" },\n });\n if (!res.ok) return;\n const doc = new DOMParser().parseFromString(\n await res.text(),\n \"text/html\",\n );\n const next = doc.getElementById(\"activity-root\");\n if (!next || next.innerHTML === root.innerHTML) return;\n root.replaceWith(next);\n } catch {\n // a failed poll just waits for the next tick\n }\n }, 5000);\n});\n";
@@ -1,2 +1,2 @@
1
1
  /** Generated from src/server/pages/client.js by scripts/embed_assets.mjs — do not edit. */
2
- export const client = "function toast(message) {\n const host = document.getElementById(\"toasts\");\n if (!host) return;\n const el = document.createElement(\"div\");\n el.className = \"toast\";\n el.setAttribute(\"role\", \"status\");\n el.textContent = message;\n host.appendChild(el);\n setTimeout(function () {\n el.remove();\n }, 5000);\n}\n\nfunction fail(card, message, retry) {\n toast(message);\n if (!card) return;\n let box = card.querySelector(\"[data-fail]\");\n if (!box) {\n box = document.createElement(\"div\");\n box.setAttribute(\"data-fail\", \"1\");\n box.className = \"fail\";\n card.appendChild(box);\n }\n box.replaceChildren();\n const p = document.createElement(\"p\");\n p.textContent = message;\n const btn = document.createElement(\"button\");\n btn.className = \"btn sm\";\n btn.type = \"button\";\n btn.textContent = \"Retry\";\n btn.addEventListener(\"click\", retry);\n box.appendChild(p);\n box.appendChild(btn);\n}\n\nfunction clearFail(card) {\n const box = card && card.querySelector(\"[data-fail]\");\n if (box) box.remove();\n}\n\nfunction skeleton(card, on) {\n if (!card) return;\n card.classList.toggle(\"busy\", on);\n}\n\nasync function api(method, url, body) {\n const res = await fetch(url, {\n method: method,\n headers: {\n \"content-type\": \"application/json\",\n \"x-requested-with\": \"co-maintainer\",\n },\n body: body === undefined ? undefined : JSON.stringify(body),\n });\n if (!res.ok) {\n let message = \"Request failed\";\n try {\n const data = await res.json();\n if (data.error && data.error.message) message = data.error.message;\n } catch {\n // body was not JSON; keep the generic message\n }\n throw new Error(message);\n }\n if (res.status === 204) return null;\n return await res.json();\n}\n\nasync function run(btn, card, fn) {\n if (btn) btn.disabled = true;\n skeleton(card, true);\n clearFail(card);\n try {\n await fn();\n } catch (err) {\n fail(card, err.message, function () {\n run(btn, card, fn);\n });\n } finally {\n if (btn) btn.disabled = false;\n skeleton(card, false);\n }\n}\n\nasync function postAndGo(url, body, next, btn) {\n const card = btn && btn.closest(\"[data-async]\");\n await run(btn, card, async function () {\n await api(\"POST\", url, body);\n location.href = next;\n });\n}\n\n// Called from the inline scripts on the repo list and repo pages, which deno lint\n// cannot see, so it looks unused from inside this file.\n// deno-lint-ignore no-unused-vars\nfunction bindToggle(btn, url, field) {\n btn.addEventListener(\"click\", async function () {\n if (btn.disabled) return;\n const on = !btn.classList.contains(\"on\");\n btn.classList.toggle(\"on\", on);\n btn.setAttribute(\"data-on\", on ? \"1\" : \"0\");\n btn.disabled = true;\n const card = btn.closest(\"[data-async]\");\n try {\n const payload = {};\n payload[field] = on;\n await api(\"PATCH\", url, payload);\n } catch (err) {\n btn.classList.toggle(\"on\", !on);\n btn.setAttribute(\"data-on\", on ? \"0\" : \"1\");\n fail(card, err.message, function () {\n btn.click();\n });\n } finally {\n btn.disabled = false;\n }\n });\n}\n\n// The helper is inlined into `<head>` by `layout.ts`, before any page script\n// runs, so this is the one place that can close over both `toast()` and\n// `bindToggle` without depending on point-in-time globals.\ndocument.addEventListener(\"DOMContentLoaded\", function () {\n // Client errors bubble to `window` from `document`.\n addEventListener(\"error\", function (ev) {\n toast((ev.error && ev.error.message) || \"Something broke\");\n });\n addEventListener(\"unhandledrejection\", function (ev) {\n toast((ev.reason && ev.reason.message) || \"Something broke\");\n });\n\n document.addEventListener(\"click\", function (ev) {\n const btn =\n ev.target && ev.target.closest\n ? ev.target.closest(\"[data-cancel], [data-post]\")\n : null;\n if (!btn) return;\n ev.preventDefault();\n if (btn.hasAttribute(\"data-cancel\")) {\n postAndGo(\n \"/api/jobs/\" + btn.getAttribute(\"data-cancel\") + \"/cancel\",\n {},\n \"/activity\",\n btn,\n );\n return;\n }\n const body = btn.getAttribute(\"data-body\");\n postAndGo(\n btn.getAttribute(\"data-post\"),\n body ? JSON.parse(body) : {},\n \"/activity\",\n btn,\n );\n });\n\n // Reads `activity-root` at call time, so it has to wait for the body even\n // though this file itself is already parsed.\n if (!document.getElementById(\"activity-root\")) return;\n setInterval(async function () {\n if (document.hidden) return;\n if (document.querySelector(\"[data-async].busy\")) return;\n const root = document.getElementById(\"activity-root\");\n if (!root) return;\n try {\n const res = await fetch(location.pathname + location.search, {\n headers: { accept: \"text/html\" },\n });\n if (!res.ok) return;\n const doc = new DOMParser().parseFromString(\n await res.text(),\n \"text/html\",\n );\n const next = doc.getElementById(\"activity-root\");\n if (!next || next.innerHTML === root.innerHTML) return;\n root.replaceWith(next);\n } catch {\n // a failed poll just waits for the next tick\n }\n }, 5000);\n});\n";
2
+ export const client = "function toast(message) {\n const host = document.getElementById(\"toasts\");\n if (!host) return;\n const el = document.createElement(\"div\");\n el.className = \"toast\";\n el.setAttribute(\"role\", \"status\");\n el.textContent = message;\n host.appendChild(el);\n setTimeout(function () {\n el.remove();\n }, 5000);\n}\n\nfunction fail(card, message, retry) {\n toast(message);\n if (!card) return;\n let box = card.querySelector(\"[data-fail]\");\n if (!box) {\n box = document.createElement(\"div\");\n box.setAttribute(\"data-fail\", \"1\");\n box.className = \"fail\";\n card.appendChild(box);\n }\n box.replaceChildren();\n const p = document.createElement(\"p\");\n p.textContent = message;\n const btn = document.createElement(\"button\");\n btn.className = \"btn sm\";\n btn.type = \"button\";\n btn.textContent = \"Retry\";\n btn.addEventListener(\"click\", retry);\n box.appendChild(p);\n box.appendChild(btn);\n}\n\nfunction clearFail(card) {\n const box = card && card.querySelector(\"[data-fail]\");\n if (box) box.remove();\n}\n\nfunction skeleton(card, on) {\n if (!card) return;\n card.classList.toggle(\"busy\", on);\n}\n\nasync function api(method, url, body) {\n const res = await fetch(url, {\n method: method,\n headers: {\n \"content-type\": \"application/json\",\n \"x-requested-with\": \"co-maintainer\",\n },\n body: body === undefined ? undefined : JSON.stringify(body),\n });\n if (!res.ok) {\n let message = \"Request failed\";\n let code = \"\";\n let detail;\n try {\n const data = await res.json();\n if (data.error && data.error.message) message = data.error.message;\n if (data.error && data.error.code) code = data.error.code;\n if (data.error) detail = data.error.detail;\n } catch {\n // body was not JSON; keep the generic message\n }\n const error = new Error(message);\n if (code) error.code = code;\n error.detail = detail;\n throw error;\n }\n if (res.status === 204) return null;\n return await res.json();\n}\n\nasync function run(btn, card, fn) {\n if (btn) btn.disabled = true;\n skeleton(card, true);\n clearFail(card);\n try {\n await fn();\n } catch (err) {\n fail(card, err.message, function () {\n run(btn, card, fn);\n });\n } finally {\n if (btn) btn.disabled = false;\n skeleton(card, false);\n }\n}\n\n/** Polls one job until it reaches a terminal status. A running `init`/`remake`\n * ignores the abort signal, so \"wait for the sync\" is the only honest option\n * the clear pages can offer for it. */\nfunction waitForJob(id) {\n return new Promise(function (resolve, reject) {\n let attempts = 0;\n async function tick() {\n attempts++;\n try {\n const res = await fetch(\"/api/jobs/\" + encodeURIComponent(id), {\n headers: { \"x-requested-with\": \"co-maintainer\" },\n });\n if (!res.ok) throw new Error(\"Could not check the running job.\");\n const job = await res.json();\n if (job.status !== \"running\" && job.status !== \"queued\") {\n resolve();\n return;\n }\n if (attempts > 1200) {\n reject(new Error(\"The sync did not finish.\"));\n return;\n }\n setTimeout(tick, 3000);\n } catch (err) {\n reject(err);\n }\n }\n tick();\n });\n}\n\n/** Clears knowledge, asking about pending jobs when the server refuses.\n * Returns false when the user backed out, so the caller does not reload.\n * A running review is abortable and a running setup is not, which is the\n * difference between the two questions. */\nasync function clearKnowledge(url, includeCache) {\n const target = includeCache ? url + \"?includeCache=1\" : url;\n const retry = target.indexOf(\"?\") >= 0 ? \"&\" : \"?\";\n try {\n await api(\"DELETE\", target);\n } catch (err) {\n if (err.code !== \"jobs_running\") throw err;\n const detail = err.detail || { queued: 0, running: [] };\n const running = detail.running || [];\n const setup = running.find(function (job) {\n return job.abortable === false;\n });\n if (setup) {\n if (\n !confirm(\n \"A sync is running and cannot be stopped. Wait for it to finish and then clear?\",\n )\n ) {\n return false;\n }\n await waitForJob(setup.id);\n await api(\"DELETE\", target);\n return;\n }\n const parts = [];\n if (running.length) parts.push(running.length + \" running review(s)\");\n if (detail.queued) parts.push(detail.queued + \" queued job(s)\");\n if (!confirm(\"Stop \" + parts.join(\" and \") + \" and clear?\")) return false;\n try {\n await api(\"DELETE\", target + retry + \"onRunning=abort\");\n } catch (retryError) {\n // A setup job was claimed between the first 409 and this retry. It\n // cannot be aborted, so wait it out like the branch above.\n if (retryError.code !== \"job_not_abortable\") throw retryError;\n const jobId = retryError.detail && retryError.detail.id;\n if (jobId) await waitForJob(jobId);\n await api(\"DELETE\", target);\n }\n }\n}\n\nasync function postAndGo(url, body, next, btn) {\n const card = btn && btn.closest(\"[data-async]\");\n await run(btn, card, async function () {\n await api(\"POST\", url, body);\n location.href = next;\n });\n}\n\n// Called from the inline scripts on the repo list and repo pages, which deno lint\n// cannot see, so it looks unused from inside this file.\n// deno-lint-ignore no-unused-vars\nfunction bindToggle(btn, url, field) {\n btn.addEventListener(\"click\", async function () {\n if (btn.disabled) return;\n const on = !btn.classList.contains(\"on\");\n btn.classList.toggle(\"on\", on);\n btn.setAttribute(\"data-on\", on ? \"1\" : \"0\");\n btn.disabled = true;\n const card = btn.closest(\"[data-async]\");\n try {\n const payload = {};\n payload[field] = on;\n await api(\"PATCH\", url, payload);\n } catch (err) {\n btn.classList.toggle(\"on\", !on);\n btn.setAttribute(\"data-on\", on ? \"0\" : \"1\");\n fail(card, err.message, function () {\n btn.click();\n });\n } finally {\n btn.disabled = false;\n }\n });\n}\n\n// The helper is inlined into `<head>` by `layout.ts`, before any page script\n// runs, so this is the one place that can close over both `toast()` and\n// `bindToggle` without depending on point-in-time globals.\ndocument.addEventListener(\"DOMContentLoaded\", function () {\n // Client errors bubble to `window` from `document`.\n addEventListener(\"error\", function (ev) {\n toast((ev.error && ev.error.message) || \"Something broke\");\n });\n addEventListener(\"unhandledrejection\", function (ev) {\n toast((ev.reason && ev.reason.message) || \"Something broke\");\n });\n\n document.addEventListener(\"click\", function (ev) {\n const btn =\n ev.target && ev.target.closest\n ? ev.target.closest(\"[data-cancel], [data-post]\")\n : null;\n if (!btn) return;\n ev.preventDefault();\n if (btn.hasAttribute(\"data-cancel\")) {\n postAndGo(\n \"/api/jobs/\" + btn.getAttribute(\"data-cancel\") + \"/cancel\",\n {},\n \"/activity\",\n btn,\n );\n return;\n }\n const body = btn.getAttribute(\"data-body\");\n postAndGo(\n btn.getAttribute(\"data-post\"),\n body ? JSON.parse(body) : {},\n \"/activity\",\n btn,\n );\n });\n\n // Reads `activity-root` at call time, so it has to wait for the body even\n // though this file itself is already parsed.\n if (!document.getElementById(\"activity-root\")) return;\n setInterval(async function () {\n if (document.hidden) return;\n if (document.querySelector(\"[data-async].busy\")) return;\n const root = document.getElementById(\"activity-root\");\n if (!root) return;\n try {\n const res = await fetch(location.pathname + location.search, {\n headers: { accept: \"text/html\" },\n });\n if (!res.ok) return;\n const doc = new DOMParser().parseFromString(\n await res.text(),\n \"text/html\",\n );\n const next = doc.getElementById(\"activity-root\");\n if (!next || next.innerHTML === root.innerHTML) return;\n root.replaceWith(next);\n } catch {\n // a failed poll just waits for the next tick\n }\n }, 5000);\n});\n";