@difflab/pi 0.5.0 → 0.6.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-copilot.md +6 -3
- package/agents/diffpi-reviewer.md +3 -3
- package/dist/commands/review.d.ts +0 -1
- package/dist/commands/review.d.ts.map +1 -1
- package/dist/extensions/index.js +344 -810
- package/dist/extensions/tuicrx.d.ts +1 -8
- package/dist/extensions/tuicrx.d.ts.map +1 -1
- package/dist/index.d.ts +7 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +197 -458
- 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-reviews.d.ts +31 -0
- package/dist/review/local-reviews.d.ts.map +1 -0
- package/dist/review/review-backend.d.ts +2 -3
- package/dist/review/review-backend.d.ts.map +1 -1
- package/dist/review/review-markdown.d.ts +1 -4
- package/dist/review/review-markdown.d.ts.map +1 -1
- package/dist/review/types.d.ts +0 -28
- package/dist/review/types.d.ts.map +1 -1
- package/dist/store.d.ts +0 -2
- package/dist/store.d.ts.map +1 -1
- package/dist/tools/index.js +185 -783
- package/dist/tools/review.d.ts +0 -30
- package/dist/tools/review.d.ts.map +1 -1
- package/dist/workflow.d.ts +47 -0
- package/dist/workflow.d.ts.map +1 -1
- package/package.json +1 -1
- package/skills/review/SKILL.md +6 -8
- package/workflows/review/address.md +8 -0
- package/workflows/review/auto.md +8 -0
- package/workflows/review/complete.md +7 -0
- package/workflows/review/edit.md +7 -0
- package/workflows/review/help.md +14 -0
- package/workflows/review/merge.md +6 -0
- package/workflows/review/new.md +7 -0
- package/workflows/review/publish.md +6 -0
- package/dist/review/local-review-backend.d.ts +0 -3
- package/dist/review/local-review-backend.d.ts.map +0 -1
- package/dist/review/review-state.d.ts +0 -20
- package/dist/review/review-state.d.ts.map +0 -1
- package/skills/review/references/workflows/address.md +0 -11
- package/skills/review/references/workflows/auto.md +0 -18
- package/skills/review/references/workflows/complete.md +0 -7
- package/skills/review/references/workflows/edit.md +0 -7
- package/skills/review/references/workflows/help.md +0 -30
- package/skills/review/references/workflows/merge.md +0 -6
- package/skills/review/references/workflows/new.md +0 -19
- package/skills/review/references/workflows/open.md +0 -3
- package/skills/review/references/workflows/publish.md +0 -8
package/dist/tools/index.js
CHANGED
|
@@ -1069,18 +1069,6 @@ 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
|
-
}
|
|
1084
1072
|
async function assertStoreLink(path, dest) {
|
|
1085
1073
|
const entry = await lstat(path);
|
|
1086
1074
|
if (!entry.isSymbolicLink())
|
|
@@ -7903,10 +7891,10 @@ function result2(text, details = {}) {
|
|
|
7903
7891
|
|
|
7904
7892
|
// src/tools/review.ts
|
|
7905
7893
|
import { existsSync as existsSync2 } from "node:fs";
|
|
7906
|
-
import { mkdir as
|
|
7894
|
+
import { mkdir as mkdir8, writeFile as writeFile6 } from "node:fs/promises";
|
|
7907
7895
|
import { join as join16 } from "node:path";
|
|
7908
7896
|
import { defineTool as defineTool6 } from "@earendil-works/pi-coding-agent";
|
|
7909
|
-
import { z as
|
|
7897
|
+
import { z as z9 } from "zod";
|
|
7910
7898
|
|
|
7911
7899
|
// src/review/types.ts
|
|
7912
7900
|
import { z as z8 } from "zod";
|
|
@@ -7931,21 +7919,6 @@ function yymmdd(date = new Date) {
|
|
|
7931
7919
|
function reviewRecordName(target, date = new Date) {
|
|
7932
7920
|
return `${yymmdd(date)}-${reviewSlug(target) || "uncommitted"}`;
|
|
7933
7921
|
}
|
|
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
|
-
}
|
|
7949
7922
|
function dedupeFindings(findings) {
|
|
7950
7923
|
const rank = { BLOCKING: 3, CONSIDER: 2, NOTE: 1 };
|
|
7951
7924
|
const byKey = new Map;
|
|
@@ -7991,18 +7964,6 @@ function renderCommentBody(finding) {
|
|
|
7991
7964
|
return `${prefix}${finding.body}${reference}`;
|
|
7992
7965
|
}
|
|
7993
7966
|
// 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
|
-
});
|
|
8006
7967
|
function renderReviewDoc(input) {
|
|
8007
7968
|
const anchored = dedupeFindings(input.findings).filter((finding) => finding.line > 0);
|
|
8008
7969
|
const lines = [`# Review: ${input.title}`, "", "## Metadata"];
|
|
@@ -8044,88 +8005,6 @@ function renderReviewDoc(input) {
|
|
|
8044
8005
|
`).trimEnd()}
|
|
8045
8006
|
`;
|
|
8046
8007
|
}
|
|
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
|
-
}
|
|
8129
8008
|
// src/review/github-review-backend.ts
|
|
8130
8009
|
function GitHubReviewBackend(vcs, number) {
|
|
8131
8010
|
const pendingReview = async () => {
|
|
@@ -8187,9 +8066,6 @@ function GitHubReviewBackend(vcs, number) {
|
|
|
8187
8066
|
id: thread.id,
|
|
8188
8067
|
file: thread.path ?? undefined,
|
|
8189
8068
|
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] : []),
|
|
8193
8069
|
body,
|
|
8194
8070
|
author: comment?.author?.login,
|
|
8195
8071
|
resolved: thread.isResolved,
|
|
@@ -8199,7 +8075,6 @@ function GitHubReviewBackend(vcs, number) {
|
|
|
8199
8075
|
});
|
|
8200
8076
|
};
|
|
8201
8077
|
return {
|
|
8202
|
-
kind: "remote",
|
|
8203
8078
|
async stage(draft) {
|
|
8204
8079
|
const pending = await pendingReview();
|
|
8205
8080
|
if (!pending) {
|
|
@@ -8257,33 +8132,6 @@ function GitHubReviewBackend(vcs, number) {
|
|
|
8257
8132
|
await ghChecked(args);
|
|
8258
8133
|
}
|
|
8259
8134
|
},
|
|
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
|
-
},
|
|
8287
8135
|
listThreads,
|
|
8288
8136
|
async reply(input) {
|
|
8289
8137
|
const threads = await listThreads();
|
|
@@ -8300,32 +8148,14 @@ function GitHubReviewBackend(vcs, number) {
|
|
|
8300
8148
|
`body=${input.body}`
|
|
8301
8149
|
]);
|
|
8302
8150
|
}
|
|
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) {
|
|
8151
|
+
if (input.resolve) {
|
|
8322
8152
|
await ghChecked([
|
|
8323
8153
|
"api",
|
|
8324
8154
|
"graphql",
|
|
8325
8155
|
"-f",
|
|
8326
|
-
`query=${
|
|
8156
|
+
`query=${GITHUB_RESOLVE_MUTATION}`,
|
|
8327
8157
|
"-f",
|
|
8328
|
-
`
|
|
8158
|
+
`threadId=${input.threadId}`
|
|
8329
8159
|
]);
|
|
8330
8160
|
}
|
|
8331
8161
|
},
|
|
@@ -8344,13 +8174,11 @@ function GitHubReviewBackend(vcs, number) {
|
|
|
8344
8174
|
function githubReviewSubmissionEndpoint(owner, repo, id, pendingReviewId) {
|
|
8345
8175
|
return pendingReviewId ? `/repos/${owner}/${repo}/pulls/${id}/reviews/${pendingReviewId}/events` : `/repos/${owner}/${repo}/pulls/${id}/reviews`;
|
|
8346
8176
|
}
|
|
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{
|
|
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}}}}}`;
|
|
8348
8178
|
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}}}`;
|
|
8349
8179
|
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}}}`;
|
|
8350
8180
|
var GITHUB_REPLY_MUTATION = `mutation($threadId:ID!,$body:String!){addPullRequestReviewThreadReply(input:{pullRequestReviewThreadId:$threadId,body:$body}){comment{id}}}`;
|
|
8351
8181
|
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}}`;
|
|
8354
8182
|
|
|
8355
8183
|
// src/review/gitlab-review-backend.ts
|
|
8356
8184
|
function GitLabReviewBackend(vcs, number) {
|
|
@@ -8387,7 +8215,6 @@ function GitLabReviewBackend(vcs, number) {
|
|
|
8387
8215
|
});
|
|
8388
8216
|
};
|
|
8389
8217
|
return {
|
|
8390
|
-
kind: "remote",
|
|
8391
8218
|
async stage(draft) {
|
|
8392
8219
|
const endpoint = `${mergeRequestEndpoint()}/draft_notes`;
|
|
8393
8220
|
if (draft.body.trim()) {
|
|
@@ -8416,36 +8243,6 @@ function GitLabReviewBackend(vcs, number) {
|
|
|
8416
8243
|
});
|
|
8417
8244
|
}
|
|
8418
8245
|
},
|
|
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
|
-
},
|
|
8449
8246
|
listThreads,
|
|
8450
8247
|
async reply(input) {
|
|
8451
8248
|
const endpoint = `${mergeRequestEndpoint()}/discussions/${encodeURIComponent(input.threadId)}`;
|
|
@@ -8457,15 +8254,7 @@ function GitLabReviewBackend(vcs, number) {
|
|
|
8457
8254
|
});
|
|
8458
8255
|
}
|
|
8459
8256
|
if (input.resolve)
|
|
8460
|
-
await
|
|
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]);
|
|
8257
|
+
await glabChecked(["api", "--method", "PUT", `${endpoint}?resolved=true`]);
|
|
8469
8258
|
},
|
|
8470
8259
|
async publish(event) {
|
|
8471
8260
|
assertReviewEventSupported(vcs.provider, event);
|
|
@@ -8505,8 +8294,17 @@ function hasGitlabDraftNotes(input) {
|
|
|
8505
8294
|
}
|
|
8506
8295
|
}
|
|
8507
8296
|
|
|
8508
|
-
// src/review/
|
|
8509
|
-
|
|
8297
|
+
// src/review/review-backend.ts
|
|
8298
|
+
function createRemoteReviewBackend(vcs, number) {
|
|
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";
|
|
8510
8308
|
|
|
8511
8309
|
// src/extensions/tuicrx.ts
|
|
8512
8310
|
import { readFile as readFile8, realpath as realpath4 } from "node:fs/promises";
|
|
@@ -8577,11 +8375,6 @@ async function resolveReviewSession(cwd, target) {
|
|
|
8577
8375
|
}
|
|
8578
8376
|
return resolveSession(cwd, target.branch);
|
|
8579
8377
|
}
|
|
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
|
-
}
|
|
8585
8378
|
async function resolvePrSession(cwd, owner, repo, number) {
|
|
8586
8379
|
const coordinate = `${owner}/${repo}`.toLowerCase();
|
|
8587
8380
|
const sessions = await listSessions(cwd);
|
|
@@ -8616,8 +8409,6 @@ async function readSession(path) {
|
|
|
8616
8409
|
function toLocalReviewThreads(session) {
|
|
8617
8410
|
const threads = [];
|
|
8618
8411
|
const add = (comment, location = {}) => {
|
|
8619
|
-
if (isLocalResponse(comment.content))
|
|
8620
|
-
return;
|
|
8621
8412
|
const author = commentAuthor(comment);
|
|
8622
8413
|
threads.push({
|
|
8623
8414
|
id: comment.id ?? `local-${threads.length + 1}`,
|
|
@@ -8643,54 +8434,6 @@ function toLocalReviewThreads(session) {
|
|
|
8643
8434
|
}
|
|
8644
8435
|
return threads;
|
|
8645
8436
|
}
|
|
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
|
-
}
|
|
8694
8437
|
async function requireTuicr() {
|
|
8695
8438
|
if (!await tuicrAvailable())
|
|
8696
8439
|
throw new Error("tuicr is not installed or is not available on PATH.");
|
|
@@ -8706,167 +8449,83 @@ async function canonicalPath3(path) {
|
|
|
8706
8449
|
}
|
|
8707
8450
|
}
|
|
8708
8451
|
|
|
8709
|
-
// src/review/local-
|
|
8710
|
-
function
|
|
8711
|
-
|
|
8712
|
-
|
|
8713
|
-
|
|
8714
|
-
|
|
8715
|
-
|
|
8716
|
-
|
|
8717
|
-
|
|
8718
|
-
|
|
8719
|
-
|
|
8720
|
-
|
|
8721
|
-
|
|
8722
|
-
|
|
8723
|
-
|
|
8724
|
-
|
|
8725
|
-
|
|
8726
|
-
|
|
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
|
-
}
|
|
8452
|
+
// src/review/local-reviews.ts
|
|
8453
|
+
async function captureLocalReview(input) {
|
|
8454
|
+
const id = reviewSlug(input.branch) || "local-review";
|
|
8455
|
+
const dir = join15(await reviewsDir(input.cwd, input.homeDir), id);
|
|
8456
|
+
await mkdir7(dir, { recursive: true });
|
|
8457
|
+
const revision = await nextRevision(dir);
|
|
8458
|
+
const output = await readFile9(input.session.path, "utf8");
|
|
8459
|
+
const comments = toLocalReviewThreads(await readSession(input.session.path));
|
|
8460
|
+
const dump = {
|
|
8461
|
+
schemaVersion: 1,
|
|
8462
|
+
id,
|
|
8463
|
+
revision,
|
|
8464
|
+
branch: input.branch,
|
|
8465
|
+
base: input.base,
|
|
8466
|
+
createdAt: (input.now ?? (() => new Date))().toISOString(),
|
|
8467
|
+
diff: input.diff,
|
|
8468
|
+
comments,
|
|
8469
|
+
tuicr: { slug: input.session.slug, output }
|
|
8744
8470
|
};
|
|
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 };
|
|
8745
8476
|
}
|
|
8746
|
-
|
|
8747
|
-
|
|
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.");
|
|
8754
|
-
}
|
|
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)));
|
|
8477
|
+
async function nextRevision(dir) {
|
|
8478
|
+
return await latestRevision(dir) + 1;
|
|
8782
8479
|
}
|
|
8783
|
-
async function
|
|
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`);
|
|
8480
|
+
async function latestRevision(dir) {
|
|
8786
8481
|
try {
|
|
8787
|
-
|
|
8788
|
-
|
|
8482
|
+
return (await readdir2(dir)).reduce((latest, name) => {
|
|
8483
|
+
const match = name.match(/^(\d+)\.json$/);
|
|
8484
|
+
return match ? Math.max(latest, Number(match[1])) : latest;
|
|
8485
|
+
}, 0);
|
|
8789
8486
|
} catch (error) {
|
|
8790
|
-
if (error.code === "ENOENT")
|
|
8791
|
-
return
|
|
8792
|
-
}
|
|
8793
|
-
if (error instanceof SyntaxError || error instanceof z10.ZodError) {
|
|
8794
|
-
throw new Error(`Cannot parse review publication state: ${path}`);
|
|
8795
|
-
}
|
|
8487
|
+
if (error.code === "ENOENT")
|
|
8488
|
+
return 0;
|
|
8796
8489
|
throw error;
|
|
8797
8490
|
}
|
|
8798
8491
|
}
|
|
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
|
-
}
|
|
8808
8492
|
// src/tools/review.ts
|
|
8809
|
-
var contextSchema =
|
|
8810
|
-
cwd:
|
|
8811
|
-
target:
|
|
8812
|
-
workingTree:
|
|
8493
|
+
var contextSchema = z9.object({
|
|
8494
|
+
cwd: z9.string().optional(),
|
|
8495
|
+
target: z9.string().optional(),
|
|
8496
|
+
workingTree: z9.boolean().optional()
|
|
8813
8497
|
});
|
|
8814
|
-
var localSchema = contextSchema.extend({ local:
|
|
8498
|
+
var localSchema = contextSchema.extend({ local: z9.boolean().optional() });
|
|
8815
8499
|
var openSchema = localSchema.extend({
|
|
8816
|
-
title:
|
|
8817
|
-
intent:
|
|
8818
|
-
issueUrl:
|
|
8819
|
-
base:
|
|
8500
|
+
title: z9.string().optional(),
|
|
8501
|
+
intent: z9.string().optional(),
|
|
8502
|
+
issueUrl: z9.string().url().optional(),
|
|
8503
|
+
base: z9.string().optional()
|
|
8820
8504
|
});
|
|
8821
8505
|
var submitSchema = localSchema.extend({
|
|
8822
8506
|
findings: findingsSchema,
|
|
8823
|
-
overallIssues:
|
|
8824
|
-
notVerified:
|
|
8825
|
-
title:
|
|
8507
|
+
overallIssues: z9.array(z9.string()).optional(),
|
|
8508
|
+
notVerified: z9.array(z9.string()).optional(),
|
|
8509
|
+
title: z9.string().optional()
|
|
8826
8510
|
});
|
|
8827
8511
|
var addCommentSchema = localSchema.extend({
|
|
8828
|
-
body:
|
|
8829
|
-
file:
|
|
8830
|
-
line:
|
|
8831
|
-
side:
|
|
8512
|
+
body: z9.string().min(1),
|
|
8513
|
+
file: z9.string().min(1),
|
|
8514
|
+
line: z9.number().int().positive(),
|
|
8515
|
+
side: z9.enum(["LEFT", "RIGHT"]).optional()
|
|
8832
8516
|
});
|
|
8833
8517
|
var respondSchema = localSchema.extend({
|
|
8834
|
-
threadId:
|
|
8835
|
-
body:
|
|
8836
|
-
question:
|
|
8837
|
-
resolve:
|
|
8518
|
+
threadId: z9.string().min(1),
|
|
8519
|
+
body: z9.string().min(1),
|
|
8520
|
+
question: z9.boolean().optional(),
|
|
8521
|
+
resolve: z9.boolean().optional()
|
|
8838
8522
|
});
|
|
8839
8523
|
var publishSchema = localSchema.extend({
|
|
8840
|
-
status:
|
|
8524
|
+
status: z9.enum(["COMMENT", "APPROVE", "REQUEST_CHANGES", "CLOSE"]).optional()
|
|
8841
8525
|
});
|
|
8842
8526
|
var completeSchema = localSchema.extend({
|
|
8843
|
-
action:
|
|
8527
|
+
action: z9.enum(["approve", "reject", "abandon"]).optional()
|
|
8844
8528
|
});
|
|
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
|
-
}
|
|
8870
8529
|
async function workingTreeDiff(cwd) {
|
|
8871
8530
|
const tracked = await run("git", ["-C", cwd, "diff", "HEAD"], { capture: "unbounded" });
|
|
8872
8531
|
if (tracked.code !== 0)
|
|
@@ -9062,7 +8721,7 @@ ${openMessage}`, {
|
|
|
9062
8721
|
const gates = await runMiseGates(review.cwd);
|
|
9063
8722
|
const baseRef = review.pr?.baseRef ?? (review.forge ? await review.forge.defaultBranch() : "local");
|
|
9064
8723
|
const artifact = await newReviewArtifactPath(review.cwd, params.local || params.workingTree || !review.pr ? "uncommitted" : await reviewTargetId(review));
|
|
9065
|
-
await
|
|
8724
|
+
await writeFile6(artifact, renderReviewDoc({
|
|
9066
8725
|
title: params.title ?? review.pr?.title ?? review.vcs.branch,
|
|
9067
8726
|
number: useLocalBackend ? undefined : review.pr?.number,
|
|
9068
8727
|
url: useLocalBackend ? undefined : review.pr?.url,
|
|
@@ -9085,17 +8744,21 @@ ${openMessage}`, {
|
|
|
9085
8744
|
count: findings.length
|
|
9086
8745
|
});
|
|
9087
8746
|
}
|
|
9088
|
-
const
|
|
9089
|
-
|
|
9090
|
-
|
|
9091
|
-
|
|
9092
|
-
|
|
9093
|
-
|
|
8747
|
+
for (const comment of comments) {
|
|
8748
|
+
await addComment(session.path, comment.body, {
|
|
8749
|
+
targetFile: comment.file,
|
|
8750
|
+
line: comment.line,
|
|
8751
|
+
side: comment.side === "LEFT" ? "old" : "new",
|
|
8752
|
+
username: localReviewAuthor(model)
|
|
8753
|
+
});
|
|
8754
|
+
}
|
|
8755
|
+
if (body.trim())
|
|
8756
|
+
await addComment(session.path, body, { username: localReviewAuthor(model) });
|
|
9094
8757
|
return result3(`Local review staged in tuicr: ${artifact}`, { artifact, count: findings.length, session });
|
|
9095
8758
|
}
|
|
9096
8759
|
if (!review.pr)
|
|
9097
8760
|
return result3(`Review written: ${artifact}. No PR/MR matches this remote target.`, { artifact });
|
|
9098
|
-
const hasDraft =
|
|
8761
|
+
const hasDraft = comments.length > 0 || body.trim().length > 0;
|
|
9099
8762
|
if (hasDraft) {
|
|
9100
8763
|
await createRemoteReviewBackend(review.vcs, review.pr.number).stage({ comments, body });
|
|
9101
8764
|
}
|
|
@@ -9131,12 +8794,12 @@ ${openMessage}`, {
|
|
|
9131
8794
|
const session = await resolveTuicrSession(review, Boolean(params.local || params.workingTree));
|
|
9132
8795
|
if (!session)
|
|
9133
8796
|
return result3("No matching tuicr session. Open review_new or review_launch_ui first.");
|
|
9134
|
-
|
|
9135
|
-
|
|
9136
|
-
|
|
9137
|
-
|
|
8797
|
+
await addComment(session.path, comment.body, {
|
|
8798
|
+
targetFile: comment.file,
|
|
8799
|
+
line: comment.line,
|
|
8800
|
+
side: comment.side === "LEFT" ? "old" : "new",
|
|
8801
|
+
username: localReviewAuthor(model)
|
|
9138
8802
|
});
|
|
9139
|
-
await backend.stage({ comments: [comment], body: "" });
|
|
9140
8803
|
return result3(`Comment added to tuicr session ${session.slug}.`, { session, comment });
|
|
9141
8804
|
}
|
|
9142
8805
|
if (!review.pr)
|
|
@@ -9148,88 +8811,70 @@ ${openMessage}`, {
|
|
|
9148
8811
|
defineTool6({
|
|
9149
8812
|
name: "review_comments",
|
|
9150
8813
|
label: "review comments",
|
|
9151
|
-
description: "
|
|
9152
|
-
promptSnippet: "Call review_comments before addressing findings",
|
|
9153
|
-
promptGuidelines: ["
|
|
8814
|
+
description: "Read remote GitHub or GitLab review threads.",
|
|
8815
|
+
promptSnippet: "Call review_comments before addressing remote findings",
|
|
8816
|
+
promptGuidelines: ["Use review_dump for a local tuicr review."],
|
|
9154
8817
|
parameters: parameters2(localSchema),
|
|
9155
|
-
executionMode: "
|
|
8818
|
+
executionMode: "parallel",
|
|
9156
8819
|
async execute(_id, input) {
|
|
9157
8820
|
const params = localSchema.parse(input);
|
|
8821
|
+
if (params.local || params.workingTree)
|
|
8822
|
+
return result3("Use review_dump for a local tuicr review.");
|
|
9158
8823
|
const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
|
|
9159
|
-
|
|
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 {
|
|
8824
|
+
if (!review.pr || !review.forge)
|
|
9173
8825
|
return result3(review.forge ? "No PR/MR matches this remote review target." : unsupportedForgeMessage());
|
|
9174
|
-
|
|
8826
|
+
const threads = await createRemoteReviewBackend(review.vcs, review.pr.number).listThreads();
|
|
9175
8827
|
return result3(threads.map((thread) => `${thread.id} ${thread.file ?? "review"}:${thread.line ?? "-"} — ${thread.body}`).join(`
|
|
9176
|
-
`) || "No comments.", {
|
|
8828
|
+
`) || "No comments.", { pr: review.pr, threads });
|
|
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 });
|
|
9177
8855
|
}
|
|
9178
8856
|
}),
|
|
9179
8857
|
defineTool6({
|
|
9180
8858
|
name: "review_respond",
|
|
9181
8859
|
label: "review respond",
|
|
9182
|
-
description: "
|
|
9183
|
-
promptSnippet: "Call review_respond after addressing a comment",
|
|
9184
|
-
promptGuidelines: ["
|
|
8860
|
+
description: "Reply directly to a remote GitHub or GitLab review thread.",
|
|
8861
|
+
promptSnippet: "Call review_respond after addressing a remote comment",
|
|
8862
|
+
promptGuidelines: ["Leave threads open unless the user explicitly requests resolution."],
|
|
9185
8863
|
parameters: parameters2(respondSchema),
|
|
9186
8864
|
executionMode: "sequential",
|
|
9187
8865
|
async execute(_id, input, _signal, _onUpdate, ctx) {
|
|
9188
8866
|
const params = respondSchema.parse(input);
|
|
8867
|
+
if (params.local)
|
|
8868
|
+
return result3("Local reviews use immutable revision dumps, not thread replies.");
|
|
9189
8869
|
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
|
-
}
|
|
9221
8870
|
if (!review.pr || !review.forge)
|
|
9222
8871
|
return result3("No remote PR/MR for this reply.");
|
|
9223
|
-
const
|
|
9224
|
-
|
|
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({
|
|
8872
|
+
const resolve = params.question ? false : params.resolve ?? false;
|
|
8873
|
+
await createRemoteReviewBackend(review.vcs, review.pr.number).reply({
|
|
9229
8874
|
threadId: params.threadId,
|
|
9230
|
-
body: withRemoteProvenance(params.body,
|
|
8875
|
+
body: withRemoteProvenance(params.body, modelRoute(ctx)),
|
|
9231
8876
|
resolve,
|
|
9232
|
-
question
|
|
8877
|
+
question: params.question
|
|
9233
8878
|
});
|
|
9234
8879
|
return result3(`Replied to ${params.threadId}${resolve ? " and resolved it" : " and left it open"}.`, {
|
|
9235
8880
|
pr: review.pr,
|
|
@@ -9240,51 +8885,40 @@ ${params.body}`, {
|
|
|
9240
8885
|
defineTool6({
|
|
9241
8886
|
name: "review_publish",
|
|
9242
8887
|
label: "review publish",
|
|
9243
|
-
description: "Publish pending remote
|
|
8888
|
+
description: "Publish pending remote review comments and status.",
|
|
9244
8889
|
promptSnippet: "Call review_publish to make remote review work public",
|
|
9245
|
-
promptGuidelines: ["
|
|
8890
|
+
promptGuidelines: ["Local reviews use revision dumps and are never published remotely."],
|
|
9246
8891
|
parameters: parameters2(publishSchema),
|
|
9247
8892
|
executionMode: "sequential",
|
|
9248
|
-
async execute(_id, input
|
|
8893
|
+
async execute(_id, input) {
|
|
9249
8894
|
const params = publishSchema.parse(input);
|
|
8895
|
+
if (params.local)
|
|
8896
|
+
return result3("Local reviews use immutable revision dumps and have no publish step.");
|
|
9250
8897
|
const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
|
|
9251
8898
|
if (!review.pr || !review.forge)
|
|
9252
8899
|
return result3("No remote PR/MR to publish.");
|
|
9253
|
-
return publishResolvedReview(review, params
|
|
8900
|
+
return publishResolvedReview(review, params);
|
|
9254
8901
|
}
|
|
9255
8902
|
}),
|
|
9256
8903
|
defineTool6({
|
|
9257
8904
|
name: "review_complete",
|
|
9258
8905
|
label: "review complete",
|
|
9259
|
-
description: "Approve, reject, or abandon a remote review
|
|
9260
|
-
promptSnippet: "Call review_complete to finish a review without merging it.",
|
|
9261
|
-
promptGuidelines: ["
|
|
8906
|
+
description: "Approve, reject, or abandon a remote review.",
|
|
8907
|
+
promptSnippet: "Call review_complete to finish a remote review without merging it.",
|
|
8908
|
+
promptGuidelines: ["Use review_dump to finish a local review revision."],
|
|
9262
8909
|
parameters: parameters2(completeSchema),
|
|
9263
8910
|
executionMode: "sequential",
|
|
9264
|
-
async execute(_id, input
|
|
8911
|
+
async execute(_id, input) {
|
|
9265
8912
|
const params = completeSchema.parse(input);
|
|
8913
|
+
if (params.local)
|
|
8914
|
+
return result3("Use review_dump to finish a local review revision.");
|
|
9266
8915
|
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
|
-
}
|
|
9282
8916
|
if (!review.pr || !review.forge)
|
|
9283
8917
|
return result3("No remote PR/MR to complete.");
|
|
9284
8918
|
if (!params.action)
|
|
9285
8919
|
return result3("Choose a complete action: approve, reject, or abandon.");
|
|
9286
8920
|
const status = { approve: "APPROVE", reject: "REQUEST_CHANGES", abandon: "CLOSE" }[params.action];
|
|
9287
|
-
return publishResolvedReview(review, { ...params, status }
|
|
8921
|
+
return publishResolvedReview(review, { ...params, status });
|
|
9288
8922
|
}
|
|
9289
8923
|
}),
|
|
9290
8924
|
defineTool6({
|
|
@@ -9293,10 +8927,10 @@ ${params.body}`, {
|
|
|
9293
8927
|
description: "Squash-merge an open, ready GitHub PR after checking its conventional subject.",
|
|
9294
8928
|
promptSnippet: "Call review_merge after confirming the PR is open, ready, and CI is settled; approval is optional",
|
|
9295
8929
|
promptGuidelines: ["This is intentionally GitHub-only until GitLab merge support is added."],
|
|
9296
|
-
parameters: parameters2(contextSchema.extend({ subject:
|
|
8930
|
+
parameters: parameters2(contextSchema.extend({ subject: z9.string().optional() })),
|
|
9297
8931
|
executionMode: "sequential",
|
|
9298
8932
|
async execute(_id, input) {
|
|
9299
|
-
const params = contextSchema.extend({ subject:
|
|
8933
|
+
const params = contextSchema.extend({ subject: z9.string().optional() }).parse(input);
|
|
9300
8934
|
const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
|
|
9301
8935
|
if (review.vcs.provider !== "github" || !review.forge)
|
|
9302
8936
|
return result3("review_merge currently supports GitHub only.");
|
|
@@ -9329,32 +8963,8 @@ ${params.body}`, {
|
|
|
9329
8963
|
})
|
|
9330
8964
|
];
|
|
9331
8965
|
}
|
|
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
|
-
}
|
|
9356
8966
|
function parameters2(schema) {
|
|
9357
|
-
return
|
|
8967
|
+
return z9.toJSONSchema(schema, { io: "input" });
|
|
9358
8968
|
}
|
|
9359
8969
|
function resolveWorkingDirectory(params) {
|
|
9360
8970
|
return params.cwd ?? process.cwd();
|
|
@@ -9370,159 +8980,17 @@ function modelRoute(ctx) {
|
|
|
9370
8980
|
throw new Error("Cannot record review provenance because Pi did not provide an active model route.");
|
|
9371
8981
|
return `${ctx.model.provider}/${ctx.model.id}`;
|
|
9372
8982
|
}
|
|
9373
|
-
async function publishResolvedReview(review, params
|
|
8983
|
+
async function publishResolvedReview(review, params) {
|
|
9374
8984
|
const status = params.status ?? "COMMENT";
|
|
9375
8985
|
const event = status === "CLOSE" ? "COMMENT" : status;
|
|
9376
8986
|
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;
|
|
9379
8987
|
if (status !== "CLOSE" && review.pr.isDraft)
|
|
9380
8988
|
await review.forge.markReady(review.pr.number);
|
|
9381
|
-
await
|
|
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
|
-
}
|
|
8989
|
+
await createRemoteReviewBackend(review.vcs, review.pr.number).publish(event);
|
|
9393
8990
|
if (status === "CLOSE")
|
|
9394
8991
|
await review.forge.closePr(review.pr.number);
|
|
9395
8992
|
const finalPr = await review.forge.viewPr(String(review.pr.number));
|
|
9396
|
-
|
|
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;
|
|
8993
|
+
return result3(`Published #${review.pr.number} (${status}).`, { pr: finalPr ?? review.pr, status });
|
|
9526
8994
|
}
|
|
9527
8995
|
async function resolveReviewContext(cwd, target) {
|
|
9528
8996
|
const vcs = await detectVcs(cwd);
|
|
@@ -9563,10 +9031,6 @@ async function assertRemoteBranchReady(cwd) {
|
|
|
9563
9031
|
if (head.stdout.trim() !== pushed.stdout.trim())
|
|
9564
9032
|
throw new Error("Push the current branch before opening a remote review.");
|
|
9565
9033
|
}
|
|
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
|
-
}
|
|
9570
9034
|
async function reviewTargetId(review) {
|
|
9571
9035
|
if (!review.pr)
|
|
9572
9036
|
return "uncommitted";
|
|
@@ -9583,60 +9047,9 @@ async function headSha(cwd) {
|
|
|
9583
9047
|
}
|
|
9584
9048
|
async function newReviewArtifactPath(cwd, target) {
|
|
9585
9049
|
const dir = await reviewsDir(cwd);
|
|
9586
|
-
await
|
|
9050
|
+
await mkdir8(dir, { recursive: true });
|
|
9587
9051
|
return uniqueRecordPath(dir, reviewRecordName(target));
|
|
9588
9052
|
}
|
|
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
|
-
}
|
|
9640
9053
|
function deriveTitle(branch) {
|
|
9641
9054
|
return branch.replace(/^(feature|feat|fix|bug|chore)\//, "").replace(/^eng-\d+-/i, "").replace(/[-_]+/g, " ").replace(/^\w/, (char) => char.toUpperCase());
|
|
9642
9055
|
}
|
|
@@ -9647,39 +9060,28 @@ function uniqueRecordPath(dir, base) {
|
|
|
9647
9060
|
path = join16(dir, `${base}-${count++}.md`);
|
|
9648
9061
|
return path;
|
|
9649
9062
|
}
|
|
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
|
-
}
|
|
9661
9063
|
|
|
9662
9064
|
// src/tools/setup.ts
|
|
9663
9065
|
import { defineTool as defineTool7 } from "@earendil-works/pi-coding-agent";
|
|
9664
|
-
import { z as
|
|
9066
|
+
import { z as z11 } from "zod";
|
|
9665
9067
|
|
|
9666
9068
|
// src/setup.ts
|
|
9667
9069
|
import { parseFrontmatter as parseFrontmatter2 } from "@earendil-works/pi-coding-agent";
|
|
9668
|
-
import { readdir as readdir4, readFile as
|
|
9070
|
+
import { readdir as readdir4, readFile as readFile12 } from "node:fs/promises";
|
|
9669
9071
|
import { homedir as homedir8 } from "node:os";
|
|
9670
9072
|
import { basename as basename6, join as join20 } from "node:path";
|
|
9671
9073
|
|
|
9672
9074
|
// src/config.ts
|
|
9673
9075
|
import { parseFrontmatter } from "@earendil-works/pi-coding-agent";
|
|
9674
|
-
import { z as
|
|
9076
|
+
import { z as z10 } from "zod";
|
|
9675
9077
|
import { homedir as homedir5 } from "node:os";
|
|
9676
9078
|
import { join as join17 } from "node:path";
|
|
9677
9079
|
|
|
9678
9080
|
// src/fsx.ts
|
|
9679
|
-
import { readdir as readdir3, readFile as
|
|
9081
|
+
import { readdir as readdir3, readFile as readFile10 } from "node:fs/promises";
|
|
9680
9082
|
async function readTextIfExists(path) {
|
|
9681
9083
|
try {
|
|
9682
|
-
return await
|
|
9084
|
+
return await readFile10(path, "utf8");
|
|
9683
9085
|
} catch (error) {
|
|
9684
9086
|
if (isMissingPath(error))
|
|
9685
9087
|
return;
|
|
@@ -9692,11 +9094,11 @@ function isMissingPath(error) {
|
|
|
9692
9094
|
|
|
9693
9095
|
// src/config.ts
|
|
9694
9096
|
var modelReferenceSchema = zx.text;
|
|
9695
|
-
var agentConfigSchema =
|
|
9696
|
-
models:
|
|
9097
|
+
var agentConfigSchema = z10.object({
|
|
9098
|
+
models: z10.array(modelReferenceSchema).optional()
|
|
9697
9099
|
}).strict();
|
|
9698
|
-
var diffpiConfigSchema =
|
|
9699
|
-
agents:
|
|
9100
|
+
var diffpiConfigSchema = z10.object({
|
|
9101
|
+
agents: z10.record(z10.string(), agentConfigSchema).optional()
|
|
9700
9102
|
}).strict();
|
|
9701
9103
|
function diffpiConfigPaths(homeDir = homedir5()) {
|
|
9702
9104
|
const directory = join17(homeDir, ".difflab", "diffpi");
|
|
@@ -9758,7 +9160,7 @@ function normalizeModelReference(value) {
|
|
|
9758
9160
|
}
|
|
9759
9161
|
|
|
9760
9162
|
// src/mcp.ts
|
|
9761
|
-
import { mkdir as
|
|
9163
|
+
import { mkdir as mkdir9, readFile as readFile11, writeFile as writeFile7 } from "node:fs/promises";
|
|
9762
9164
|
import { homedir as homedir6 } from "node:os";
|
|
9763
9165
|
import { dirname as dirname8, join as join18 } from "node:path";
|
|
9764
9166
|
var mcp = {
|
|
@@ -9776,8 +9178,8 @@ var mcp = {
|
|
|
9776
9178
|
const next = { ...current, mcpServers: nextServers };
|
|
9777
9179
|
const changed = JSON.stringify(current) !== JSON.stringify(next);
|
|
9778
9180
|
if (changed && !options.dryRun) {
|
|
9779
|
-
await
|
|
9780
|
-
await
|
|
9181
|
+
await mkdir9(dirname8(path), { recursive: true });
|
|
9182
|
+
await writeFile7(path, `${JSON.stringify(next, null, 2)}
|
|
9781
9183
|
`, "utf8");
|
|
9782
9184
|
}
|
|
9783
9185
|
return { path, changed, existed: currentText !== undefined, planned: changed && options.dryRun === true };
|
|
@@ -9806,7 +9208,7 @@ function getParsedConfig(content, path) {
|
|
|
9806
9208
|
}
|
|
9807
9209
|
async function getOptionalFile2(path) {
|
|
9808
9210
|
try {
|
|
9809
|
-
return await
|
|
9211
|
+
return await readFile11(path, "utf8");
|
|
9810
9212
|
} catch (error) {
|
|
9811
9213
|
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
9812
9214
|
return;
|
|
@@ -9818,7 +9220,7 @@ function isRecord(value) {
|
|
|
9818
9220
|
}
|
|
9819
9221
|
|
|
9820
9222
|
// src/pi.ts
|
|
9821
|
-
import { mkdir as
|
|
9223
|
+
import { mkdir as mkdir10, writeFile as writeFile8 } from "node:fs/promises";
|
|
9822
9224
|
import { homedir as homedir7 } from "node:os";
|
|
9823
9225
|
import { dirname as dirname9, join as join19 } from "node:path";
|
|
9824
9226
|
var pi = {
|
|
@@ -9837,8 +9239,8 @@ async function ensurePiAgent(filename, content, agentDir = resolvePiAgentDir(),
|
|
|
9837
9239
|
const currentText = await readTextIfExists(path);
|
|
9838
9240
|
const changed = currentText !== content;
|
|
9839
9241
|
if (changed && !dryRun) {
|
|
9840
|
-
await
|
|
9841
|
-
await
|
|
9242
|
+
await mkdir10(dirname9(path), { recursive: true });
|
|
9243
|
+
await writeFile8(path, content, "utf8");
|
|
9842
9244
|
}
|
|
9843
9245
|
return { path, changed, existed: currentText !== undefined, planned: changed && dryRun };
|
|
9844
9246
|
}
|
|
@@ -9874,8 +9276,8 @@ async function ensurePiConfig(path, update, dryRun = false) {
|
|
|
9874
9276
|
const next = update(current);
|
|
9875
9277
|
const changed = JSON.stringify(current) !== JSON.stringify(next);
|
|
9876
9278
|
if (changed && !dryRun) {
|
|
9877
|
-
await
|
|
9878
|
-
await
|
|
9279
|
+
await mkdir10(dirname9(path), { recursive: true });
|
|
9280
|
+
await writeFile8(path, `${JSON.stringify(next, null, 2)}
|
|
9879
9281
|
`, "utf8");
|
|
9880
9282
|
}
|
|
9881
9283
|
return { path, changed, existed: currentText !== undefined, planned: changed && dryRun };
|
|
@@ -10013,7 +9415,7 @@ async function ensurePiAgents(options = {}) {
|
|
|
10013
9415
|
const actions = [];
|
|
10014
9416
|
for (const entry of entries) {
|
|
10015
9417
|
const id = basename6(entry.name, ".md").replace(/^diffpi-/, "");
|
|
10016
|
-
const source = await
|
|
9418
|
+
const source = await readFile12(join20(bundledAgentsDir, entry.name), "utf8");
|
|
10017
9419
|
const content = materializeAgentModels(source, id, userConfig.config, options.availableModels);
|
|
10018
9420
|
const result = await pi.agentEnsure(entry.name, content, agentDir, options.dryRun);
|
|
10019
9421
|
actions.push(getConfigSetupAction(`pi agent ${id}`, result));
|
|
@@ -10218,7 +9620,7 @@ function reportProgress(options, message) {
|
|
|
10218
9620
|
options.onProgress?.(message);
|
|
10219
9621
|
}
|
|
10220
9622
|
async function packageVersion2() {
|
|
10221
|
-
const source = await
|
|
9623
|
+
const source = await readFile12(join20(resolveBundledAgentsDir(), "..", "package.json"), "utf8");
|
|
10222
9624
|
let value;
|
|
10223
9625
|
try {
|
|
10224
9626
|
value = JSON.parse(source);
|
|
@@ -10234,13 +9636,13 @@ function getRecord(value) {
|
|
|
10234
9636
|
}
|
|
10235
9637
|
|
|
10236
9638
|
// src/tools/setup.ts
|
|
10237
|
-
var setupParametersSchema =
|
|
10238
|
-
issueTracker:
|
|
10239
|
-
forges:
|
|
10240
|
-
forge:
|
|
10241
|
-
bindZedKey:
|
|
9639
|
+
var setupParametersSchema = z11.object({
|
|
9640
|
+
issueTracker: z11.enum(["none", "linear", "jira"]).default("none").describe("Issue tracker MCP server to configure. Use none unless the user explicitly selects Linear or Jira."),
|
|
9641
|
+
forges: z11.array(z11.enum(["github", "gitlab"])).default([]).describe("Hosted VCS MCP servers to configure. Installs matching CLIs and registers each MCP server."),
|
|
9642
|
+
forge: z11.enum(["none", "github", "gitlab"]).optional().describe("Deprecated single-host alias. Prefer forges for one or more VCS MCP servers."),
|
|
9643
|
+
bindZedKey: z11.boolean().default(false).describe("Opt in to a Zed keybinding for the tuicr review task.")
|
|
10242
9644
|
});
|
|
10243
|
-
var setupParameters =
|
|
9645
|
+
var setupParameters = z11.toJSONSchema(setupParametersSchema, { io: "input" });
|
|
10244
9646
|
var diffpiSetupTool = defineTool7({
|
|
10245
9647
|
name: "diffpi_setup",
|
|
10246
9648
|
label: "diffpi setup",
|
|
@@ -10313,11 +9715,11 @@ ${lines.join(`
|
|
|
10313
9715
|
|
|
10314
9716
|
// src/tools/templates.ts
|
|
10315
9717
|
import { defineTool as defineTool8 } from "@earendil-works/pi-coding-agent";
|
|
10316
|
-
import { z as
|
|
10317
|
-
var templateSchema =
|
|
10318
|
-
name:
|
|
10319
|
-
variables:
|
|
10320
|
-
homeDir:
|
|
9718
|
+
import { z as z12 } from "zod";
|
|
9719
|
+
var templateSchema = z12.object({
|
|
9720
|
+
name: z12.string().min(1),
|
|
9721
|
+
variables: z12.record(z12.string(), z12.string()).optional(),
|
|
9722
|
+
homeDir: z12.string().optional()
|
|
10321
9723
|
});
|
|
10322
9724
|
var diffpiTemplateTool = defineTool8({
|
|
10323
9725
|
name: "diffpi_template",
|
|
@@ -10325,7 +9727,7 @@ var diffpiTemplateTool = defineTool8({
|
|
|
10325
9727
|
description: "Load a bundled Diffpi template or a user override and render named variables.",
|
|
10326
9728
|
promptSnippet: "Use diffpi_template for package workflow templates",
|
|
10327
9729
|
promptGuidelines: ["User overrides live under ~/.difflab/diffpi/templates."],
|
|
10328
|
-
parameters:
|
|
9730
|
+
parameters: z12.toJSONSchema(templateSchema, { io: "input" }),
|
|
10329
9731
|
executionMode: "parallel",
|
|
10330
9732
|
async execute(_id, input) {
|
|
10331
9733
|
const params = templateSchema.parse(input);
|