@difflab/pi 0.5.0-rc.202609231305.97666c2 → 0.5.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/README.md +5 -5
- package/agents/diffpi-reviewer.md +3 -3
- package/dist/commands/review.d.ts +1 -0
- package/dist/commands/review.d.ts.map +1 -1
- package/dist/extensions/index.js +804 -296
- package/dist/extensions/tuicrx.d.ts +8 -1
- package/dist/extensions/tuicrx.d.ts.map +1 -1
- package/dist/index.d.ts +8 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +458 -197
- package/dist/review/github-review-backend.d.ts.map +1 -1
- package/dist/review/gitlab-review-backend.d.ts.map +1 -1
- package/dist/review/index.d.ts +6 -6
- package/dist/review/index.d.ts.map +1 -1
- package/dist/review/local-review-backend.d.ts +3 -0
- package/dist/review/local-review-backend.d.ts.map +1 -0
- package/dist/review/review-backend.d.ts +3 -2
- package/dist/review/review-backend.d.ts.map +1 -1
- package/dist/review/review-markdown.d.ts +4 -1
- package/dist/review/review-markdown.d.ts.map +1 -1
- package/dist/review/review-state.d.ts +20 -0
- package/dist/review/review-state.d.ts.map +1 -0
- package/dist/review/types.d.ts +28 -0
- package/dist/review/types.d.ts.map +1 -1
- package/dist/store.d.ts +2 -0
- package/dist/store.d.ts.map +1 -1
- package/dist/tools/index.js +783 -185
- package/dist/tools/review.d.ts +30 -0
- package/dist/tools/review.d.ts.map +1 -1
- package/dist/workflow.d.ts +0 -6
- package/dist/workflow.d.ts.map +1 -1
- package/package.json +1 -1
- package/skills/review/SKILL.md +8 -6
- package/skills/review/references/workflows/address.md +11 -0
- package/skills/review/references/workflows/auto.md +18 -0
- package/skills/review/references/workflows/complete.md +7 -0
- package/skills/review/references/workflows/edit.md +7 -0
- package/skills/review/references/workflows/help.md +30 -0
- package/skills/review/references/workflows/merge.md +6 -0
- package/skills/review/references/workflows/new.md +19 -0
- package/skills/review/references/workflows/open.md +3 -0
- package/skills/review/references/workflows/publish.md +8 -0
- package/dist/review/local-reviews.d.ts +0 -31
- package/dist/review/local-reviews.d.ts.map +0 -1
- package/workflows/review/address.md +0 -8
- package/workflows/review/auto.md +0 -8
- package/workflows/review/complete.md +0 -7
- package/workflows/review/edit.md +0 -7
- package/workflows/review/help.md +0 -14
- package/workflows/review/merge.md +0 -6
- package/workflows/review/new.md +0 -7
- package/workflows/review/publish.md +0 -6
package/dist/tools/index.js
CHANGED
|
@@ -1069,6 +1069,18 @@ async function reviewsDir(cwd, homeDir = homedir2()) {
|
|
|
1069
1069
|
await mkdir2(dir, { recursive: true });
|
|
1070
1070
|
return dir;
|
|
1071
1071
|
}
|
|
1072
|
+
async function completedReviewsDir(cwd, homeDir = homedir2()) {
|
|
1073
|
+
const store = await ensureStore(cwd, homeDir);
|
|
1074
|
+
const dir = join6(store.link, "reviews");
|
|
1075
|
+
await mkdir2(dir, { recursive: true });
|
|
1076
|
+
return dir;
|
|
1077
|
+
}
|
|
1078
|
+
async function sessionsDir(cwd, homeDir = homedir2()) {
|
|
1079
|
+
const store = await ensureStore(cwd, homeDir);
|
|
1080
|
+
const dir = join6(store.link, "sessions");
|
|
1081
|
+
await mkdir2(dir, { recursive: true });
|
|
1082
|
+
return dir;
|
|
1083
|
+
}
|
|
1072
1084
|
async function assertStoreLink(path, dest) {
|
|
1073
1085
|
const entry = await lstat(path);
|
|
1074
1086
|
if (!entry.isSymbolicLink())
|
|
@@ -7891,10 +7903,10 @@ function result2(text, details = {}) {
|
|
|
7891
7903
|
|
|
7892
7904
|
// src/tools/review.ts
|
|
7893
7905
|
import { existsSync as existsSync2 } from "node:fs";
|
|
7894
|
-
import { mkdir as
|
|
7906
|
+
import { mkdir as mkdir7, readdir as readdir2, readFile as readFile11, rename as rename3, stat, unlink as unlink2, writeFile as writeFile7 } from "node:fs/promises";
|
|
7895
7907
|
import { join as join16 } from "node:path";
|
|
7896
7908
|
import { defineTool as defineTool6 } from "@earendil-works/pi-coding-agent";
|
|
7897
|
-
import { z as
|
|
7909
|
+
import { z as z11 } from "zod";
|
|
7898
7910
|
|
|
7899
7911
|
// src/review/types.ts
|
|
7900
7912
|
import { z as z8 } from "zod";
|
|
@@ -7919,6 +7931,21 @@ function yymmdd(date = new Date) {
|
|
|
7919
7931
|
function reviewRecordName(target, date = new Date) {
|
|
7920
7932
|
return `${yymmdd(date)}-${reviewSlug(target) || "uncommitted"}`;
|
|
7921
7933
|
}
|
|
7934
|
+
function localResponseMarker(threadId) {
|
|
7935
|
+
return `<!-- diffpi -->`;
|
|
7936
|
+
}
|
|
7937
|
+
function isLocalResponse(body) {
|
|
7938
|
+
return /^<!-- diffpi(?:-local-response:[A-Za-z0-9_-]+)? -->\n/m.test(body);
|
|
7939
|
+
}
|
|
7940
|
+
function parseReviewThreadAction(body) {
|
|
7941
|
+
const match = body.match(/^\[(REOPEN|RESOLVE|DELETE)\]\s*/i);
|
|
7942
|
+
if (!match)
|
|
7943
|
+
return { body };
|
|
7944
|
+
return {
|
|
7945
|
+
action: match[1].toLowerCase(),
|
|
7946
|
+
body: body.slice(match[0].length)
|
|
7947
|
+
};
|
|
7948
|
+
}
|
|
7922
7949
|
function dedupeFindings(findings) {
|
|
7923
7950
|
const rank = { BLOCKING: 3, CONSIDER: 2, NOTE: 1 };
|
|
7924
7951
|
const byKey = new Map;
|
|
@@ -7964,6 +7991,18 @@ function renderCommentBody(finding) {
|
|
|
7964
7991
|
return `${prefix}${finding.body}${reference}`;
|
|
7965
7992
|
}
|
|
7966
7993
|
// src/review/review-markdown.ts
|
|
7994
|
+
import { z as z9 } from "zod";
|
|
7995
|
+
var reviewThreadRecordSchema = z9.object({
|
|
7996
|
+
id: z9.string().min(1),
|
|
7997
|
+
file: z9.string().nullable().optional().transform((value) => value ?? undefined),
|
|
7998
|
+
line: z9.number().int().positive().nullable().optional().transform((value) => value ?? undefined),
|
|
7999
|
+
body: z9.string(),
|
|
8000
|
+
author: z9.string().optional(),
|
|
8001
|
+
resolved: z9.boolean(),
|
|
8002
|
+
addressed: z9.boolean().optional(),
|
|
8003
|
+
question: z9.boolean(),
|
|
8004
|
+
replies: z9.array(z9.string()).optional()
|
|
8005
|
+
});
|
|
7967
8006
|
function renderReviewDoc(input) {
|
|
7968
8007
|
const anchored = dedupeFindings(input.findings).filter((finding) => finding.line > 0);
|
|
7969
8008
|
const lines = [`# Review: ${input.title}`, "", "## Metadata"];
|
|
@@ -8005,6 +8044,88 @@ function renderReviewDoc(input) {
|
|
|
8005
8044
|
`).trimEnd()}
|
|
8006
8045
|
`;
|
|
8007
8046
|
}
|
|
8047
|
+
function renderThreadArtifact(title, target, threads, options = {}) {
|
|
8048
|
+
const records = threads.map((thread) => ({
|
|
8049
|
+
id: thread.id,
|
|
8050
|
+
file: thread.file,
|
|
8051
|
+
line: thread.line,
|
|
8052
|
+
body: thread.body,
|
|
8053
|
+
author: thread.author,
|
|
8054
|
+
resolved: thread.resolved,
|
|
8055
|
+
addressed: thread.addressed,
|
|
8056
|
+
question: thread.question,
|
|
8057
|
+
replies: thread.replies
|
|
8058
|
+
}));
|
|
8059
|
+
const payload = Buffer.from(JSON.stringify(records), "utf8").toString("base64url");
|
|
8060
|
+
const lines = [
|
|
8061
|
+
`<!-- diffpi-threads:${payload} -->`,
|
|
8062
|
+
`# Review threads: ${title}`,
|
|
8063
|
+
"",
|
|
8064
|
+
"## Metadata",
|
|
8065
|
+
"",
|
|
8066
|
+
`- Target: ${target}`,
|
|
8067
|
+
`- Pulled: ${options.timestamp ?? new Date().toISOString()}`
|
|
8068
|
+
];
|
|
8069
|
+
if (options.number !== undefined)
|
|
8070
|
+
lines.push(`- PR/MR: #${options.number}${options.url ? ` — ${options.url}` : ""}`);
|
|
8071
|
+
lines.push("", "## Replies", "");
|
|
8072
|
+
for (const thread of threads) {
|
|
8073
|
+
const id = Buffer.from(thread.id, "utf8").toString("base64url");
|
|
8074
|
+
lines.push(`### ${thread.file ?? "review"}:${thread.line ?? "n/a"} (${thread.id})`, "", `<!-- diffpi-reply-start:${id} -->`, thread.reply ?? "", `<!-- diffpi-reply-end:${id} -->`, "");
|
|
8075
|
+
}
|
|
8076
|
+
lines.push("## Source comments", "");
|
|
8077
|
+
for (const thread of threads) {
|
|
8078
|
+
lines.push(`### ${thread.file ?? "review"}:${thread.line ?? "n/a"} — ${thread.author ?? "unknown"}`, "", `Thread: ${thread.id}`, `Status: ${thread.resolved ? "resolved" : thread.addressed ? "addressed" : "open"}`, "", thread.body, "", "---", "");
|
|
8079
|
+
}
|
|
8080
|
+
return `${lines.join(`
|
|
8081
|
+
`).trimEnd()}
|
|
8082
|
+
`;
|
|
8083
|
+
}
|
|
8084
|
+
function parseThreadArtifact(content) {
|
|
8085
|
+
const payload = content.match(/^<!-- diffpi-threads:([A-Za-z0-9_-]+) -->$/m)?.[1];
|
|
8086
|
+
if (!payload)
|
|
8087
|
+
throw new Error("This file is not a Diffpi thread artifact.");
|
|
8088
|
+
let threads;
|
|
8089
|
+
try {
|
|
8090
|
+
const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
8091
|
+
threads = z9.array(reviewThreadRecordSchema).parse(decoded);
|
|
8092
|
+
} catch {
|
|
8093
|
+
throw new Error("Cannot parse the Diffpi thread artifact payload.");
|
|
8094
|
+
}
|
|
8095
|
+
const replies = content.split(/^## Source comments$/m, 1)[0] ?? "";
|
|
8096
|
+
return threads.map((thread) => {
|
|
8097
|
+
const id = Buffer.from(thread.id, "utf8").toString("base64url");
|
|
8098
|
+
const startMarker = `<!-- diffpi-reply-start:${id} -->
|
|
8099
|
+
`;
|
|
8100
|
+
const endMarker = `
|
|
8101
|
+
<!-- diffpi-reply-end:${id} -->`;
|
|
8102
|
+
const start = replies.indexOf(startMarker);
|
|
8103
|
+
const end = start < 0 ? -1 : replies.indexOf(endMarker, start + startMarker.length);
|
|
8104
|
+
const reply = start >= 0 && end >= 0 ? replies.slice(start + startMarker.length, end).trim() : "";
|
|
8105
|
+
return reply ? { ...thread, reply } : thread;
|
|
8106
|
+
});
|
|
8107
|
+
}
|
|
8108
|
+
function upsertThreadReply(content, threadId, body, question, resolved) {
|
|
8109
|
+
const threads = parseThreadArtifact(content);
|
|
8110
|
+
const thread = threads.find((candidate) => candidate.id === threadId);
|
|
8111
|
+
if (!thread)
|
|
8112
|
+
throw new Error(`Review thread ${threadId} was not found in the local artifact.`);
|
|
8113
|
+
thread.reply = body;
|
|
8114
|
+
thread.addressed = true;
|
|
8115
|
+
if (question !== undefined)
|
|
8116
|
+
thread.question = question;
|
|
8117
|
+
if (resolved !== undefined)
|
|
8118
|
+
thread.resolved = resolved;
|
|
8119
|
+
const title = content.match(/^# Review threads: (.+)$/m)?.[1] ?? "review";
|
|
8120
|
+
const target = content.match(/^- Target: (.+)$/m)?.[1] ?? "local";
|
|
8121
|
+
const timestamp = content.match(/^- Pulled: (.+)$/m)?.[1];
|
|
8122
|
+
const pr = content.match(/^- PR\/MR: #(\d+)(?: — (.+))?$/m);
|
|
8123
|
+
return renderThreadArtifact(title, target, threads, {
|
|
8124
|
+
timestamp,
|
|
8125
|
+
number: pr ? Number.parseInt(pr[1], 10) : undefined,
|
|
8126
|
+
url: pr?.[2]
|
|
8127
|
+
});
|
|
8128
|
+
}
|
|
8008
8129
|
// src/review/github-review-backend.ts
|
|
8009
8130
|
function GitHubReviewBackend(vcs, number) {
|
|
8010
8131
|
const pendingReview = async () => {
|
|
@@ -8066,6 +8187,9 @@ function GitHubReviewBackend(vcs, number) {
|
|
|
8066
8187
|
id: thread.id,
|
|
8067
8188
|
file: thread.path ?? undefined,
|
|
8068
8189
|
line: thread.line ?? undefined,
|
|
8190
|
+
rootCommentId: comment?.id,
|
|
8191
|
+
commentIds: nodes.flatMap((node) => [node.id, node.databaseId?.toString()].filter((id) => Boolean(id))),
|
|
8192
|
+
commentNodeIds: nodes.flatMap((node) => node.id ? [node.id] : []),
|
|
8069
8193
|
body,
|
|
8070
8194
|
author: comment?.author?.login,
|
|
8071
8195
|
resolved: thread.isResolved,
|
|
@@ -8075,6 +8199,7 @@ function GitHubReviewBackend(vcs, number) {
|
|
|
8075
8199
|
});
|
|
8076
8200
|
};
|
|
8077
8201
|
return {
|
|
8202
|
+
kind: "remote",
|
|
8078
8203
|
async stage(draft) {
|
|
8079
8204
|
const pending = await pendingReview();
|
|
8080
8205
|
if (!pending) {
|
|
@@ -8132,6 +8257,33 @@ function GitHubReviewBackend(vcs, number) {
|
|
|
8132
8257
|
await ghChecked(args);
|
|
8133
8258
|
}
|
|
8134
8259
|
},
|
|
8260
|
+
async readDraft() {
|
|
8261
|
+
const pending = await pendingReview();
|
|
8262
|
+
if (!pending)
|
|
8263
|
+
return { comments: [], body: "" };
|
|
8264
|
+
const review = await ghChecked(["api", `/repos/${vcs.owner}/${vcs.repo}/pulls/${number}/reviews/${pending.id}`]);
|
|
8265
|
+
const comments = await ghChecked([
|
|
8266
|
+
"api",
|
|
8267
|
+
`/repos/${vcs.owner}/${vcs.repo}/pulls/${number}/reviews/${pending.id}/comments`
|
|
8268
|
+
]);
|
|
8269
|
+
let reviewData;
|
|
8270
|
+
let commentData;
|
|
8271
|
+
try {
|
|
8272
|
+
reviewData = JSON.parse(review.stdout);
|
|
8273
|
+
commentData = JSON.parse(comments.stdout);
|
|
8274
|
+
} catch {
|
|
8275
|
+
throw new Error("Cannot parse the pending GitHub review as JSON.");
|
|
8276
|
+
}
|
|
8277
|
+
return {
|
|
8278
|
+
body: reviewData.body ?? "",
|
|
8279
|
+
comments: commentData.map((comment) => ({
|
|
8280
|
+
file: comment.path,
|
|
8281
|
+
line: comment.line ?? comment.original_line ?? undefined,
|
|
8282
|
+
side: comment.side ?? undefined,
|
|
8283
|
+
body: comment.body
|
|
8284
|
+
}))
|
|
8285
|
+
};
|
|
8286
|
+
},
|
|
8135
8287
|
listThreads,
|
|
8136
8288
|
async reply(input) {
|
|
8137
8289
|
const threads = await listThreads();
|
|
@@ -8148,14 +8300,32 @@ function GitHubReviewBackend(vcs, number) {
|
|
|
8148
8300
|
`body=${input.body}`
|
|
8149
8301
|
]);
|
|
8150
8302
|
}
|
|
8151
|
-
if (input.resolve)
|
|
8303
|
+
if (input.resolve)
|
|
8304
|
+
await this.setResolved?.(input.threadId, true);
|
|
8305
|
+
},
|
|
8306
|
+
async setResolved(threadId, resolved) {
|
|
8307
|
+
await ghChecked([
|
|
8308
|
+
"api",
|
|
8309
|
+
"graphql",
|
|
8310
|
+
"-f",
|
|
8311
|
+
`query=${resolved ? GITHUB_RESOLVE_MUTATION : GITHUB_UNRESOLVE_MUTATION}`,
|
|
8312
|
+
"-f",
|
|
8313
|
+
`threadId=${threadId}`
|
|
8314
|
+
]);
|
|
8315
|
+
},
|
|
8316
|
+
async deleteThread(threadId) {
|
|
8317
|
+
const thread = (await listThreads()).find((candidate) => candidate.id === threadId);
|
|
8318
|
+
const commentNodeIds = thread?.commentNodeIds ?? (thread?.rootCommentId ? [thread.rootCommentId] : []);
|
|
8319
|
+
if (!commentNodeIds.length)
|
|
8320
|
+
throw new Error(`Cannot delete GitHub thread ${threadId}: comment node IDs are missing.`);
|
|
8321
|
+
for (const commentId of commentNodeIds) {
|
|
8152
8322
|
await ghChecked([
|
|
8153
8323
|
"api",
|
|
8154
8324
|
"graphql",
|
|
8155
8325
|
"-f",
|
|
8156
|
-
`query=${
|
|
8326
|
+
`query=${GITHUB_DELETE_COMMENT_MUTATION}`,
|
|
8157
8327
|
"-f",
|
|
8158
|
-
`
|
|
8328
|
+
`commentId=${commentId}`
|
|
8159
8329
|
]);
|
|
8160
8330
|
}
|
|
8161
8331
|
},
|
|
@@ -8174,11 +8344,13 @@ function GitHubReviewBackend(vcs, number) {
|
|
|
8174
8344
|
function githubReviewSubmissionEndpoint(owner, repo, id, pendingReviewId) {
|
|
8175
8345
|
return pendingReviewId ? `/repos/${owner}/${repo}/pulls/${id}/reviews/${pendingReviewId}/events` : `/repos/${owner}/${repo}/pulls/${id}/reviews`;
|
|
8176
8346
|
}
|
|
8177
|
-
var GITHUB_THREADS_QUERY = `query($owner:String!,$repo:String!,$number:Int!,$after:String){repository(owner:$owner,name:$repo){pullRequest(number:$number){reviewThreads(first:100,after:$after){nodes{id,isResolved,path,line,comments(first:100){nodes{body,author{login}}}}pageInfo{hasNextPage,endCursor}}}}}`;
|
|
8347
|
+
var GITHUB_THREADS_QUERY = `query($owner:String!,$repo:String!,$number:Int!,$after:String){repository(owner:$owner,name:$repo){pullRequest(number:$number){reviewThreads(first:100,after:$after){nodes{id,isResolved,path,line,comments(first:100){nodes{id,databaseId,body,author{login}}}}pageInfo{hasNextPage,endCursor}}}}}`;
|
|
8178
8348
|
var GITHUB_ADD_THREAD_MUTATION = `mutation($reviewId:ID!,$body:String!,$path:String!,$line:Int!,$side:DiffSide!){addPullRequestReviewThread(input:{pullRequestReviewId:$reviewId,body:$body,path:$path,line:$line,side:$side}){thread{id}}}`;
|
|
8179
8349
|
var GITHUB_ADD_FILE_THREAD_MUTATION = `mutation($reviewId:ID!,$body:String!,$path:String!){addPullRequestReviewThread(input:{pullRequestReviewId:$reviewId,body:$body,path:$path,subjectType:FILE}){thread{id}}}`;
|
|
8180
8350
|
var GITHUB_REPLY_MUTATION = `mutation($threadId:ID!,$body:String!){addPullRequestReviewThreadReply(input:{pullRequestReviewThreadId:$threadId,body:$body}){comment{id}}}`;
|
|
8181
8351
|
var GITHUB_RESOLVE_MUTATION = `mutation($threadId:ID!){resolveReviewThread(input:{threadId:$threadId}){thread{isResolved}}}`;
|
|
8352
|
+
var GITHUB_UNRESOLVE_MUTATION = `mutation($threadId:ID!){unresolveReviewThread(input:{threadId:$threadId}){thread{isResolved}}}`;
|
|
8353
|
+
var GITHUB_DELETE_COMMENT_MUTATION = `mutation($commentId:ID!){deletePullRequestReviewComment(input:{id:$commentId}){clientMutationId}}`;
|
|
8182
8354
|
|
|
8183
8355
|
// src/review/gitlab-review-backend.ts
|
|
8184
8356
|
function GitLabReviewBackend(vcs, number) {
|
|
@@ -8215,6 +8387,7 @@ function GitLabReviewBackend(vcs, number) {
|
|
|
8215
8387
|
});
|
|
8216
8388
|
};
|
|
8217
8389
|
return {
|
|
8390
|
+
kind: "remote",
|
|
8218
8391
|
async stage(draft) {
|
|
8219
8392
|
const endpoint = `${mergeRequestEndpoint()}/draft_notes`;
|
|
8220
8393
|
if (draft.body.trim()) {
|
|
@@ -8243,6 +8416,36 @@ function GitLabReviewBackend(vcs, number) {
|
|
|
8243
8416
|
});
|
|
8244
8417
|
}
|
|
8245
8418
|
},
|
|
8419
|
+
async readDraft() {
|
|
8420
|
+
const result = await glabChecked(["api", `${mergeRequestEndpoint()}/draft_notes`]);
|
|
8421
|
+
let notes;
|
|
8422
|
+
try {
|
|
8423
|
+
notes = JSON.parse(result.stdout);
|
|
8424
|
+
} catch {
|
|
8425
|
+
throw new Error("Cannot parse GitLab draft notes as JSON.");
|
|
8426
|
+
}
|
|
8427
|
+
const comments = [];
|
|
8428
|
+
const body = [];
|
|
8429
|
+
for (const note of notes) {
|
|
8430
|
+
if (!note.note)
|
|
8431
|
+
continue;
|
|
8432
|
+
const file = note.position?.new_path ?? note.position?.old_path;
|
|
8433
|
+
const line = note.position?.new_line ?? note.position?.old_line;
|
|
8434
|
+
if (file && line) {
|
|
8435
|
+
comments.push({
|
|
8436
|
+
file,
|
|
8437
|
+
line,
|
|
8438
|
+
side: note.position?.new_line ? "RIGHT" : "LEFT",
|
|
8439
|
+
body: note.note
|
|
8440
|
+
});
|
|
8441
|
+
} else {
|
|
8442
|
+
body.push(note.note);
|
|
8443
|
+
}
|
|
8444
|
+
}
|
|
8445
|
+
return { comments, body: body.join(`
|
|
8446
|
+
|
|
8447
|
+
`) };
|
|
8448
|
+
},
|
|
8246
8449
|
listThreads,
|
|
8247
8450
|
async reply(input) {
|
|
8248
8451
|
const endpoint = `${mergeRequestEndpoint()}/discussions/${encodeURIComponent(input.threadId)}`;
|
|
@@ -8254,7 +8457,15 @@ function GitLabReviewBackend(vcs, number) {
|
|
|
8254
8457
|
});
|
|
8255
8458
|
}
|
|
8256
8459
|
if (input.resolve)
|
|
8257
|
-
await
|
|
8460
|
+
await this.setResolved?.(input.threadId, true);
|
|
8461
|
+
},
|
|
8462
|
+
async setResolved(threadId, resolved) {
|
|
8463
|
+
const endpoint = `${mergeRequestEndpoint()}/discussions/${encodeURIComponent(threadId)}`;
|
|
8464
|
+
await glabChecked(["api", "--method", "PUT", `${endpoint}?resolved=${resolved}`]);
|
|
8465
|
+
},
|
|
8466
|
+
async deleteThread(threadId) {
|
|
8467
|
+
const endpoint = `${mergeRequestEndpoint()}/discussions/${encodeURIComponent(threadId)}`;
|
|
8468
|
+
await glabChecked(["api", "--method", "DELETE", endpoint]);
|
|
8258
8469
|
},
|
|
8259
8470
|
async publish(event) {
|
|
8260
8471
|
assertReviewEventSupported(vcs.provider, event);
|
|
@@ -8294,17 +8505,8 @@ function hasGitlabDraftNotes(input) {
|
|
|
8294
8505
|
}
|
|
8295
8506
|
}
|
|
8296
8507
|
|
|
8297
|
-
// src/review/review-backend.ts
|
|
8298
|
-
|
|
8299
|
-
if (vcs.provider === "github")
|
|
8300
|
-
return GitHubReviewBackend(vcs, number);
|
|
8301
|
-
if (vcs.provider === "gitlab")
|
|
8302
|
-
return GitLabReviewBackend(vcs, number);
|
|
8303
|
-
throw new Error("A remote review backend requires a GitHub or GitLab repository.");
|
|
8304
|
-
}
|
|
8305
|
-
// src/review/local-reviews.ts
|
|
8306
|
-
import { mkdir as mkdir7, readdir as readdir2, readFile as readFile9, unlink as unlink2, writeFile as writeFile5 } from "node:fs/promises";
|
|
8307
|
-
import { join as join15 } from "node:path";
|
|
8508
|
+
// src/review/local-review-backend.ts
|
|
8509
|
+
import { readFile as readFile9, writeFile as writeFile5 } from "node:fs/promises";
|
|
8308
8510
|
|
|
8309
8511
|
// src/extensions/tuicrx.ts
|
|
8310
8512
|
import { readFile as readFile8, realpath as realpath4 } from "node:fs/promises";
|
|
@@ -8375,6 +8577,11 @@ async function resolveReviewSession(cwd, target) {
|
|
|
8375
8577
|
}
|
|
8376
8578
|
return resolveSession(cwd, target.branch);
|
|
8377
8579
|
}
|
|
8580
|
+
async function resolvePublishSession(cwd, target) {
|
|
8581
|
+
if (target.owner && target.repo && target.number !== undefined)
|
|
8582
|
+
return resolvePrSession(cwd, target.owner, target.repo, target.number);
|
|
8583
|
+
return resolveSession(cwd, target.branch);
|
|
8584
|
+
}
|
|
8378
8585
|
async function resolvePrSession(cwd, owner, repo, number) {
|
|
8379
8586
|
const coordinate = `${owner}/${repo}`.toLowerCase();
|
|
8380
8587
|
const sessions = await listSessions(cwd);
|
|
@@ -8409,6 +8616,8 @@ async function readSession(path) {
|
|
|
8409
8616
|
function toLocalReviewThreads(session) {
|
|
8410
8617
|
const threads = [];
|
|
8411
8618
|
const add = (comment, location = {}) => {
|
|
8619
|
+
if (isLocalResponse(comment.content))
|
|
8620
|
+
return;
|
|
8412
8621
|
const author = commentAuthor(comment);
|
|
8413
8622
|
threads.push({
|
|
8414
8623
|
id: comment.id ?? `local-${threads.length + 1}`,
|
|
@@ -8434,6 +8643,54 @@ function toLocalReviewThreads(session) {
|
|
|
8434
8643
|
}
|
|
8435
8644
|
return threads;
|
|
8436
8645
|
}
|
|
8646
|
+
function toFindings(session, options = {}) {
|
|
8647
|
+
const comments = [];
|
|
8648
|
+
const include = (comment) => (!options.agentOnly || commentAuthor(comment)?.startsWith("Agent: ")) && (!options.excludeLocalResponses || !isLocalResponse(comment.content));
|
|
8649
|
+
const publishableBody = (comment) => {
|
|
8650
|
+
const author = commentAuthor(comment);
|
|
8651
|
+
const route = author?.match(/^Agent:\s*(.+)$/)?.[1];
|
|
8652
|
+
return route ? withRemoteProvenance(comment.content, route) : comment.content;
|
|
8653
|
+
};
|
|
8654
|
+
const bodyParts = (session.review_comments ?? []).flatMap((comment) => include(comment) ? [publishableBody(comment)] : []);
|
|
8655
|
+
for (const [file, entry] of Object.entries(session.files ?? {})) {
|
|
8656
|
+
for (const fileComment of entry.file_comments ?? []) {
|
|
8657
|
+
if (!include(fileComment))
|
|
8658
|
+
continue;
|
|
8659
|
+
const comment = {
|
|
8660
|
+
file,
|
|
8661
|
+
body: fileComment.content,
|
|
8662
|
+
...fileComment.id ? { sourceCommentId: String(fileComment.id) } : {}
|
|
8663
|
+
};
|
|
8664
|
+
const author = commentAuthor(fileComment);
|
|
8665
|
+
if (author)
|
|
8666
|
+
comment.author = author;
|
|
8667
|
+
comments.push(comment);
|
|
8668
|
+
}
|
|
8669
|
+
for (const [lineKey, lineComments] of Object.entries(entry.line_comments ?? {})) {
|
|
8670
|
+
const line = Number.parseInt(lineKey, 10);
|
|
8671
|
+
if (!Number.isFinite(line))
|
|
8672
|
+
continue;
|
|
8673
|
+
for (const lineComment of lineComments) {
|
|
8674
|
+
if (!include(lineComment))
|
|
8675
|
+
continue;
|
|
8676
|
+
const comment = {
|
|
8677
|
+
file,
|
|
8678
|
+
line,
|
|
8679
|
+
side: lineComment.side === "old" ? "LEFT" : "RIGHT",
|
|
8680
|
+
body: lineComment.content,
|
|
8681
|
+
...lineComment.id ? { sourceCommentId: String(lineComment.id) } : {}
|
|
8682
|
+
};
|
|
8683
|
+
const author = commentAuthor(lineComment);
|
|
8684
|
+
if (author)
|
|
8685
|
+
comment.author = author;
|
|
8686
|
+
comments.push(comment);
|
|
8687
|
+
}
|
|
8688
|
+
}
|
|
8689
|
+
}
|
|
8690
|
+
return { comments, body: bodyParts.join(`
|
|
8691
|
+
|
|
8692
|
+
`) };
|
|
8693
|
+
}
|
|
8437
8694
|
async function requireTuicr() {
|
|
8438
8695
|
if (!await tuicrAvailable())
|
|
8439
8696
|
throw new Error("tuicr is not installed or is not available on PATH.");
|
|
@@ -8449,83 +8706,167 @@ async function canonicalPath3(path) {
|
|
|
8449
8706
|
}
|
|
8450
8707
|
}
|
|
8451
8708
|
|
|
8452
|
-
// src/review/local-
|
|
8453
|
-
|
|
8454
|
-
|
|
8455
|
-
|
|
8456
|
-
|
|
8457
|
-
|
|
8458
|
-
|
|
8459
|
-
|
|
8460
|
-
|
|
8461
|
-
|
|
8462
|
-
|
|
8463
|
-
|
|
8464
|
-
|
|
8465
|
-
|
|
8466
|
-
|
|
8467
|
-
|
|
8468
|
-
|
|
8469
|
-
|
|
8709
|
+
// src/review/local-review-backend.ts
|
|
8710
|
+
function LocalReviewBackend(options) {
|
|
8711
|
+
return {
|
|
8712
|
+
kind: "local",
|
|
8713
|
+
async stage(draft) {
|
|
8714
|
+
for (const comment of draft.comments) {
|
|
8715
|
+
await addComment(options.session, comment.body, {
|
|
8716
|
+
targetFile: comment.file,
|
|
8717
|
+
line: comment.line,
|
|
8718
|
+
side: comment.side === "LEFT" ? "old" : "new",
|
|
8719
|
+
username: options.author
|
|
8720
|
+
});
|
|
8721
|
+
}
|
|
8722
|
+
if (draft.body.trim())
|
|
8723
|
+
await addComment(options.session, draft.body, { username: options.author });
|
|
8724
|
+
},
|
|
8725
|
+
async readDraft() {
|
|
8726
|
+
return toFindings(await readSession(options.session), { excludeLocalResponses: true });
|
|
8727
|
+
},
|
|
8728
|
+
async listThreads() {
|
|
8729
|
+
try {
|
|
8730
|
+
return parseThreadArtifact(await readFile9(options.artifactPath, "utf8"));
|
|
8731
|
+
} catch (error) {
|
|
8732
|
+
if (error.code === "ENOENT")
|
|
8733
|
+
return [];
|
|
8734
|
+
throw error;
|
|
8735
|
+
}
|
|
8736
|
+
},
|
|
8737
|
+
async reply(input) {
|
|
8738
|
+
const content = await readFile9(options.artifactPath, "utf8");
|
|
8739
|
+
await writeFile5(options.artifactPath, upsertThreadReply(content, input.threadId, input.body, input.question, input.resolve), "utf8");
|
|
8740
|
+
},
|
|
8741
|
+
async publish() {
|
|
8742
|
+
throw new Error("Promote a local draft through a remote review backend before publishing it.");
|
|
8743
|
+
}
|
|
8470
8744
|
};
|
|
8471
|
-
const path = join15(dir, `${revision}.json`);
|
|
8472
|
-
await writeFile5(path, `${JSON.stringify(dump, null, 2)}
|
|
8473
|
-
`, { encoding: "utf8", flag: "wx" });
|
|
8474
|
-
await unlink2(input.session.path);
|
|
8475
|
-
return { path, dump };
|
|
8476
8745
|
}
|
|
8477
|
-
|
|
8478
|
-
|
|
8746
|
+
|
|
8747
|
+
// src/review/review-backend.ts
|
|
8748
|
+
function createRemoteReviewBackend(vcs, number) {
|
|
8749
|
+
if (vcs.provider === "github")
|
|
8750
|
+
return GitHubReviewBackend(vcs, number);
|
|
8751
|
+
if (vcs.provider === "gitlab")
|
|
8752
|
+
return GitLabReviewBackend(vcs, number);
|
|
8753
|
+
throw new Error("A remote review backend requires a GitHub or GitLab repository.");
|
|
8479
8754
|
}
|
|
8480
|
-
|
|
8755
|
+
function createLocalReviewBackend(options) {
|
|
8756
|
+
return LocalReviewBackend(options);
|
|
8757
|
+
}
|
|
8758
|
+
// src/review/review-state.ts
|
|
8759
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
8760
|
+
import { readFile as readFile10, rename as rename2, writeFile as writeFile6 } from "node:fs/promises";
|
|
8761
|
+
import { join as join15 } from "node:path";
|
|
8762
|
+
import { z as z10 } from "zod";
|
|
8763
|
+
var reviewPublicationStateSchema = z10.object({
|
|
8764
|
+
target: z10.string().optional(),
|
|
8765
|
+
bodies: z10.array(z10.string()).default([]),
|
|
8766
|
+
comments: z10.array(z10.string()).default([]),
|
|
8767
|
+
stagedComments: z10.array(z10.string()).default([]),
|
|
8768
|
+
replies: z10.array(z10.string()).default([]),
|
|
8769
|
+
overlayPath: z10.string().optional()
|
|
8770
|
+
});
|
|
8771
|
+
function reviewBodyFingerprint(body) {
|
|
8772
|
+
return digest(body);
|
|
8773
|
+
}
|
|
8774
|
+
function reviewCommentFingerprint(comment) {
|
|
8775
|
+
return digest([comment.file, String(comment.line), comment.side ?? "RIGHT", comment.body].join("\x00"));
|
|
8776
|
+
}
|
|
8777
|
+
function reviewReplyFingerprint(threadId, body) {
|
|
8778
|
+
return digest(`${threadId}\x00${body}`);
|
|
8779
|
+
}
|
|
8780
|
+
function unpublishedReviewComments(comments, knownFingerprints) {
|
|
8781
|
+
return comments.filter((comment) => !knownFingerprints.has(reviewCommentFingerprint(comment)));
|
|
8782
|
+
}
|
|
8783
|
+
async function loadReviewPublicationState(cwd, vcs, number, homeDir) {
|
|
8784
|
+
const target = `${vcs.provider}:${vcs.owner}/${vcs.repo}#${number}`;
|
|
8785
|
+
const path = join15(await sessionsDir(cwd, homeDir), `review-publish-${digest(target).slice(0, 16)}.json`);
|
|
8481
8786
|
try {
|
|
8482
|
-
|
|
8483
|
-
|
|
8484
|
-
return match ? Math.max(latest, Number(match[1])) : latest;
|
|
8485
|
-
}, 0);
|
|
8787
|
+
const parsed = reviewPublicationStateSchema.parse(JSON.parse(await readFile10(path, "utf8")));
|
|
8788
|
+
return { path, state: { ...parsed, target } };
|
|
8486
8789
|
} catch (error) {
|
|
8487
|
-
if (error.code === "ENOENT")
|
|
8488
|
-
return
|
|
8790
|
+
if (error.code === "ENOENT") {
|
|
8791
|
+
return { path, state: { target, bodies: [], comments: [], stagedComments: [], replies: [] } };
|
|
8792
|
+
}
|
|
8793
|
+
if (error instanceof SyntaxError || error instanceof z10.ZodError) {
|
|
8794
|
+
throw new Error(`Cannot parse review publication state: ${path}`);
|
|
8795
|
+
}
|
|
8489
8796
|
throw error;
|
|
8490
8797
|
}
|
|
8491
8798
|
}
|
|
8799
|
+
async function saveReviewPublicationState(path, state) {
|
|
8800
|
+
const temp = `${path}.${process.pid}.tmp`;
|
|
8801
|
+
await writeFile6(temp, `${JSON.stringify(state, null, 2)}
|
|
8802
|
+
`, "utf8");
|
|
8803
|
+
await rename2(temp, path);
|
|
8804
|
+
}
|
|
8805
|
+
function digest(value) {
|
|
8806
|
+
return createHash2("sha256").update(value).digest("hex");
|
|
8807
|
+
}
|
|
8492
8808
|
// src/tools/review.ts
|
|
8493
|
-
var contextSchema =
|
|
8494
|
-
cwd:
|
|
8495
|
-
target:
|
|
8496
|
-
workingTree:
|
|
8809
|
+
var contextSchema = z11.object({
|
|
8810
|
+
cwd: z11.string().optional(),
|
|
8811
|
+
target: z11.string().optional(),
|
|
8812
|
+
workingTree: z11.boolean().optional()
|
|
8497
8813
|
});
|
|
8498
|
-
var localSchema = contextSchema.extend({ local:
|
|
8814
|
+
var localSchema = contextSchema.extend({ local: z11.boolean().optional() });
|
|
8499
8815
|
var openSchema = localSchema.extend({
|
|
8500
|
-
title:
|
|
8501
|
-
intent:
|
|
8502
|
-
issueUrl:
|
|
8503
|
-
base:
|
|
8816
|
+
title: z11.string().optional(),
|
|
8817
|
+
intent: z11.string().optional(),
|
|
8818
|
+
issueUrl: z11.string().url().optional(),
|
|
8819
|
+
base: z11.string().optional()
|
|
8504
8820
|
});
|
|
8505
8821
|
var submitSchema = localSchema.extend({
|
|
8506
8822
|
findings: findingsSchema,
|
|
8507
|
-
overallIssues:
|
|
8508
|
-
notVerified:
|
|
8509
|
-
title:
|
|
8823
|
+
overallIssues: z11.array(z11.string()).optional(),
|
|
8824
|
+
notVerified: z11.array(z11.string()).optional(),
|
|
8825
|
+
title: z11.string().optional()
|
|
8510
8826
|
});
|
|
8511
8827
|
var addCommentSchema = localSchema.extend({
|
|
8512
|
-
body:
|
|
8513
|
-
file:
|
|
8514
|
-
line:
|
|
8515
|
-
side:
|
|
8828
|
+
body: z11.string().min(1),
|
|
8829
|
+
file: z11.string().min(1),
|
|
8830
|
+
line: z11.number().int().positive(),
|
|
8831
|
+
side: z11.enum(["LEFT", "RIGHT"]).optional()
|
|
8516
8832
|
});
|
|
8517
8833
|
var respondSchema = localSchema.extend({
|
|
8518
|
-
threadId:
|
|
8519
|
-
body:
|
|
8520
|
-
question:
|
|
8521
|
-
resolve:
|
|
8834
|
+
threadId: z11.string().min(1),
|
|
8835
|
+
body: z11.string().min(1),
|
|
8836
|
+
question: z11.boolean().optional(),
|
|
8837
|
+
resolve: z11.boolean().optional()
|
|
8522
8838
|
});
|
|
8523
8839
|
var publishSchema = localSchema.extend({
|
|
8524
|
-
status:
|
|
8840
|
+
status: z11.enum(["COMMENT", "APPROVE", "REQUEST_CHANGES", "CLOSE"]).optional()
|
|
8525
8841
|
});
|
|
8526
8842
|
var completeSchema = localSchema.extend({
|
|
8527
|
-
action:
|
|
8843
|
+
action: z11.enum(["approve", "reject", "abandon"]).optional()
|
|
8528
8844
|
});
|
|
8845
|
+
function hasReviewDraft(comments, body) {
|
|
8846
|
+
return comments.length > 0 || body.trim().length > 0;
|
|
8847
|
+
}
|
|
8848
|
+
function partitionReviewComments(comments, knownFingerprints, model) {
|
|
8849
|
+
const normalized = comments.map((comment) => {
|
|
8850
|
+
const published = { ...comment, body: withCommentProvenance(comment, model) };
|
|
8851
|
+
return { source: comment, published, fingerprint: reviewCommentFingerprint(published) };
|
|
8852
|
+
});
|
|
8853
|
+
return {
|
|
8854
|
+
checkpointed: normalized.filter((comment) => knownFingerprints.has(comment.fingerprint)),
|
|
8855
|
+
candidates: normalized.filter((comment) => !knownFingerprints.has(comment.fingerprint))
|
|
8856
|
+
};
|
|
8857
|
+
}
|
|
8858
|
+
function resolveReviewThread(comment, threads) {
|
|
8859
|
+
const sourceCommentId = comment.sourceCommentId;
|
|
8860
|
+
if (sourceCommentId) {
|
|
8861
|
+
const identified = threads.find((thread) => thread.id === sourceCommentId || thread.rootCommentId === sourceCommentId || thread.commentIds?.includes(sourceCommentId));
|
|
8862
|
+
if (identified)
|
|
8863
|
+
return identified;
|
|
8864
|
+
}
|
|
8865
|
+
const matches = threads.filter((thread) => thread.file === comment.file && (thread.line ?? undefined) === (comment.line ?? undefined));
|
|
8866
|
+
if (matches.length > 1)
|
|
8867
|
+
throw new Error(`Ambiguous remote review threads at ${comment.file}:${comment.line ?? "file"}: ${matches.map((thread) => thread.id).join(", ")}.`);
|
|
8868
|
+
return matches[0];
|
|
8869
|
+
}
|
|
8529
8870
|
async function workingTreeDiff(cwd) {
|
|
8530
8871
|
const tracked = await run("git", ["-C", cwd, "diff", "HEAD"], { capture: "unbounded" });
|
|
8531
8872
|
if (tracked.code !== 0)
|
|
@@ -8721,7 +9062,7 @@ ${openMessage}`, {
|
|
|
8721
9062
|
const gates = await runMiseGates(review.cwd);
|
|
8722
9063
|
const baseRef = review.pr?.baseRef ?? (review.forge ? await review.forge.defaultBranch() : "local");
|
|
8723
9064
|
const artifact = await newReviewArtifactPath(review.cwd, params.local || params.workingTree || !review.pr ? "uncommitted" : await reviewTargetId(review));
|
|
8724
|
-
await
|
|
9065
|
+
await writeFile7(artifact, renderReviewDoc({
|
|
8725
9066
|
title: params.title ?? review.pr?.title ?? review.vcs.branch,
|
|
8726
9067
|
number: useLocalBackend ? undefined : review.pr?.number,
|
|
8727
9068
|
url: useLocalBackend ? undefined : review.pr?.url,
|
|
@@ -8744,21 +9085,17 @@ ${openMessage}`, {
|
|
|
8744
9085
|
count: findings.length
|
|
8745
9086
|
});
|
|
8746
9087
|
}
|
|
8747
|
-
|
|
8748
|
-
|
|
8749
|
-
|
|
8750
|
-
|
|
8751
|
-
|
|
8752
|
-
|
|
8753
|
-
});
|
|
8754
|
-
}
|
|
8755
|
-
if (body.trim())
|
|
8756
|
-
await addComment(session.path, body, { username: localReviewAuthor(model) });
|
|
9088
|
+
const backend = createLocalReviewBackend({
|
|
9089
|
+
session: session.path,
|
|
9090
|
+
artifactPath: artifact,
|
|
9091
|
+
author: localReviewAuthor(model)
|
|
9092
|
+
});
|
|
9093
|
+
await backend.stage({ comments, body });
|
|
8757
9094
|
return result3(`Local review staged in tuicr: ${artifact}`, { artifact, count: findings.length, session });
|
|
8758
9095
|
}
|
|
8759
9096
|
if (!review.pr)
|
|
8760
9097
|
return result3(`Review written: ${artifact}. No PR/MR matches this remote target.`, { artifact });
|
|
8761
|
-
const hasDraft = comments
|
|
9098
|
+
const hasDraft = hasReviewDraft(comments, body);
|
|
8762
9099
|
if (hasDraft) {
|
|
8763
9100
|
await createRemoteReviewBackend(review.vcs, review.pr.number).stage({ comments, body });
|
|
8764
9101
|
}
|
|
@@ -8794,12 +9131,12 @@ ${openMessage}`, {
|
|
|
8794
9131
|
const session = await resolveTuicrSession(review, Boolean(params.local || params.workingTree));
|
|
8795
9132
|
if (!session)
|
|
8796
9133
|
return result3("No matching tuicr session. Open review_new or review_launch_ui first.");
|
|
8797
|
-
|
|
8798
|
-
|
|
8799
|
-
|
|
8800
|
-
|
|
8801
|
-
username: localReviewAuthor(model)
|
|
9134
|
+
const backend = createLocalReviewBackend({
|
|
9135
|
+
session: session.path,
|
|
9136
|
+
artifactPath: "",
|
|
9137
|
+
author: localReviewAuthor(model)
|
|
8802
9138
|
});
|
|
9139
|
+
await backend.stage({ comments: [comment], body: "" });
|
|
8803
9140
|
return result3(`Comment added to tuicr session ${session.slug}.`, { session, comment });
|
|
8804
9141
|
}
|
|
8805
9142
|
if (!review.pr)
|
|
@@ -8811,70 +9148,88 @@ ${openMessage}`, {
|
|
|
8811
9148
|
defineTool6({
|
|
8812
9149
|
name: "review_comments",
|
|
8813
9150
|
label: "review comments",
|
|
8814
|
-
description: "
|
|
8815
|
-
promptSnippet: "Call review_comments before addressing
|
|
8816
|
-
promptGuidelines: ["
|
|
9151
|
+
description: "Pull review threads or local tuicr comments and write a target-named artifact.",
|
|
9152
|
+
promptSnippet: "Call review_comments before addressing findings",
|
|
9153
|
+
promptGuidelines: ["Pass local=true to prepare the local reply overlay."],
|
|
8817
9154
|
parameters: parameters2(localSchema),
|
|
8818
|
-
executionMode: "
|
|
9155
|
+
executionMode: "sequential",
|
|
8819
9156
|
async execute(_id, input) {
|
|
8820
9157
|
const params = localSchema.parse(input);
|
|
8821
|
-
if (params.local || params.workingTree)
|
|
8822
|
-
return result3("Use review_dump for a local tuicr review.");
|
|
8823
9158
|
const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
|
|
8824
|
-
|
|
9159
|
+
let threads;
|
|
9160
|
+
let artifact;
|
|
9161
|
+
if (params.local || params.workingTree) {
|
|
9162
|
+
const session = await resolveTuicrSession(review, true);
|
|
9163
|
+
if (!session)
|
|
9164
|
+
return result3("No matching tuicr session found.");
|
|
9165
|
+
artifact = await localThreadArtifactPath(review.cwd, session.slug);
|
|
9166
|
+
threads = await syncLocalThreadArtifact(artifact, review.pr?.title ?? review.vcs.branch, session.slug, toLocalReviewThreads(await readSession(session.path)));
|
|
9167
|
+
} else if (review.pr && review.forge) {
|
|
9168
|
+
threads = await createRemoteReviewBackend(review.vcs, review.pr.number).listThreads();
|
|
9169
|
+
const target = await reviewTargetId(review);
|
|
9170
|
+
artifact = await newReviewArtifactPath(review.cwd, target);
|
|
9171
|
+
await writeFile7(artifact, renderThreadArtifact(review.pr.title, target, threads, { number: review.pr.number, url: review.pr.url }), "utf8");
|
|
9172
|
+
} else {
|
|
8825
9173
|
return result3(review.forge ? "No PR/MR matches this remote review target." : unsupportedForgeMessage());
|
|
8826
|
-
|
|
9174
|
+
}
|
|
8827
9175
|
return result3(threads.map((thread) => `${thread.id} ${thread.file ?? "review"}:${thread.line ?? "-"} — ${thread.body}`).join(`
|
|
8828
|
-
`) || "No comments.", {
|
|
8829
|
-
}
|
|
8830
|
-
}),
|
|
8831
|
-
defineTool6({
|
|
8832
|
-
name: "review_dump",
|
|
8833
|
-
label: "review dump",
|
|
8834
|
-
description: "Save one immutable local tuicr review revision and remove its completed session.",
|
|
8835
|
-
promptSnippet: "Call review_dump before applying local review feedback",
|
|
8836
|
-
promptGuidelines: ["Each dump advances the local review revision."],
|
|
8837
|
-
parameters: parameters2(localSchema),
|
|
8838
|
-
executionMode: "sequential",
|
|
8839
|
-
async execute(_id, input) {
|
|
8840
|
-
const params = localSchema.parse(input);
|
|
8841
|
-
const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
|
|
8842
|
-
const session = await resolveTuicrSession(review, true);
|
|
8843
|
-
if (!session)
|
|
8844
|
-
return result3("No matching local tuicr session found.");
|
|
8845
|
-
const base = review.pr?.baseRef ?? (review.forge ? await review.forge.defaultBranch() : "HEAD");
|
|
8846
|
-
const captured = await captureLocalReview({
|
|
8847
|
-
cwd: review.cwd,
|
|
8848
|
-
branch: review.vcs.branch,
|
|
8849
|
-
base,
|
|
8850
|
-
diff: await workingTreeDiff(review.cwd),
|
|
8851
|
-
session
|
|
8852
|
-
});
|
|
8853
|
-
return result3(captured.dump.comments.map((thread) => `${thread.id} ${thread.file ?? "review"}:${thread.line ?? "-"} — ${thread.body}`).join(`
|
|
8854
|
-
`) || "No comments.", { artifact: captured.path, review: captured.dump, threads: captured.dump.comments });
|
|
9176
|
+
`) || "No comments.", { artifact, threads });
|
|
8855
9177
|
}
|
|
8856
9178
|
}),
|
|
8857
9179
|
defineTool6({
|
|
8858
9180
|
name: "review_respond",
|
|
8859
9181
|
label: "review respond",
|
|
8860
|
-
description: "
|
|
8861
|
-
promptSnippet: "Call review_respond after addressing a
|
|
8862
|
-
promptGuidelines: ["
|
|
9182
|
+
description: "Post local question replies to tuicr and record their publish overlay, or reply to remote threads.",
|
|
9183
|
+
promptSnippet: "Call review_respond after addressing a comment",
|
|
9184
|
+
promptGuidelines: ["Question replies remain unresolved."],
|
|
8863
9185
|
parameters: parameters2(respondSchema),
|
|
8864
9186
|
executionMode: "sequential",
|
|
8865
9187
|
async execute(_id, input, _signal, _onUpdate, ctx) {
|
|
8866
9188
|
const params = respondSchema.parse(input);
|
|
8867
|
-
if (params.local)
|
|
8868
|
-
return result3("Local reviews use immutable revision dumps, not thread replies.");
|
|
8869
9189
|
const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
|
|
9190
|
+
const model = modelRoute(ctx);
|
|
9191
|
+
if (params.local) {
|
|
9192
|
+
const tuicrSession = await resolveTuicrSession(review, true);
|
|
9193
|
+
if (!tuicrSession)
|
|
9194
|
+
return result3("No matching tuicr session. Open review_new or review_launch_ui first.");
|
|
9195
|
+
const artifact = await localThreadArtifactPath(review.cwd, tuicrSession.slug);
|
|
9196
|
+
if (!existsSync2(artifact))
|
|
9197
|
+
return result3("No local review artifact. Run review_comments with local=true first.");
|
|
9198
|
+
const threads = await readThreadArtifact(artifact);
|
|
9199
|
+
const known = threads.find((thread) => thread.id === params.threadId);
|
|
9200
|
+
const question = known?.question === true || params.question === true || !known && params.question === undefined;
|
|
9201
|
+
const awaitUserDeletion = !question && params.resolve === true;
|
|
9202
|
+
await addComment(tuicrSession.path, `${localResponseMarker(params.threadId)}
|
|
9203
|
+
${params.body}`, {
|
|
9204
|
+
targetFile: known?.file,
|
|
9205
|
+
line: known?.line,
|
|
9206
|
+
side: "new",
|
|
9207
|
+
username: localReviewAuthor(model)
|
|
9208
|
+
});
|
|
9209
|
+
await createLocalReviewBackend({
|
|
9210
|
+
session: tuicrSession.path,
|
|
9211
|
+
artifactPath: artifact,
|
|
9212
|
+
author: localReviewAuthor(model)
|
|
9213
|
+
}).reply({
|
|
9214
|
+
threadId: params.threadId,
|
|
9215
|
+
body: params.body,
|
|
9216
|
+
resolve: false,
|
|
9217
|
+
question
|
|
9218
|
+
});
|
|
9219
|
+
return result3(`Local ${question ? "answer" : "response"} posted to tuicr and recorded in ${artifact}${awaitUserDeletion ? "; delete the source comment in tuicr to resolve it" : ""}.`, { artifact, question, awaitUserDeletion, session: tuicrSession });
|
|
9220
|
+
}
|
|
8870
9221
|
if (!review.pr || !review.forge)
|
|
8871
9222
|
return result3("No remote PR/MR for this reply.");
|
|
8872
|
-
const
|
|
8873
|
-
await
|
|
9223
|
+
const remote = createRemoteReviewBackend(review.vcs, review.pr.number);
|
|
9224
|
+
const remoteThreads = await remote.listThreads();
|
|
9225
|
+
const known = remoteThreads.find((thread) => thread.id === params.threadId);
|
|
9226
|
+
const question = known?.question === true || params.question === true || !known && params.question === undefined;
|
|
9227
|
+
const resolve = question ? false : params.resolve ?? true;
|
|
9228
|
+
await remote.reply({
|
|
8874
9229
|
threadId: params.threadId,
|
|
8875
|
-
body: withRemoteProvenance(params.body,
|
|
9230
|
+
body: withRemoteProvenance(params.body, model),
|
|
8876
9231
|
resolve,
|
|
8877
|
-
question
|
|
9232
|
+
question
|
|
8878
9233
|
});
|
|
8879
9234
|
return result3(`Replied to ${params.threadId}${resolve ? " and resolved it" : " and left it open"}.`, {
|
|
8880
9235
|
pr: review.pr,
|
|
@@ -8885,40 +9240,51 @@ ${openMessage}`, {
|
|
|
8885
9240
|
defineTool6({
|
|
8886
9241
|
name: "review_publish",
|
|
8887
9242
|
label: "review publish",
|
|
8888
|
-
description: "Publish pending remote review comments and status.",
|
|
9243
|
+
description: "Publish pending remote draft PR/MR review comments and status, or promote a local tuicr draft before publishing.",
|
|
8889
9244
|
promptSnippet: "Call review_publish to make remote review work public",
|
|
8890
|
-
promptGuidelines: ["
|
|
9245
|
+
promptGuidelines: ["Statuses are COMMENT, APPROVE, REQUEST_CHANGES, or CLOSE."],
|
|
8891
9246
|
parameters: parameters2(publishSchema),
|
|
8892
9247
|
executionMode: "sequential",
|
|
8893
|
-
async execute(_id, input) {
|
|
9248
|
+
async execute(_id, input, _signal, _onUpdate, ctx) {
|
|
8894
9249
|
const params = publishSchema.parse(input);
|
|
8895
|
-
if (params.local)
|
|
8896
|
-
return result3("Local reviews use immutable revision dumps and have no publish step.");
|
|
8897
9250
|
const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
|
|
8898
9251
|
if (!review.pr || !review.forge)
|
|
8899
9252
|
return result3("No remote PR/MR to publish.");
|
|
8900
|
-
return publishResolvedReview(review, params);
|
|
9253
|
+
return publishResolvedReview(review, params, modelRoute(ctx));
|
|
8901
9254
|
}
|
|
8902
9255
|
}),
|
|
8903
9256
|
defineTool6({
|
|
8904
9257
|
name: "review_complete",
|
|
8905
9258
|
label: "review complete",
|
|
8906
|
-
description: "Approve, reject, or abandon a remote review.",
|
|
8907
|
-
promptSnippet: "Call review_complete to finish a
|
|
8908
|
-
promptGuidelines: ["
|
|
9259
|
+
description: "Approve, reject, or abandon a remote review, or archive a local review artifact.",
|
|
9260
|
+
promptSnippet: "Call review_complete to finish a review without merging it.",
|
|
9261
|
+
promptGuidelines: ["A local completion archives the Diffpi artifact and deletes the matching tuicr session."],
|
|
8909
9262
|
parameters: parameters2(completeSchema),
|
|
8910
9263
|
executionMode: "sequential",
|
|
8911
|
-
async execute(_id, input) {
|
|
9264
|
+
async execute(_id, input, _signal, _onUpdate, ctx) {
|
|
8912
9265
|
const params = completeSchema.parse(input);
|
|
8913
|
-
if (params.local)
|
|
8914
|
-
return result3("Use review_dump to finish a local review revision.");
|
|
8915
9266
|
const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
|
|
9267
|
+
if (params.local) {
|
|
9268
|
+
const session = await resolveTuicrSession(review, true);
|
|
9269
|
+
if (!session)
|
|
9270
|
+
return result3("No matching local tuicr session to close.");
|
|
9271
|
+
const artifact = await localThreadArtifactPath(review.cwd, session.slug);
|
|
9272
|
+
if (!existsSync2(artifact))
|
|
9273
|
+
return result3("No local review artifact. Run review_comments with local=true first.");
|
|
9274
|
+
const archived = await uniqueRecordPath(await completedReviewsDir(review.cwd), reviewSlug(session.slug));
|
|
9275
|
+
await rename3(artifact, archived);
|
|
9276
|
+
await unlink2(session.path);
|
|
9277
|
+
return result3(`Archived ${archived} and deleted the tuicr session ${session.slug}.`, {
|
|
9278
|
+
artifact: archived,
|
|
9279
|
+
session
|
|
9280
|
+
});
|
|
9281
|
+
}
|
|
8916
9282
|
if (!review.pr || !review.forge)
|
|
8917
9283
|
return result3("No remote PR/MR to complete.");
|
|
8918
9284
|
if (!params.action)
|
|
8919
9285
|
return result3("Choose a complete action: approve, reject, or abandon.");
|
|
8920
9286
|
const status = { approve: "APPROVE", reject: "REQUEST_CHANGES", abandon: "CLOSE" }[params.action];
|
|
8921
|
-
return publishResolvedReview(review, { ...params, status });
|
|
9287
|
+
return publishResolvedReview(review, { ...params, status }, modelRoute(ctx));
|
|
8922
9288
|
}
|
|
8923
9289
|
}),
|
|
8924
9290
|
defineTool6({
|
|
@@ -8927,10 +9293,10 @@ ${openMessage}`, {
|
|
|
8927
9293
|
description: "Squash-merge an open, ready GitHub PR after checking its conventional subject.",
|
|
8928
9294
|
promptSnippet: "Call review_merge after confirming the PR is open, ready, and CI is settled; approval is optional",
|
|
8929
9295
|
promptGuidelines: ["This is intentionally GitHub-only until GitLab merge support is added."],
|
|
8930
|
-
parameters: parameters2(contextSchema.extend({ subject:
|
|
9296
|
+
parameters: parameters2(contextSchema.extend({ subject: z11.string().optional() })),
|
|
8931
9297
|
executionMode: "sequential",
|
|
8932
9298
|
async execute(_id, input) {
|
|
8933
|
-
const params = contextSchema.extend({ subject:
|
|
9299
|
+
const params = contextSchema.extend({ subject: z11.string().optional() }).parse(input);
|
|
8934
9300
|
const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
|
|
8935
9301
|
if (review.vcs.provider !== "github" || !review.forge)
|
|
8936
9302
|
return result3("review_merge currently supports GitHub only.");
|
|
@@ -8963,8 +9329,32 @@ ${openMessage}`, {
|
|
|
8963
9329
|
})
|
|
8964
9330
|
];
|
|
8965
9331
|
}
|
|
9332
|
+
async function syncLocalThreadArtifact(path, title, sessionSlug, threads) {
|
|
9333
|
+
let previous = [];
|
|
9334
|
+
try {
|
|
9335
|
+
previous = parseThreadArtifact(await readFile11(path, "utf8"));
|
|
9336
|
+
} catch (error) {
|
|
9337
|
+
if (error.code !== "ENOENT")
|
|
9338
|
+
throw error;
|
|
9339
|
+
}
|
|
9340
|
+
const previousById = new Map(previous.map((thread) => [thread.id, thread]));
|
|
9341
|
+
const currentIds = new Set(threads.map((thread) => thread.id));
|
|
9342
|
+
const current = threads.map((thread) => {
|
|
9343
|
+
const existing = previousById.get(thread.id);
|
|
9344
|
+
return {
|
|
9345
|
+
...thread,
|
|
9346
|
+
resolved: false,
|
|
9347
|
+
addressed: existing?.addressed ?? Boolean(existing?.reply),
|
|
9348
|
+
reply: existing?.reply
|
|
9349
|
+
};
|
|
9350
|
+
});
|
|
9351
|
+
const removed = previous.filter((thread) => !currentIds.has(thread.id)).map((thread) => ({ ...thread, resolved: true }));
|
|
9352
|
+
const merged = [...current, ...removed];
|
|
9353
|
+
await writeFile7(path, renderThreadArtifact(title, sessionSlug, merged), "utf8");
|
|
9354
|
+
return merged;
|
|
9355
|
+
}
|
|
8966
9356
|
function parameters2(schema) {
|
|
8967
|
-
return
|
|
9357
|
+
return z11.toJSONSchema(schema, { io: "input" });
|
|
8968
9358
|
}
|
|
8969
9359
|
function resolveWorkingDirectory(params) {
|
|
8970
9360
|
return params.cwd ?? process.cwd();
|
|
@@ -8980,17 +9370,159 @@ function modelRoute(ctx) {
|
|
|
8980
9370
|
throw new Error("Cannot record review provenance because Pi did not provide an active model route.");
|
|
8981
9371
|
return `${ctx.model.provider}/${ctx.model.id}`;
|
|
8982
9372
|
}
|
|
8983
|
-
async function publishResolvedReview(review, params) {
|
|
9373
|
+
async function publishResolvedReview(review, params, model) {
|
|
8984
9374
|
const status = params.status ?? "COMMENT";
|
|
8985
9375
|
const event = status === "CLOSE" ? "COMMENT" : status;
|
|
8986
9376
|
assertReviewEventSupported(review.vcs.provider, event);
|
|
9377
|
+
const remote = createRemoteReviewBackend(review.vcs, review.pr.number);
|
|
9378
|
+
const promotion = params.local ? await promoteLocalReview(review, remote, model) : undefined;
|
|
8987
9379
|
if (status !== "CLOSE" && review.pr.isDraft)
|
|
8988
9380
|
await review.forge.markReady(review.pr.number);
|
|
8989
|
-
await
|
|
9381
|
+
await remote.publish(event);
|
|
9382
|
+
if (promotion) {
|
|
9383
|
+
promotion.publication.state.bodies = [
|
|
9384
|
+
...new Set([...promotion.publication.state.bodies, ...promotion.bodyFingerprints])
|
|
9385
|
+
];
|
|
9386
|
+
promotion.publication.state.comments = [
|
|
9387
|
+
...new Set([...promotion.publication.state.comments, ...promotion.commentFingerprints])
|
|
9388
|
+
];
|
|
9389
|
+
const published = new Set(promotion.commentFingerprints);
|
|
9390
|
+
promotion.publication.state.stagedComments = promotion.publication.state.stagedComments.filter((fingerprint) => !published.has(fingerprint));
|
|
9391
|
+
await saveReviewPublicationState(promotion.publication.path, promotion.publication.state);
|
|
9392
|
+
}
|
|
8990
9393
|
if (status === "CLOSE")
|
|
8991
9394
|
await review.forge.closePr(review.pr.number);
|
|
8992
9395
|
const finalPr = await review.forge.viewPr(String(review.pr.number));
|
|
8993
|
-
|
|
9396
|
+
const promotedBodies = promotion?.promotedBodies ?? 0;
|
|
9397
|
+
const promotedComments = promotion?.promotedComments ?? 0;
|
|
9398
|
+
const promotedReplies = promotion?.promotedReplies ?? 0;
|
|
9399
|
+
return result3(`Published #${review.pr.number} (${status}); promoted ${promotedBodies} review bodies, ${promotedComments} comments, and ${promotedReplies} replies.`, { pr: finalPr ?? review.pr, status, promotedBodies, promotedComments, promotedReplies });
|
|
9400
|
+
}
|
|
9401
|
+
async function promoteLocalReview(review, remote, model) {
|
|
9402
|
+
const publication = await loadReviewPublicationState(review.cwd, review.vcs, review.pr.number);
|
|
9403
|
+
const session = await resolvePublishSession(review.cwd, {
|
|
9404
|
+
branch: review.vcs.branch,
|
|
9405
|
+
owner: review.vcs.provider === "none" ? undefined : review.vcs.owner,
|
|
9406
|
+
repo: review.vcs.provider === "none" ? undefined : review.vcs.repo,
|
|
9407
|
+
number: review.pr?.number
|
|
9408
|
+
});
|
|
9409
|
+
if (!session)
|
|
9410
|
+
throw new Error("No matching tuicr session to publish.");
|
|
9411
|
+
const sessionIsWorkingTree = session.kind === "local";
|
|
9412
|
+
const local = createLocalReviewBackend({
|
|
9413
|
+
session: session.path,
|
|
9414
|
+
artifactPath: "",
|
|
9415
|
+
author: localReviewAuthor(model)
|
|
9416
|
+
});
|
|
9417
|
+
const draft = await local.readDraft();
|
|
9418
|
+
const remoteDraft = await remote.readDraft();
|
|
9419
|
+
const knownRootComments = new Set([
|
|
9420
|
+
...publication.state.comments,
|
|
9421
|
+
...publication.state.stagedComments,
|
|
9422
|
+
...remoteDraft.comments.map(reviewCommentFingerprint)
|
|
9423
|
+
]);
|
|
9424
|
+
const { checkpointed, candidates } = partitionReviewComments(draft.comments, knownRootComments, model);
|
|
9425
|
+
const remoteThreads = await remote.listThreads();
|
|
9426
|
+
const threadReplies = sessionIsWorkingTree ? [] : candidates.flatMap(({ source: comment }) => {
|
|
9427
|
+
const thread = resolveReviewThread(comment, remoteThreads);
|
|
9428
|
+
return thread ? [{ comment, thread }] : [];
|
|
9429
|
+
});
|
|
9430
|
+
const unmatchedActions = sessionIsWorkingTree ? [] : candidates.map(({ source }) => source).filter((comment) => parseReviewThreadAction(comment.body).action && !threadReplies.some((reply) => reply.comment === comment));
|
|
9431
|
+
if (unmatchedActions.length > 0) {
|
|
9432
|
+
const locations = unmatchedActions.map(({ file, line }) => `${file}:${line ?? "?"}`).join(", ");
|
|
9433
|
+
throw new Error(`Cannot apply review thread action: no matching remote thread at ${locations}.`);
|
|
9434
|
+
}
|
|
9435
|
+
const comments = candidates.filter(({ source }) => !threadReplies.some((reply) => reply.comment === source)).map(({ published }) => published);
|
|
9436
|
+
const body = draft.body.trim();
|
|
9437
|
+
const bodyFingerprints = body ? [reviewBodyFingerprint(body)] : [];
|
|
9438
|
+
const commentFingerprints = [
|
|
9439
|
+
...checkpointed.map(({ fingerprint }) => fingerprint),
|
|
9440
|
+
...comments.map(reviewCommentFingerprint)
|
|
9441
|
+
];
|
|
9442
|
+
const knownBodies = new Set(publication.state.bodies);
|
|
9443
|
+
if (remoteDraft.body.trim())
|
|
9444
|
+
knownBodies.add(reviewBodyFingerprint(remoteDraft.body));
|
|
9445
|
+
const unpublishedBody = body && !knownBodies.has(reviewBodyFingerprint(body)) ? body : "";
|
|
9446
|
+
const unpublished = unpublishedReviewComments(comments, knownRootComments);
|
|
9447
|
+
if (unpublishedBody)
|
|
9448
|
+
await remote.stage({ comments: [], body: unpublishedBody });
|
|
9449
|
+
let promotedComments = 0;
|
|
9450
|
+
for (const comment of unpublished) {
|
|
9451
|
+
await remote.stage({ comments: [comment], body: "" });
|
|
9452
|
+
publication.state.stagedComments.push(reviewCommentFingerprint(comment));
|
|
9453
|
+
await saveReviewPublicationState(publication.path, publication.state);
|
|
9454
|
+
promotedComments += 1;
|
|
9455
|
+
}
|
|
9456
|
+
const promotedReplies = await promoteLocalReplies(review, remote, publication, model, sessionIsWorkingTree) + await promoteLocationReplies(remote, publication, threadReplies, model);
|
|
9457
|
+
return {
|
|
9458
|
+
publication,
|
|
9459
|
+
bodyFingerprints,
|
|
9460
|
+
commentFingerprints,
|
|
9461
|
+
promotedBodies: unpublishedBody ? 1 : 0,
|
|
9462
|
+
promotedComments,
|
|
9463
|
+
promotedReplies
|
|
9464
|
+
};
|
|
9465
|
+
}
|
|
9466
|
+
async function promoteLocationReplies(remote, publication, replies, model) {
|
|
9467
|
+
let count = 0;
|
|
9468
|
+
for (const { comment, thread } of replies) {
|
|
9469
|
+
const parsed = parseReviewThreadAction(comment.body);
|
|
9470
|
+
const body = parsed.body.trim();
|
|
9471
|
+
const action = parsed.action;
|
|
9472
|
+
const fingerprint = reviewReplyFingerprint(thread.id, `${action ?? "reply"}\x00${body}`);
|
|
9473
|
+
if (publication.state.replies.includes(fingerprint))
|
|
9474
|
+
continue;
|
|
9475
|
+
if (action === "delete") {
|
|
9476
|
+
if (!remote.deleteThread)
|
|
9477
|
+
throw new Error(`The review backend cannot delete thread ${thread.id}.`);
|
|
9478
|
+
await remote.deleteThread(thread.id);
|
|
9479
|
+
publication.state.replies.push(fingerprint);
|
|
9480
|
+
await saveReviewPublicationState(publication.path, publication.state);
|
|
9481
|
+
count += 1;
|
|
9482
|
+
continue;
|
|
9483
|
+
}
|
|
9484
|
+
const remoteBody = withCommentProvenance({ ...comment, body }, model);
|
|
9485
|
+
if (body && !thread.replies?.some((reply) => reply === body || reply === remoteBody)) {
|
|
9486
|
+
await remote.reply({
|
|
9487
|
+
threadId: thread.id,
|
|
9488
|
+
body: remoteBody,
|
|
9489
|
+
resolve: false
|
|
9490
|
+
});
|
|
9491
|
+
}
|
|
9492
|
+
if (action && remote.setResolved)
|
|
9493
|
+
await remote.setResolved(thread.id, action === "resolve");
|
|
9494
|
+
publication.state.replies.push(fingerprint);
|
|
9495
|
+
await saveReviewPublicationState(publication.path, publication.state);
|
|
9496
|
+
count += 1;
|
|
9497
|
+
}
|
|
9498
|
+
return count;
|
|
9499
|
+
}
|
|
9500
|
+
async function promoteLocalReplies(review, remote, publication, model, workingTree) {
|
|
9501
|
+
const target = workingTree ? "uncommitted" : await reviewTargetId(review);
|
|
9502
|
+
const artifact = workingTree ? await latestThreadArtifact(review.cwd, review, target) : publication.state.overlayPath ?? await latestThreadArtifact(review.cwd, review, target);
|
|
9503
|
+
if (!artifact)
|
|
9504
|
+
return 0;
|
|
9505
|
+
if (!workingTree)
|
|
9506
|
+
publication.state.overlayPath = artifact;
|
|
9507
|
+
let count = 0;
|
|
9508
|
+
for (const thread of await readThreadArtifact(artifact)) {
|
|
9509
|
+
if (!thread.reply)
|
|
9510
|
+
continue;
|
|
9511
|
+
const body = withRemoteProvenance(thread.reply, model);
|
|
9512
|
+
const fingerprint = reviewReplyFingerprint(thread.id, body);
|
|
9513
|
+
if (publication.state.replies.includes(fingerprint))
|
|
9514
|
+
continue;
|
|
9515
|
+
await remote.reply({
|
|
9516
|
+
threadId: thread.id,
|
|
9517
|
+
body,
|
|
9518
|
+
resolve: !thread.question,
|
|
9519
|
+
question: thread.question
|
|
9520
|
+
});
|
|
9521
|
+
publication.state.replies.push(fingerprint);
|
|
9522
|
+
await saveReviewPublicationState(publication.path, publication.state);
|
|
9523
|
+
count += 1;
|
|
9524
|
+
}
|
|
9525
|
+
return count;
|
|
8994
9526
|
}
|
|
8995
9527
|
async function resolveReviewContext(cwd, target) {
|
|
8996
9528
|
const vcs = await detectVcs(cwd);
|
|
@@ -9031,6 +9563,10 @@ async function assertRemoteBranchReady(cwd) {
|
|
|
9031
9563
|
if (head.stdout.trim() !== pushed.stdout.trim())
|
|
9032
9564
|
throw new Error("Push the current branch before opening a remote review.");
|
|
9033
9565
|
}
|
|
9566
|
+
function withCommentProvenance(comment, fallbackModel) {
|
|
9567
|
+
const route = comment.author?.match(/^Agent:\s*(.+)$/)?.[1];
|
|
9568
|
+
return route ? withRemoteProvenance(comment.body, route || fallbackModel) : comment.body;
|
|
9569
|
+
}
|
|
9034
9570
|
async function reviewTargetId(review) {
|
|
9035
9571
|
if (!review.pr)
|
|
9036
9572
|
return "uncommitted";
|
|
@@ -9047,9 +9583,60 @@ async function headSha(cwd) {
|
|
|
9047
9583
|
}
|
|
9048
9584
|
async function newReviewArtifactPath(cwd, target) {
|
|
9049
9585
|
const dir = await reviewsDir(cwd);
|
|
9050
|
-
await
|
|
9586
|
+
await mkdir7(dir, { recursive: true });
|
|
9051
9587
|
return uniqueRecordPath(dir, reviewRecordName(target));
|
|
9052
9588
|
}
|
|
9589
|
+
async function localThreadArtifactPath(cwd, sessionSlug) {
|
|
9590
|
+
const dir = await reviewsDir(cwd);
|
|
9591
|
+
return join16(dir, `${reviewSlug(sessionSlug) || "local-review"}.md`);
|
|
9592
|
+
}
|
|
9593
|
+
async function readThreadArtifact(path) {
|
|
9594
|
+
try {
|
|
9595
|
+
return parseThreadArtifact(await readFile11(path, "utf8"));
|
|
9596
|
+
} catch (error) {
|
|
9597
|
+
if (error.code === "ENOENT") {
|
|
9598
|
+
throw new Error(`Expected local reply overlay is missing: ${path}`);
|
|
9599
|
+
}
|
|
9600
|
+
throw error;
|
|
9601
|
+
}
|
|
9602
|
+
}
|
|
9603
|
+
async function latestThreadArtifact(cwd, review, target) {
|
|
9604
|
+
try {
|
|
9605
|
+
return await findLatestThreadArtifact(cwd, review, target);
|
|
9606
|
+
} catch (error) {
|
|
9607
|
+
throw new Error(`Cannot locate the latest review thread artifact for ${target}.`, { cause: error });
|
|
9608
|
+
}
|
|
9609
|
+
}
|
|
9610
|
+
async function findLatestThreadArtifact(cwd, review, target) {
|
|
9611
|
+
const dir = await reviewsDir(cwd);
|
|
9612
|
+
const suffix = reviewSlug(target) || "uncommitted";
|
|
9613
|
+
const names = await listReviewArtifactNames(dir);
|
|
9614
|
+
const candidates = await Promise.all(names.flatMap((name) => {
|
|
9615
|
+
if (!name.endsWith(".md"))
|
|
9616
|
+
return [];
|
|
9617
|
+
return [
|
|
9618
|
+
(async () => {
|
|
9619
|
+
const path = join16(dir, name);
|
|
9620
|
+
const info = await stat(path);
|
|
9621
|
+
return { path, name, content: await readFile11(path, "utf8"), modified: info.mtimeMs };
|
|
9622
|
+
})()
|
|
9623
|
+
];
|
|
9624
|
+
}));
|
|
9625
|
+
return candidates.filter(({ name, content }) => {
|
|
9626
|
+
if (!content.startsWith("<!-- diffpi-threads:"))
|
|
9627
|
+
return false;
|
|
9628
|
+
if (review.pr && target !== "local" && target !== "uncommitted")
|
|
9629
|
+
return content.includes(`- PR/MR: #${review.pr.number}`);
|
|
9630
|
+
return artifactNameMatches(name, suffix);
|
|
9631
|
+
}).sort((a, b) => b.modified - a.modified)[0]?.path;
|
|
9632
|
+
}
|
|
9633
|
+
async function listReviewArtifactNames(dir) {
|
|
9634
|
+
try {
|
|
9635
|
+
return await readdir2(dir);
|
|
9636
|
+
} catch (error) {
|
|
9637
|
+
throw new Error(`Cannot read review artifacts in ${dir}.`, { cause: error });
|
|
9638
|
+
}
|
|
9639
|
+
}
|
|
9053
9640
|
function deriveTitle(branch) {
|
|
9054
9641
|
return branch.replace(/^(feature|feat|fix|bug|chore)\//, "").replace(/^eng-\d+-/i, "").replace(/[-_]+/g, " ").replace(/^\w/, (char) => char.toUpperCase());
|
|
9055
9642
|
}
|
|
@@ -9060,28 +9647,39 @@ function uniqueRecordPath(dir, base) {
|
|
|
9060
9647
|
path = join16(dir, `${base}-${count++}.md`);
|
|
9061
9648
|
return path;
|
|
9062
9649
|
}
|
|
9650
|
+
function artifactNameMatches(name, suffix) {
|
|
9651
|
+
if (!name.endsWith(".md"))
|
|
9652
|
+
return false;
|
|
9653
|
+
const stem = name.slice(0, -3);
|
|
9654
|
+
const marker = `-${suffix}`;
|
|
9655
|
+
const markerIndex = stem.lastIndexOf(marker);
|
|
9656
|
+
if (markerIndex < 0)
|
|
9657
|
+
return false;
|
|
9658
|
+
const tail = stem.slice(markerIndex + marker.length);
|
|
9659
|
+
return tail === "" || /^-\d+$/.test(tail);
|
|
9660
|
+
}
|
|
9063
9661
|
|
|
9064
9662
|
// src/tools/setup.ts
|
|
9065
9663
|
import { defineTool as defineTool7 } from "@earendil-works/pi-coding-agent";
|
|
9066
|
-
import { z as
|
|
9664
|
+
import { z as z13 } from "zod";
|
|
9067
9665
|
|
|
9068
9666
|
// src/setup.ts
|
|
9069
9667
|
import { parseFrontmatter as parseFrontmatter2 } from "@earendil-works/pi-coding-agent";
|
|
9070
|
-
import { readdir as readdir4, readFile as
|
|
9668
|
+
import { readdir as readdir4, readFile as readFile14 } from "node:fs/promises";
|
|
9071
9669
|
import { homedir as homedir8 } from "node:os";
|
|
9072
9670
|
import { basename as basename6, join as join20 } from "node:path";
|
|
9073
9671
|
|
|
9074
9672
|
// src/config.ts
|
|
9075
9673
|
import { parseFrontmatter } from "@earendil-works/pi-coding-agent";
|
|
9076
|
-
import { z as
|
|
9674
|
+
import { z as z12 } from "zod";
|
|
9077
9675
|
import { homedir as homedir5 } from "node:os";
|
|
9078
9676
|
import { join as join17 } from "node:path";
|
|
9079
9677
|
|
|
9080
9678
|
// src/fsx.ts
|
|
9081
|
-
import { readdir as readdir3, readFile as
|
|
9679
|
+
import { readdir as readdir3, readFile as readFile12 } from "node:fs/promises";
|
|
9082
9680
|
async function readTextIfExists(path) {
|
|
9083
9681
|
try {
|
|
9084
|
-
return await
|
|
9682
|
+
return await readFile12(path, "utf8");
|
|
9085
9683
|
} catch (error) {
|
|
9086
9684
|
if (isMissingPath(error))
|
|
9087
9685
|
return;
|
|
@@ -9094,11 +9692,11 @@ function isMissingPath(error) {
|
|
|
9094
9692
|
|
|
9095
9693
|
// src/config.ts
|
|
9096
9694
|
var modelReferenceSchema = zx.text;
|
|
9097
|
-
var agentConfigSchema =
|
|
9098
|
-
models:
|
|
9695
|
+
var agentConfigSchema = z12.object({
|
|
9696
|
+
models: z12.array(modelReferenceSchema).optional()
|
|
9099
9697
|
}).strict();
|
|
9100
|
-
var diffpiConfigSchema =
|
|
9101
|
-
agents:
|
|
9698
|
+
var diffpiConfigSchema = z12.object({
|
|
9699
|
+
agents: z12.record(z12.string(), agentConfigSchema).optional()
|
|
9102
9700
|
}).strict();
|
|
9103
9701
|
function diffpiConfigPaths(homeDir = homedir5()) {
|
|
9104
9702
|
const directory = join17(homeDir, ".difflab", "diffpi");
|
|
@@ -9160,7 +9758,7 @@ function normalizeModelReference(value) {
|
|
|
9160
9758
|
}
|
|
9161
9759
|
|
|
9162
9760
|
// src/mcp.ts
|
|
9163
|
-
import { mkdir as
|
|
9761
|
+
import { mkdir as mkdir8, readFile as readFile13, writeFile as writeFile8 } from "node:fs/promises";
|
|
9164
9762
|
import { homedir as homedir6 } from "node:os";
|
|
9165
9763
|
import { dirname as dirname8, join as join18 } from "node:path";
|
|
9166
9764
|
var mcp = {
|
|
@@ -9178,8 +9776,8 @@ var mcp = {
|
|
|
9178
9776
|
const next = { ...current, mcpServers: nextServers };
|
|
9179
9777
|
const changed = JSON.stringify(current) !== JSON.stringify(next);
|
|
9180
9778
|
if (changed && !options.dryRun) {
|
|
9181
|
-
await
|
|
9182
|
-
await
|
|
9779
|
+
await mkdir8(dirname8(path), { recursive: true });
|
|
9780
|
+
await writeFile8(path, `${JSON.stringify(next, null, 2)}
|
|
9183
9781
|
`, "utf8");
|
|
9184
9782
|
}
|
|
9185
9783
|
return { path, changed, existed: currentText !== undefined, planned: changed && options.dryRun === true };
|
|
@@ -9208,7 +9806,7 @@ function getParsedConfig(content, path) {
|
|
|
9208
9806
|
}
|
|
9209
9807
|
async function getOptionalFile2(path) {
|
|
9210
9808
|
try {
|
|
9211
|
-
return await
|
|
9809
|
+
return await readFile13(path, "utf8");
|
|
9212
9810
|
} catch (error) {
|
|
9213
9811
|
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
9214
9812
|
return;
|
|
@@ -9220,7 +9818,7 @@ function isRecord(value) {
|
|
|
9220
9818
|
}
|
|
9221
9819
|
|
|
9222
9820
|
// src/pi.ts
|
|
9223
|
-
import { mkdir as
|
|
9821
|
+
import { mkdir as mkdir9, writeFile as writeFile9 } from "node:fs/promises";
|
|
9224
9822
|
import { homedir as homedir7 } from "node:os";
|
|
9225
9823
|
import { dirname as dirname9, join as join19 } from "node:path";
|
|
9226
9824
|
var pi = {
|
|
@@ -9239,8 +9837,8 @@ async function ensurePiAgent(filename, content, agentDir = resolvePiAgentDir(),
|
|
|
9239
9837
|
const currentText = await readTextIfExists(path);
|
|
9240
9838
|
const changed = currentText !== content;
|
|
9241
9839
|
if (changed && !dryRun) {
|
|
9242
|
-
await
|
|
9243
|
-
await
|
|
9840
|
+
await mkdir9(dirname9(path), { recursive: true });
|
|
9841
|
+
await writeFile9(path, content, "utf8");
|
|
9244
9842
|
}
|
|
9245
9843
|
return { path, changed, existed: currentText !== undefined, planned: changed && dryRun };
|
|
9246
9844
|
}
|
|
@@ -9276,8 +9874,8 @@ async function ensurePiConfig(path, update, dryRun = false) {
|
|
|
9276
9874
|
const next = update(current);
|
|
9277
9875
|
const changed = JSON.stringify(current) !== JSON.stringify(next);
|
|
9278
9876
|
if (changed && !dryRun) {
|
|
9279
|
-
await
|
|
9280
|
-
await
|
|
9877
|
+
await mkdir9(dirname9(path), { recursive: true });
|
|
9878
|
+
await writeFile9(path, `${JSON.stringify(next, null, 2)}
|
|
9281
9879
|
`, "utf8");
|
|
9282
9880
|
}
|
|
9283
9881
|
return { path, changed, existed: currentText !== undefined, planned: changed && dryRun };
|
|
@@ -9415,7 +10013,7 @@ async function ensurePiAgents(options = {}) {
|
|
|
9415
10013
|
const actions = [];
|
|
9416
10014
|
for (const entry of entries) {
|
|
9417
10015
|
const id = basename6(entry.name, ".md").replace(/^diffpi-/, "");
|
|
9418
|
-
const source = await
|
|
10016
|
+
const source = await readFile14(join20(bundledAgentsDir, entry.name), "utf8");
|
|
9419
10017
|
const content = materializeAgentModels(source, id, userConfig.config, options.availableModels);
|
|
9420
10018
|
const result = await pi.agentEnsure(entry.name, content, agentDir, options.dryRun);
|
|
9421
10019
|
actions.push(getConfigSetupAction(`pi agent ${id}`, result));
|
|
@@ -9620,7 +10218,7 @@ function reportProgress(options, message) {
|
|
|
9620
10218
|
options.onProgress?.(message);
|
|
9621
10219
|
}
|
|
9622
10220
|
async function packageVersion2() {
|
|
9623
|
-
const source = await
|
|
10221
|
+
const source = await readFile14(join20(resolveBundledAgentsDir(), "..", "package.json"), "utf8");
|
|
9624
10222
|
let value;
|
|
9625
10223
|
try {
|
|
9626
10224
|
value = JSON.parse(source);
|
|
@@ -9636,13 +10234,13 @@ function getRecord(value) {
|
|
|
9636
10234
|
}
|
|
9637
10235
|
|
|
9638
10236
|
// src/tools/setup.ts
|
|
9639
|
-
var setupParametersSchema =
|
|
9640
|
-
issueTracker:
|
|
9641
|
-
forges:
|
|
9642
|
-
forge:
|
|
9643
|
-
bindZedKey:
|
|
10237
|
+
var setupParametersSchema = z13.object({
|
|
10238
|
+
issueTracker: z13.enum(["none", "linear", "jira"]).default("none").describe("Issue tracker MCP server to configure. Use none unless the user explicitly selects Linear or Jira."),
|
|
10239
|
+
forges: z13.array(z13.enum(["github", "gitlab"])).default([]).describe("Hosted VCS MCP servers to configure. Installs matching CLIs and registers each MCP server."),
|
|
10240
|
+
forge: z13.enum(["none", "github", "gitlab"]).optional().describe("Deprecated single-host alias. Prefer forges for one or more VCS MCP servers."),
|
|
10241
|
+
bindZedKey: z13.boolean().default(false).describe("Opt in to a Zed keybinding for the tuicr review task.")
|
|
9644
10242
|
});
|
|
9645
|
-
var setupParameters =
|
|
10243
|
+
var setupParameters = z13.toJSONSchema(setupParametersSchema, { io: "input" });
|
|
9646
10244
|
var diffpiSetupTool = defineTool7({
|
|
9647
10245
|
name: "diffpi_setup",
|
|
9648
10246
|
label: "diffpi setup",
|
|
@@ -9715,11 +10313,11 @@ ${lines.join(`
|
|
|
9715
10313
|
|
|
9716
10314
|
// src/tools/templates.ts
|
|
9717
10315
|
import { defineTool as defineTool8 } from "@earendil-works/pi-coding-agent";
|
|
9718
|
-
import { z as
|
|
9719
|
-
var templateSchema =
|
|
9720
|
-
name:
|
|
9721
|
-
variables:
|
|
9722
|
-
homeDir:
|
|
10316
|
+
import { z as z14 } from "zod";
|
|
10317
|
+
var templateSchema = z14.object({
|
|
10318
|
+
name: z14.string().min(1),
|
|
10319
|
+
variables: z14.record(z14.string(), z14.string()).optional(),
|
|
10320
|
+
homeDir: z14.string().optional()
|
|
9723
10321
|
});
|
|
9724
10322
|
var diffpiTemplateTool = defineTool8({
|
|
9725
10323
|
name: "diffpi_template",
|
|
@@ -9727,7 +10325,7 @@ var diffpiTemplateTool = defineTool8({
|
|
|
9727
10325
|
description: "Load a bundled Diffpi template or a user override and render named variables.",
|
|
9728
10326
|
promptSnippet: "Use diffpi_template for package workflow templates",
|
|
9729
10327
|
promptGuidelines: ["User overrides live under ~/.difflab/diffpi/templates."],
|
|
9730
|
-
parameters:
|
|
10328
|
+
parameters: z14.toJSONSchema(templateSchema, { io: "input" }),
|
|
9731
10329
|
executionMode: "parallel",
|
|
9732
10330
|
async execute(_id, input) {
|
|
9733
10331
|
const params = templateSchema.parse(input);
|