@plannotator/pi-extension 0.19.10 → 0.19.11
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/generated/agent-review-message.ts +148 -0
- package/generated/claude-review.ts +5 -1
- package/generated/codex-review.ts +0 -82
- package/generated/jj-core.ts +329 -0
- package/generated/review-args.ts +86 -0
- package/generated/review-core.ts +73 -1
- package/generated/tour-review.ts +17 -33
- package/generated/vcs-core.ts +452 -0
- package/index.ts +9 -4
- package/package.json +1 -1
- package/plannotator-browser.ts +143 -133
- package/plannotator-events.ts +5 -1
- package/plannotator.html +3 -3
- package/review-editor.html +98 -51
- package/server/serverReview.ts +30 -102
- package/server/vcs.ts +120 -0
- package/server.ts +17 -3
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// @generated — DO NOT EDIT. Source: packages/server/agent-review-message.ts
|
|
2
|
+
import {
|
|
3
|
+
JJ_TRUNK_REVSET,
|
|
4
|
+
jjLineBaseRevset,
|
|
5
|
+
parseWorktreeDiffType,
|
|
6
|
+
type DiffType,
|
|
7
|
+
} from "./review-core.js";
|
|
8
|
+
import type { PRMetadata } from "./pr-provider.js";
|
|
9
|
+
|
|
10
|
+
export interface AgentReviewUserMessageOptions {
|
|
11
|
+
defaultBranch?: string;
|
|
12
|
+
hasLocalAccess?: boolean;
|
|
13
|
+
prDiffScope?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface LocalDiffInstruction {
|
|
17
|
+
target: string;
|
|
18
|
+
inspect: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Build the dynamic user message shared by local Claude and Codex review jobs. */
|
|
22
|
+
export function buildAgentReviewUserMessage(
|
|
23
|
+
patch: string,
|
|
24
|
+
diffType: DiffType,
|
|
25
|
+
options?: AgentReviewUserMessageOptions,
|
|
26
|
+
prMetadata?: PRMetadata,
|
|
27
|
+
): string {
|
|
28
|
+
if (prMetadata) {
|
|
29
|
+
if (options?.prDiffScope === "full-stack") {
|
|
30
|
+
return [
|
|
31
|
+
`Full-stack review of ${prMetadata.url}`,
|
|
32
|
+
"",
|
|
33
|
+
"This is a stacked PR. The diff below shows ALL accumulated changes from the repository default branch through this PR's head (not just this PR's own layer).",
|
|
34
|
+
"Review the complete diff for issues that span the stack.",
|
|
35
|
+
"",
|
|
36
|
+
"```diff",
|
|
37
|
+
patch,
|
|
38
|
+
"```",
|
|
39
|
+
].join("\n");
|
|
40
|
+
}
|
|
41
|
+
if (options?.hasLocalAccess) {
|
|
42
|
+
return [
|
|
43
|
+
prMetadata.url,
|
|
44
|
+
"",
|
|
45
|
+
"You are in a local worktree checked out at the PR head. The code is available locally.",
|
|
46
|
+
`To see the PR changes, diff against the remote base branch: git diff origin/${prMetadata.baseBranch}...HEAD`,
|
|
47
|
+
"Do NOT diff against the local `main` branch; it may be stale. Always use origin/.",
|
|
48
|
+
].join("\n");
|
|
49
|
+
}
|
|
50
|
+
return prMetadata.url;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const instruction = getLocalDiffInstruction(diffType, options?.defaultBranch);
|
|
54
|
+
if (instruction) {
|
|
55
|
+
return `Review ${instruction.target}. ${instruction.inspect} Provide prioritized, actionable findings.`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return [
|
|
59
|
+
"Review the following code changes and provide prioritized findings.",
|
|
60
|
+
"",
|
|
61
|
+
"```diff",
|
|
62
|
+
patch,
|
|
63
|
+
"```",
|
|
64
|
+
].join("\n");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function getLocalDiffInstruction(
|
|
68
|
+
diffType: DiffType,
|
|
69
|
+
defaultBranch?: string,
|
|
70
|
+
): LocalDiffInstruction | null {
|
|
71
|
+
const effectiveDiffType = normalizeLocalDiffType(diffType);
|
|
72
|
+
|
|
73
|
+
switch (effectiveDiffType) {
|
|
74
|
+
case "uncommitted":
|
|
75
|
+
return {
|
|
76
|
+
target: "the current code changes (staged, unstaged, and untracked files)",
|
|
77
|
+
inspect: "Inspect the working tree changes locally.",
|
|
78
|
+
};
|
|
79
|
+
case "staged":
|
|
80
|
+
return {
|
|
81
|
+
target: "the currently staged code changes",
|
|
82
|
+
inspect: "Run `git diff --staged` to inspect the changes.",
|
|
83
|
+
};
|
|
84
|
+
case "unstaged":
|
|
85
|
+
return {
|
|
86
|
+
target: "the unstaged code changes (tracked modifications and untracked files)",
|
|
87
|
+
inspect: "Inspect the unstaged working tree changes locally.",
|
|
88
|
+
};
|
|
89
|
+
case "last-commit":
|
|
90
|
+
return {
|
|
91
|
+
target: "the code changes introduced in the last commit",
|
|
92
|
+
inspect: "Run `git diff HEAD~1..HEAD` to inspect the changes.",
|
|
93
|
+
};
|
|
94
|
+
case "branch": {
|
|
95
|
+
const base = defaultBranch || "main";
|
|
96
|
+
return {
|
|
97
|
+
target: `the code changes against the base branch '${base}'`,
|
|
98
|
+
inspect: `Run \`git diff ${base}..HEAD\` to inspect the changes.`,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
case "merge-base": {
|
|
102
|
+
const base = defaultBranch || "main";
|
|
103
|
+
return {
|
|
104
|
+
target: `the PR-style diff against base '${base}'`,
|
|
105
|
+
inspect: `First find the common ancestor with \`git merge-base ${base} HEAD\`, then run \`git diff <merge-base>..HEAD\` using that commit to inspect only the changes introduced on this branch (matches GitHub's PR view).`,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
case "all":
|
|
109
|
+
return {
|
|
110
|
+
target: "every file in the repository",
|
|
111
|
+
inspect: "All files are shown as additions, diffed against an empty tree.",
|
|
112
|
+
};
|
|
113
|
+
case "jj-current":
|
|
114
|
+
return {
|
|
115
|
+
target: "the current JJ change",
|
|
116
|
+
inspect: "Run `jj diff --git -r @` to inspect the changes.",
|
|
117
|
+
};
|
|
118
|
+
case "jj-last":
|
|
119
|
+
return {
|
|
120
|
+
target: "the previous JJ change",
|
|
121
|
+
inspect: "Run `jj diff --git -r @-` to inspect the changes.",
|
|
122
|
+
};
|
|
123
|
+
case "jj-line": {
|
|
124
|
+
const base = defaultBranch || JJ_TRUNK_REVSET;
|
|
125
|
+
const baseRevset = jjLineBaseRevset(base);
|
|
126
|
+
return {
|
|
127
|
+
target: `the JJ line of work against \`${base}\``,
|
|
128
|
+
inspect: `Run \`jj diff --git --from ${shellQuote(baseRevset)} --to @\` to inspect the changes.`,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
case "jj-all":
|
|
132
|
+
return {
|
|
133
|
+
target: "all files in the JJ workspace",
|
|
134
|
+
inspect: "Run `jj diff --git --from 'root()' --to @` to inspect the changes.",
|
|
135
|
+
};
|
|
136
|
+
default:
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function normalizeLocalDiffType(diffType: DiffType): string {
|
|
142
|
+
const worktree = parseWorktreeDiffType(diffType);
|
|
143
|
+
return worktree?.subType ?? diffType;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function shellQuote(value: string): string {
|
|
147
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
148
|
+
}
|
|
@@ -89,7 +89,7 @@ project guidance files explicitly expand your scope.
|
|
|
89
89
|
## Pipeline
|
|
90
90
|
|
|
91
91
|
Step 1: Gather context
|
|
92
|
-
- Retrieve the PR diff (gh pr diff or
|
|
92
|
+
- Retrieve the PR diff or local diff (gh pr diff, git diff, or jj diff)
|
|
93
93
|
- Read CLAUDE.md and REVIEW.md at the repo root and in every directory
|
|
94
94
|
containing modified files
|
|
95
95
|
- Build a map of which rules apply to which file paths
|
|
@@ -207,6 +207,10 @@ export function buildClaudeCommand(prompt: string, model: string = "claude-opus-
|
|
|
207
207
|
"Bash(git grep:*)", "Bash(git ls-remote:*)", "Bash(git ls-tree:*)",
|
|
208
208
|
"Bash(git merge-base:*)", "Bash(git remote:*)", "Bash(git rev-parse:*)",
|
|
209
209
|
"Bash(git show-ref:*)",
|
|
210
|
+
// JJ (read-only)
|
|
211
|
+
"Bash(jj status:*)", "Bash(jj diff:*)", "Bash(jj log:*)",
|
|
212
|
+
"Bash(jj show:*)", "Bash(jj file show:*)", "Bash(jj cat:*)",
|
|
213
|
+
"Bash(jj bookmark list:*)",
|
|
210
214
|
"Bash(wc:*)",
|
|
211
215
|
].join(",");
|
|
212
216
|
|
|
@@ -10,8 +10,6 @@ import { join } from "node:path";
|
|
|
10
10
|
import { homedir, tmpdir } from "node:os";
|
|
11
11
|
import { appendFile, mkdir, unlink, writeFile, readFile } from "node:fs/promises";
|
|
12
12
|
import { existsSync } from "node:fs";
|
|
13
|
-
import type { DiffType } from "./review-core.js";
|
|
14
|
-
import type { PRMetadata } from "./pr-provider.js";
|
|
15
13
|
import { toRelativePath } from "./path-utils.js";
|
|
16
14
|
|
|
17
15
|
// ---------------------------------------------------------------------------
|
|
@@ -159,86 +157,6 @@ Ignore non-blocking issues such as style, formatting, typos, documentation, and
|
|
|
159
157
|
FORMATTING GUIDELINES:
|
|
160
158
|
The finding description should be one paragraph.`;
|
|
161
159
|
|
|
162
|
-
// ---------------------------------------------------------------------------
|
|
163
|
-
// User message builder
|
|
164
|
-
// ---------------------------------------------------------------------------
|
|
165
|
-
|
|
166
|
-
/** Build the dynamic user message based on review context. */
|
|
167
|
-
export function buildCodexReviewUserMessage(
|
|
168
|
-
patch: string,
|
|
169
|
-
diffType: DiffType,
|
|
170
|
-
options?: { defaultBranch?: string; hasLocalAccess?: boolean; prDiffScope?: string },
|
|
171
|
-
prMetadata?: PRMetadata,
|
|
172
|
-
): string {
|
|
173
|
-
// PR/MR mode — pass the link, with local context if --local
|
|
174
|
-
if (prMetadata) {
|
|
175
|
-
if (options?.prDiffScope === "full-stack") {
|
|
176
|
-
return [
|
|
177
|
-
`Full-stack review of ${prMetadata.url}`,
|
|
178
|
-
"",
|
|
179
|
-
"This is a stacked PR. The diff below shows ALL accumulated changes from the repository default branch through this PR's head (not just this PR's own layer).",
|
|
180
|
-
"Review the complete diff for issues that span the stack.",
|
|
181
|
-
"",
|
|
182
|
-
"```diff",
|
|
183
|
-
patch,
|
|
184
|
-
"```",
|
|
185
|
-
].join("\n");
|
|
186
|
-
}
|
|
187
|
-
if (options?.hasLocalAccess) {
|
|
188
|
-
return [
|
|
189
|
-
prMetadata.url,
|
|
190
|
-
"",
|
|
191
|
-
"You are in a local worktree checked out at the PR head. The code is available locally.",
|
|
192
|
-
`To see the PR changes, diff against the remote base branch: git diff origin/${prMetadata.baseBranch}...HEAD`,
|
|
193
|
-
"Do NOT diff against the local `main` branch — it may be stale. Always use origin/.",
|
|
194
|
-
].join("\n");
|
|
195
|
-
}
|
|
196
|
-
return prMetadata.url;
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
// Local mode — Codex has full file/git access
|
|
200
|
-
const effectiveDiffType = diffType.startsWith("worktree:")
|
|
201
|
-
? diffType.split(":").pop() || "uncommitted"
|
|
202
|
-
: diffType;
|
|
203
|
-
|
|
204
|
-
switch (effectiveDiffType) {
|
|
205
|
-
case "uncommitted":
|
|
206
|
-
return "Review the current code changes (staged, unstaged, and untracked files) and provide prioritized findings.";
|
|
207
|
-
|
|
208
|
-
case "staged":
|
|
209
|
-
return "Review the currently staged code changes (`git diff --staged`) and provide prioritized findings.";
|
|
210
|
-
|
|
211
|
-
case "unstaged":
|
|
212
|
-
return "Review the unstaged code changes (tracked modifications and untracked files) and provide prioritized findings.";
|
|
213
|
-
|
|
214
|
-
case "last-commit":
|
|
215
|
-
return "Review the code changes introduced in the last commit (`git diff HEAD~1..HEAD`) and provide prioritized findings.";
|
|
216
|
-
|
|
217
|
-
case "branch": {
|
|
218
|
-
const base = options?.defaultBranch || "main";
|
|
219
|
-
return `Review the code changes against the base branch '${base}'. Run \`git diff ${base}..HEAD\` to inspect the changes. Provide prioritized, actionable findings.`;
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
case "merge-base": {
|
|
223
|
-
const base = options?.defaultBranch || "main";
|
|
224
|
-
return `Review the PR-style diff against base '${base}'. First find the common ancestor with \`git merge-base ${base} HEAD\`, then run \`git diff <merge-base>..HEAD\` using that commit to inspect only the changes introduced on this branch (matches GitHub's PR view). Provide prioritized, actionable findings.`;
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
case "all":
|
|
228
|
-
return "Review every file in the repository (all files shown as additions, diffed against an empty tree). Provide prioritized, actionable findings.";
|
|
229
|
-
|
|
230
|
-
default:
|
|
231
|
-
// p4 or unknown — fall back to generic with inlined diff
|
|
232
|
-
return [
|
|
233
|
-
"Review the following code changes and provide prioritized findings.",
|
|
234
|
-
"",
|
|
235
|
-
"```diff",
|
|
236
|
-
patch,
|
|
237
|
-
"```",
|
|
238
|
-
].join("\n");
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
|
|
242
160
|
// ---------------------------------------------------------------------------
|
|
243
161
|
// Command builder
|
|
244
162
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
// @generated — DO NOT EDIT. Source: packages/shared/jj-core.ts
|
|
2
|
+
import { basename } from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
type DiffResult,
|
|
5
|
+
type DiffType,
|
|
6
|
+
type GitCommandResult,
|
|
7
|
+
type GitContext,
|
|
8
|
+
type GitDiffOptions,
|
|
9
|
+
JJ_TRUNK_REVSET,
|
|
10
|
+
jjLineBaseRevset,
|
|
11
|
+
validateFilePath,
|
|
12
|
+
} from "./review-core";
|
|
13
|
+
|
|
14
|
+
export {
|
|
15
|
+
JJ_TRUNK_REVSET,
|
|
16
|
+
jjCompareTargetRevset,
|
|
17
|
+
jjLineBaseRevset,
|
|
18
|
+
parseRemoteBookmark,
|
|
19
|
+
} from "./review-core";
|
|
20
|
+
|
|
21
|
+
export interface ReviewJjRuntime {
|
|
22
|
+
runJj: (
|
|
23
|
+
args: string[],
|
|
24
|
+
options?: { cwd?: string; timeoutMs?: number },
|
|
25
|
+
) => Promise<GitCommandResult>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function detectJjWorkspace(
|
|
29
|
+
runtime: ReviewJjRuntime,
|
|
30
|
+
cwd?: string,
|
|
31
|
+
): Promise<string | null> {
|
|
32
|
+
const result = await runtime.runJj(["workspace", "root"], { cwd });
|
|
33
|
+
return result.exitCode === 0 ? result.stdout.trim() || null : null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function getJjContext(
|
|
37
|
+
runtime: ReviewJjRuntime,
|
|
38
|
+
cwd?: string,
|
|
39
|
+
): Promise<GitContext> {
|
|
40
|
+
const root = await detectJjWorkspace(runtime, cwd);
|
|
41
|
+
const targets = await listJjCompareTargets(runtime, root ?? cwd);
|
|
42
|
+
const defaultTarget = selectDefaultJjCompareTarget(targets);
|
|
43
|
+
const contextCwd = root ?? cwd;
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
currentBranch: "",
|
|
47
|
+
defaultBranch: defaultTarget,
|
|
48
|
+
diffOptions: [
|
|
49
|
+
{ id: "jj-current", label: "Current change" },
|
|
50
|
+
{ id: "jj-last", label: "Last change" },
|
|
51
|
+
{ id: "jj-line", label: "Line of work" },
|
|
52
|
+
{ id: "jj-all", label: "All files" },
|
|
53
|
+
],
|
|
54
|
+
worktrees: [],
|
|
55
|
+
availableBranches: targets,
|
|
56
|
+
compareTarget: {
|
|
57
|
+
diffTypes: ["jj-line"],
|
|
58
|
+
fallback: defaultTarget,
|
|
59
|
+
picker: {
|
|
60
|
+
rowLabel: "from revision",
|
|
61
|
+
triggerLabel: "revision",
|
|
62
|
+
triggerTitlePrefix: "Compare against",
|
|
63
|
+
searchPlaceholder: "Search bookmarks...",
|
|
64
|
+
emptyText: "No bookmarks match.",
|
|
65
|
+
localGroupLabel: "Bookmarks",
|
|
66
|
+
remoteGroupLabel: "Remote bookmarks",
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
repository: contextCwd ? { displayFallback: basename(contextCwd) } : undefined,
|
|
70
|
+
cwd: contextCwd,
|
|
71
|
+
vcsType: "jj",
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function runJjDiff(
|
|
76
|
+
runtime: ReviewJjRuntime,
|
|
77
|
+
diffType: DiffType,
|
|
78
|
+
defaultBranch: string,
|
|
79
|
+
cwd?: string,
|
|
80
|
+
options?: GitDiffOptions,
|
|
81
|
+
): Promise<DiffResult> {
|
|
82
|
+
const compareTarget = defaultBranch.length > 0 ? defaultBranch : JJ_TRUNK_REVSET;
|
|
83
|
+
const args = getJjDiffArgs(diffType, compareTarget, options);
|
|
84
|
+
if (!args) return { patch: "", label: "Unknown diff type" };
|
|
85
|
+
|
|
86
|
+
const result = await runtime.runJj(args.args, { cwd });
|
|
87
|
+
if (result.exitCode !== 0) {
|
|
88
|
+
return { patch: "", label: args.label, error: firstErrorLine(result.stderr) };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const patch = options?.hideWhitespace ? dropHunklessGitDiffChunks(result.stdout) : result.stdout;
|
|
92
|
+
return { patch, label: args.label };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function dropHunklessGitDiffChunks(patch: string): string {
|
|
96
|
+
if (!patch.includes("diff --git ")) return patch;
|
|
97
|
+
|
|
98
|
+
const chunks = patch.split(/^diff --git /m);
|
|
99
|
+
const prefix = chunks.shift() ?? "";
|
|
100
|
+
const filtered = chunks
|
|
101
|
+
.filter(hasReviewableGitDiffChunk)
|
|
102
|
+
.map((chunk) => `diff --git ${chunk}`)
|
|
103
|
+
.join("");
|
|
104
|
+
|
|
105
|
+
return `${prefix}${filtered}`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function hasReviewableGitDiffChunk(chunk: string): boolean {
|
|
109
|
+
if (/^@@@? /m.test(chunk)) return true;
|
|
110
|
+
return /^(new file mode|deleted file mode|old mode|new mode|rename from|rename to|copy from|copy to|GIT binary patch|Binary files |similarity index|dissimilarity index)/m.test(chunk);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function getJjFileContentsForDiff(
|
|
114
|
+
runtime: ReviewJjRuntime,
|
|
115
|
+
diffType: DiffType,
|
|
116
|
+
defaultBranch: string,
|
|
117
|
+
filePath: string,
|
|
118
|
+
oldPath?: string,
|
|
119
|
+
cwd?: string,
|
|
120
|
+
): Promise<{ oldContent: string | null; newContent: string | null }> {
|
|
121
|
+
validateFilePath(filePath);
|
|
122
|
+
if (oldPath) validateFilePath(oldPath);
|
|
123
|
+
|
|
124
|
+
const oldFilePath = oldPath === undefined || oldPath.length === 0 ? filePath : oldPath;
|
|
125
|
+
const root = await detectJjWorkspace(runtime, cwd);
|
|
126
|
+
const fileCwd = root ?? cwd;
|
|
127
|
+
|
|
128
|
+
switch (diffType) {
|
|
129
|
+
case "jj-current":
|
|
130
|
+
return {
|
|
131
|
+
oldContent: await jjFileContent(runtime, "@-", oldFilePath, fileCwd),
|
|
132
|
+
newContent: await jjFileContent(runtime, "@", filePath, fileCwd),
|
|
133
|
+
};
|
|
134
|
+
case "jj-last": {
|
|
135
|
+
const parentRev = await resolveJjParent(runtime, "@-", fileCwd);
|
|
136
|
+
return {
|
|
137
|
+
oldContent: parentRev ? await jjFileContent(runtime, parentRev, oldFilePath, fileCwd) : null,
|
|
138
|
+
newContent: await jjFileContent(runtime, "@-", filePath, fileCwd),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
case "jj-line": {
|
|
142
|
+
const compareTarget = defaultBranch.length > 0 ? defaultBranch : JJ_TRUNK_REVSET;
|
|
143
|
+
return {
|
|
144
|
+
oldContent: await jjFileContent(runtime, jjLineBaseRevset(compareTarget), oldFilePath, fileCwd),
|
|
145
|
+
newContent: await jjFileContent(runtime, "@", filePath, fileCwd),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
case "jj-all":
|
|
149
|
+
return {
|
|
150
|
+
oldContent: null,
|
|
151
|
+
newContent: await jjFileContent(runtime, "@", filePath, fileCwd),
|
|
152
|
+
};
|
|
153
|
+
default:
|
|
154
|
+
return { oldContent: null, newContent: null };
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function getJjDiffArgs(
|
|
159
|
+
diffType: DiffType,
|
|
160
|
+
compareTarget: string,
|
|
161
|
+
options?: GitDiffOptions,
|
|
162
|
+
): { args: string[]; label: string } | null {
|
|
163
|
+
const whitespaceArgs = options?.hideWhitespace ? ["-w"] : [];
|
|
164
|
+
|
|
165
|
+
switch (diffType) {
|
|
166
|
+
case "jj-current":
|
|
167
|
+
return { args: ["diff", "--git", ...whitespaceArgs, "-r", "@"], label: "Current change" };
|
|
168
|
+
case "jj-last":
|
|
169
|
+
return { args: ["diff", "--git", ...whitespaceArgs, "-r", "@-"], label: "Last change" };
|
|
170
|
+
case "jj-line":
|
|
171
|
+
return {
|
|
172
|
+
args: ["diff", "--git", ...whitespaceArgs, "--from", jjLineBaseRevset(compareTarget), "--to", "@"],
|
|
173
|
+
label: `Line of work vs ${compareTarget}`,
|
|
174
|
+
};
|
|
175
|
+
case "jj-all":
|
|
176
|
+
return { args: ["diff", "--git", ...whitespaceArgs, "--from", "root()", "--to", "@"], label: "All files" };
|
|
177
|
+
default:
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function selectDefaultJjCompareTarget(targets: { local: string[]; remote: string[] }): string {
|
|
183
|
+
const preferredNames = ["main", "develop", "master", "trunk"];
|
|
184
|
+
for (const name of preferredNames) {
|
|
185
|
+
const preferredRemote = [
|
|
186
|
+
`${name}@origin`,
|
|
187
|
+
`${name}@upstream`,
|
|
188
|
+
`${name}@git`,
|
|
189
|
+
...targets.remote.filter((target) => target.startsWith(`${name}@`)).sort(),
|
|
190
|
+
].find((target) => targets.remote.includes(target));
|
|
191
|
+
if (preferredRemote) return preferredRemote;
|
|
192
|
+
|
|
193
|
+
if (targets.local.includes(name)) return name;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// `trunk()` honors JJ's repository default bookmark/remote and user aliases
|
|
197
|
+
// when the repository has no obvious default bookmark to compare against.
|
|
198
|
+
return JJ_TRUNK_REVSET;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function listJjCompareTargets(
|
|
202
|
+
runtime: ReviewJjRuntime,
|
|
203
|
+
cwd?: string,
|
|
204
|
+
): Promise<{ local: string[]; remote: string[] }> {
|
|
205
|
+
const [localResult, remoteResult] = await Promise.all([
|
|
206
|
+
runtime.runJj([
|
|
207
|
+
"bookmark",
|
|
208
|
+
"list",
|
|
209
|
+
"--sort",
|
|
210
|
+
"committer-date-",
|
|
211
|
+
"--sort",
|
|
212
|
+
"name",
|
|
213
|
+
"-T",
|
|
214
|
+
"if(remote, '', if(present, json(name) ++ '\\n', ''))",
|
|
215
|
+
], { cwd }),
|
|
216
|
+
runtime.runJj([
|
|
217
|
+
"bookmark",
|
|
218
|
+
"list",
|
|
219
|
+
"--all-remotes",
|
|
220
|
+
"--sort",
|
|
221
|
+
"committer-date-",
|
|
222
|
+
"--sort",
|
|
223
|
+
"name",
|
|
224
|
+
"-T",
|
|
225
|
+
"if(remote, if(present, json(name) ++ '\\t' ++ json(remote) ++ '\\n', ''), '')",
|
|
226
|
+
], { cwd }),
|
|
227
|
+
]);
|
|
228
|
+
|
|
229
|
+
const local = localResult.exitCode === 0 ? parseJjBookmarkList(localResult.stdout) : [];
|
|
230
|
+
const remote = remoteResult.exitCode === 0 ? parseJjRemoteBookmarkList(remoteResult.stdout) : [];
|
|
231
|
+
|
|
232
|
+
return {
|
|
233
|
+
local,
|
|
234
|
+
remote,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function parseJjBookmarkList(stdout: string): string[] {
|
|
239
|
+
const seen = new Set<string>();
|
|
240
|
+
const bookmarks: string[] = [];
|
|
241
|
+
|
|
242
|
+
for (const rawLine of splitJjTemplateRecords(stdout)) {
|
|
243
|
+
const line = rawLine.trim();
|
|
244
|
+
if (!line) continue;
|
|
245
|
+
|
|
246
|
+
const bookmark = parseSerializedJjString(line);
|
|
247
|
+
if (!bookmark || seen.has(bookmark)) continue;
|
|
248
|
+
|
|
249
|
+
seen.add(bookmark);
|
|
250
|
+
bookmarks.push(bookmark);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return bookmarks;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export function parseJjRemoteBookmarkList(stdout: string): string[] {
|
|
257
|
+
const seen = new Set<string>();
|
|
258
|
+
const bookmarks: string[] = [];
|
|
259
|
+
|
|
260
|
+
for (const rawLine of splitJjTemplateRecords(stdout)) {
|
|
261
|
+
const line = rawLine.trim();
|
|
262
|
+
if (!line) continue;
|
|
263
|
+
|
|
264
|
+
const fields = splitJjTemplateFields(line);
|
|
265
|
+
if (!fields) continue;
|
|
266
|
+
|
|
267
|
+
const [nameField, remoteField] = fields;
|
|
268
|
+
const name = parseSerializedJjString(nameField);
|
|
269
|
+
const remote = parseSerializedJjString(remoteField);
|
|
270
|
+
if (!name || !remote) continue;
|
|
271
|
+
|
|
272
|
+
const bookmark = `${name}@${remote}`;
|
|
273
|
+
if (seen.has(bookmark)) continue;
|
|
274
|
+
|
|
275
|
+
seen.add(bookmark);
|
|
276
|
+
bookmarks.push(bookmark);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return bookmarks;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function splitJjTemplateRecords(stdout: string): string[] {
|
|
283
|
+
return stdout.split(/\n|\\n/g);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function splitJjTemplateFields(line: string): [string, string] | null {
|
|
287
|
+
const literalTab = line.indexOf("\t");
|
|
288
|
+
if (literalTab !== -1) return [line.slice(0, literalTab), line.slice(literalTab + 1)];
|
|
289
|
+
|
|
290
|
+
const escapedTab = line.indexOf("\\t");
|
|
291
|
+
if (escapedTab !== -1) return [line.slice(0, escapedTab), line.slice(escapedTab + 2)];
|
|
292
|
+
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function parseSerializedJjString(value: string): string | null {
|
|
297
|
+
try {
|
|
298
|
+
const parsed = JSON.parse(value);
|
|
299
|
+
return typeof parsed === "string" ? parsed : null;
|
|
300
|
+
} catch {
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async function jjFileContent(
|
|
306
|
+
runtime: ReviewJjRuntime,
|
|
307
|
+
rev: string,
|
|
308
|
+
filePath: string,
|
|
309
|
+
cwd?: string,
|
|
310
|
+
): Promise<string | null> {
|
|
311
|
+
const result = await runtime.runJj(["file", "show", "-r", rev, "--", filePath], { cwd });
|
|
312
|
+
return result.exitCode === 0 ? result.stdout : null;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
async function resolveJjParent(
|
|
316
|
+
runtime: ReviewJjRuntime,
|
|
317
|
+
rev: string,
|
|
318
|
+
cwd?: string,
|
|
319
|
+
): Promise<string | null> {
|
|
320
|
+
const result = await runtime.runJj(["log", "-r", rev, "--no-graph", "-T", "parents.map(|p| p.change_id()).join(' ')", "--limit", "1"], { cwd });
|
|
321
|
+
const parent = result.stdout.trim().split(/\s+/).find(Boolean);
|
|
322
|
+
return result.exitCode === 0 && parent ? parent : null;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function firstErrorLine(stderr: string): string | undefined {
|
|
326
|
+
const line = stderr.split("\n").find((value) => value.trim().length > 0)?.trim();
|
|
327
|
+
if (!line) return undefined;
|
|
328
|
+
return line.length > 200 ? line.slice(0, 200) + "..." : line;
|
|
329
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// @generated — DO NOT EDIT. Source: packages/shared/review-args.ts
|
|
2
|
+
import type { VcsSelection } from "./vcs-core";
|
|
3
|
+
import { stripWrappingQuotes } from "./resolve-file";
|
|
4
|
+
|
|
5
|
+
export interface ParsedReviewArgs {
|
|
6
|
+
prUrl?: string;
|
|
7
|
+
vcsType?: VcsSelection;
|
|
8
|
+
useLocal: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function parseReviewArgs(input: string | string[]): ParsedReviewArgs {
|
|
12
|
+
const tokens = Array.isArray(input)
|
|
13
|
+
? input.map((token) => stripWrappingQuotes(token.trim())).filter(Boolean)
|
|
14
|
+
: tokenizeReviewArgs(input ?? "");
|
|
15
|
+
|
|
16
|
+
let vcsType: VcsSelection | undefined;
|
|
17
|
+
let useLocal = true;
|
|
18
|
+
const positional: string[] = [];
|
|
19
|
+
|
|
20
|
+
for (const token of tokens) {
|
|
21
|
+
switch (token) {
|
|
22
|
+
case "--git":
|
|
23
|
+
vcsType = "git";
|
|
24
|
+
break;
|
|
25
|
+
case "--local":
|
|
26
|
+
useLocal = true;
|
|
27
|
+
break;
|
|
28
|
+
case "--no-local":
|
|
29
|
+
useLocal = false;
|
|
30
|
+
break;
|
|
31
|
+
default:
|
|
32
|
+
positional.push(token);
|
|
33
|
+
break;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const target = positional[0];
|
|
38
|
+
return {
|
|
39
|
+
prUrl: target && isReviewUrl(target) ? target : undefined,
|
|
40
|
+
vcsType,
|
|
41
|
+
useLocal,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isReviewUrl(value: string): boolean {
|
|
46
|
+
return value.startsWith("http://") || value.startsWith("https://");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function tokenizeReviewArgs(input: string): string[] {
|
|
50
|
+
const raw = input.trim();
|
|
51
|
+
if (!raw) return [];
|
|
52
|
+
|
|
53
|
+
const tokens: string[] = [];
|
|
54
|
+
let current = "";
|
|
55
|
+
let quote: "'" | "\"" | undefined;
|
|
56
|
+
|
|
57
|
+
for (let i = 0; i < raw.length; i++) {
|
|
58
|
+
const char = raw[i];
|
|
59
|
+
if (quote) {
|
|
60
|
+
if (char === quote) {
|
|
61
|
+
quote = undefined;
|
|
62
|
+
} else {
|
|
63
|
+
current += char;
|
|
64
|
+
}
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (char === "'" || char === "\"") {
|
|
69
|
+
quote = char;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (/\s/.test(char)) {
|
|
74
|
+
if (current) {
|
|
75
|
+
tokens.push(current);
|
|
76
|
+
current = "";
|
|
77
|
+
}
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
current += char;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (current) tokens.push(current);
|
|
85
|
+
return tokens.map((token) => stripWrappingQuotes(token.trim())).filter(Boolean);
|
|
86
|
+
}
|