mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
f08764063d
* feat(review): support multi-repo workspace reviews (#527) * fix(workspace): address critical issues from deep review - Fix race condition in label generation by pre-computing labels sequentially - Fix rewritePatchLine to support quoted paths and rename/copy headers - Add separator between aggregated patches to avoid invalid diffs - Normalize input paths in resolveWorkspaceFilePath - Add timeout to PR discovery (15s) to prevent server hangs - Fix PATCH /api/workspace/repo to rollback state on failure via applyRepoMutation - Validate body.source runtime (must be 'local' or 'pr') - Snapshot active repo in agent jobs at launch to prevent race in onJobComplete - Prevent double-prefixing of agent findings when paths are already prefixed - Fix frontend findWorkspaceRepoForPath to use longest-prefix matching - Fix shared types: diffType uses DiffType, platformUser is string | null * fix: remove duplicate gitRuntime export in vcs.ts * fix: resolve remaining merge conflict in local review mode, remove stale detectManagedVcs import * Add local multi-repo workspace review support * Fix workspace review edge cases * Remove session query from browser launch * Add switchable workspace review modes * fix(review): cover opencode workspace bridge * fix(review): clean workspace review plumbing * fix(review): clarify workspace agent finding paths * fix(review): preserve diff paths with spaces --------- Co-authored-by: Michael Ramos <mdramos8@gmail.com>
147 lines
5.3 KiB
TypeScript
147 lines
5.3 KiB
TypeScript
import { describe, expect, test } from "bun:test";
|
|
import { writeFile, mkdtemp } from "node:fs/promises";
|
|
import { existsSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import {
|
|
buildTourClaudeCommand,
|
|
buildTourUserMessage,
|
|
parseTourStreamOutput,
|
|
parseTourFileOutput,
|
|
} from "./tour-review";
|
|
|
|
const stop = {
|
|
title: "Add retry",
|
|
gist: "Wrap the fetch in a retry.",
|
|
detail: "Three attempts, exponential backoff.",
|
|
transition: "",
|
|
anchors: [{ file: "src/a.ts", line: 1, end_line: 10, hunk: "", label: "retry" }],
|
|
};
|
|
const validOutput = {
|
|
title: "Retry pass",
|
|
greeting: "hi",
|
|
intent: "",
|
|
before: "",
|
|
after: "",
|
|
key_takeaways: [],
|
|
stops: [stop],
|
|
qa_checklist: [],
|
|
};
|
|
|
|
describe("parseTourStreamOutput", () => {
|
|
test("returns parsed output from terminal result event", () => {
|
|
const stdout = [
|
|
JSON.stringify({ type: "assistant" }),
|
|
JSON.stringify({ type: "result", is_error: false, structured_output: validOutput }),
|
|
].join("\n");
|
|
expect(parseTourStreamOutput(stdout)).toEqual(validOutput);
|
|
});
|
|
|
|
test("returns null when result has is_error: true", () => {
|
|
const stdout = JSON.stringify({ type: "result", is_error: true, structured_output: validOutput });
|
|
expect(parseTourStreamOutput(stdout)).toBeNull();
|
|
});
|
|
|
|
test("returns null when stops array is empty", () => {
|
|
const stdout = JSON.stringify({ type: "result", is_error: false, structured_output: { stops: [] } });
|
|
expect(parseTourStreamOutput(stdout)).toBeNull();
|
|
});
|
|
|
|
test("returns null when structured_output has no stops key", () => {
|
|
const stdout = JSON.stringify({ type: "result", is_error: false, structured_output: {} });
|
|
expect(parseTourStreamOutput(stdout)).toBeNull();
|
|
});
|
|
|
|
test("returns null on empty input", () => {
|
|
expect(parseTourStreamOutput("")).toBeNull();
|
|
expect(parseTourStreamOutput(" \n ")).toBeNull();
|
|
});
|
|
|
|
test("does not throw on truncated/malformed JSON", () => {
|
|
const stdout = '{"type":"assistant"}\n{"type":"result","is_err';
|
|
expect(parseTourStreamOutput(stdout)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("parseTourFileOutput", () => {
|
|
test("returns null when file missing", async () => {
|
|
const missing = join(tmpdir(), "plannotator-tour-missing-" + Date.now() + ".json");
|
|
expect(await parseTourFileOutput(missing)).toBeNull();
|
|
});
|
|
|
|
test("returns parsed output and unlinks file", async () => {
|
|
const dir = await mkdtemp(join(tmpdir(), "plannotator-tour-"));
|
|
const file = join(dir, "out.json");
|
|
await writeFile(file, JSON.stringify(validOutput));
|
|
const result = await parseTourFileOutput(file);
|
|
expect(result).toEqual(validOutput);
|
|
expect(existsSync(file)).toBe(false);
|
|
});
|
|
|
|
test("returns null and unlinks file when JSON is malformed", async () => {
|
|
const dir = await mkdtemp(join(tmpdir(), "plannotator-tour-"));
|
|
const file = join(dir, "out.json");
|
|
await writeFile(file, "{not json");
|
|
const result = await parseTourFileOutput(file);
|
|
expect(result).toBeNull();
|
|
expect(existsSync(file)).toBe(false);
|
|
});
|
|
|
|
test("returns null when stops array is empty", async () => {
|
|
const dir = await mkdtemp(join(tmpdir(), "plannotator-tour-"));
|
|
const file = join(dir, "out.json");
|
|
await writeFile(file, JSON.stringify({ stops: [] }));
|
|
expect(await parseTourFileOutput(file)).toBeNull();
|
|
});
|
|
|
|
test("returns null when stops key missing", async () => {
|
|
const dir = await mkdtemp(join(tmpdir(), "plannotator-tour-"));
|
|
const file = join(dir, "out.json");
|
|
await writeFile(file, JSON.stringify({ other: 1 }));
|
|
expect(await parseTourFileOutput(file)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("buildTourUserMessage", () => {
|
|
const patch = "diff --git a/src/large.ts b/src/large.ts\n+const value = 1;\n";
|
|
|
|
test("builds JJ tour instructions without inlining the patch", () => {
|
|
const cases = [
|
|
["jj-current", "jj diff --git -r @"],
|
|
["jj-last", "jj diff --git -r @-"],
|
|
["jj-line", "jj diff --git --from 'heads(::@ & ::(trunk()))' --to @"],
|
|
["jj-all", "jj diff --git --from 'root()' --to @"],
|
|
] as const;
|
|
|
|
for (const [diffType, command] of cases) {
|
|
const message = buildTourUserMessage(patch, diffType, { defaultBranch: "trunk()" });
|
|
expect(message).toContain("Walk the reviewer through");
|
|
expect(message).toContain(command);
|
|
expect(message).not.toContain(patch);
|
|
}
|
|
});
|
|
|
|
test("falls back to the inline patch for unknown local diff types", () => {
|
|
const message = buildTourUserMessage(patch, "p4-default");
|
|
|
|
expect(message).toContain("Walk the reviewer through the following code changes");
|
|
expect(message).toContain(patch);
|
|
});
|
|
});
|
|
|
|
describe("buildTourClaudeCommand", () => {
|
|
test("allows read-only JJ commands", () => {
|
|
const command = buildTourClaudeCommand("tour").command;
|
|
const allowedTools = command[command.indexOf("--allowedTools") + 1];
|
|
|
|
expect(allowedTools).toContain("Bash(jj status:*)");
|
|
expect(allowedTools).toContain("Bash(jj diff:*)");
|
|
expect(allowedTools).toContain("Bash(jj log:*)");
|
|
expect(allowedTools).toContain("Bash(jj show:*)");
|
|
expect(allowedTools).toContain("Bash(jj file show:*)");
|
|
expect(allowedTools).toContain("Bash(jj cat:*)");
|
|
expect(allowedTools).toContain("Bash(jj bookmark list:*)");
|
|
expect(allowedTools).toContain("Bash(git -C:*)");
|
|
});
|
|
});
|