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 +2 -4
- package/dist/src/cli/commands/clear.d.ts +1 -0
- package/dist/src/cli/commands/clear.js +152 -0
- package/dist/src/cli/commands/registry.d.ts +4 -0
- package/dist/src/cli/commands/registry.js +48 -0
- package/dist/src/cli/commands/uninstall.d.ts +1 -0
- package/dist/src/cli/commands/uninstall.js +43 -0
- package/dist/src/cli/main.js +10 -0
- package/dist/src/config.d.ts +14 -0
- package/dist/src/config.js +38 -1
- package/dist/src/local/review_lock.js +2 -2
- package/dist/src/server/api/knowledge.d.ts +7 -0
- package/dist/src/server/api/knowledge.js +68 -0
- package/dist/src/server/api/repos.js +5 -0
- package/dist/src/server/app.js +4 -0
- package/dist/src/server/pages/client.d.ts +1 -1
- package/dist/src/server/pages/client.js +1 -1
- package/dist/src/server/pages/knowledge.js +20 -1
- package/dist/src/server/pages/settings.js +18 -0
- package/dist/src/services/clear.d.ts +70 -0
- package/dist/src/services/clear.js +310 -0
- package/dist/src/store/app_db.d.ts +3 -0
- package/dist/src/store/app_db.js +5 -0
- package/dist/src/store/cache_db.d.ts +11 -0
- package/dist/src/store/cache_db.js +60 -1
- package/dist/src/store/repos.d.ts +3 -0
- package/dist/src/store/repos.js +8 -0
- package/dist/src/util/runtime.d.ts +3 -0
- package/dist/src/util/runtime.js +6 -1
- package/package.json +2 -4
|
@@ -13,7 +13,7 @@ export function renderKnowledge(username, data, viewId) {
|
|
|
13
13
|
? `<div class="notice"><div class="txt">Built ${when(data.repo.knowledge_built_at)}.${drift
|
|
14
14
|
? ` Since then: <b>${drift.prs_since} new pull requests</b>, <b>${drift.prs_updated} changed pull requests</b>, <b>${capped(drift.commits_since, MAX_COMMITS_COUNTED)} new commits</b> and <b>${capped(drift.files_changed, COMPARE_FILE_CAP)} changed files</b>. <span class="muted">Checked ${when(drift.as_of)}.</span>`
|
|
15
15
|
: ""}</div></div>`
|
|
16
|
-
: `<div class="notice info"><div class="txt">Knowledge has not been built yet.
|
|
16
|
+
: `<div class="notice info"><div class="txt">Knowledge has not been built yet. Use Sync to build it.</div></div>`;
|
|
17
17
|
const viewer = viewing
|
|
18
18
|
? `<div class="card"><div class="hd"><h2>${text(viewing.title)}</h2>
|
|
19
19
|
<a class="btn sm" style="margin-left:auto" href="/repos/${text(name)}/knowledge">Close</a></div>
|
|
@@ -53,12 +53,31 @@ export function renderKnowledge(username, data, viewId) {
|
|
|
53
53
|
<div class="hd"><h2>History</h2></div>
|
|
54
54
|
<div class="bd">${empty("Only the current knowledge is kept", "Older versions are not stored yet.")}</div>
|
|
55
55
|
</div>
|
|
56
|
+
<div class="card" data-async>
|
|
57
|
+
${skSlot()}
|
|
58
|
+
<div class="hd"><h2>Clear</h2></div>
|
|
59
|
+
<div class="bd">
|
|
60
|
+
<p class="muted" style="margin:0 0 16px">Delete the guides and the saved settings for this repository. The next Sync builds them from scratch.</p>
|
|
61
|
+
<div class="field wide"><label><input type="checkbox" id="clear-cache"> Also delete the cached evidence: pull request listings, costs, clones and worktrees</label>
|
|
62
|
+
<div class="hint">Without it the next build still reuses the files and pull requests it already paid for.</div></div>
|
|
63
|
+
</div>
|
|
64
|
+
<div class="ft"><button id="clear" style="color:var(--red)">Clear knowledge</button></div>
|
|
65
|
+
</div>
|
|
56
66
|
</div>
|
|
57
67
|
</div>
|
|
58
68
|
<script>
|
|
59
69
|
document.getElementById("update").addEventListener("click", function() {
|
|
60
70
|
postAndGo("/api/repos/" + ${JSON.stringify(name)} + "/remake", {}, "/activity", this);
|
|
61
71
|
});
|
|
72
|
+
document.getElementById("clear").addEventListener("click", function() {
|
|
73
|
+
if (!confirm("Delete the guides and saved settings for this repository?")) return;
|
|
74
|
+
var includeCache = document.getElementById("clear-cache").checked;
|
|
75
|
+
var btn = this;
|
|
76
|
+
run(btn, btn.closest("[data-async]"), async function() {
|
|
77
|
+
var done = await clearKnowledge(${JSON.stringify(`/api/repos/${name}/knowledge`)}, includeCache);
|
|
78
|
+
if (done !== false) location.reload();
|
|
79
|
+
});
|
|
80
|
+
});
|
|
62
81
|
</script>`,
|
|
63
82
|
}));
|
|
64
83
|
}
|
|
@@ -239,6 +239,15 @@ Leave blank to keep the current key"></textarea></div>
|
|
|
239
239
|
</div>
|
|
240
240
|
<div class="ft"><button class="primary" id="save-password">Change password</button></div>
|
|
241
241
|
</div>
|
|
242
|
+
<div class="card" id="danger" data-async>
|
|
243
|
+
${skSlot()}
|
|
244
|
+
<div class="hd"><h2>Clear all knowledge</h2></div>
|
|
245
|
+
<div class="bd">
|
|
246
|
+
<p class="muted" style="margin:0 0 16px">Delete every repository's guides and saved settings on this server. Repository records, reviews and tokens stay, so this is not an uninstall.</p>
|
|
247
|
+
<div class="field wide"><label><input type="checkbox" id="clear-all-cache"> Also delete the cached evidence: pull request listings, costs, clones and worktrees</label></div>
|
|
248
|
+
</div>
|
|
249
|
+
<div class="ft"><button id="clear-all" style="color:var(--red)">Clear all knowledge</button></div>
|
|
250
|
+
</div>
|
|
242
251
|
<div class="card" id="about">
|
|
243
252
|
<div class="hd"><h2>About</h2></div>
|
|
244
253
|
<div class="bd">
|
|
@@ -501,6 +510,15 @@ document.getElementById("save-def").addEventListener("click", function() {
|
|
|
501
510
|
if (policy !== "__custom") body.reviewPolicy = policy === "" ? null : policy;
|
|
502
511
|
save(this, body);
|
|
503
512
|
});
|
|
513
|
+
document.getElementById("clear-all").addEventListener("click", function() {
|
|
514
|
+
if (!confirm("Delete the guides and saved settings for every repository on this server?")) return;
|
|
515
|
+
var includeCache = document.getElementById("clear-all-cache").checked;
|
|
516
|
+
var btn = this;
|
|
517
|
+
run(btn, btn.closest("[data-async]"), async function() {
|
|
518
|
+
var done = await clearKnowledge("/api/knowledge", includeCache);
|
|
519
|
+
if (done !== false) location.reload();
|
|
520
|
+
});
|
|
521
|
+
});
|
|
504
522
|
</script>`,
|
|
505
523
|
}));
|
|
506
524
|
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { JobRow } from "../store/rows.ts";
|
|
2
|
+
/** Job cancel reasons, so the Activity page can say who asked. */
|
|
3
|
+
export declare const CLEARED_REASON = "knowledge_cleared";
|
|
4
|
+
export type ClearOptions = {
|
|
5
|
+
/** Also drop the fetched evidence (pull request listings, AI job cache,
|
|
6
|
+
* costs, local review carry-over, clones, worktrees). Without it the next
|
|
7
|
+
* init still finds what it paid for and only the knowledge is gone. */
|
|
8
|
+
includeCache?: boolean;
|
|
9
|
+
};
|
|
10
|
+
export type ClearRepoResult = {
|
|
11
|
+
repo: string;
|
|
12
|
+
guides: number;
|
|
13
|
+
cacheRows: number;
|
|
14
|
+
settings: boolean;
|
|
15
|
+
clones: boolean;
|
|
16
|
+
worktrees: boolean;
|
|
17
|
+
};
|
|
18
|
+
export type ClearAllResult = {
|
|
19
|
+
repos: ClearRepoResult[];
|
|
20
|
+
settings: boolean;
|
|
21
|
+
cacheFile: boolean;
|
|
22
|
+
clones: boolean;
|
|
23
|
+
worktrees: boolean;
|
|
24
|
+
};
|
|
25
|
+
export declare function isSafeRepoName(repo: string): boolean;
|
|
26
|
+
/** Opens app.db for the work when it already exists, and never creates one:
|
|
27
|
+
* a repository that was never inited has nothing to clear there. Reuses an
|
|
28
|
+
* open connection (the dashboard) and closes one it opened itself. */
|
|
29
|
+
export declare function withAppDbIfPresent<T>(fn: () => T | Promise<T>): Promise<T | undefined>;
|
|
30
|
+
/** The repositories `clear all` should visit: the guide directories under
|
|
31
|
+
* `reposDir()` plus everything `config.json` still remembers. Names that
|
|
32
|
+
* cannot be joined onto a path are dropped: `config.json` is editable by
|
|
33
|
+
* hand, and a key like `acme/..` would otherwise resolve to the repos root
|
|
34
|
+
* and take every repository with it. */
|
|
35
|
+
export declare function listStoredRepos(): Promise<string[]>;
|
|
36
|
+
/** What queued and running jobs exist for these repositories. Empty when
|
|
37
|
+
* app.db is not open, which is the same as "no jobs to worry about". */
|
|
38
|
+
export declare function repoJobs(repos: string[]): {
|
|
39
|
+
queued: JobRow[];
|
|
40
|
+
running: JobRow[];
|
|
41
|
+
};
|
|
42
|
+
/** Cancels queued jobs so a cleared repository does not rebuild itself from
|
|
43
|
+
* the queue. Running jobs are the caller's problem: reviews can be aborted,
|
|
44
|
+
* a running setup cannot (its fetch loops ignore the signal). */
|
|
45
|
+
export declare function cancelQueuedJobs(repos: string[], reason?: string): number;
|
|
46
|
+
/** Aborts running reviews. They never write guides, so clearing while one
|
|
47
|
+
* runs is safe either way; aborting stops the AI spend too. */
|
|
48
|
+
export declare function abortRunningReviews(repos: string[], reason?: string): number;
|
|
49
|
+
/** Running rows left behind by a crashed `serve`. Only safe when the caller
|
|
50
|
+
* holds the app.db lock, which the CLI guard guarantees. */
|
|
51
|
+
export declare function cancelOrphanedJobs(repos: string[], reason?: string): number;
|
|
52
|
+
/** Forgets one repository: knowledge, guides, cache (when asked) and the
|
|
53
|
+
* settings `init` remembered. */
|
|
54
|
+
export declare function clearRepo(repo: string, options?: ClearOptions): Promise<ClearRepoResult>;
|
|
55
|
+
/** Forgets exactly the repositories it is given. The dashboard route and the
|
|
56
|
+
* CLI pass the same snapshot they negotiated jobs against, so the list cannot
|
|
57
|
+
* move between the check and the delete. The app.db file and its review
|
|
58
|
+
* history stay: only the knowledge is gone. `--include-cache` takes the whole
|
|
59
|
+
* cache database plus every clone and worktree, the same as deleting
|
|
60
|
+
* `cache.db` by hand. */
|
|
61
|
+
export declare function clearRepos(repos: string[], options?: ClearOptions): Promise<ClearAllResult>;
|
|
62
|
+
/** Forgets every repository on this machine, from a fresh snapshot. */
|
|
63
|
+
export declare function clearAllKnowledge(options?: ClearOptions): Promise<ClearAllResult>;
|
|
64
|
+
export type UninstallResult = {
|
|
65
|
+
paths: string[];
|
|
66
|
+
};
|
|
67
|
+
/** Removes every co-maintainer data file. The npm package itself cannot be
|
|
68
|
+
* removed from inside the running CLI, so the command prints how to do that
|
|
69
|
+
* instead. Refuses nothing here; the caller checks for a running `serve`. */
|
|
70
|
+
export declare function uninstallData(): Promise<UninstallResult>;
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
/** Deleting what `init` produced, for the CLI's `clear`/`uninstall` and the
|
|
2
|
+
* dashboard's clear actions.
|
|
3
|
+
*
|
|
4
|
+
* The dashboard and the CLI share this module on purpose: the same data has to
|
|
5
|
+
* disappear whether a command or a button asked for it. The callers keep what
|
|
6
|
+
* differs instead: the CLI owns the `serve` lock guard, its prompts and its
|
|
7
|
+
* output, the dashboard route owns job negotiation before calling in.
|
|
8
|
+
*
|
|
9
|
+
* Deleting is ordered so an interruption never leaves the friendlier state:
|
|
10
|
+
* the app.db knowledge stamp goes first, then the guide files, then the
|
|
11
|
+
* rebuildable cache. An error partway leaves a repository that reads as "not
|
|
12
|
+
* built", which the next `init` fixes. */
|
|
13
|
+
import { cacheDbPath, cloneDir, clonesDir, configPath, getCacheDir, getConfigDir, locksDir, readConfig, removeAllRepoConfig, removeRepoConfig, repoWorktreesDir, reposDir, toolsDir, worktreesDir, } from "../config.js";
|
|
14
|
+
import { withKnowledgeLock } from "../review/guides.js";
|
|
15
|
+
import { appDbLockPath, appDbPath, backupPaths, closeAppDb, isAppDbOpen, openAppDb, } from "../store/app_db.js";
|
|
16
|
+
import { cacheDbExists, cacheDeleteRepoEvidence, cacheDeleteRepoKnowledge, } from "../store/cache_db.js";
|
|
17
|
+
import { clearRepoKnowledge } from "../store/repos.js";
|
|
18
|
+
import { listJobs, setJobStatus } from "../store/jobs.js";
|
|
19
|
+
import { cancel } from "./jobs.js";
|
|
20
|
+
import { isNotFound, readDir, remove, rmdir, stat } from "../util/runtime.js";
|
|
21
|
+
/** Job cancel reasons, so the Activity page can say who asked. */
|
|
22
|
+
export const CLEARED_REASON = "knowledge_cleared";
|
|
23
|
+
/** A repository name that is safe to join onto a directory. Rejects the `..`
|
|
24
|
+
* segment the API's route pattern would otherwise accept. */
|
|
25
|
+
const REPO_NAME = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
|
|
26
|
+
export function isSafeRepoName(repo) {
|
|
27
|
+
if (!REPO_NAME.test(repo))
|
|
28
|
+
return false;
|
|
29
|
+
return repo.split("/").every((part) => part !== "." && part !== "..");
|
|
30
|
+
}
|
|
31
|
+
function assertSafeRepoName(repo) {
|
|
32
|
+
if (!isSafeRepoName(repo)) {
|
|
33
|
+
throw new Error(`Not a repository name: ${repo}`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
async function pathExists(path) {
|
|
37
|
+
try {
|
|
38
|
+
await stat(path);
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
if (isNotFound(error))
|
|
43
|
+
return false;
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
async function removeIfExists(path, options) {
|
|
48
|
+
if (!(await pathExists(path)))
|
|
49
|
+
return false;
|
|
50
|
+
await remove(path, options);
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
async function countEntries(dir) {
|
|
54
|
+
let count = 0;
|
|
55
|
+
try {
|
|
56
|
+
for await (const _entry of readDir(dir))
|
|
57
|
+
count++;
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
if (!isNotFound(error))
|
|
61
|
+
throw error;
|
|
62
|
+
}
|
|
63
|
+
return count;
|
|
64
|
+
}
|
|
65
|
+
/** Opens app.db for the work when it already exists, and never creates one:
|
|
66
|
+
* a repository that was never inited has nothing to clear there. Reuses an
|
|
67
|
+
* open connection (the dashboard) and closes one it opened itself. */
|
|
68
|
+
export async function withAppDbIfPresent(fn) {
|
|
69
|
+
if (isAppDbOpen())
|
|
70
|
+
return await fn();
|
|
71
|
+
if (!(await pathExists(appDbPath())))
|
|
72
|
+
return undefined;
|
|
73
|
+
await openAppDb();
|
|
74
|
+
try {
|
|
75
|
+
return await fn();
|
|
76
|
+
}
|
|
77
|
+
finally {
|
|
78
|
+
await closeAppDb();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/** The repositories `clear all` should visit: the guide directories under
|
|
82
|
+
* `reposDir()` plus everything `config.json` still remembers. Names that
|
|
83
|
+
* cannot be joined onto a path are dropped: `config.json` is editable by
|
|
84
|
+
* hand, and a key like `acme/..` would otherwise resolve to the repos root
|
|
85
|
+
* and take every repository with it. */
|
|
86
|
+
export async function listStoredRepos() {
|
|
87
|
+
const repos = new Set();
|
|
88
|
+
try {
|
|
89
|
+
for await (const owner of readDir(reposDir())) {
|
|
90
|
+
if (!owner.isDirectory || owner.isSymlink)
|
|
91
|
+
continue;
|
|
92
|
+
try {
|
|
93
|
+
for await (const child of readDir(`${reposDir()}/${owner.name}`)) {
|
|
94
|
+
if (child.isDirectory && !child.isSymlink) {
|
|
95
|
+
repos.add(`${owner.name}/${child.name}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
if (!isNotFound(error))
|
|
101
|
+
throw error;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
if (!isNotFound(error))
|
|
107
|
+
throw error;
|
|
108
|
+
}
|
|
109
|
+
for (const repo of Object.keys(readConfig().repos ?? {}))
|
|
110
|
+
repos.add(repo);
|
|
111
|
+
return [...repos].filter(isSafeRepoName).sort();
|
|
112
|
+
}
|
|
113
|
+
/** Guide files live in one directory per repository. The count is taken
|
|
114
|
+
* before the removal so the caller can report what went. An owner directory
|
|
115
|
+
* left empty by its last repository is pruned: it is a leftover, not data. */
|
|
116
|
+
async function removeRepoGuides(repo) {
|
|
117
|
+
let removed = 0;
|
|
118
|
+
await withKnowledgeLock(repo, async () => {
|
|
119
|
+
const dir = `${reposDir()}/${repo}`;
|
|
120
|
+
removed = await countEntries(dir);
|
|
121
|
+
if (removed > 0)
|
|
122
|
+
await remove(dir, { recursive: true });
|
|
123
|
+
else
|
|
124
|
+
await removeIfExists(dir, { recursive: true });
|
|
125
|
+
await rmdir(dir.slice(0, dir.lastIndexOf("/"))).catch(() => { });
|
|
126
|
+
});
|
|
127
|
+
return removed;
|
|
128
|
+
}
|
|
129
|
+
/** `sync` used to keep its state as `.cache/<repo>/state.json` beside the
|
|
130
|
+
* working directory, and `readState` still falls back to it. Leaving it
|
|
131
|
+
* behind would let a `sync` run after a clear, which clear promises cannot
|
|
132
|
+
* happen. Best effort: it only exists on machines that ran the old layout. */
|
|
133
|
+
async function removeLegacyState(repo) {
|
|
134
|
+
await removeIfExists(`${process.cwd()}/.cache/${repo}`, { recursive: true });
|
|
135
|
+
}
|
|
136
|
+
async function clearRepoCache(repo, includeCache) {
|
|
137
|
+
if (!(await cacheDbExists()))
|
|
138
|
+
return 0;
|
|
139
|
+
let removed = await cacheDeleteRepoKnowledge(repo);
|
|
140
|
+
if (includeCache)
|
|
141
|
+
removed += await cacheDeleteRepoEvidence(repo);
|
|
142
|
+
return removed;
|
|
143
|
+
}
|
|
144
|
+
/** What queued and running jobs exist for these repositories. Empty when
|
|
145
|
+
* app.db is not open, which is the same as "no jobs to worry about". */
|
|
146
|
+
export function repoJobs(repos) {
|
|
147
|
+
if (!isAppDbOpen())
|
|
148
|
+
return { queued: [], running: [] };
|
|
149
|
+
const wanted = new Set(repos);
|
|
150
|
+
const queued = [];
|
|
151
|
+
const running = [];
|
|
152
|
+
for (const job of listJobs({})) {
|
|
153
|
+
if (!wanted.has(job.repo))
|
|
154
|
+
continue;
|
|
155
|
+
if (job.status === "queued")
|
|
156
|
+
queued.push(job);
|
|
157
|
+
else if (job.status === "running")
|
|
158
|
+
running.push(job);
|
|
159
|
+
}
|
|
160
|
+
return { queued, running };
|
|
161
|
+
}
|
|
162
|
+
/** Cancels queued jobs so a cleared repository does not rebuild itself from
|
|
163
|
+
* the queue. Running jobs are the caller's problem: reviews can be aborted,
|
|
164
|
+
* a running setup cannot (its fetch loops ignore the signal). */
|
|
165
|
+
export function cancelQueuedJobs(repos, reason = CLEARED_REASON) {
|
|
166
|
+
if (!isAppDbOpen())
|
|
167
|
+
return 0;
|
|
168
|
+
let canceled = 0;
|
|
169
|
+
for (const job of repoJobs(repos).queued) {
|
|
170
|
+
if (cancel(job.id, reason))
|
|
171
|
+
canceled++;
|
|
172
|
+
}
|
|
173
|
+
return canceled;
|
|
174
|
+
}
|
|
175
|
+
/** Aborts running reviews. They never write guides, so clearing while one
|
|
176
|
+
* runs is safe either way; aborting stops the AI spend too. */
|
|
177
|
+
export function abortRunningReviews(repos, reason = CLEARED_REASON) {
|
|
178
|
+
if (!isAppDbOpen())
|
|
179
|
+
return 0;
|
|
180
|
+
let aborted = 0;
|
|
181
|
+
for (const job of repoJobs(repos).running) {
|
|
182
|
+
if (job.type !== "review" && job.type !== "remote_review")
|
|
183
|
+
continue;
|
|
184
|
+
if (cancel(job.id, reason))
|
|
185
|
+
aborted++;
|
|
186
|
+
}
|
|
187
|
+
return aborted;
|
|
188
|
+
}
|
|
189
|
+
/** Running rows left behind by a crashed `serve`. Only safe when the caller
|
|
190
|
+
* holds the app.db lock, which the CLI guard guarantees. */
|
|
191
|
+
export function cancelOrphanedJobs(repos, reason = CLEARED_REASON) {
|
|
192
|
+
if (!isAppDbOpen())
|
|
193
|
+
return 0;
|
|
194
|
+
let canceled = 0;
|
|
195
|
+
for (const job of repoJobs(repos).running) {
|
|
196
|
+
setJobStatus(job.id, "canceled", { cancel_reason: reason });
|
|
197
|
+
canceled++;
|
|
198
|
+
}
|
|
199
|
+
return canceled;
|
|
200
|
+
}
|
|
201
|
+
/** Forgets one repository: knowledge, guides, cache (when asked) and the
|
|
202
|
+
* settings `init` remembered. */
|
|
203
|
+
export async function clearRepo(repo, options = {}) {
|
|
204
|
+
assertSafeRepoName(repo);
|
|
205
|
+
await withAppDbIfPresent(() => clearRepoKnowledge(repo));
|
|
206
|
+
const guides = await removeRepoGuides(repo);
|
|
207
|
+
const cacheRows = await clearRepoCache(repo, Boolean(options.includeCache));
|
|
208
|
+
await removeLegacyState(repo);
|
|
209
|
+
const settings = await removeRepoConfig(repo);
|
|
210
|
+
const clones = options.includeCache
|
|
211
|
+
? await removeIfExists(cloneDir(repo), { recursive: true })
|
|
212
|
+
: false;
|
|
213
|
+
const worktrees = options.includeCache
|
|
214
|
+
? await removeIfExists(repoWorktreesDir(repo), { recursive: true })
|
|
215
|
+
: false;
|
|
216
|
+
return { repo, guides, cacheRows, settings, clones, worktrees };
|
|
217
|
+
}
|
|
218
|
+
/** Forgets exactly the repositories it is given. The dashboard route and the
|
|
219
|
+
* CLI pass the same snapshot they negotiated jobs against, so the list cannot
|
|
220
|
+
* move between the check and the delete. The app.db file and its review
|
|
221
|
+
* history stay: only the knowledge is gone. `--include-cache` takes the whole
|
|
222
|
+
* cache database plus every clone and worktree, the same as deleting
|
|
223
|
+
* `cache.db` by hand. */
|
|
224
|
+
export async function clearRepos(repos, options = {}) {
|
|
225
|
+
for (const repo of repos)
|
|
226
|
+
assertSafeRepoName(repo);
|
|
227
|
+
const results = [];
|
|
228
|
+
await withAppDbIfPresent(() => {
|
|
229
|
+
for (const repo of repos)
|
|
230
|
+
clearRepoKnowledge(repo);
|
|
231
|
+
});
|
|
232
|
+
for (const repo of repos) {
|
|
233
|
+
const guides = await removeRepoGuides(repo);
|
|
234
|
+
const cacheRows = options.includeCache
|
|
235
|
+
? 0
|
|
236
|
+
: await clearRepoCache(repo, false);
|
|
237
|
+
await removeLegacyState(repo);
|
|
238
|
+
results.push({
|
|
239
|
+
repo,
|
|
240
|
+
guides,
|
|
241
|
+
cacheRows,
|
|
242
|
+
settings: false,
|
|
243
|
+
clones: false,
|
|
244
|
+
worktrees: false,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
const cacheFile = options.includeCache
|
|
248
|
+
? await removeIfExists(cacheDbPath())
|
|
249
|
+
: false;
|
|
250
|
+
const clones = options.includeCache
|
|
251
|
+
? await removeIfExists(clonesDir(), { recursive: true })
|
|
252
|
+
: false;
|
|
253
|
+
const worktrees = options.includeCache
|
|
254
|
+
? await removeIfExists(worktreesDir(), { recursive: true })
|
|
255
|
+
: false;
|
|
256
|
+
const settings = await removeAllRepoConfig();
|
|
257
|
+
return { repos: results, settings, cacheFile, clones, worktrees };
|
|
258
|
+
}
|
|
259
|
+
/** Forgets every repository on this machine, from a fresh snapshot. */
|
|
260
|
+
export async function clearAllKnowledge(options = {}) {
|
|
261
|
+
return await clearRepos(await listStoredRepos(), options);
|
|
262
|
+
}
|
|
263
|
+
/** Every file and directory co-maintainer keeps on this machine. The `tools`
|
|
264
|
+
* directory (the codegraph install) goes too: uninstall means the disk is
|
|
265
|
+
* back to what it was before the first `init`. */
|
|
266
|
+
function uninstallTargets() {
|
|
267
|
+
const appDb = appDbPath();
|
|
268
|
+
const cacheDb = cacheDbPath();
|
|
269
|
+
return [
|
|
270
|
+
configPath(),
|
|
271
|
+
cacheDb,
|
|
272
|
+
`${cacheDb}-journal`,
|
|
273
|
+
appDb,
|
|
274
|
+
`${appDb}-wal`,
|
|
275
|
+
`${appDb}-shm`,
|
|
276
|
+
appDbLockPath(),
|
|
277
|
+
backupPaths().version,
|
|
278
|
+
reposDir(),
|
|
279
|
+
clonesDir(),
|
|
280
|
+
worktreesDir(),
|
|
281
|
+
locksDir(),
|
|
282
|
+
toolsDir(),
|
|
283
|
+
backupPaths().root,
|
|
284
|
+
];
|
|
285
|
+
}
|
|
286
|
+
/** Removes every co-maintainer data file. The npm package itself cannot be
|
|
287
|
+
* removed from inside the running CLI, so the command prints how to do that
|
|
288
|
+
* instead. Refuses nothing here; the caller checks for a running `serve`. */
|
|
289
|
+
export async function uninstallData() {
|
|
290
|
+
// On Windows an open handle blocks the delete, and leaving wal/shm behind
|
|
291
|
+
// would be worse than the file itself.
|
|
292
|
+
if (isAppDbOpen())
|
|
293
|
+
await closeAppDb();
|
|
294
|
+
const removed = [];
|
|
295
|
+
for (const target of uninstallTargets()) {
|
|
296
|
+
if (await removeIfExists(target, { recursive: true })) {
|
|
297
|
+
removed.push(target);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
// The containing `co-maintainer` directories only survive if something
|
|
301
|
+
// else put files there. `rmdir` refuses a non-empty directory, which is
|
|
302
|
+
// what we want.
|
|
303
|
+
for (const dir of [
|
|
304
|
+
`${getConfigDir()}/co-maintainer`,
|
|
305
|
+
`${getCacheDir()}/co-maintainer`,
|
|
306
|
+
]) {
|
|
307
|
+
await rmdir(dir).catch(() => { });
|
|
308
|
+
}
|
|
309
|
+
return { paths: removed };
|
|
310
|
+
}
|
|
@@ -3,6 +3,9 @@ import { type BackupPaths } from "./backup.ts";
|
|
|
3
3
|
export declare function appDbDir(): string;
|
|
4
4
|
/** `CM_APP_DB` overrides the path — tests point it at a temp file. */
|
|
5
5
|
export declare function appDbPath(): string;
|
|
6
|
+
/** The lock file on its own, so `uninstall` deletes it with the database
|
|
7
|
+
* instead of leaving a pid file that names a process that no longer exists. */
|
|
8
|
+
export declare function appDbLockPath(): string;
|
|
6
9
|
/** Where the backup taken before a migration lives, and what it covers. */
|
|
7
10
|
export declare function backupPaths(): BackupPaths;
|
|
8
11
|
/** The pid holding the database lock, or `undefined` when nothing live does. */
|
package/dist/src/store/app_db.js
CHANGED
|
@@ -17,6 +17,11 @@ export function appDbPath() {
|
|
|
17
17
|
function lockPath() {
|
|
18
18
|
return `${appDbPath()}.lock`;
|
|
19
19
|
}
|
|
20
|
+
/** The lock file on its own, so `uninstall` deletes it with the database
|
|
21
|
+
* instead of leaving a pid file that names a process that no longer exists. */
|
|
22
|
+
export function appDbLockPath() {
|
|
23
|
+
return lockPath();
|
|
24
|
+
}
|
|
20
25
|
/** Where the backup taken before a migration lives, and what it covers. */
|
|
21
26
|
export function backupPaths() {
|
|
22
27
|
return {
|
|
@@ -2,6 +2,17 @@ export declare function cacheGet(namespace: string, key: string): Promise<string
|
|
|
2
2
|
export declare function cacheSet(namespace: string, key: string, value: string): Promise<void>;
|
|
3
3
|
export declare function cacheDelete(namespace: string, key: string): Promise<void>;
|
|
4
4
|
export declare function cacheDeletePrefix(namespace: string, prefix: string): Promise<void>;
|
|
5
|
+
/** Whether cache.db exists, so a `clear` that has nothing to delete does not
|
|
6
|
+
* create one just to close it again. */
|
|
7
|
+
export declare function cacheDbExists(): Promise<boolean>;
|
|
8
|
+
/** The skill state is the repository's own knowledge, so `clear` always drops
|
|
9
|
+
* it: without it the next build starts from `init` rather than `sync`. */
|
|
10
|
+
export declare function cacheDeleteRepoKnowledge(repo: string): Promise<number>;
|
|
11
|
+
/** Everything `init` fetched or paid for, kept unless the caller asked for a
|
|
12
|
+
* cold next build. The global `pricing` cache stays either way. Keys live in
|
|
13
|
+
* three shapes: an exact repository key, `${repo}:...`, and the local review
|
|
14
|
+
* carry-over's NUL separated `${repo}\0...`. */
|
|
15
|
+
export declare function cacheDeleteRepoEvidence(repo: string): Promise<number>;
|
|
5
16
|
/** Every value under `namespace` whose key starts with `prefix`, newest first.
|
|
6
17
|
* `recordAiCost` keys each job `${repo}:${uuid}`, so the probe estimate reads
|
|
7
18
|
* a repository's own job history without a second index to keep in sync. */
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Database } from "./sqlite.js";
|
|
2
2
|
import { cacheDbPath, getCacheDir } from "../config.js";
|
|
3
|
-
import { mkdirSync } from "../util/runtime.js";
|
|
3
|
+
import { isNotFound, mkdirSync, stat } from "../util/runtime.js";
|
|
4
4
|
/** Paths whose schema this process has already created. Keyed by path rather
|
|
5
5
|
* than a single boolean: `cacheDbPath()` reads the environment at call time
|
|
6
6
|
* and tests point it at a temp directory, so a process-level flag would skip
|
|
@@ -67,6 +67,65 @@ export async function cacheDeletePrefix(namespace, prefix) {
|
|
|
67
67
|
db.close();
|
|
68
68
|
}
|
|
69
69
|
}
|
|
70
|
+
/** Whether cache.db exists, so a `clear` that has nothing to delete does not
|
|
71
|
+
* create one just to close it again. */
|
|
72
|
+
export async function cacheDbExists() {
|
|
73
|
+
try {
|
|
74
|
+
await stat(cacheDbPath());
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
if (isNotFound(error))
|
|
79
|
+
return false;
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/** Escapes LIKE's own wildcards and the escape character. A repository name
|
|
84
|
+
* may contain `_`, which LIKE would otherwise read as "any character". */
|
|
85
|
+
function likePrefix(prefix) {
|
|
86
|
+
return `${prefix.replace(/[\\%_]/g, "\\$&")}%`;
|
|
87
|
+
}
|
|
88
|
+
function deleteRows(db, namespace, pattern) {
|
|
89
|
+
if ("exact" in pattern) {
|
|
90
|
+
db.prepare("DELETE FROM cache WHERE namespace = ? AND cache_key = ?").run(namespace, pattern.exact);
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
db.prepare("DELETE FROM cache WHERE namespace = ? AND cache_key LIKE ? ESCAPE '\\'").run(namespace, likePrefix(pattern.prefix));
|
|
94
|
+
}
|
|
95
|
+
return db.changes;
|
|
96
|
+
}
|
|
97
|
+
/** The skill state is the repository's own knowledge, so `clear` always drops
|
|
98
|
+
* it: without it the next build starts from `init` rather than `sync`. */
|
|
99
|
+
export async function cacheDeleteRepoKnowledge(repo) {
|
|
100
|
+
const db = openDatabase();
|
|
101
|
+
try {
|
|
102
|
+
return deleteRows(db, "state", { exact: repo });
|
|
103
|
+
}
|
|
104
|
+
finally {
|
|
105
|
+
db.close();
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/** Everything `init` fetched or paid for, kept unless the caller asked for a
|
|
109
|
+
* cold next build. The global `pricing` cache stays either way. Keys live in
|
|
110
|
+
* three shapes: an exact repository key, `${repo}:...`, and the local review
|
|
111
|
+
* carry-over's NUL separated `${repo}\0...`. */
|
|
112
|
+
export async function cacheDeleteRepoEvidence(repo) {
|
|
113
|
+
const db = openDatabase();
|
|
114
|
+
try {
|
|
115
|
+
let removed = 0;
|
|
116
|
+
for (const namespace of ["pr-listing", "probe"]) {
|
|
117
|
+
removed += deleteRows(db, namespace, { exact: repo });
|
|
118
|
+
}
|
|
119
|
+
for (const namespace of ["ai-jobs", "cost"]) {
|
|
120
|
+
removed += deleteRows(db, namespace, { prefix: `${repo}:` });
|
|
121
|
+
}
|
|
122
|
+
removed += deleteRows(db, "local-review", { prefix: `${repo}\0` });
|
|
123
|
+
return removed;
|
|
124
|
+
}
|
|
125
|
+
finally {
|
|
126
|
+
db.close();
|
|
127
|
+
}
|
|
128
|
+
}
|
|
70
129
|
/** Every value under `namespace` whose key starts with `prefix`, newest first.
|
|
71
130
|
* `recordAiCost` keys each job `${repo}:${uuid}`, so the probe estimate reads
|
|
72
131
|
* a repository's own job history without a second index to keep in sync. */
|
|
@@ -4,6 +4,9 @@ export declare function deactivateRepo(fullName: string): void;
|
|
|
4
4
|
export declare function deactivateReposForInstallation(installationId: number): void;
|
|
5
5
|
export declare function updateRepoSettings(fullName: string, patch: Partial<Pick<RepoRow, "auto_review" | "review_scope" | "skip_drafts" | "skip_bots" | "use_codegraph" | "review_policy_json">>): void;
|
|
6
6
|
export declare function markKnowledgeBuilt(fullName: string, baseSha: string): void;
|
|
7
|
+
/** Forgets the built knowledge: the stamp every review and the dashboard read
|
|
8
|
+
* as "built", and the drift baseline that only meant anything against it. */
|
|
9
|
+
export declare function clearRepoKnowledge(fullName: string): void;
|
|
7
10
|
export declare function setInstallationId(fullName: string, installationId: number): void;
|
|
8
11
|
export declare function getRepo(fullName: string): RepoRow | undefined;
|
|
9
12
|
/** Case-insensitive match on `repos.full_name`. */
|
package/dist/src/store/repos.js
CHANGED
|
@@ -36,6 +36,14 @@ export function markKnowledgeBuilt(fullName, baseSha) {
|
|
|
36
36
|
// baseline, so a rebuild makes them meaningless rather than merely stale.
|
|
37
37
|
deleteDrift(fullName);
|
|
38
38
|
}
|
|
39
|
+
/** Forgets the built knowledge: the stamp every review and the dashboard read
|
|
40
|
+
* as "built", and the drift baseline that only meant anything against it. */
|
|
41
|
+
export function clearRepoKnowledge(fullName) {
|
|
42
|
+
getAppDb()
|
|
43
|
+
.prepare(`UPDATE repos SET knowledge_built_at = NULL, knowledge_base_sha = NULL WHERE full_name = ?`)
|
|
44
|
+
.run(fullName);
|
|
45
|
+
deleteDrift(fullName);
|
|
46
|
+
}
|
|
39
47
|
export function setInstallationId(fullName, installationId) {
|
|
40
48
|
getAppDb()
|
|
41
49
|
.prepare(`UPDATE repos SET installation_id = ? WHERE full_name = ?`)
|
|
@@ -29,6 +29,9 @@ export declare function mkdir(path: string | URL, options?: {
|
|
|
29
29
|
export declare function remove(path: string | URL, options?: {
|
|
30
30
|
recursive?: boolean;
|
|
31
31
|
}): Promise<void>;
|
|
32
|
+
/** Removes an empty directory only. Unlike `remove`, a non-empty directory
|
|
33
|
+
* fails, which is what callers that prune parents after deleting files want. */
|
|
34
|
+
export declare function rmdir(path: string | URL): Promise<void>;
|
|
32
35
|
export declare function stat(path: string | URL): Promise<Stats>;
|
|
33
36
|
export declare function lstat(path: string | URL): Promise<Stats>;
|
|
34
37
|
export declare function readLink(path: string | URL): Promise<string>;
|
package/dist/src/util/runtime.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* two silent-break classes too: `"windows"` vs `"win32"`, and
|
|
7
7
|
* `Deno.errors.NotFound` vs `ENOENT`.
|
|
8
8
|
*/
|
|
9
|
-
import { chmod as fsChmod, lstat as fsLstat, mkdir as fsMkdir, mkdtemp as fsMkdtemp, readFile as fsReadFile, readdir, readlink as fsReadLink, rename as fsRename, rm as fsRm, stat as fsStat, writeFile as fsWriteFile, } from "node:fs/promises";
|
|
9
|
+
import { chmod as fsChmod, lstat as fsLstat, mkdir as fsMkdir, mkdtemp as fsMkdtemp, readFile as fsReadFile, readdir, readlink as fsReadLink, rename as fsRename, rmdir as fsRmdir, rm as fsRm, stat as fsStat, writeFile as fsWriteFile, } from "node:fs/promises";
|
|
10
10
|
import { mkdirSync as fsMkdirSync, mkdtempSync as fsMkdtempSync, readFileSync as fsReadFileSync, rmSync as fsRmSync, statSync as fsStatSync, writeFileSync as fsWriteFileSync, } from "node:fs";
|
|
11
11
|
import { spawn, spawnSync } from "node:child_process";
|
|
12
12
|
import { tmpdir } from "node:os";
|
|
@@ -81,6 +81,11 @@ export function mkdir(path, options) {
|
|
|
81
81
|
export function remove(path, options) {
|
|
82
82
|
return fsRm(path, { recursive: options?.recursive ?? false, force: false });
|
|
83
83
|
}
|
|
84
|
+
/** Removes an empty directory only. Unlike `remove`, a non-empty directory
|
|
85
|
+
* fails, which is what callers that prune parents after deleting files want. */
|
|
86
|
+
export function rmdir(path) {
|
|
87
|
+
return fsRmdir(path);
|
|
88
|
+
}
|
|
84
89
|
export function stat(path) {
|
|
85
90
|
return fsStat(path);
|
|
86
91
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "co-maintainer",
|
|
3
|
-
"version": "0.5.
|
|
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",
|