Skip to content

Commit 080ea4d

Browse files
committed
Integrate CR3 navigation with the reviewed CR2 read service
2 parents 65e86dd + 4dd5bb5 commit 080ea4d

14 files changed

Lines changed: 2815 additions & 18 deletions

‎apps/desktop/src/main/commands.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { registerAttachmentCommands } from "./attachment-commands.js";
22
import { cliHealth } from "./engine/cli-path.js";
33
import { ThreadContextReader } from "./engine/thread-context.js";
4+
import { CodeReviewService } from "./engine/code-review/read-service.js";
45
import type { BackendId, BridgeCommands, ThreadEvent } from "../shared/types.js";
56
import type { Store } from "./engine/store.js";
67
import type { ThreadRunner } from "./engine/runner.js";
@@ -30,6 +31,7 @@ export function registerCommands(handle: RegisterCommand, {
3031
}): void {
3132
const sessionCliSettings = store.settings;
3233
const threadContexts = new ThreadContextReader({ enabled: false });
34+
const codeReview = new CodeReviewService({ enabled: false });
3335
function cwdFor(threadId: string): string {
3436
const thread = store.thread(threadId);
3537
if (!thread) throw new Error(`unknown thread ${threadId}`);
@@ -115,6 +117,7 @@ export function registerCommands(handle: RegisterCommand, {
115117
handle("canvas:close", () => {});
116118
handle("files:list", ({ threadId }) => listWorkspaceFiles(cwdFor(threadId)));
117119
handle("thread:context", ({ threadId }) => threadContexts.read(cwdFor(threadId)));
120+
handle("code-review:read", (request) => codeReview.read(request));
118121
handle("files:read", ({ threadId, path }) => readWorkspaceFile(cwdFor(threadId), path));
119122
handle("changes:status", ({ threadId }) => gitx.status(cwdFor(threadId)));
120123
handle("changes:diff", ({ threadId, path: rel, original, fullContext }) => gitx.diff(cwdFor(threadId), rel, original, fullContext));
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import { execFile } from "node:child_process";
2+
import fs from "node:fs";
3+
import os from "node:os";
4+
import path from "node:path";
5+
6+
/**
7+
* One read through the person's own `gh` login (#320 decision 3): `gh api <path> --method GET --include`
8+
* on github.com. Modex never sees or stores the token, and nothing here writes.
9+
*/
10+
export interface GhCall {
11+
/** A path main built itself, every segment encoded; checked again here. */
12+
endpoint: string;
13+
accept: (typeof MEDIA_TYPES)[number];
14+
/** The ETag of the copy main holds, for a conditional read that GitHub does not charge. */
15+
etag: string | null;
16+
maxBytes: number;
17+
timeoutMs: number;
18+
signal: AbortSignal;
19+
}
20+
21+
/** An HTTP answer, or why there is none. Nothing gh prints to stderr is kept: it can name accounts or tokens. */
22+
export type GhAnswer =
23+
| { kind: "http"; status: number; headers: Record<string, string>; body: Buffer }
24+
| { kind: "no-cli" | "signed-out" | "offline" | "timeout" | "cancelled" | "too-large" | "failed" };
25+
26+
export type GhTransport = (call: GhCall) => Promise<GhAnswer>;
27+
28+
const MEDIA_TYPES = ["application/vnd.github+json", "application/vnd.github.raw"] as const;
29+
const SEGMENT = "[A-Za-z0-9%_.~*'()!-]+";
30+
const QUERY = "[A-Za-z0-9%=&+_.~*'()!-]+";
31+
/** `user`, the issue search, or a path under `repos/`: no flag, host, placeholder, space or dot segment. */
32+
const ENDPOINT = new RegExp(`^(?:user|search/issues\\?${QUERY}|repos(?:/${SEGMENT}){2,}(?:\\?${QUERY})?)$`);
33+
const ETAG = /^(?:W\/)?"[\x21\x23-\x7e]{1,200}"$/;
34+
/** What gh prints when it cannot reach the API at all. */
35+
const NETWORK = /dial tcp|no such host|connection (?:refused|reset)|network is unreachable|i\/o timeout|TLS handshake|proxyconnect|EOF/i;
36+
37+
export function ghArgs(call: GhCall): string[] {
38+
const route = call.endpoint.split("?")[0]!;
39+
if (!ENDPOINT.test(call.endpoint) || route.split("/").some((segment) => segment === "." || segment === "..")) {
40+
throw new Error("Code Review endpoint is not allowed.");
41+
}
42+
if (!MEDIA_TYPES.includes(call.accept)) throw new Error("Unsupported Code Review media type.");
43+
if (call.etag !== null && !ETAG.test(call.etag)) throw new Error("Invalid ETag.");
44+
return ["api", call.endpoint, "--hostname", "github.com", "--method", "GET", "--include", "-H", `Accept: ${call.accept}`,
45+
"-H", "X-GitHub-Api-Version: 2022-11-28", ...(call.etag ? ["-H", `If-None-Match: ${call.etag}`] : [])];
46+
}
47+
48+
/** gh's `--include` output: a status line ending in LF, headers ending in CRLF, a blank line, then the body. */
49+
export function parseIncluded(stdout: Buffer): { status: number; headers: Record<string, string>; body: Buffer } | null {
50+
const lineEnd = stdout.indexOf(0x0a);
51+
if (lineEnd < 0) return null;
52+
const match = /^HTTP\/\d(?:\.\d)? (\d{3})(?: [^\r\n]*)?$/.exec(stdout.subarray(0, lineEnd).toString("latin1").replace(/\r$/, ""));
53+
if (!match) return null;
54+
const rest = stdout.subarray(lineEnd + 1);
55+
let block: string, body: Buffer;
56+
if (rest.subarray(0, 2).toString("latin1") === "\r\n") {
57+
block = "";
58+
body = rest.subarray(2);
59+
} else {
60+
const end = rest.indexOf("\r\n\r\n");
61+
if (end < 0) return null;
62+
block = rest.subarray(0, end).toString("latin1");
63+
body = rest.subarray(end + 4);
64+
}
65+
const headers: Record<string, string> = {};
66+
for (const line of block ? block.split(/\r?\n/) : []) {
67+
const colon = line.indexOf(":");
68+
if (colon <= 0) return null;
69+
const name = line.slice(0, colon).trim().toLowerCase(), value = line.slice(colon + 1).trim();
70+
headers[name] = Object.hasOwn(headers, name) ? `${headers[name]}, ${value}` : value;
71+
}
72+
return { status: Number(match[1]), headers, body };
73+
}
74+
75+
/** No shell, prompt, pager, colour, update check or debug output, and only absolute PATH entries. */
76+
function environment(): NodeJS.ProcessEnv {
77+
const searchPath = [...new Set([...(process.env.PATH ?? "").split(path.delimiter).filter(path.isAbsolute), "/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"])].join(path.delimiter);
78+
const env: NodeJS.ProcessEnv = { ...process.env, PATH: searchPath, GH_PROMPT_DISABLED: "1", GH_PAGER: "cat", NO_COLOR: "1", CLICOLOR: "0",
79+
GH_NO_UPDATE_NOTIFIER: "1", GH_SPINNER_DISABLED: "1" };
80+
delete env.GH_DEBUG;
81+
delete env.DEBUG;
82+
return env;
83+
}
84+
85+
export function ghTransport(bin = "gh"): GhTransport {
86+
return (call) => {
87+
const args = ghArgs(call);
88+
return new Promise((resolve) => {
89+
execFile(bin, args, { cwd: os.homedir(), env: environment(), encoding: "buffer", timeout: call.timeoutMs, maxBuffer: call.maxBytes, signal: call.signal, windowsHide: true },
90+
(error, stdout, stderr) => {
91+
const failure = error as (Omit<NodeJS.ErrnoException, "code"> & { killed?: boolean; code?: string | number }) | null;
92+
if (call.signal.aborted) return resolve({ kind: "cancelled" });
93+
if (failure?.code === "ENOENT") return resolve({ kind: "no-cli" });
94+
if (failure?.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") return resolve({ kind: "too-large" });
95+
if (failure?.killed) return resolve({ kind: "timeout" });
96+
const answer = parseIncluded(stdout);
97+
// gh exits 1 for a 304 or a 4xx it printed in full; after a 2xx it means the body was cut short.
98+
if (answer && failure && answer.status >= 200 && answer.status < 300) return resolve({ kind: "failed" });
99+
if (answer) return resolve({ kind: "http", ...answer });
100+
if (failure?.code === 4) return resolve({ kind: "signed-out" });
101+
resolve({ kind: failure && NETWORK.test(stderr.toString("utf8")) ? "offline" : "failed" });
102+
});
103+
});
104+
};
105+
}
106+
107+
/**
108+
* A fingerprint of gh's hosts.yml, which `gh auth switch`, `login` and `logout` rewrite: a change means
109+
* the account may have changed under main. "absent" when there is none; null where it can't be read,
110+
* and main then confirms the account after every read instead.
111+
*/
112+
export function ghHostsStamp(): string | null {
113+
const dir = process.env.GH_CONFIG_DIR || (process.env.XDG_CONFIG_HOME ? path.join(process.env.XDG_CONFIG_HOME, "gh")
114+
: process.platform === "win32" && process.env.AppData ? path.join(process.env.AppData, "GitHub CLI") : path.join(os.homedir(), ".config", "gh"));
115+
try {
116+
const stat = fs.statSync(path.join(dir, "hosts.yml"));
117+
// The change time too: a same-size rewrite within one modification-time tick still moves it.
118+
return `${stat.ino}:${stat.size}:${stat.mtimeMs}:${stat.ctimeMs}`;
119+
} catch (error) {
120+
return (error as NodeJS.ErrnoException).code === "ENOENT" ? "absent" : null;
121+
}
122+
}

0 commit comments

Comments
 (0)