Files
Michael Ramos 64062af9a1 feat: Portable Guided Reviews — export, share links, agent-authored guides, guides.show (#1324)
A Guided Review can now leave Plannotator: as a single self-contained HTML file that renders exactly like the in-app guide, as an encrypted-by-default share link on guides.show, or authored by any agent through the new guide CLI.

Highlights: packages/guide-viewer extracted from review-editor at the injection seam (read-only host, no third renderer); guides.show Worker with R2-backed share storage, per-IP rate limiting on creation, delete tokens hashed at rest, and 128-bit ids; portable exports pin the viewer by SRI hash with budget and manifest gates in PR CI and at deploy; two-runtime parity across Bun and Pi verified; v0.27.x saved guides load unchanged. Retention is indefinite by explicit decision, to revisit with the lean sharing refactor.

Decision record: adr/decisions/007-portable-guided-reviews-20260815.md
2026-08-16 12:17:13 -07:00

258 lines
9.5 KiB
TypeScript

export interface DiffPathPair {
oldPath?: string;
newPath?: string;
}
/** Single-char C escapes git emits (see git's quote.c) mapped to their byte. */
const C_ESCAPES: Record<string, number> = {
'"': 0x22, "\\": 0x5c, a: 0x07, b: 0x08, f: 0x0c,
n: 0x0a, r: 0x0d, t: 0x09, v: 0x0b,
};
/**
* Undo git's C-style path quoting (core.quotePath): `"caf\303\251.txt"` →
* `café.txt`. Octal escapes are RAW BYTES of the UTF-8 encoded path —
* consecutive octal escapes are collected into a byte buffer and decoded as
* one UTF-8 sequence. JSON.parse cannot do this: octal escapes are invalid
* JSON, so non-ASCII names silently kept their literal `\303\251` form and
* broke every downstream file access.
*
* Literal (non-escaped) characters are appended as-is, NOT pushed through the
* byte decoder — our own quoteGitPath (JSON.stringify, used when synthesizing
* workspace patch headers) leaves unicode unescaped inside quotes, and those
* headers round-trip through this function too. Unquoted values pass through
* untouched.
*/
export function unquoteGitPath(value: string): string {
if (!value.startsWith('"') || !value.endsWith('"')) return value;
const inner = value.slice(1, -1);
const decoder = new TextDecoder();
let out = "";
let pendingBytes: number[] = [];
const flush = (): void => {
if (pendingBytes.length > 0) {
out += decoder.decode(new Uint8Array(pendingBytes));
pendingBytes = [];
}
};
for (let i = 0; i < inner.length; i++) {
if (inner[i] !== "\\") {
// Appending code units in order preserves any literal unicode,
// including surrogate pairs.
flush();
out += inner[i];
continue;
}
const next = inner[i + 1];
if (next >= "0" && next <= "7") {
// Octal escape: up to 3 digits, one raw byte of the UTF-8 path.
let oct = "";
let j = i + 1;
while (j < inner.length && oct.length < 3 && inner[j] >= "0" && inner[j] <= "7") {
oct += inner[j];
j++;
}
pendingBytes.push(parseInt(oct, 8) & 0xff);
i = j - 1;
} else if (next !== undefined && next in C_ESCAPES) {
flush();
out += String.fromCharCode(C_ESCAPES[next]);
i++;
} else if (next === "u" && /^[0-9a-fA-F]{4}$/.test(inner.slice(i + 2, i + 6))) {
// \uXXXX: never emitted by git, but our own quoteGitPath is
// JSON.stringify, which uses it for control chars lacking a short
// JSON escape (e.g. a vertical tab, \u000b) — synthesized workspace headers round-trip
// through here, so this must decode or the path keeps a literal
// backslash and file access breaks.
flush();
out += String.fromCharCode(parseInt(inner.slice(i + 2, i + 6), 16));
i += 5;
} else {
// Unknown escape — keep the backslash literally.
flush();
out += "\\";
}
}
flush();
return out;
}
export function quoteGitPath(value: string): string {
if (!/[\s"\\]/.test(value)) return value;
return JSON.stringify(value);
}
function stripUnquotedPathMetadata(token: string): string {
if (token.startsWith('"')) return token;
const tabIndex = token.indexOf("\t");
return tabIndex === -1 ? token : token.slice(0, tabIndex);
}
export function parsePatchPathToken(token: string, side: "a" | "b"): string | null {
const pathToken = stripUnquotedPathMetadata(token);
if (pathToken === "/dev/null") return "/dev/null";
const unquoted = unquoteGitPath(pathToken);
const prefix = `${side}/`;
return unquoted.startsWith(prefix) ? unquoted.slice(prefix.length) : null;
}
function scanHeaderToken(input: string): { token: string; rest: string } | null {
const trimmed = input.trimStart();
if (!trimmed) return null;
if (trimmed.startsWith('"')) {
let escaped = false;
for (let i = 1; i < trimmed.length; i += 1) {
const char = trimmed[i];
if (escaped) {
escaped = false;
continue;
}
if (char === "\\") {
escaped = true;
continue;
}
if (char === '"') {
return { token: trimmed.slice(0, i + 1), rest: trimmed.slice(i + 1) };
}
}
return null;
}
const space = trimmed.indexOf(" ");
if (space === -1) return { token: trimmed, rest: "" };
return { token: trimmed.slice(0, space), rest: trimmed.slice(space + 1) };
}
export function parseDiffGitHeader(header: string): DiffPathPair {
const prefix = "diff --git ";
if (!header.startsWith(prefix)) return {};
const rest = header.slice(prefix.length);
if (rest.trimStart().startsWith('"')) {
const first = scanHeaderToken(rest);
const second = first ? scanHeaderToken(first.rest) : null;
if (first && second) {
const oldPath = parsePatchPathToken(first.token, "a");
const newPath = parsePatchPathToken(second.token, "b");
return {
oldPath: oldPath && oldPath !== "/dev/null" ? oldPath : undefined,
newPath: newPath && newPath !== "/dev/null" ? newPath : undefined,
};
}
}
const match = header.match(/^diff --git a\/(.+) b\/(.+)$/);
if (!match) return {};
return { oldPath: match[1], newPath: match[2] };
}
export function formatPatchPathToken(side: "a" | "b", filePath: string): string {
if (filePath === "/dev/null") return filePath;
return quoteGitPath(`${side}/${filePath}`);
}
export function parseDiffMetadataPathToken(token: string): string {
if (token === "/dev/null") return token;
return unquoteGitPath(token);
}
export function formatDiffMetadataPathToken(filePath: string): string {
if (filePath === "/dev/null") return filePath;
return quoteGitPath(filePath);
}
export function parseDiffFilePathLines(lines: string[]): DiffPathPair {
let oldPath: string | undefined;
let newPath: string | undefined;
for (const line of lines) {
if (line.startsWith("@@ ") || line === "GIT binary patch") break;
if (line.startsWith("--- ")) {
const parsed = parsePatchPathToken(line.slice(4), "a");
if (parsed && parsed !== "/dev/null") oldPath = parsed;
} else if (line.startsWith("+++ ")) {
const parsed = parsePatchPathToken(line.slice(4), "b");
if (parsed && parsed !== "/dev/null") newPath = parsed;
}
}
return { oldPath, newPath };
}
/**
* Extended-header line the review core injects into the display-only stub it
* emits for a file whose bytes exceed the review size cap. Without it the stub
* is indistinguishable from a genuine binary file, and the UI could only show
* a header-only card with no counts and no explanation — which reads as broken.
*
* Lives here (rather than in review-core) so the browser bundle can detect the
* shape without pulling the whole node-facing review core in. Both server
* runtimes get it from review-core, which is vendored to Pi alongside this file.
*
* The `#` prefix is what makes detection unambiguous: diff CONTENT lines are
* always prefixed with `+`, `-`, or a space, so a bare match on this exact line
* can only come from the extended header we wrote. Git ignores unknown
* extended-header lines, and @pierre/diffs parses the stub identically with or
* without it.
*/
export const OVERSIZED_REVIEW_STUB_MARKER = "#plannotator-oversized-file";
/**
* Human-readable form of the cap for UI copy. The authoritative byte value is
* `MAX_REVIEW_FILE_CONTENT_BYTES` in review-core, which is node-facing; a
* review-core test asserts the two never drift.
*/
export const OVERSIZED_REVIEW_STUB_LIMIT_LABEL = "5 MB";
/** True when `patch` is one of our oversized-file stubs (see the marker above). */
export function isOversizedReviewStubPatch(patch: string): boolean {
return patch.split("\n").some((line) => line === OVERSIZED_REVIEW_STUB_MARKER);
}
/**
* True when a single file's patch chunk carries a binary marker and no hunks,
* so a diff renderer has literally nothing to draw for it.
*
* The GENERAL case, of which `isOversizedReviewStubPatch` above is the one
* specific case we can name: git emits this shape for real binary files, and
* the review core emits it for files it declined to read. Either way the card
* renders as a bare header with no counts and no body, which reads as a broken
* or empty diff rather than as content that was deliberately not shown.
*
* Callers that can say something more specific should ask the marker predicate
* FIRST and fall back to this one, so a marker-carrying stub is explained once,
* by the message that knows why.
*
* Scanning stops at the first hunk header: content lines always carry a `+`,
* `-`, or space prefix, so a `Binary files ` line at column zero before any
* `@@ ` can only be the extended header git (or the stub builder) wrote.
*/
export function isContentlessBinaryPatch(patch: string): boolean {
let hasBinaryMarker = false;
for (const line of patch.split("\n")) {
if (line.startsWith("@@ ")) return false;
if (line.startsWith("Binary files ") || line === "GIT binary patch") {
hasBinaryMarker = true;
}
}
return hasBinaryMarker;
}
export function parseDiffMetadataPathLines(lines: string[]): DiffPathPair {
let oldPath: string | undefined;
let newPath: string | undefined;
for (const line of lines) {
if (line.startsWith("rename from ") || line.startsWith("copy from ")) {
const parsed = parseDiffMetadataPathToken(line.slice(line.indexOf(" from ") + " from ".length));
if (parsed !== "/dev/null") oldPath = parsed;
} else if (line.startsWith("rename to ") || line.startsWith("copy to ")) {
const parsed = parseDiffMetadataPathToken(line.slice(line.indexOf(" to ") + " to ".length));
if (parsed !== "/dev/null") newPath = parsed;
}
}
return { oldPath, newPath };
}