co-maintainer 0.4.0-beta.2 → 0.4.0
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/LICENSE +21 -21
- package/dist/package.json +17 -2
- package/dist/src/cli/commands/serve.js +8 -8
- package/dist/src/cli/prompt.js +6 -0
- package/dist/src/cli/review_args.js +5 -1
- package/dist/src/pr/codegraph_tool_args.d.ts +5 -0
- package/dist/src/pr/codegraph_tool_args.js +24 -1
- package/dist/src/remote/fixtures.js +10 -2
- package/dist/src/remote/schema.d.ts +2 -1
- package/dist/src/remote/schema.js +3 -2
- package/dist/src/server/pages/router.js +11 -2
- package/dist/src/store/sqlite.d.ts +2 -1
- package/dist/src/store/sqlite.js +20 -5
- package/dist/src/tools/codegraph.d.ts +4 -1
- package/dist/src/tools/codegraph.js +12 -6
- package/dist/src/tools/codegraph_exec.d.ts +7 -1
- package/dist/src/tools/codegraph_exec.js +13 -5
- package/dist/src/util/runtime.d.ts +14 -1
- package/dist/src/util/runtime.js +34 -7
- package/package.json +17 -2
package/LICENSE
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2026 Murat Kirazkaya
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in all
|
|
13
|
-
copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Murat Kirazkaya
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/dist/package.json
CHANGED
|
@@ -1,10 +1,25 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "co-maintainer",
|
|
3
|
-
"version": "0.4.0
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Analyzes a GitHub repository and writes repository-specific contribution guidance.",
|
|
5
5
|
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/GroophyLifefor/co-maintainer"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://github.com/GroophyLifefor/co-maintainer#readme",
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/GroophyLifefor/co-maintainer/issues"
|
|
13
|
+
},
|
|
14
|
+
"keywords": [
|
|
15
|
+
"github",
|
|
16
|
+
"pull-request",
|
|
17
|
+
"code-review",
|
|
18
|
+
"maintainer",
|
|
19
|
+
"ai"
|
|
20
|
+
],
|
|
6
21
|
"bin": {
|
|
7
|
-
"co-maintainer": "
|
|
22
|
+
"co-maintainer": "dist/main.js"
|
|
8
23
|
},
|
|
9
24
|
"files": [
|
|
10
25
|
"dist/",
|
|
@@ -67,15 +67,15 @@ export function resolveWebhookUrl(args, port, configured) {
|
|
|
67
67
|
// `new URL` is lenient: "http://http://host/:5000/x" parses with host
|
|
68
68
|
// "http" and the real URL buried in the path, and "http:///x" treats "x"
|
|
69
69
|
// as the host. GitHub needs a routable host and a real webhook path, so
|
|
70
|
-
// reject a scheme
|
|
71
|
-
//
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
if (
|
|
70
|
+
// reject a leaked scheme, an empty authority, and a path that is just "/".
|
|
71
|
+
// A single-label host (an internal Docker/k8s name) or an IPv6 literal is
|
|
72
|
+
// legitimate, and both are indistinguishable from the "http:///x" spelling
|
|
73
|
+
// once parsed, so the empty authority is caught on the raw string instead.
|
|
74
|
+
const hasAuthority = /^https?:\/\/[^/?#]+/.test(raw.trim());
|
|
75
|
+
if (!hasAuthority ||
|
|
76
|
+
url.pathname.startsWith("//") ||
|
|
76
77
|
url.pathname.includes("://") ||
|
|
77
|
-
url.pathname === "/"
|
|
78
|
-
!hasQualifiedHost) {
|
|
78
|
+
url.pathname === "/") {
|
|
79
79
|
die("--webhook-url must be an absolute http(s) URL with a webhook path, " +
|
|
80
80
|
"e.g. --webhook-url=https://example.com/github/webhook");
|
|
81
81
|
}
|
package/dist/src/cli/prompt.js
CHANGED
|
@@ -9,6 +9,12 @@ async function ask(question) {
|
|
|
9
9
|
try {
|
|
10
10
|
return (await rl.question(question)).trim();
|
|
11
11
|
}
|
|
12
|
+
catch {
|
|
13
|
+
// `question` rejects when stdin closes mid-prompt (Ctrl-D, a detached
|
|
14
|
+
// pipeline, a killed parent). Treat that as "no answer" so the caller's
|
|
15
|
+
// own fallback/required validation runs instead of an uncaught rejection.
|
|
16
|
+
return "";
|
|
17
|
+
}
|
|
12
18
|
finally {
|
|
13
19
|
rl.close();
|
|
14
20
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { parseArgs } from "./args.js";
|
|
1
|
+
import { parseArgs, setCliInteractive } from "./args.js";
|
|
2
2
|
function die(message) {
|
|
3
3
|
throw new Error(message);
|
|
4
4
|
}
|
|
@@ -50,6 +50,10 @@ export function filterReviewConfigArgs(raw) {
|
|
|
50
50
|
export async function parseReviewArgs(args) {
|
|
51
51
|
const positional = args.filter((a) => !a.startsWith("--"));
|
|
52
52
|
const flags = reviewFlags(args);
|
|
53
|
+
// `--json` must never prompt (plan §8.7). `parseArgs` only receives the
|
|
54
|
+
// filtered args, and `--json` is stripped before it sees them, so the flag
|
|
55
|
+
// has to be applied here rather than after the parse returns.
|
|
56
|
+
setCliInteractive(!flags.json);
|
|
53
57
|
const isPr = positional.length >= 2 &&
|
|
54
58
|
/^[^/]+\/[^/]+$/.test(positional[0]) &&
|
|
55
59
|
/^\d+$/.test(positional[1]);
|
|
@@ -2,6 +2,11 @@ type Args = Record<string, unknown>;
|
|
|
2
2
|
export declare function toolError(message: string): string;
|
|
3
3
|
export declare function rejectUnknownKeys(args: Args, allowed: ReadonlySet<string>): string | null;
|
|
4
4
|
export declare function rejectFlagLike(value: string, label: string): string | null;
|
|
5
|
+
/** `codegraph_exec.ts` routes through `cmd /c` on Windows (the CLI is a `.cmd`
|
|
6
|
+
* shim), and `cmd.exe` re-parses every argument, so a value carrying `&`, `|`,
|
|
7
|
+
* `>` or `%` can append a second command. None of them have a legitimate place
|
|
8
|
+
* in a search term, symbol name or repo-relative path, so refuse them. */
|
|
9
|
+
export declare function rejectShellMetacharacters(value: string, label: string): string | null;
|
|
5
10
|
/** Repo-relative path only — no `..`, no absolute paths (plan E103). */
|
|
6
11
|
export declare function rejectEscapingPath(file: string): string | null;
|
|
7
12
|
export declare function guardToolArgs(args: Args, allowed: ReadonlySet<string>): string | null;
|
|
@@ -14,11 +14,24 @@ export function rejectFlagLike(value, label) {
|
|
|
14
14
|
}
|
|
15
15
|
return null;
|
|
16
16
|
}
|
|
17
|
+
/** `codegraph_exec.ts` routes through `cmd /c` on Windows (the CLI is a `.cmd`
|
|
18
|
+
* shim), and `cmd.exe` re-parses every argument, so a value carrying `&`, `|`,
|
|
19
|
+
* `>` or `%` can append a second command. None of them have a legitimate place
|
|
20
|
+
* in a search term, symbol name or repo-relative path, so refuse them. */
|
|
21
|
+
export function rejectShellMetacharacters(value, label) {
|
|
22
|
+
if (/[&|<>^%\r\n]/.test(value)) {
|
|
23
|
+
return toolError(`${label} must not contain shell metacharacters`);
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
17
27
|
/** Repo-relative path only — no `..`, no absolute paths (plan E103). */
|
|
18
28
|
export function rejectEscapingPath(file) {
|
|
19
29
|
const flag = rejectFlagLike(file, "file");
|
|
20
30
|
if (flag)
|
|
21
31
|
return flag;
|
|
32
|
+
const shell = rejectShellMetacharacters(file, "file");
|
|
33
|
+
if (shell)
|
|
34
|
+
return shell;
|
|
22
35
|
const norm = file.replace(/\\/g, "/");
|
|
23
36
|
if (norm.startsWith("/") ||
|
|
24
37
|
norm.startsWith("//") ||
|
|
@@ -30,5 +43,15 @@ export function rejectEscapingPath(file) {
|
|
|
30
43
|
return null;
|
|
31
44
|
}
|
|
32
45
|
export function guardToolArgs(args, allowed) {
|
|
33
|
-
|
|
46
|
+
const unknown = rejectUnknownKeys(args, allowed);
|
|
47
|
+
if (unknown)
|
|
48
|
+
return unknown;
|
|
49
|
+
for (const [key, value] of Object.entries(args)) {
|
|
50
|
+
if (typeof value !== "string")
|
|
51
|
+
continue;
|
|
52
|
+
const shell = rejectShellMetacharacters(value, key);
|
|
53
|
+
if (shell)
|
|
54
|
+
return shell;
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
34
57
|
}
|
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
/** Fixture directory hashing for plan §15.3.
|
|
1
|
+
/** Fixture directory hashing for plan §15.3.
|
|
2
|
+
*
|
|
3
|
+
* The hash exists to catch schema/content drift in the wire fixtures, not
|
|
4
|
+
* line-ending drift, so `\r\n` is normalized to `\n` before hashing. Without
|
|
5
|
+
* that the digest depends on the checkout: `.gitattributes` keeps the repo at
|
|
6
|
+
* LF, but a Windows working tree with `autocrlf` hands the test CRLF and a
|
|
7
|
+
* different digest. */
|
|
2
8
|
import { readDir, readFile } from "../util/runtime.js";
|
|
3
9
|
export async function hashRemoteFixtures(version) {
|
|
4
10
|
const dir = new URL(`./fixtures/v${version}/`, import.meta.url);
|
|
@@ -11,9 +17,11 @@ export async function hashRemoteFixtures(version) {
|
|
|
11
17
|
names.sort();
|
|
12
18
|
const chunks = [];
|
|
13
19
|
const enc = new TextEncoder();
|
|
20
|
+
const dec = new TextDecoder();
|
|
14
21
|
for (const name of names) {
|
|
15
22
|
chunks.push(enc.encode(`${name}\n`));
|
|
16
|
-
|
|
23
|
+
const raw = dec.decode(await readFile(new URL(name, dir)));
|
|
24
|
+
chunks.push(enc.encode(raw.replace(/\r\n/g, "\n")));
|
|
17
25
|
chunks.push(enc.encode("\n"));
|
|
18
26
|
}
|
|
19
27
|
const total = chunks.reduce((n, c) => n + c.length, 0);
|
|
@@ -6,7 +6,8 @@ export declare const MIN_SERVER_SCHEMA = 1;
|
|
|
6
6
|
export declare const REMOTE_CLI_UPGRADE_COMMAND = "npm install -g co-maintainer@latest";
|
|
7
7
|
export declare const REMOTE_MAX_BODY_BYTES = 52428800;
|
|
8
8
|
export declare const REMOTE_SYNC_INTERVAL_SECONDS = 3;
|
|
9
|
-
/** SHA-256 of sorted `fixtures/v<N>/*.json` (name + contents
|
|
9
|
+
/** SHA-256 of sorted `fixtures/v<N>/*.json` (name + contents, `\r\n` normalized
|
|
10
|
+
* to `\n`); bump with schema version. */
|
|
10
11
|
export declare const REMOTE_FIXTURE_HASH: Record<number, string>;
|
|
11
12
|
export declare const REMOTE_SYNC_STATUSES: readonly ["queued", "running", "done", "failed", "canceled"];
|
|
12
13
|
export type RemoteSyncStatus = (typeof REMOTE_SYNC_STATUSES)[number];
|
|
@@ -6,9 +6,10 @@ export const MIN_SERVER_SCHEMA = 1;
|
|
|
6
6
|
export const REMOTE_CLI_UPGRADE_COMMAND = "npm install -g co-maintainer@latest";
|
|
7
7
|
export const REMOTE_MAX_BODY_BYTES = 52_428_800;
|
|
8
8
|
export const REMOTE_SYNC_INTERVAL_SECONDS = 3;
|
|
9
|
-
/** SHA-256 of sorted `fixtures/v<N>/*.json` (name + contents
|
|
9
|
+
/** SHA-256 of sorted `fixtures/v<N>/*.json` (name + contents, `\r\n` normalized
|
|
10
|
+
* to `\n`); bump with schema version. */
|
|
10
11
|
export const REMOTE_FIXTURE_HASH = {
|
|
11
|
-
1: "
|
|
12
|
+
1: "71e4e5f8637eab4b53d47817a688812a517310d967abe26aa0d7f4e7d7d65092",
|
|
12
13
|
};
|
|
13
14
|
export const REMOTE_SYNC_STATUSES = [
|
|
14
15
|
"queued",
|
|
@@ -20,7 +20,16 @@ import { getRepo } from "../../store/repos.js";
|
|
|
20
20
|
import { getJob } from "../../store/jobs.js";
|
|
21
21
|
import { getLogsSince } from "../../services/jobs.js";
|
|
22
22
|
import { listInstallationsWithRepos } from "../../github/app.js";
|
|
23
|
-
const REPO = /^\/repos\/([^/]+)\/([^/]+)(?:\/(pulls|knowledge|settings)(?:\/(\d+))?)?$/;
|
|
23
|
+
const REPO = /^\/repos\/([^/]+)\/([^/]+)(?:\/(pulls|knowledge|settings|remote)(?:\/(\d+))?)?$/;
|
|
24
|
+
/** Matches a repository page URL. Only `pulls` is keyed by a number, so a stray
|
|
25
|
+
* suffix such as `/repos/o/r/remote/7` must fall through to a 404 rather than
|
|
26
|
+
* render the listing and silently drop the number. */
|
|
27
|
+
function matchRepo(pathname) {
|
|
28
|
+
const match = REPO.exec(pathname);
|
|
29
|
+
if (match && match[4] && match[3] !== "pulls")
|
|
30
|
+
return null;
|
|
31
|
+
return match;
|
|
32
|
+
}
|
|
24
33
|
export async function handlePageRequest(request, deps, ip) {
|
|
25
34
|
const url = new URL(request.url);
|
|
26
35
|
if (url.pathname === "/styles.css" && request.method === "GET") {
|
|
@@ -126,7 +135,7 @@ export async function handlePageRequest(request, deps, ip) {
|
|
|
126
135
|
const config = readConfig();
|
|
127
136
|
return renderSettings(username, config, config.webhookUrl || deps.webhookUrl || "");
|
|
128
137
|
}
|
|
129
|
-
const match =
|
|
138
|
+
const match = matchRepo(url.pathname);
|
|
130
139
|
if (match && request.method === "GET") {
|
|
131
140
|
const fullName = `${decodeURIComponent(match[1])}/${decodeURIComponent(match[2])}`;
|
|
132
141
|
const row = requireRepo(fullName);
|
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
* surface the store layer was written against: a synchronous `Database`,
|
|
3
3
|
* `prepare<T>()` generics, `.get()/.all()/.run()`, and `db.changes`.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
5
|
+
* Three behaviours of the JSR driver must be reproduced on top of
|
|
6
6
|
* `node:sqlite`:
|
|
7
7
|
* - `undefined` parameters bind as SQL NULL (node:sqlite rejects undefined)
|
|
8
|
+
* - `boolean` parameters bind as 0/1 (node:sqlite rejects booleans)
|
|
8
9
|
* - `db.changes` reports the rows modified by the most recent `run()`, which
|
|
9
10
|
* `node:sqlite` only returns from the statement call
|
|
10
11
|
*/
|
package/dist/src/store/sqlite.js
CHANGED
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
* surface the store layer was written against: a synchronous `Database`,
|
|
3
3
|
* `prepare<T>()` generics, `.get()/.all()/.run()`, and `db.changes`.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
5
|
+
* Three behaviours of the JSR driver must be reproduced on top of
|
|
6
6
|
* `node:sqlite`:
|
|
7
7
|
* - `undefined` parameters bind as SQL NULL (node:sqlite rejects undefined)
|
|
8
|
+
* - `boolean` parameters bind as 0/1 (node:sqlite rejects booleans)
|
|
8
9
|
* - `db.changes` reports the rows modified by the most recent `run()`, which
|
|
9
10
|
* `node:sqlite` only returns from the statement call
|
|
10
11
|
*/
|
|
@@ -32,11 +33,25 @@ export class Statement {
|
|
|
32
33
|
}
|
|
33
34
|
}
|
|
34
35
|
/** Normalizes call arguments: a single object binds by name, everything else
|
|
35
|
-
* is a positional list
|
|
36
|
+
* is a positional list. Values `node:sqlite` refuses are mapped to what it
|
|
37
|
+
* accepts — `undefined` to NULL, booleans to 0/1 — on both paths, so a field
|
|
38
|
+
* typed `boolean | undefined` survives a named bind too. */
|
|
36
39
|
function args(params) {
|
|
37
|
-
if (params.length === 1 && isBindObject(params[0]))
|
|
38
|
-
|
|
39
|
-
|
|
40
|
+
if (params.length === 1 && isBindObject(params[0])) {
|
|
41
|
+
const bound = {};
|
|
42
|
+
for (const [key, value] of Object.entries(params[0])) {
|
|
43
|
+
bound[key] = normalize(value);
|
|
44
|
+
}
|
|
45
|
+
return [bound];
|
|
46
|
+
}
|
|
47
|
+
return params.map((value) => normalize(value));
|
|
48
|
+
}
|
|
49
|
+
function normalize(value) {
|
|
50
|
+
if (value === undefined)
|
|
51
|
+
return null;
|
|
52
|
+
if (typeof value === "boolean")
|
|
53
|
+
return value ? 1 : 0;
|
|
54
|
+
return value;
|
|
40
55
|
}
|
|
41
56
|
function isBindObject(value) {
|
|
42
57
|
return (typeof value === "object" &&
|
|
@@ -15,10 +15,13 @@ export type EnsureOptions = {
|
|
|
15
15
|
allowInstall?: boolean;
|
|
16
16
|
interactive?: boolean;
|
|
17
17
|
run?: Runner;
|
|
18
|
-
|
|
18
|
+
/** `node:readline/promises` made this async; both shapes are accepted. */
|
|
19
|
+
confirm?: (question: string) => boolean | Promise<boolean>;
|
|
19
20
|
log?: (message: string) => void;
|
|
20
21
|
/** Overrides `toolsDir()`, for tests. */
|
|
21
22
|
root?: string;
|
|
23
|
+
/** Overrides `process.exit`, for tests. */
|
|
24
|
+
exit?: (code: number) => never;
|
|
22
25
|
};
|
|
23
26
|
export declare function runCommand(command: string, args: string[]): Promise<CommandResult>;
|
|
24
27
|
/** One version per directory, so several can sit side by side and an upgrade
|
|
@@ -85,6 +85,9 @@ export async function ensureCodegraph(options = {}) {
|
|
|
85
85
|
const run = options.run ?? runCommand;
|
|
86
86
|
const log = options.log ?? ((message) => console.log(message));
|
|
87
87
|
const interactive = options.interactive ?? process.stdin.isTTY === true;
|
|
88
|
+
// Annotated so TypeScript can see the calls below never return and keeps the
|
|
89
|
+
// `Presence` narrowing intact.
|
|
90
|
+
const exit = options.exit ?? process.exit;
|
|
88
91
|
const present = await detect(CODEGRAPH_VERSION, root, run);
|
|
89
92
|
if (present.state === "ok")
|
|
90
93
|
return present.path;
|
|
@@ -96,14 +99,16 @@ export async function ensureCodegraph(options = {}) {
|
|
|
96
99
|
if (!interactive) {
|
|
97
100
|
log(`[codegraph] co-maintainer needs codegraph ${CODEGRAPH_VERSION} to index the repository.\n` +
|
|
98
101
|
`Run it with --allow-tool-install, or install it yourself:\n ${installHint(CODEGRAPH_VERSION, root)}`);
|
|
99
|
-
|
|
102
|
+
exit(1);
|
|
100
103
|
}
|
|
101
104
|
const confirm = options.confirm ?? defaultConfirm;
|
|
102
|
-
|
|
105
|
+
// `defaultConfirm` is async, so an unawaited call is always truthy and the
|
|
106
|
+
// user's "no" was silently ignored. Await it before deciding.
|
|
107
|
+
const approved = await confirm(`co-maintainer needs codegraph ${CODEGRAPH_VERSION} to index the repository.\n` +
|
|
103
108
|
`Install it into ${versionDir(CODEGRAPH_VERSION, root)} (your global PATH is not touched)?`);
|
|
104
109
|
if (!approved) {
|
|
105
110
|
log("[codegraph] declined; nothing was installed");
|
|
106
|
-
|
|
111
|
+
exit(1);
|
|
107
112
|
}
|
|
108
113
|
}
|
|
109
114
|
const { command, args } = installCommand(CODEGRAPH_VERSION, root);
|
|
@@ -112,12 +117,12 @@ export async function ensureCodegraph(options = {}) {
|
|
|
112
117
|
const result = await run(command, args);
|
|
113
118
|
if (result.code !== 0) {
|
|
114
119
|
log(`[codegraph] install failed (exit ${result.code}): ${result.stderr.trim() || result.stdout.trim()}`);
|
|
115
|
-
|
|
120
|
+
exit(1);
|
|
116
121
|
}
|
|
117
122
|
const after = await detect(CODEGRAPH_VERSION, root, run);
|
|
118
123
|
if (after.state !== "ok") {
|
|
119
124
|
log(`[codegraph] install finished but ${binaryPath(CODEGRAPH_VERSION, root)} is still not usable`);
|
|
120
|
-
|
|
125
|
+
exit(1);
|
|
121
126
|
}
|
|
122
127
|
log(`[codegraph] ready at ${after.path}`);
|
|
123
128
|
return after.path;
|
|
@@ -141,7 +146,8 @@ export async function ensureCodegraphForReview(options = {}) {
|
|
|
141
146
|
};
|
|
142
147
|
}
|
|
143
148
|
const confirm = options.confirm ?? defaultConfirm;
|
|
144
|
-
|
|
149
|
+
// Same as `ensureCodegraph`: an unawaited async confirm is always truthy.
|
|
150
|
+
const approved = await confirm(`co-maintainer needs codegraph ${CODEGRAPH_VERSION} to index this repository.\n` +
|
|
145
151
|
`Install into ${versionDir(CODEGRAPH_VERSION, root)}?`);
|
|
146
152
|
if (!approved)
|
|
147
153
|
return { reason: "codegraph install declined" };
|
|
@@ -7,7 +7,13 @@ export type ExecOptions = {
|
|
|
7
7
|
env?: Record<string, string>;
|
|
8
8
|
};
|
|
9
9
|
export type CodegraphRunner = (binary: string, args: string[], worktree: string) => Promise<CommandResult>;
|
|
10
|
-
/** Run the codegraph CLI in a repo root (plan §13.4).
|
|
10
|
+
/** Run the codegraph CLI in a repo root (plan §13.4).
|
|
11
|
+
*
|
|
12
|
+
* On Windows the CLI is a `.cmd`/`.ps1` shim, and `node:child_process.spawn`
|
|
13
|
+
* cannot execute those without a shell — it fails with `EINVAL`. `Deno.Command`
|
|
14
|
+
* resolved them natively, so this wrapper is needed only after the Node move.
|
|
15
|
+
* Route through `cmd /c` exactly like `pr/checkout.ts` does for `git`.
|
|
16
|
+
*/
|
|
11
17
|
export declare function execCodegraph(binary: string, args: string[], cwd: string, options?: ExecOptions): Promise<CommandResult>;
|
|
12
18
|
export declare function createCodegraphRunner(indexDir: string): CodegraphRunner;
|
|
13
19
|
export declare function runCodegraphTool(binary: string, args: string[], worktree: string, runner: CodegraphRunner): Promise<string>;
|
|
@@ -1,9 +1,15 @@
|
|
|
1
|
-
import { commandOutput, commandSpawn, envToObject, } from "../util/runtime.js";
|
|
1
|
+
import { commandOutput, commandSpawn, envToObject, isWindows, } from "../util/runtime.js";
|
|
2
2
|
export const LOCAL_CODEGRAPH_DIR = ".co-maintainer-codegraph";
|
|
3
3
|
export const SERVER_CODEGRAPH_DIR = ".codegraph";
|
|
4
4
|
const TOOL_TIMEOUT_MS = 60_000;
|
|
5
5
|
const INDEX_COMMANDS = new Set(["init", "sync", "index", "unlock"]);
|
|
6
|
-
/** Run the codegraph CLI in a repo root (plan §13.4).
|
|
6
|
+
/** Run the codegraph CLI in a repo root (plan §13.4).
|
|
7
|
+
*
|
|
8
|
+
* On Windows the CLI is a `.cmd`/`.ps1` shim, and `node:child_process.spawn`
|
|
9
|
+
* cannot execute those without a shell — it fails with `EINVAL`. `Deno.Command`
|
|
10
|
+
* resolved them natively, so this wrapper is needed only after the Node move.
|
|
11
|
+
* Route through `cmd /c` exactly like `pr/checkout.ts` does for `git`.
|
|
12
|
+
*/
|
|
7
13
|
export async function execCodegraph(binary, args, cwd, options = {}) {
|
|
8
14
|
const indexDir = options.codegraphDir ?? LOCAL_CODEGRAPH_DIR;
|
|
9
15
|
const env = {
|
|
@@ -12,18 +18,20 @@ export async function execCodegraph(binary, args, cwd, options = {}) {
|
|
|
12
18
|
...options.env,
|
|
13
19
|
};
|
|
14
20
|
const timeoutMs = options.timeoutMs === undefined ? TOOL_TIMEOUT_MS : options.timeoutMs;
|
|
21
|
+
const windows = isWindows();
|
|
15
22
|
const commandOptions = {
|
|
16
|
-
args,
|
|
23
|
+
args: windows ? ["/c", binary, ...args] : args,
|
|
17
24
|
cwd,
|
|
18
25
|
env,
|
|
19
26
|
stdout: "piped",
|
|
20
27
|
stderr: "piped",
|
|
21
28
|
};
|
|
29
|
+
const target = windows ? "cmd" : binary;
|
|
22
30
|
if (timeoutMs === null) {
|
|
23
|
-
const output = await commandOutput(
|
|
31
|
+
const output = await commandOutput(target, commandOptions);
|
|
24
32
|
return decode(output);
|
|
25
33
|
}
|
|
26
|
-
const proc = commandSpawn(
|
|
34
|
+
const proc = commandSpawn(target, commandOptions);
|
|
27
35
|
let timedOut = false;
|
|
28
36
|
const timer = setTimeout(() => {
|
|
29
37
|
timedOut = true;
|
|
@@ -40,7 +40,11 @@ export declare function makeTempDir(options?: {
|
|
|
40
40
|
export declare function makeTempDirSync(options?: {
|
|
41
41
|
prefix?: string;
|
|
42
42
|
}): string;
|
|
43
|
-
/** A uniquely named temp file (created empty), mirroring `Deno.makeTempFile`.
|
|
43
|
+
/** A uniquely named temp file (created empty), mirroring `Deno.makeTempFile`.
|
|
44
|
+
*
|
|
45
|
+
* `wx` fails on collision instead of truncating an existing file, so the retry
|
|
46
|
+
* loop preserves `mkdtemp`'s uniqueness without leaving a directory behind —
|
|
47
|
+
* callers only ever delete the returned file. */
|
|
44
48
|
export declare function makeTempFile(options?: {
|
|
45
49
|
prefix?: string;
|
|
46
50
|
suffix?: string;
|
|
@@ -87,4 +91,13 @@ export declare function commandSpawn(command: string, options?: CommandOptions):
|
|
|
87
91
|
* POSIX: signal 0 checks existence without delivering; `ESRCH` means dead,
|
|
88
92
|
* while `EPERM` (or anything else) means it exists but is not ours — assume
|
|
89
93
|
* alive rather than risk two writers on the same database. */
|
|
94
|
+
/** Decides Windows PID liveness from a `tasklist` invocation. A matching row is
|
|
95
|
+
* CSV and starts with the quoted image name; the "no tasks" notice is prose, so
|
|
96
|
+
* the distinction is locale-independent. A spawn failure or an empty body is
|
|
97
|
+
* indeterminate and reports alive: refusing to start beats letting a second
|
|
98
|
+
* writer take over a live lock. */
|
|
99
|
+
export declare function livenessFromTasklist(result: {
|
|
100
|
+
error?: unknown;
|
|
101
|
+
stdout?: string | null;
|
|
102
|
+
}): boolean;
|
|
90
103
|
export declare function isProcessAlive(pid: number): boolean;
|
package/dist/src/util/runtime.js
CHANGED
|
@@ -102,13 +102,27 @@ export function makeTempDir(options) {
|
|
|
102
102
|
export function makeTempDirSync(options) {
|
|
103
103
|
return fsMkdtempSync(join(tmpdir(), options?.prefix ?? "cm-"));
|
|
104
104
|
}
|
|
105
|
-
/** A uniquely named temp file (created empty), mirroring `Deno.makeTempFile`.
|
|
105
|
+
/** A uniquely named temp file (created empty), mirroring `Deno.makeTempFile`.
|
|
106
|
+
*
|
|
107
|
+
* `wx` fails on collision instead of truncating an existing file, so the retry
|
|
108
|
+
* loop preserves `mkdtemp`'s uniqueness without leaving a directory behind —
|
|
109
|
+
* callers only ever delete the returned file. */
|
|
106
110
|
export async function makeTempFile(options) {
|
|
107
|
-
const
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
111
|
+
const prefix = options?.prefix ?? "cm-";
|
|
112
|
+
const suffix = options?.suffix ?? "";
|
|
113
|
+
for (let attempt = 0; attempt < 10; attempt++) {
|
|
114
|
+
const path = join(tmpdir(), `${prefix}${crypto.randomUUID()}${suffix}`);
|
|
115
|
+
try {
|
|
116
|
+
await fsWriteFile(path, "", { flag: "wx" });
|
|
117
|
+
return path;
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
if (error.code === "EEXIST")
|
|
121
|
+
continue;
|
|
122
|
+
throw error;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
throw new Error(`could not create a temp file with prefix ${prefix}`);
|
|
112
126
|
}
|
|
113
127
|
export async function* readDir(dir) {
|
|
114
128
|
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
@@ -189,12 +203,25 @@ export function commandSpawn(command, options = {}) {
|
|
|
189
203
|
* POSIX: signal 0 checks existence without delivering; `ESRCH` means dead,
|
|
190
204
|
* while `EPERM` (or anything else) means it exists but is not ours — assume
|
|
191
205
|
* alive rather than risk two writers on the same database. */
|
|
206
|
+
/** Decides Windows PID liveness from a `tasklist` invocation. A matching row is
|
|
207
|
+
* CSV and starts with the quoted image name; the "no tasks" notice is prose, so
|
|
208
|
+
* the distinction is locale-independent. A spawn failure or an empty body is
|
|
209
|
+
* indeterminate and reports alive: refusing to start beats letting a second
|
|
210
|
+
* writer take over a live lock. */
|
|
211
|
+
export function livenessFromTasklist(result) {
|
|
212
|
+
if (result.error)
|
|
213
|
+
return true;
|
|
214
|
+
const out = (result.stdout ?? "").trim();
|
|
215
|
+
if (out === "")
|
|
216
|
+
return true;
|
|
217
|
+
return out.startsWith('"');
|
|
218
|
+
}
|
|
192
219
|
export function isProcessAlive(pid) {
|
|
193
220
|
if (isWindows()) {
|
|
194
221
|
const result = spawnSync("tasklist", ["/FI", `PID eq ${pid}`, "/FO", "CSV", "/NH"], {
|
|
195
222
|
encoding: "utf8",
|
|
196
223
|
});
|
|
197
|
-
return (result
|
|
224
|
+
return livenessFromTasklist(result);
|
|
198
225
|
}
|
|
199
226
|
try {
|
|
200
227
|
process.kill(pid, 0);
|
package/package.json
CHANGED
|
@@ -1,10 +1,25 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "co-maintainer",
|
|
3
|
-
"version": "0.4.0
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Analyzes a GitHub repository and writes repository-specific contribution guidance.",
|
|
5
5
|
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/GroophyLifefor/co-maintainer"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://github.com/GroophyLifefor/co-maintainer#readme",
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/GroophyLifefor/co-maintainer/issues"
|
|
13
|
+
},
|
|
14
|
+
"keywords": [
|
|
15
|
+
"github",
|
|
16
|
+
"pull-request",
|
|
17
|
+
"code-review",
|
|
18
|
+
"maintainer",
|
|
19
|
+
"ai"
|
|
20
|
+
],
|
|
6
21
|
"bin": {
|
|
7
|
-
"co-maintainer": "
|
|
22
|
+
"co-maintainer": "dist/main.js"
|
|
8
23
|
},
|
|
9
24
|
"files": [
|
|
10
25
|
"dist/",
|