prreviewbuddy 0.75.0 → 0.76.9
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/CHANGELOG.md +137 -0
- package/dist/{delete_review-BWKGCqwI.js → delete_review-BX3lu5Q7.js} +2267 -760
- package/dist/main.js +1 -1
- package/dist/server.js +215 -64
- package/package.json +1 -1
- package/static/sidebar.css +13 -0
- package/static/workspace.js +2 -2
|
@@ -3304,7 +3304,7 @@ async function ensureCommitByPlainFetch(root, sha) {
|
|
|
3304
3304
|
if (await hasCommit(root, sha)) return;
|
|
3305
3305
|
throw new TargetError(`The commit this request is on (${sha.slice(0, 7)}) is not in this repository, even after fetching.`);
|
|
3306
3306
|
}
|
|
3307
|
-
function where(source, remote) {
|
|
3307
|
+
function where$1(source, remote) {
|
|
3308
3308
|
const repo = `${source.repo.host}/${source.repo.owner}/${source.repo.repo}`;
|
|
3309
3309
|
return remote ? `${remote} (${repo})` : source.url;
|
|
3310
3310
|
}
|
|
@@ -3314,7 +3314,7 @@ function gitSaid(error) {
|
|
|
3314
3314
|
}
|
|
3315
3315
|
function couldNotFetch(source, remote, error) {
|
|
3316
3316
|
const said = gitSaid(error);
|
|
3317
|
-
return `Could not fetch the head commit for pull request #${source.number} from ${where(source, remote)}.` + (said ? ` Git said: ${said}` : "");
|
|
3317
|
+
return `Could not fetch the head commit for pull request #${source.number} from ${where$1(source, remote)}.` + (said ? ` Git said: ${said}` : "");
|
|
3318
3318
|
}
|
|
3319
3319
|
async function wrongCommit(root, source, internal, expected) {
|
|
3320
3320
|
const found = await refSha(root, internal);
|
|
@@ -4751,8 +4751,6 @@ function emptyProcessedDiscussion() {
|
|
|
4751
4751
|
}
|
|
4752
4752
|
};
|
|
4753
4753
|
}
|
|
4754
|
-
//#endregion
|
|
4755
|
-
//#region ../../packages/review-harness/src/domain/discussion/discussion_processor.ts
|
|
4756
4754
|
var DEFAULTS = {
|
|
4757
4755
|
excludeBots: true,
|
|
4758
4756
|
maxCommentsPerThread: 6,
|
|
@@ -5177,7 +5175,16 @@ function tally(items, outstanding, byReviewer) {
|
|
|
5177
5175
|
};
|
|
5178
5176
|
}
|
|
5179
5177
|
function issuesOutstanding(issues, dispositions) {
|
|
5180
|
-
|
|
5178
|
+
const counts = tally(issues, (issue) => isIssueOutstanding(issue, dispositions), (issue) => issue.reconciliationStatus !== "resolved" && dispositions.isDismissed(issue));
|
|
5179
|
+
const byConversation = issues.filter(resolvedByConversation).length;
|
|
5180
|
+
return byConversation > 0 ? {
|
|
5181
|
+
...counts,
|
|
5182
|
+
byConversation
|
|
5183
|
+
} : counts;
|
|
5184
|
+
}
|
|
5185
|
+
/** A finding whose resolution, as it stands, rests on the pull request conversation rather than a commit. */
|
|
5186
|
+
function resolvedByConversation(issue) {
|
|
5187
|
+
return issue.reconciliationStatus === "resolved" && issue.statusHistory?.at(-1)?.basis === "conversation";
|
|
5181
5188
|
}
|
|
5182
5189
|
function questionsOutstanding(questions, answered) {
|
|
5183
5190
|
return tally(questions, (question) => isQuestionOutstanding(question, answered), (question) => question.reconciliationStatus !== "answered" && answered.has(question.id));
|
|
@@ -6756,12 +6763,12 @@ var UPDATE_PHASES = {
|
|
|
6756
6763
|
done: "Re-read pull request conversation"
|
|
6757
6764
|
},
|
|
6758
6765
|
context: {
|
|
6759
|
-
doing: "Preparing a checkout of the
|
|
6760
|
-
done: "Prepared a checkout of the
|
|
6766
|
+
doing: "Preparing a checkout of the latest commit",
|
|
6767
|
+
done: "Prepared a checkout of the latest commit"
|
|
6761
6768
|
},
|
|
6762
6769
|
analysing: {
|
|
6763
|
-
doing: "Reassessing the review against
|
|
6764
|
-
done: "Reassessed the review against
|
|
6770
|
+
doing: "Reassessing the review against what changed",
|
|
6771
|
+
done: "Reassessed the review against what changed"
|
|
6765
6772
|
},
|
|
6766
6773
|
done: {
|
|
6767
6774
|
doing: "Finishing",
|
|
@@ -7055,14 +7062,19 @@ function sanitisePrContextReporting(value) {
|
|
|
7055
7062
|
if (thread) discussions.push(thread);
|
|
7056
7063
|
if (discussions.length === 400) break;
|
|
7057
7064
|
}
|
|
7065
|
+
const coverage = sanitiseCoverage(value.coverage);
|
|
7066
|
+
if (coverage && discussions.length === 400) coverage.threads = "capped";
|
|
7058
7067
|
return {
|
|
7059
7068
|
discussion: {
|
|
7060
7069
|
schemaVersion: 1,
|
|
7061
7070
|
discussions,
|
|
7062
|
-
decisions:
|
|
7071
|
+
decisions: sanitiseDecisions(value.decisions),
|
|
7072
|
+
...Array.isArray(value.reviews) ? { reviews: sanitiseDecisions(value.reviews) } : {},
|
|
7063
7073
|
overallDecision: DECISIONS$1.includes(value.overallDecision) ? value.overallDecision : null,
|
|
7064
7074
|
truncated: value.truncated === true || discussions.length === 400,
|
|
7065
|
-
|
|
7075
|
+
...coverage ? { coverage } : {},
|
|
7076
|
+
fetchedAt: Date.now(),
|
|
7077
|
+
...typeof value.requestedAt === "number" && Number.isFinite(value.requestedAt) ? { requestedAt: value.requestedAt } : {}
|
|
7066
7078
|
},
|
|
7067
7079
|
droppedThreads: offered - discussions.length
|
|
7068
7080
|
};
|
|
@@ -7095,6 +7107,7 @@ function sanitiseComment(value) {
|
|
|
7095
7107
|
const id = label(value.id);
|
|
7096
7108
|
const body = typeof value.body === "string" ? value.body.trim() : "";
|
|
7097
7109
|
if (!id || !body) return null;
|
|
7110
|
+
const editedAt = timestamp(value.editedAt);
|
|
7098
7111
|
return {
|
|
7099
7112
|
id,
|
|
7100
7113
|
author: label(value.author) || "unknown",
|
|
@@ -7102,7 +7115,8 @@ function sanitiseComment(value) {
|
|
|
7102
7115
|
body,
|
|
7103
7116
|
timestamp: timestamp(value.timestamp),
|
|
7104
7117
|
replyToId: label(value.replyToId) || null,
|
|
7105
|
-
url: href$1(value.url)
|
|
7118
|
+
url: href$1(value.url),
|
|
7119
|
+
...editedAt ? { editedAt } : {}
|
|
7106
7120
|
};
|
|
7107
7121
|
}
|
|
7108
7122
|
function sanitiseLocation(value) {
|
|
@@ -7117,11 +7131,16 @@ function sanitiseLocation(value) {
|
|
|
7117
7131
|
commitSha: label(value.commitSha) || null
|
|
7118
7132
|
};
|
|
7119
7133
|
}
|
|
7134
|
+
function sanitiseDecisions(value) {
|
|
7135
|
+
return Array.isArray(value) ? value.map(sanitiseDecision).filter((d) => d !== null) : [];
|
|
7136
|
+
}
|
|
7120
7137
|
function sanitiseDecision(value) {
|
|
7121
7138
|
if (!isRecord$1(value)) return null;
|
|
7122
7139
|
const reviewer = label(value.reviewer);
|
|
7123
7140
|
if (!reviewer || !VERDICTS$1.includes(value.verdict)) return null;
|
|
7141
|
+
const id = label(value.id);
|
|
7124
7142
|
return {
|
|
7143
|
+
...id ? { id } : {},
|
|
7125
7144
|
reviewer,
|
|
7126
7145
|
reviewerIsBot: value.reviewerIsBot === true,
|
|
7127
7146
|
verdict: value.verdict,
|
|
@@ -7129,6 +7148,24 @@ function sanitiseDecision(value) {
|
|
|
7129
7148
|
timestamp: timestamp(value.timestamp)
|
|
7130
7149
|
};
|
|
7131
7150
|
}
|
|
7151
|
+
var COVERAGES = ["complete", "capped"];
|
|
7152
|
+
/**
|
|
7153
|
+
* All three collections or nothing. A coverage record with a collection missing or misspelled says
|
|
7154
|
+
* nothing provable about that collection, and "complete" is the one value that must be proved.
|
|
7155
|
+
*/
|
|
7156
|
+
function sanitiseCoverage(value) {
|
|
7157
|
+
if (!isRecord$1(value)) return null;
|
|
7158
|
+
const read = (field) => COVERAGES.includes(field) ? field : null;
|
|
7159
|
+
const threads = read(value.threads);
|
|
7160
|
+
const conversation = read(value.conversation);
|
|
7161
|
+
const reviews = read(value.reviews);
|
|
7162
|
+
if (!threads || !conversation || !reviews) return null;
|
|
7163
|
+
return {
|
|
7164
|
+
threads,
|
|
7165
|
+
conversation,
|
|
7166
|
+
reviews
|
|
7167
|
+
};
|
|
7168
|
+
}
|
|
7132
7169
|
function sanitisePullRequest(value) {
|
|
7133
7170
|
if (!isRecord$1(value)) return null;
|
|
7134
7171
|
const number = count$1(value.number);
|
|
@@ -7376,7 +7413,14 @@ async function ghGraphql(query, variables, cwd, env) {
|
|
|
7376
7413
|
"-F",
|
|
7377
7414
|
`number=${variables.number}`
|
|
7378
7415
|
];
|
|
7379
|
-
|
|
7416
|
+
for (const name of [
|
|
7417
|
+
"threadCursor",
|
|
7418
|
+
"commentCursor",
|
|
7419
|
+
"reviewCursor"
|
|
7420
|
+
]) {
|
|
7421
|
+
const cursor = variables[name];
|
|
7422
|
+
if (cursor !== void 0) args.push("-f", `${name}=${cursor}`);
|
|
7423
|
+
}
|
|
7380
7424
|
const body = await ghJson(args, cwd, env);
|
|
7381
7425
|
if (typeof body !== "object" || body === null || !("data" in body)) throw new GhError("The GitHub API answered without a data payload.");
|
|
7382
7426
|
return body.data;
|
|
@@ -7433,6 +7477,7 @@ function mapComment(raw) {
|
|
|
7433
7477
|
const body = asString(node.body);
|
|
7434
7478
|
if (!id || body === null) return null;
|
|
7435
7479
|
const { login, isBot } = readAuthor(node.author);
|
|
7480
|
+
const editedAt = asString(node.lastEditedAt);
|
|
7436
7481
|
return {
|
|
7437
7482
|
id,
|
|
7438
7483
|
author: login,
|
|
@@ -7440,11 +7485,36 @@ function mapComment(raw) {
|
|
|
7440
7485
|
body,
|
|
7441
7486
|
timestamp: asString(node.createdAt) ?? "",
|
|
7442
7487
|
replyToId: asString(path(node, "replyTo", "id")),
|
|
7443
|
-
url: asString(node.url) ?? ""
|
|
7488
|
+
url: asString(node.url) ?? "",
|
|
7489
|
+
...editedAt ? { editedAt } : {}
|
|
7444
7490
|
};
|
|
7445
7491
|
}
|
|
7446
|
-
function mapComments(
|
|
7447
|
-
return
|
|
7492
|
+
function mapComments(nodes) {
|
|
7493
|
+
return nodes.map(mapComment).filter((c) => c !== null);
|
|
7494
|
+
}
|
|
7495
|
+
/**
|
|
7496
|
+
* A thread's comment nodes in order: the opening comment, then the most recent replies, each once.
|
|
7497
|
+
*
|
|
7498
|
+
* Two connections because one cannot say "the first and the last few". When a thread is short the
|
|
7499
|
+
* two overlap completely, so the merge is by id. The single `comments` connection is what payloads
|
|
7500
|
+
* recorded before the split carry, and it is read the same way.
|
|
7501
|
+
*/
|
|
7502
|
+
function threadCommentNodes(node) {
|
|
7503
|
+
const merged = [];
|
|
7504
|
+
const seen = /* @__PURE__ */ new Set();
|
|
7505
|
+
for (const raw of [
|
|
7506
|
+
...nodesOf(node.opening),
|
|
7507
|
+
...nodesOf(node.recent),
|
|
7508
|
+
...nodesOf(node.comments)
|
|
7509
|
+
]) {
|
|
7510
|
+
const id = asString(asRecord(raw)?.id);
|
|
7511
|
+
if (id !== null) {
|
|
7512
|
+
if (seen.has(id)) continue;
|
|
7513
|
+
seen.add(id);
|
|
7514
|
+
}
|
|
7515
|
+
merged.push(raw);
|
|
7516
|
+
}
|
|
7517
|
+
return merged;
|
|
7448
7518
|
}
|
|
7449
7519
|
function mapLocation(node, comments) {
|
|
7450
7520
|
const file = asString(node.path);
|
|
@@ -7480,10 +7550,10 @@ function mapThread(raw) {
|
|
|
7480
7550
|
if (!node) return null;
|
|
7481
7551
|
const id = asString(node.id);
|
|
7482
7552
|
if (!id) return null;
|
|
7483
|
-
const rawComments =
|
|
7484
|
-
const comments = mapComments(
|
|
7553
|
+
const rawComments = threadCommentNodes(node);
|
|
7554
|
+
const comments = mapComments(rawComments);
|
|
7485
7555
|
if (comments.length === 0) return null;
|
|
7486
|
-
const totalCount = asNumber(path(node, "comments", "totalCount")) ?? comments.length;
|
|
7556
|
+
const totalCount = asNumber(path(node, "opening", "totalCount")) ?? asNumber(path(node, "comments", "totalCount")) ?? comments.length;
|
|
7487
7557
|
return {
|
|
7488
7558
|
id,
|
|
7489
7559
|
type: "review_thread",
|
|
@@ -7529,7 +7599,9 @@ function mapReview(raw) {
|
|
|
7529
7599
|
const verdict = state ? VERDICTS[state] : void 0;
|
|
7530
7600
|
if (!verdict) return null;
|
|
7531
7601
|
const { login, isBot } = readAuthor(node.author);
|
|
7602
|
+
const id = asString(node.id);
|
|
7532
7603
|
return {
|
|
7604
|
+
...id ? { id } : {},
|
|
7533
7605
|
reviewer: login,
|
|
7534
7606
|
reviewerIsBot: isBot,
|
|
7535
7607
|
verdict,
|
|
@@ -7537,15 +7609,73 @@ function mapReview(raw) {
|
|
|
7537
7609
|
timestamp: asString(node.submittedAt) ?? ""
|
|
7538
7610
|
};
|
|
7539
7611
|
}
|
|
7612
|
+
/**
|
|
7613
|
+
* The standing verdict per reviewer: their newest submission. What `latestReviews` used to answer,
|
|
7614
|
+
* derived from the full history instead so both views come from one fetch and cannot disagree.
|
|
7615
|
+
* Ordered by each reviewer's first appearance, which keeps the rendered list stable across fetches.
|
|
7616
|
+
*/
|
|
7617
|
+
function latestPerReviewer(reviews) {
|
|
7618
|
+
const latest = /* @__PURE__ */ new Map();
|
|
7619
|
+
for (const review of reviews) {
|
|
7620
|
+
const held = latest.get(review.reviewer);
|
|
7621
|
+
if (!held || review.timestamp >= held.timestamp) latest.set(review.reviewer, review);
|
|
7622
|
+
}
|
|
7623
|
+
return [...latest.values()];
|
|
7624
|
+
}
|
|
7625
|
+
/**
|
|
7626
|
+
* Whether the last page of a collection says it ended there.
|
|
7627
|
+
*
|
|
7628
|
+
* Complete only on the provider's own word. A collection with no `pageInfo` at all, which is every
|
|
7629
|
+
* payload recorded before it was requested, proves nothing and is therefore capped: coverage is
|
|
7630
|
+
* what lets a delta call a missing comment deleted, so guessing "complete" would manufacture
|
|
7631
|
+
* deletions out of a page boundary.
|
|
7632
|
+
*/
|
|
7633
|
+
function coverageOf(lastConnection, more) {
|
|
7634
|
+
const info = asRecord(lastConnection?.pageInfo);
|
|
7635
|
+
if (!info || typeof info[more] !== "boolean") return {
|
|
7636
|
+
coverage: "capped",
|
|
7637
|
+
provenCapped: false
|
|
7638
|
+
};
|
|
7639
|
+
return info[more] ? {
|
|
7640
|
+
coverage: "capped",
|
|
7641
|
+
provenCapped: true
|
|
7642
|
+
} : {
|
|
7643
|
+
coverage: "complete",
|
|
7644
|
+
provenCapped: false
|
|
7645
|
+
};
|
|
7646
|
+
}
|
|
7540
7647
|
function mapDiscussionResponseReporting(pages, options) {
|
|
7541
7648
|
const discussions = [];
|
|
7542
|
-
const
|
|
7649
|
+
const legacyDecisions = [];
|
|
7650
|
+
let sawLatest = false;
|
|
7651
|
+
const reviews = [];
|
|
7543
7652
|
const seenThreadIds = /* @__PURE__ */ new Set();
|
|
7653
|
+
const seenReviewIds = /* @__PURE__ */ new Set();
|
|
7654
|
+
let sawReviews = false;
|
|
7655
|
+
let lastThreads = null;
|
|
7656
|
+
let lastConversation = null;
|
|
7657
|
+
let lastReviews = null;
|
|
7544
7658
|
let refused = 0;
|
|
7545
7659
|
let overallDecision = null;
|
|
7546
7660
|
for (const page of pages) {
|
|
7547
7661
|
const pr = path(page, "repository", "pullRequest");
|
|
7548
7662
|
if (!asRecord(pr)) continue;
|
|
7663
|
+
lastThreads = asRecord(path(pr, "reviewThreads")) ?? lastThreads;
|
|
7664
|
+
lastConversation = asRecord(path(pr, "comments")) ?? lastConversation;
|
|
7665
|
+
const reviewConnection = asRecord(path(pr, "reviews"));
|
|
7666
|
+
if (reviewConnection) {
|
|
7667
|
+
sawReviews = true;
|
|
7668
|
+
lastReviews = reviewConnection;
|
|
7669
|
+
for (const raw of nodesOf(reviewConnection)) {
|
|
7670
|
+
const review = mapReview(raw);
|
|
7671
|
+
if (!review) continue;
|
|
7672
|
+
if (review.id) {
|
|
7673
|
+
if (seenReviewIds.has(review.id)) continue;
|
|
7674
|
+
seenReviewIds.add(review.id);
|
|
7675
|
+
}
|
|
7676
|
+
reviews.push(review);
|
|
7677
|
+
}
|
|
7678
|
+
}
|
|
7549
7679
|
for (const raw of nodesOf(path(pr, "reviewThreads"))) {
|
|
7550
7680
|
const thread = mapThread(raw);
|
|
7551
7681
|
if (!thread) refused++;
|
|
@@ -7562,25 +7692,46 @@ function mapDiscussionResponseReporting(pages, options) {
|
|
|
7562
7692
|
discussions.push(conversation);
|
|
7563
7693
|
}
|
|
7564
7694
|
}
|
|
7695
|
+
if (asRecord(path(pr, "latestReviews"))) sawLatest = true;
|
|
7565
7696
|
for (const raw of nodesOf(path(pr, "latestReviews"))) {
|
|
7566
7697
|
const review = mapReview(raw);
|
|
7567
|
-
if (review)
|
|
7698
|
+
if (review) legacyDecisions.push(review);
|
|
7568
7699
|
}
|
|
7569
7700
|
const decision = asString(path(pr, "reviewDecision"));
|
|
7570
7701
|
if (decision && DECISIONS[decision]) overallDecision = DECISIONS[decision];
|
|
7571
7702
|
}
|
|
7703
|
+
const threads = coverageOf(lastThreads, "hasNextPage");
|
|
7704
|
+
const conversation = coverageOf(lastConversation, "hasPreviousPage");
|
|
7705
|
+
const reviewCoverage = coverageOf(lastReviews, "hasPreviousPage");
|
|
7706
|
+
reviews.sort((a, b) => a.timestamp < b.timestamp ? -1 : a.timestamp > b.timestamp ? 1 : 0);
|
|
7707
|
+
const coverage = {
|
|
7708
|
+
threads: options.truncated ? "capped" : threads.coverage,
|
|
7709
|
+
conversation: conversation.coverage,
|
|
7710
|
+
reviews: sawReviews ? reviewCoverage.coverage : "capped"
|
|
7711
|
+
};
|
|
7572
7712
|
return {
|
|
7573
7713
|
discussion: {
|
|
7574
7714
|
schemaVersion: 1,
|
|
7575
7715
|
discussions,
|
|
7576
|
-
decisions,
|
|
7716
|
+
decisions: sawLatest || !sawReviews ? legacyDecisions : latestPerReviewer(reviews),
|
|
7717
|
+
...sawReviews ? { reviews } : {},
|
|
7577
7718
|
overallDecision,
|
|
7578
|
-
truncated: options.truncated,
|
|
7579
|
-
|
|
7719
|
+
truncated: options.truncated || threads.provenCapped || conversation.provenCapped || reviewCoverage.provenCapped,
|
|
7720
|
+
coverage,
|
|
7721
|
+
fetchedAt: options.fetchedAt,
|
|
7722
|
+
...options.requestedAt !== void 0 ? { requestedAt: options.requestedAt } : {}
|
|
7580
7723
|
},
|
|
7581
7724
|
refused
|
|
7582
7725
|
};
|
|
7583
7726
|
}
|
|
7727
|
+
var RECENT_COMMENTS_PER_THREAD = 9;
|
|
7728
|
+
var COMMENT_FIELDS = `
|
|
7729
|
+
id
|
|
7730
|
+
body
|
|
7731
|
+
createdAt
|
|
7732
|
+
lastEditedAt
|
|
7733
|
+
url
|
|
7734
|
+
author { login __typename }`;
|
|
7584
7735
|
var THREAD_FIELDS = `
|
|
7585
7736
|
pageInfo { hasNextPage endCursor }
|
|
7586
7737
|
nodes {
|
|
@@ -7591,20 +7742,41 @@ var THREAD_FIELDS = `
|
|
|
7591
7742
|
line
|
|
7592
7743
|
originalLine
|
|
7593
7744
|
diffSide
|
|
7594
|
-
comments(first:
|
|
7745
|
+
opening: comments(first: 1) {
|
|
7595
7746
|
totalCount
|
|
7596
|
-
nodes {
|
|
7597
|
-
id
|
|
7598
|
-
|
|
7599
|
-
|
|
7600
|
-
|
|
7601
|
-
|
|
7747
|
+
nodes {${COMMENT_FIELDS}
|
|
7748
|
+
replyTo { id }
|
|
7749
|
+
originalCommit { oid }
|
|
7750
|
+
}
|
|
7751
|
+
}
|
|
7752
|
+
recent: comments(last: ${RECENT_COMMENTS_PER_THREAD}) {
|
|
7753
|
+
nodes {${COMMENT_FIELDS}
|
|
7602
7754
|
replyTo { id }
|
|
7603
7755
|
originalCommit { oid }
|
|
7604
7756
|
}
|
|
7605
7757
|
}
|
|
7606
7758
|
}`;
|
|
7607
|
-
|
|
7759
|
+
var CONVERSATION_FIELDS = `
|
|
7760
|
+
pageInfo { hasPreviousPage startCursor }
|
|
7761
|
+
nodes {${COMMENT_FIELDS}
|
|
7762
|
+
}`;
|
|
7763
|
+
var REVIEW_NODE = `
|
|
7764
|
+
id
|
|
7765
|
+
state
|
|
7766
|
+
body
|
|
7767
|
+
submittedAt
|
|
7768
|
+
author { login __typename }`;
|
|
7769
|
+
var REVIEW_FIELDS = `
|
|
7770
|
+
pageInfo { hasPreviousPage startCursor }
|
|
7771
|
+
nodes {${REVIEW_NODE}
|
|
7772
|
+
}`;
|
|
7773
|
+
/**
|
|
7774
|
+
* First page: one page of each collection.
|
|
7775
|
+
*
|
|
7776
|
+
* Its variables are unchanged, so the extension's transport, which walks threads alone, keeps
|
|
7777
|
+
* sending this document as it always has. The mapper reads each collection's `pageInfo`, so a
|
|
7778
|
+
* collection nobody walked further is recorded as capped rather than presented as whole.
|
|
7779
|
+
*/
|
|
7608
7780
|
var PR_DISCUSSION_QUERY = `
|
|
7609
7781
|
query PRDiscussion($owner: String!, $repo: String!, $number: Int!, $threadCursor: String) {
|
|
7610
7782
|
repository(owner: $owner, name: $repo) {
|
|
@@ -7613,29 +7785,20 @@ query PRDiscussion($owner: String!, $repo: String!, $number: Int!, $threadCursor
|
|
|
7613
7785
|
reviewThreads(first: 50, after: $threadCursor) {${THREAD_FIELDS}
|
|
7614
7786
|
}
|
|
7615
7787
|
latestReviews(first: 50) {
|
|
7616
|
-
nodes {
|
|
7617
|
-
state
|
|
7618
|
-
body
|
|
7619
|
-
submittedAt
|
|
7620
|
-
author { login __typename }
|
|
7788
|
+
nodes {${REVIEW_NODE}
|
|
7621
7789
|
}
|
|
7622
7790
|
}
|
|
7623
|
-
|
|
7624
|
-
|
|
7625
|
-
|
|
7626
|
-
body
|
|
7627
|
-
createdAt
|
|
7628
|
-
url
|
|
7629
|
-
author { login __typename }
|
|
7630
|
-
}
|
|
7791
|
+
reviews(last: 50) {${REVIEW_FIELDS}
|
|
7792
|
+
}
|
|
7793
|
+
comments(last: 50) {${CONVERSATION_FIELDS}
|
|
7631
7794
|
}
|
|
7632
7795
|
}
|
|
7633
7796
|
}
|
|
7634
7797
|
rateLimit { cost remaining }
|
|
7635
7798
|
}`;
|
|
7636
7799
|
/**
|
|
7637
|
-
* Continuation pages
|
|
7638
|
-
* burn
|
|
7800
|
+
* Continuation pages, one document per collection. Re-requesting the others on every page would
|
|
7801
|
+
* burn nodes re-fetching data we already hold.
|
|
7639
7802
|
*/
|
|
7640
7803
|
var PR_DISCUSSION_THREADS_QUERY = `
|
|
7641
7804
|
query PRDiscussionThreads($owner: String!, $repo: String!, $number: Int!, $threadCursor: String!) {
|
|
@@ -7647,6 +7810,35 @@ query PRDiscussionThreads($owner: String!, $repo: String!, $number: Int!, $threa
|
|
|
7647
7810
|
}
|
|
7648
7811
|
rateLimit { cost remaining }
|
|
7649
7812
|
}`;
|
|
7813
|
+
/** Older conversation comments, walking backwards from the page before. */
|
|
7814
|
+
var PR_DISCUSSION_COMMENTS_QUERY = `
|
|
7815
|
+
query PRDiscussionComments($owner: String!, $repo: String!, $number: Int!, $commentCursor: String!) {
|
|
7816
|
+
repository(owner: $owner, name: $repo) {
|
|
7817
|
+
pullRequest(number: $number) {
|
|
7818
|
+
comments(last: 50, before: $commentCursor) {${CONVERSATION_FIELDS}
|
|
7819
|
+
}
|
|
7820
|
+
}
|
|
7821
|
+
}
|
|
7822
|
+
rateLimit { cost remaining }
|
|
7823
|
+
}`;
|
|
7824
|
+
/**
|
|
7825
|
+
* Earlier review submissions, walking backwards from the newest like conversation comments do.
|
|
7826
|
+
*
|
|
7827
|
+
* Newest first because a cap that binds should cost the oldest history, never the reviews a delta
|
|
7828
|
+
* exists to find. Measured, not supposed: a busy public request ran to more than 150 reviews and
|
|
7829
|
+
* the forward walk stopped before its latest ones. The standing verdicts do not depend on this
|
|
7830
|
+
* walk: `latestReviews` above answers them whole, one per reviewer.
|
|
7831
|
+
*/
|
|
7832
|
+
var PR_DISCUSSION_REVIEWS_QUERY = `
|
|
7833
|
+
query PRDiscussionReviews($owner: String!, $repo: String!, $number: Int!, $reviewCursor: String!) {
|
|
7834
|
+
repository(owner: $owner, name: $repo) {
|
|
7835
|
+
pullRequest(number: $number) {
|
|
7836
|
+
reviews(last: 50, before: $reviewCursor) {${REVIEW_FIELDS}
|
|
7837
|
+
}
|
|
7838
|
+
}
|
|
7839
|
+
}
|
|
7840
|
+
rateLimit { cost remaining }
|
|
7841
|
+
}`;
|
|
7650
7842
|
//#endregion
|
|
7651
7843
|
//#region ../../packages/review-harness/src/forge/adapters/github/github_adapter.ts
|
|
7652
7844
|
var GITHUB_FORGE_ID = "github";
|
|
@@ -7911,30 +8103,54 @@ async function findRequest(repo, branch, sha, env, cwd) {
|
|
|
7911
8103
|
return null;
|
|
7912
8104
|
}
|
|
7913
8105
|
async function fetchConversation$1(repo, number, env, cwd) {
|
|
7914
|
-
const
|
|
7915
|
-
if (!availability.ok) return {
|
|
7916
|
-
kind: availability.kind,
|
|
7917
|
-
cli: availability.cli
|
|
7918
|
-
};
|
|
8106
|
+
const requestedAt = Date.now();
|
|
7919
8107
|
try {
|
|
7920
|
-
const
|
|
7921
|
-
|
|
7922
|
-
|
|
7923
|
-
|
|
7924
|
-
|
|
7925
|
-
|
|
7926
|
-
|
|
7927
|
-
|
|
7928
|
-
|
|
7929
|
-
|
|
7930
|
-
|
|
7931
|
-
|
|
7932
|
-
|
|
7933
|
-
|
|
7934
|
-
|
|
7935
|
-
|
|
7936
|
-
|
|
8108
|
+
const base = {
|
|
8109
|
+
owner: repo.owner,
|
|
8110
|
+
repo: repo.repo,
|
|
8111
|
+
number
|
|
8112
|
+
};
|
|
8113
|
+
const identity = viewRequest(repo, String(number), cwd, env);
|
|
8114
|
+
identity.catch(() => void 0);
|
|
8115
|
+
const first = await ghGraphql(PR_DISCUSSION_QUERY, base, cwd, env);
|
|
8116
|
+
const walk = async (connection, query, cursorName, maxPages) => {
|
|
8117
|
+
const pages = [];
|
|
8118
|
+
let cursor = nextCursor(first, connection);
|
|
8119
|
+
for (let page = 1; cursor && page < maxPages; page++) {
|
|
8120
|
+
const data = await ghGraphql(query, {
|
|
8121
|
+
...base,
|
|
8122
|
+
[cursorName]: cursor
|
|
8123
|
+
}, cwd, env);
|
|
8124
|
+
pages.push(data);
|
|
8125
|
+
cursor = nextCursor(data, connection);
|
|
8126
|
+
}
|
|
8127
|
+
return pages;
|
|
8128
|
+
};
|
|
8129
|
+
const [threads, comments, reviews] = await Promise.all([
|
|
8130
|
+
walk("reviewThreads", PR_DISCUSSION_THREADS_QUERY, "threadCursor", 3),
|
|
8131
|
+
walk("comments", PR_DISCUSSION_COMMENTS_QUERY, "commentCursor", 3),
|
|
8132
|
+
walk("reviews", PR_DISCUSSION_REVIEWS_QUERY, "reviewCursor", 3)
|
|
8133
|
+
]);
|
|
8134
|
+
return assemble$1([
|
|
8135
|
+
first,
|
|
8136
|
+
...threads,
|
|
8137
|
+
...comments,
|
|
8138
|
+
...reviews
|
|
8139
|
+
], false, await identity, requestedAt);
|
|
7937
8140
|
} catch (error) {
|
|
8141
|
+
let availability;
|
|
8142
|
+
try {
|
|
8143
|
+
availability = await ghAvailable(cwd, env, repo.host);
|
|
8144
|
+
} catch (missing) {
|
|
8145
|
+
return {
|
|
8146
|
+
kind: "failed",
|
|
8147
|
+
detail: missing.message
|
|
8148
|
+
};
|
|
8149
|
+
}
|
|
8150
|
+
if (!availability.ok) return {
|
|
8151
|
+
kind: availability.kind,
|
|
8152
|
+
cli: availability.cli
|
|
8153
|
+
};
|
|
7938
8154
|
return {
|
|
7939
8155
|
kind: "failed",
|
|
7940
8156
|
detail: error.message
|
|
@@ -7948,10 +8164,11 @@ async function fetchConversation$1(repo, number, env, cwd) {
|
|
|
7948
8164
|
* a real repository once; this is where every decision about what the reviewer ends up seeing is
|
|
7949
8165
|
* actually made, and it should be reachable from a unit test.
|
|
7950
8166
|
*/
|
|
7951
|
-
function assemble$1(pages, truncated, raw) {
|
|
8167
|
+
function assemble$1(pages, truncated, raw, requestedAt) {
|
|
7952
8168
|
const mapped = mapDiscussionResponseReporting(pages, {
|
|
7953
8169
|
truncated,
|
|
7954
|
-
fetchedAt: Date.now()
|
|
8170
|
+
fetchedAt: Date.now(),
|
|
8171
|
+
...requestedAt !== void 0 ? { requestedAt } : {}
|
|
7955
8172
|
});
|
|
7956
8173
|
const sanitised = sanitisePrContextReporting(mapped.discussion);
|
|
7957
8174
|
const pullRequest = sanitisePullRequest(identity(raw));
|
|
@@ -7975,9 +8192,19 @@ function assemble$1(pages, truncated, raw) {
|
|
|
7975
8192
|
}
|
|
7976
8193
|
};
|
|
7977
8194
|
}
|
|
7978
|
-
|
|
8195
|
+
/**
|
|
8196
|
+
* Where a collection's walk goes next, or undefined when the page says it was the last.
|
|
8197
|
+
*
|
|
8198
|
+
* Conversation comments and reviews walk backwards from the newest, so theirs is the previous
|
|
8199
|
+
* page's start; threads walk forwards.
|
|
8200
|
+
*/
|
|
8201
|
+
function nextCursor(data, connection) {
|
|
7979
8202
|
const record = (value) => typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
|
|
7980
|
-
const info = record(record(record(record(record(data)?.repository)?.pullRequest)?.
|
|
8203
|
+
const info = record(record(record(record(record(data)?.repository)?.pullRequest)?.[connection])?.pageInfo);
|
|
8204
|
+
if (connection === "comments" || connection === "reviews") {
|
|
8205
|
+
if (info?.hasPreviousPage !== true) return void 0;
|
|
8206
|
+
return typeof info.startCursor === "string" ? info.startCursor : void 0;
|
|
8207
|
+
}
|
|
7981
8208
|
if (info?.hasNextPage !== true) return void 0;
|
|
7982
8209
|
return typeof info.endCursor === "string" ? info.endCursor : void 0;
|
|
7983
8210
|
}
|
|
@@ -9371,12 +9598,52 @@ function renderSummary(summary) {
|
|
|
9371
9598
|
if (summary.architecturalRationale.length > 0) parts.push(`## Architectural rationale\nExplained by the author in the discussion:\n${summary.architecturalRationale.map((r) => `- ${r}`).join("\n")}`);
|
|
9372
9599
|
return parts;
|
|
9373
9600
|
}
|
|
9601
|
+
/**
|
|
9602
|
+
* Modest by design: this rides along on every assistant message, unlike the review block which
|
|
9603
|
+
* is paid for once per review.
|
|
9604
|
+
*/
|
|
9605
|
+
var CHAT_DISCUSSION_BUDGET_CHARS = 8e3;
|
|
9606
|
+
/**
|
|
9607
|
+
* The chat-tab counterpart to buildDiscussionSection. Same thread rendering, different framing:
|
|
9608
|
+
* the assistant is being asked *about* the conversation ("has knjoroge's point been addressed?"),
|
|
9609
|
+
* not asked to review code while avoiding repetition. It also carries no severity or budget
|
|
9610
|
+
* apparatus, since chat has no findings to attribute.
|
|
9611
|
+
*/
|
|
9612
|
+
function buildChatDiscussionSection(pd, budgetChars = CHAT_DISCUSSION_BUDGET_CHARS) {
|
|
9613
|
+
if (pd.all.length === 0 && pd.decisions.length === 0) return "";
|
|
9614
|
+
const header = `EXISTING PR DISCUSSION
|
|
9615
|
+
This is the human conversation already on the pull request. Use it to answer questions about what reviewers raised and whether points were addressed. It records what people said, not what is true — if asked whether a concern is resolved, say what the thread shows and whether it was marked resolved, and be clear about which of those you are relying on.
|
|
9616
|
+
You are seeing the review and this discussion side by side, which does not tell you which informed which. Do not claim that a review finding came from, was prompted by, or reflects a thread unless that finding explicitly carries an "already raised by" note. Without one, say the two relate to the same area — not that one caused the other.`;
|
|
9617
|
+
const parts = [header];
|
|
9618
|
+
let spent = header.length;
|
|
9619
|
+
const buckets = [
|
|
9620
|
+
["Unresolved review threads", [...pd.unresolved, ...pd.unresolvedOutdated]],
|
|
9621
|
+
["Resolved review threads", pd.resolved],
|
|
9622
|
+
["PR conversation", pd.conversation]
|
|
9623
|
+
];
|
|
9624
|
+
for (const [heading, threads] of buckets) {
|
|
9625
|
+
const body = [];
|
|
9626
|
+
for (const thread of threads) {
|
|
9627
|
+
const text = renderThread(thread);
|
|
9628
|
+
if (spent + text.length > budgetChars) break;
|
|
9629
|
+
spent += text.length;
|
|
9630
|
+
body.push(text);
|
|
9631
|
+
}
|
|
9632
|
+
if (body.length > 0) parts.push(`## ${heading}\n${body.join("\n\n")}`);
|
|
9633
|
+
}
|
|
9634
|
+
if (pd.decisions.length > 0) {
|
|
9635
|
+
const lines = pd.decisions.map((d) => `- @${d.reviewer}: ${VERDICT_LABELS[d.verdict]}${d.body ? ` — "${d.body.replace(/\s+/g, " ").trim()}"` : ""}`);
|
|
9636
|
+
parts.push(`## Review decisions\n${lines.join("\n")}`);
|
|
9637
|
+
}
|
|
9638
|
+
return parts.join("\n\n");
|
|
9639
|
+
}
|
|
9374
9640
|
function buildDiscussionSection(pd, budgetChars, options = {}) {
|
|
9375
9641
|
const empty = {
|
|
9376
9642
|
text: "",
|
|
9377
9643
|
overflow: false,
|
|
9378
9644
|
includedThreads: 0,
|
|
9379
|
-
omittedThreads: 0
|
|
9645
|
+
omittedThreads: 0,
|
|
9646
|
+
includedThreadIds: []
|
|
9380
9647
|
};
|
|
9381
9648
|
if (!(pd.all.length > 0 || pd.decisions.length > 0 || pd.overallDecision !== null) || budgetChars <= 0) return empty;
|
|
9382
9649
|
const perFile = options.maxThreadsPerFile ?? DEFAULT_MAX_THREADS_PER_FILE;
|
|
@@ -9411,6 +9678,7 @@ function buildDiscussionSection(pd, budgetChars, options = {}) {
|
|
|
9411
9678
|
let omitted = 0;
|
|
9412
9679
|
const perFileNotes = [];
|
|
9413
9680
|
const rendered = [];
|
|
9681
|
+
const includedThreadIds = [];
|
|
9414
9682
|
if (options.summary) {
|
|
9415
9683
|
rendered.push(...renderSummary(options.summary));
|
|
9416
9684
|
included = options.summary.unresolved.length;
|
|
@@ -9428,6 +9696,7 @@ function buildDiscussionSection(pd, budgetChars, options = {}) {
|
|
|
9428
9696
|
}
|
|
9429
9697
|
spent += text.length;
|
|
9430
9698
|
body.push(text);
|
|
9699
|
+
includedThreadIds.push(thread.id);
|
|
9431
9700
|
included++;
|
|
9432
9701
|
}
|
|
9433
9702
|
if (body.length > 0) rendered.push(`${bucket.heading}\n${body.join("\n\n")}`);
|
|
@@ -9450,7 +9719,8 @@ function buildDiscussionSection(pd, budgetChars, options = {}) {
|
|
|
9450
9719
|
].filter((part) => part !== "").join("\n\n"),
|
|
9451
9720
|
overflow: omitted > 0,
|
|
9452
9721
|
includedThreads: included,
|
|
9453
|
-
omittedThreads: omitted
|
|
9722
|
+
omittedThreads: omitted,
|
|
9723
|
+
includedThreadIds
|
|
9454
9724
|
};
|
|
9455
9725
|
}
|
|
9456
9726
|
//#endregion
|
|
@@ -9866,6 +10136,7 @@ ${diff}`;
|
|
|
9866
10136
|
breakdown: buildPromptBudget(sections).breakdown
|
|
9867
10137
|
},
|
|
9868
10138
|
discussionOverflow: discussion.overflow,
|
|
10139
|
+
discussionThreadIds: discussion.includedThreadIds,
|
|
9869
10140
|
partiallyReadFiles: partialFiles.length,
|
|
9870
10141
|
partialFiles
|
|
9871
10142
|
};
|
|
@@ -11159,237 +11430,988 @@ function applyMotivation(result, candidate, authorDescription) {
|
|
|
11159
11430
|
*/
|
|
11160
11431
|
var SUMMARY_GUIDANCE = `"summary": WHAT the change does, for a reviewer about to read it. Roughly 80-120 words, and fewer for a small change. Name the parts that change and what each now does. Do not explain why the change exists, and do not judge whether it is correct or safe: other fields carry both.`;
|
|
11161
11432
|
//#endregion
|
|
11162
|
-
//#region ../../packages/review-harness/src/domain/
|
|
11163
|
-
|
|
11164
|
-
|
|
11165
|
-
|
|
11166
|
-
|
|
11167
|
-
|
|
11168
|
-
|
|
11169
|
-
|
|
11170
|
-
|
|
11171
|
-
|
|
11172
|
-
|
|
11173
|
-
|
|
11174
|
-
"sections": [{
|
|
11175
|
-
"id": string,
|
|
11176
|
-
"order": number,
|
|
11177
|
-
"title": string,
|
|
11178
|
-
"oneLineSummary": string,
|
|
11179
|
-
"purpose": string,
|
|
11180
|
-
"files": [{ "path": string, "focus": string }],
|
|
11181
|
-
"whyReview": string,
|
|
11182
|
-
"checklist": string[],
|
|
11183
|
-
"priority": "implementation" | "supporting" | "tests" | "documentation",
|
|
11184
|
-
"reviewAttention": "high" | "medium" | "low",
|
|
11185
|
-
"reviewWeight": 1 | 2 | 3 | 4 | 5,
|
|
11186
|
-
"recommendedStartingPoint": boolean
|
|
11187
|
-
}],
|
|
11188
|
-
"issues": [{ "id": string, "severity": "critical" | "major" | "minor" | "nit", "title": string, "description": string, "file"?: string, "line"?: number, "sectionId"?: string, "confidence"?: "high" | "medium" | "low", "existingDiscussion"?: { "threadId": string, "relation": "same" | "related" } }],
|
|
11189
|
-
"questions": [{ "id": string, "question": string, "importance"?: "high" | "medium" | "low", "whyThisMatters"?: string, "file"?: string, "sectionId"?: string }],
|
|
11190
|
-
"recommendation": string,
|
|
11191
|
-
"nextSteps": string[]
|
|
11433
|
+
//#region ../../packages/review-harness/src/domain/discussion/discussion_delta.ts
|
|
11434
|
+
/**
|
|
11435
|
+
* Which collections of a snapshot were capped. A snapshot from before coverage was recorded can
|
|
11436
|
+
* say only whether its thread list was truncated, so that is all it is taken to say.
|
|
11437
|
+
*/
|
|
11438
|
+
function partialOf(current) {
|
|
11439
|
+
if (!current.coverage) return current.truncated ? ["threads"] : [];
|
|
11440
|
+
return [
|
|
11441
|
+
"threads",
|
|
11442
|
+
"conversation",
|
|
11443
|
+
"reviews"
|
|
11444
|
+
].filter((collection) => current.coverage?.[collection] === "capped");
|
|
11192
11445
|
}
|
|
11193
|
-
|
|
11194
|
-
Field guidance:
|
|
11195
|
-
- ${SUMMARY_GUIDANCE}
|
|
11196
|
-
- "motivation": WHY this change exists, which is a different question from what it does ("summary" covers that). Name the problem being solved or the goal being served, never the mechanics: "adds a retry loop" is what the code does, "the sync job was failing on transient network errors" is why. Weigh sources in this order: the PR Description and any issue it links; then tests and user-visible behaviour; then commit messages and docs; then the implementation itself. Choose exactly one "status":
|
|
11197
|
-
- "stated": the author's own words in the PR Description say why. "sourceQuote" is REQUIRED: a short passage (one sentence or less, at least three words) copied character for character from the PR Description, which says why. Copy it exactly as written, keeping backticks, punctuation, hyphenation and spelling; never paraphrase, shorten words, join two sentences or correct a typo. Only the PR Description counts as the author stating it: a reason that appears only in commit messages, review comments, or an issue the description merely links to is not "stated" (use "inferred" and cite it as evidence). Never use "stated" when the description only restates what the code does. If the change itself also shows the reason, include it as "evidence".
|
|
11198
|
-
- "inferred": nothing states it, but the change gives real evidence of it. "evidence" is REQUIRED and non-empty: each entry names what points to the motive, such as a test asserting the fixed behaviour, an issue reference, or a commit message, with "file" set to the changed file it is in where there is one. Never infer a motive from names alone (a branch, a function or a file being called "fix-x" is not evidence). Word the summary as a likely reading, not a fact: "This appears to..." rather than "This fixes...".
|
|
11199
|
-
- "unclear": neither the description nor the change establishes why, AND knowing why would change how this change should be reviewed (for example, whether a behaviour change is intended). "authorQuestion" is REQUIRED: one focused question for the author that would settle it. Do not also ask that question, in any wording, in "questions".
|
|
11200
|
-
If the motive is unclear and knowing it would not change the review, omit "motivation" entirely. Do not add it just to fill the field.
|
|
11201
|
-
- "sections": partition the pull request into logical implementation sections (e.g. "Authentication", "Database Migration", "API Changes"), grouped by responsibility/purpose, not by directory or file type. Identify a sensible number of sections — most PRs will naturally have between 2 and 8, but do not invent or split sections simply to reach a target; let the actual structure of the change decide the count. For each section: "oneLineSummary" is a single sentence; "purpose" explains why this part of the change exists; "whyReview" is a short note aimed at the reviewer about what to focus on; "checklist" is a short list of things to verify (review prompts, not findings — never report bugs, code smells, or recommendations inside a section, that's what "issues" is for). Assign "order" starting at 1 in the sequence a reviewer should walk through the sections (narrative order, not file count or alphabetical). "reviewWeight" is a relative time-budget indicator from 1 (skim) to 5 (spend the most time here), independent of "priority" and "reviewAttention". Set "recommendedStartingPoint": true on exactly one section — whichever one a reviewer should actually read first for the rest of the review to make sense. This is not necessarily the section with "order": 1 (e.g. a foundational database migration might naturally come first while a reviewer should still start with the authentication logic it enables). Set it to false on every other section. Every file in the diff must appear in exactly one section — the sections are the only file list the developer sees, so an omitted file is a file they never review. Files with no natural home (lockfiles, dependency bumps, generated output, formatting-only changes) go in a single final catch-all section — title it for what it contains, e.g. "Supporting changes" or "Dependency and config updates" — with "priority": "supporting" and "reviewAttention": "low". Never drop a file just because it is uninteresting; say that it is uninteresting instead.
|
|
11202
|
-
- "files" inside a section: "path" is the file path exactly as it appears in the diff. "focus" is REQUIRED on every file — if a file is worth listing in a section, it is worth explaining why the reviewer has been brought to it. Keep it to one or two short sentences, roughly 20-35 words, never more than two sentences. Make it actionable rather than descriptive: name the role this specific file plays in the section, then say what the reviewer should verify there. For example: "Introduces the new association reader. Verify it preserves the existing lookup behaviour." / "Implements the retry path. Confirm error handling matches the existing gateway implementation." / "Registers the generated behaviour with Active Record. Check the hook fires for subclasses too." Do not restate the section's summary or checklist, do not use bullet points, and do not write filler such as "Review this file carefully."
|
|
11203
|
-
- "file" on an issue or question: prefer anchoring a finding to the changed file responsible for the concern, named exactly as it appears in the diff. If an unchanged repository file is necessary context and no changed-file location accurately represents the finding, it may be referenced as an unchanged related file. Never list an unchanged file in a section's "files" array, and never describe an unchanged file as part of the pull request.
|
|
11204
|
-
- "sectionId" on issues/questions: reference the "id" of the section that item belongs to. Omit it rather than guessing if an item doesn't clearly belong to one section.
|
|
11205
|
-
- "recommendation": a 1-2 sentence merge verdict aimed at the developer (e.g. "Approve" or "Request changes — fix the unhandled rejection before merging"). Do not just restate the risk level, and do not copy Guide section content verbatim into this field.
|
|
11206
|
-
- "nextSteps": concrete actions to take before merging (specific tests to run, files to double-check), not a restatement of the Guide.
|
|
11207
|
-
- "issues": report only issues worth the reviewer's attention — do not invent issues to fill out the array, and do not omit significant issues. Let the count reflect the actual PR (a trivial PR may have zero or one issue; a large or risky PR may have several).
|
|
11208
|
-
- "existingDiscussion" on an issue: set this only when a thread in the PR DISCUSSION CONTEXT block already raises this same concern, and you have independently confirmed from the diff that it still applies. "threadId" must be copied verbatim from that thread's [T_...] marker — never invent an id, never guess, and omit the field entirely if no thread matches. Set "relation" to "same" when the thread already makes this exact point, or "related" when it discusses this area without covering the finding — a reviewer acts differently on "someone said this already" than on "someone was looking near here". Still write the issue normally; this field only records that the point was raised before, so the reviewer is not shown it as a fresh discovery. Do not use it to pad the review with issues you would otherwise have left out.
|
|
11209
|
-
- "confidence" on an issue: how sure you are the issue is real, on the same high/medium/low scale as a question's "importance". This is a separate axis from "severity", which is how much it would matter if real — a critical issue you are unsure about and a nit you are certain of are different things to act on, so do not let one field stand in for the other. Use LOW when you are reasoning from a diff excerpt without the surrounding code, and say what would confirm it in the description. Omit the field rather than guessing a middle value.
|
|
11210
|
-
- "questions": each one is a genuine open item the reviewer needs answered before they can sign off, not a rhetorical prompt. "importance" is how much attention it deserves, on the same high/medium/low scale as a section's "reviewAttention" (e.g. a question about a possibly-exposed secret is "high"; a naming preference is "low"). "whyThisMatters" is 1-2 sentences explaining the concrete consequence of the question going unanswered — not a restatement of the question itself.
|
|
11211
|
-
- Every section's "oneLineSummary"/"purpose"/"whyReview", each file's "focus", each question's "whyThisMatters", and every text field inside "motivation" are rendered as plain prose paragraphs, not markdown — write them as normal sentences. Do not use "- " bullet points or numbered lists inside these fields; if you're enumerating several points, join them into flowing sentences instead. Inline code/identifiers may use backtick spans (e.g. \`ErrorSeverity\`) — those do render.
|
|
11212
|
-
- "summary", "recommendation" and each "nextSteps" entry are rendered as Markdown, so use it where it carries meaning: backtick spans for identifiers, a fenced block for a snippet worth reading as code, a short bullet list where the content genuinely is a list. Prose is still the default and these fields are still short. Do not add headings, and do not turn a paragraph into bullets to look organised.
|
|
11213
|
-
|
|
11214
|
-
This JSON object is the only output the developer sees. The free-text sections above (Overview, Guide, Issues, Questions, Follow-up Prompts) are for your own internal reasoning — do not copy them verbatim into any JSON field.`;
|
|
11215
|
-
function buildStructuredReviewPrompt(pr, options = {}) {
|
|
11216
|
-
const { prompt, discussionOverflow, partiallyReadFiles, partialFiles } = buildChatReviewPrompt(pr, [], {
|
|
11217
|
-
discussionSummary: options.discussionSummary,
|
|
11218
|
-
repositoryAccess: options.repositoryAccess,
|
|
11219
|
-
changeInventory: options.changeInventory,
|
|
11220
|
-
guideCoverage: options.guideCoverage
|
|
11221
|
-
});
|
|
11446
|
+
function refOf(thread) {
|
|
11222
11447
|
return {
|
|
11223
|
-
|
|
11224
|
-
|
|
11225
|
-
|
|
11226
|
-
partialFiles: partialFiles ?? []
|
|
11448
|
+
id: thread.id,
|
|
11449
|
+
type: thread.type,
|
|
11450
|
+
location: thread.location
|
|
11227
11451
|
};
|
|
11228
11452
|
}
|
|
11453
|
+
function hasHuman(thread) {
|
|
11454
|
+
return thread.comments.some((comment) => !isBot(comment));
|
|
11455
|
+
}
|
|
11456
|
+
/** The review history if the snapshot has one, else its standing verdicts, else none. */
|
|
11457
|
+
function reviewsOf(discussion) {
|
|
11458
|
+
return discussion.reviews ?? discussion.decisions ?? [];
|
|
11459
|
+
}
|
|
11460
|
+
function reviewKey(review) {
|
|
11461
|
+
return `${review.reviewer}\u0000${review.verdict}\u0000${review.timestamp}`;
|
|
11462
|
+
}
|
|
11463
|
+
function after(timestamp, epochMs) {
|
|
11464
|
+
const at = Date.parse(timestamp);
|
|
11465
|
+
return !Number.isNaN(at) && at > epochMs;
|
|
11466
|
+
}
|
|
11467
|
+
/** Everything on the request, offered as new: for a baseline of `none` or `unknown`. */
|
|
11468
|
+
function everything(current, baseline) {
|
|
11469
|
+
const added = [];
|
|
11470
|
+
for (const thread of current.discussions) for (const comment of thread.comments) if (!isBot(comment)) added.push({
|
|
11471
|
+
thread: refOf(thread),
|
|
11472
|
+
comment,
|
|
11473
|
+
newThread: true
|
|
11474
|
+
});
|
|
11475
|
+
return {
|
|
11476
|
+
baseline,
|
|
11477
|
+
added,
|
|
11478
|
+
edited: [],
|
|
11479
|
+
removed: [],
|
|
11480
|
+
vanishedThreads: [],
|
|
11481
|
+
newReviews: reviewsOf(current).filter((review) => !review.reviewerIsBot),
|
|
11482
|
+
resolved: [],
|
|
11483
|
+
reopened: [],
|
|
11484
|
+
partial: partialOf(current)
|
|
11485
|
+
};
|
|
11486
|
+
}
|
|
11487
|
+
function diffDiscussion(baseline, current) {
|
|
11488
|
+
if (baseline === null) return everything(current, "none");
|
|
11489
|
+
if (baseline === "unknown") return everything(current, "unknown");
|
|
11490
|
+
const before = new Map(baseline.discussions.map((thread) => [thread.id, thread]));
|
|
11491
|
+
const nowIds = new Set(current.discussions.map((thread) => thread.id));
|
|
11492
|
+
const delta = {
|
|
11493
|
+
baseline: "recorded",
|
|
11494
|
+
added: [],
|
|
11495
|
+
edited: [],
|
|
11496
|
+
removed: [],
|
|
11497
|
+
vanishedThreads: [],
|
|
11498
|
+
newReviews: [],
|
|
11499
|
+
resolved: [],
|
|
11500
|
+
reopened: [],
|
|
11501
|
+
partial: partialOf(current)
|
|
11502
|
+
};
|
|
11503
|
+
for (const thread of current.discussions) {
|
|
11504
|
+
const previous = before.get(thread.id);
|
|
11505
|
+
const ref = refOf(thread);
|
|
11506
|
+
const previousComments = new Map((previous?.comments ?? []).map((comment) => [comment.id, comment]));
|
|
11507
|
+
for (const comment of thread.comments) {
|
|
11508
|
+
if (isBot(comment)) continue;
|
|
11509
|
+
const held = previousComments.get(comment.id);
|
|
11510
|
+
if (!held) delta.added.push({
|
|
11511
|
+
thread: ref,
|
|
11512
|
+
comment,
|
|
11513
|
+
newThread: !previous
|
|
11514
|
+
});
|
|
11515
|
+
else if (comment.editedAt && comment.editedAt !== held.editedAt) {
|
|
11516
|
+
if (held.editedAt || after(comment.editedAt, baseline.fetchedAt)) delta.edited.push({
|
|
11517
|
+
thread: ref,
|
|
11518
|
+
comment,
|
|
11519
|
+
newThread: false,
|
|
11520
|
+
previous: held
|
|
11521
|
+
});
|
|
11522
|
+
}
|
|
11523
|
+
}
|
|
11524
|
+
if (!previous) continue;
|
|
11525
|
+
if (hasHuman(thread)) {
|
|
11526
|
+
if (thread.resolved && !previous.resolved) delta.resolved.push(ref);
|
|
11527
|
+
if (!thread.resolved && previous.resolved) delta.reopened.push(ref);
|
|
11528
|
+
}
|
|
11529
|
+
if (previous.truncatedCommentCount === 0 && thread.truncatedCommentCount === 0) {
|
|
11530
|
+
const nowComments = new Set(thread.comments.map((comment) => comment.id));
|
|
11531
|
+
for (const comment of previous.comments) if (!isBot(comment) && !nowComments.has(comment.id)) delta.removed.push({
|
|
11532
|
+
thread: ref,
|
|
11533
|
+
comment,
|
|
11534
|
+
newThread: false
|
|
11535
|
+
});
|
|
11536
|
+
}
|
|
11537
|
+
}
|
|
11538
|
+
const complete = (collection) => baseline.coverage?.[collection] === "complete" && current.coverage?.[collection] === "complete";
|
|
11539
|
+
for (const previous of baseline.discussions) {
|
|
11540
|
+
if (nowIds.has(previous.id) || !hasHuman(previous)) continue;
|
|
11541
|
+
if (previous.type === "review_thread" && complete("threads")) delta.vanishedThreads.push(refOf(previous));
|
|
11542
|
+
else if (previous.type === "conversation" && complete("conversation")) {
|
|
11543
|
+
for (const comment of previous.comments) if (!isBot(comment)) delta.removed.push({
|
|
11544
|
+
thread: refOf(previous),
|
|
11545
|
+
comment,
|
|
11546
|
+
newThread: false
|
|
11547
|
+
});
|
|
11548
|
+
}
|
|
11549
|
+
}
|
|
11550
|
+
const history = baseline.reviews;
|
|
11551
|
+
const seenIds = new Set((history ?? []).flatMap((review) => review.id ? [review.id] : []));
|
|
11552
|
+
const seenKeys = new Set((history ?? []).map(reviewKey));
|
|
11553
|
+
for (const review of reviewsOf(current)) {
|
|
11554
|
+
if (review.reviewerIsBot) continue;
|
|
11555
|
+
if (history ? !(review.id && seenIds.has(review.id)) && !seenKeys.has(reviewKey(review)) : after(review.timestamp, baseline.fetchedAt)) delta.newReviews.push(review);
|
|
11556
|
+
}
|
|
11557
|
+
return delta;
|
|
11558
|
+
}
|
|
11229
11559
|
/**
|
|
11230
|
-
*
|
|
11560
|
+
* Whether anything moved that is worth an analysis.
|
|
11231
11561
|
*
|
|
11232
|
-
*
|
|
11233
|
-
*
|
|
11234
|
-
*
|
|
11562
|
+
* Mechanical, by the processor's own rule: a comment that is only a pleasantry or an emoji is not a
|
|
11563
|
+
* change to reason about on its own, and a bot never was. A thread resolved, a review submitted or
|
|
11564
|
+
* a comment removed always is. Whether any of it matters to a finding is the model's question.
|
|
11565
|
+
*
|
|
11566
|
+
* An edit counts when either side of it says something. Rewriting a substantive comment into
|
|
11567
|
+
* "thanks!" withdraws what it said exactly as deleting it would, and deleting always counts.
|
|
11235
11568
|
*/
|
|
11236
|
-
function
|
|
11237
|
-
const
|
|
11238
|
-
const
|
|
11239
|
-
return
|
|
11240
|
-
result: applied.result,
|
|
11241
|
-
...applied.correction ? { motivationCorrection: applied.correction } : {}
|
|
11242
|
-
};
|
|
11569
|
+
function hasDiscussionChanges(delta) {
|
|
11570
|
+
const substantive = (entry) => !isNoise(entry.comment);
|
|
11571
|
+
const substantiveEdit = (entry) => substantive(entry) || entry.previous !== void 0 && !isNoise(entry.previous);
|
|
11572
|
+
return delta.added.some(substantive) || delta.edited.some(substantiveEdit) || delta.removed.length > 0 || delta.vanishedThreads.length > 0 || delta.newReviews.length > 0 || delta.resolved.length > 0 || delta.reopened.length > 0;
|
|
11243
11573
|
}
|
|
11244
|
-
/**
|
|
11245
|
-
|
|
11246
|
-
|
|
11247
|
-
|
|
11248
|
-
|
|
11249
|
-
|
|
11250
|
-
|
|
11251
|
-
|
|
11252
|
-
|
|
11253
|
-
|
|
11254
|
-
|
|
11255
|
-
|
|
11256
|
-
|
|
11257
|
-
|
|
11258
|
-
|
|
11574
|
+
/**
|
|
11575
|
+
* The conversation as an analysis has now taken it into account: the current snapshot, with every
|
|
11576
|
+
* change it was not shown put back the way the baseline had it.
|
|
11577
|
+
*
|
|
11578
|
+
* What an update records as its baseline. Recording the whole current snapshot would mark a change
|
|
11579
|
+
* that was held back for length, or never shown at all, as accounted for, and the next update would
|
|
11580
|
+
* find nothing new where the model has in fact seen nothing. So each withheld change is undone:
|
|
11581
|
+
* an unseen comment is taken out, an unseen edit restored to what it said before, an unseen
|
|
11582
|
+
* deletion put back, an unseen resolution flipped back, an unseen review dropped from the history.
|
|
11583
|
+
* Each then reads as new to the next update, exactly as it did to this one.
|
|
11584
|
+
*
|
|
11585
|
+
* Everything not in the delta is taken from the current snapshot as it is (bots, coverage, the
|
|
11586
|
+
* standing verdicts), because none of it is evidence the model was or was not given.
|
|
11587
|
+
*/
|
|
11588
|
+
/**
|
|
11589
|
+
* The conversation as a first analysis took it into account: the snapshot without the threads its
|
|
11590
|
+
* prompt offered but left out, for length or past the per-file cap.
|
|
11591
|
+
*
|
|
11592
|
+
* The first analysis's half of `deliveredSnapshot`. Recording the whole snapshot would mark those
|
|
11593
|
+
* threads as considered, and an update with nothing new since would find nothing to show. Taken
|
|
11594
|
+
* away, they read to that update as new, which to the model they are.
|
|
11595
|
+
*
|
|
11596
|
+
* Only threads offered and not rendered are taken away. A thread the processor set aside, a bot's or
|
|
11597
|
+
* one of pleasantries only, stays, because no update shows it either and nothing was held back.
|
|
11598
|
+
*/
|
|
11599
|
+
function withoutOmittedThreads(discussion, offered, rendered) {
|
|
11600
|
+
const shown = new Set(rendered);
|
|
11601
|
+
const omitted = new Set(offered.filter((id) => !shown.has(id)));
|
|
11602
|
+
if (omitted.size === 0) return discussion;
|
|
11259
11603
|
return {
|
|
11260
|
-
|
|
11261
|
-
|
|
11262
|
-
|
|
11263
|
-
|
|
11264
|
-
|
|
11265
|
-
|
|
11266
|
-
|
|
11267
|
-
|
|
11268
|
-
|
|
11269
|
-
|
|
11270
|
-
|
|
11271
|
-
|
|
11604
|
+
...discussion,
|
|
11605
|
+
discussions: discussion.discussions.filter((thread) => !omitted.has(thread.id))
|
|
11606
|
+
};
|
|
11607
|
+
}
|
|
11608
|
+
/** The edit time a restored, never-edited comment carries: earlier than any edit a forge reports. */
|
|
11609
|
+
var NEVER_SEEN_EDIT = "1970-01-01T00:00:00.000Z";
|
|
11610
|
+
function deliveredSnapshot(current, baseline, withheld) {
|
|
11611
|
+
if (withheld.added.length + withheld.edited.length + withheld.removed.length + withheld.vanishedThreads.length + withheld.newReviews.length + withheld.resolved.length + withheld.reopened.length === 0) return current;
|
|
11612
|
+
const next = structuredClone(current);
|
|
11613
|
+
const threads = new Map(next.discussions.map((thread) => [thread.id, thread]));
|
|
11614
|
+
const before = new Map((baseline && baseline !== "unknown" ? baseline.discussions : []).map((thread) => [thread.id, thread]));
|
|
11615
|
+
/** A thread the current snapshot no longer has, put back as the baseline held it. */
|
|
11616
|
+
const restore = (id) => {
|
|
11617
|
+
if (threads.has(id)) return threads.get(id);
|
|
11618
|
+
const held = before.get(id);
|
|
11619
|
+
if (!held) return void 0;
|
|
11620
|
+
const thread = structuredClone(held);
|
|
11621
|
+
next.discussions.push(thread);
|
|
11622
|
+
threads.set(id, thread);
|
|
11623
|
+
return thread;
|
|
11624
|
+
};
|
|
11625
|
+
for (const entry of withheld.added) {
|
|
11626
|
+
const thread = threads.get(entry.thread.id);
|
|
11627
|
+
if (thread) thread.comments = thread.comments.filter((comment) => comment.id !== entry.comment.id);
|
|
11628
|
+
}
|
|
11629
|
+
for (const entry of withheld.edited) {
|
|
11630
|
+
const thread = threads.get(entry.thread.id);
|
|
11631
|
+
const previous = entry.previous && {
|
|
11632
|
+
...entry.previous,
|
|
11633
|
+
editedAt: entry.previous.editedAt || NEVER_SEEN_EDIT
|
|
11634
|
+
};
|
|
11635
|
+
if (thread && previous) thread.comments = thread.comments.map((comment) => comment.id === previous.id ? previous : comment);
|
|
11636
|
+
}
|
|
11637
|
+
for (const entry of withheld.removed) {
|
|
11638
|
+
const thread = restore(entry.thread.id);
|
|
11639
|
+
if (thread && !thread.comments.some((comment) => comment.id === entry.comment.id)) thread.comments.push(entry.comment);
|
|
11640
|
+
}
|
|
11641
|
+
for (const ref of withheld.vanishedThreads) restore(ref.id);
|
|
11642
|
+
for (const ref of withheld.resolved) {
|
|
11643
|
+
const thread = threads.get(ref.id);
|
|
11644
|
+
if (thread) thread.resolved = false;
|
|
11645
|
+
}
|
|
11646
|
+
for (const ref of withheld.reopened) {
|
|
11647
|
+
const thread = threads.get(ref.id);
|
|
11648
|
+
if (thread) thread.resolved = true;
|
|
11649
|
+
}
|
|
11650
|
+
if (withheld.newReviews.length && next.reviews) {
|
|
11651
|
+
const unseen = new Set(withheld.newReviews);
|
|
11652
|
+
const unseenIds = new Set(withheld.newReviews.flatMap((review) => review.id ? [review.id] : []));
|
|
11653
|
+
const unseenKeys = new Set(withheld.newReviews.map(reviewKey));
|
|
11654
|
+
next.reviews = next.reviews.filter((review) => !unseen.has(review) && !(review.id && unseenIds.has(review.id)) && !unseenKeys.has(reviewKey(review)));
|
|
11655
|
+
}
|
|
11656
|
+
next.discussions = next.discussions.filter((thread) => thread.comments.length > 0);
|
|
11657
|
+
return next;
|
|
11272
11658
|
}
|
|
11273
|
-
var EXPLAIN_SIMPLY_PROMPT = "Explain this review in plain English for someone unfamiliar with this codebase. Avoid jargon, use a simple everyday analogy if it helps, and keep it skippable — a couple of short paragraphs is enough.";
|
|
11274
11659
|
//#endregion
|
|
11275
|
-
//#region ../../packages/review-harness/src/domain/
|
|
11276
|
-
var SCOPE_BOUNDARY = `Scope:
|
|
11277
|
-
Judge the complexity this change introduces or materially expands. That is the whole of what you are reviewing.
|
|
11278
|
-
Pre-existing complexity around the change is context you may use to understand it, and is not a finding. Do not attribute it to this author.
|
|
11279
|
-
The one exception: where this change makes existing complexity worse, or where the work could not have been done without taking it on, that is in scope and you should say which of the two it is.
|
|
11280
|
-
Do not propose refactoring architecture the change merely touches.`;
|
|
11281
|
-
var EVIDENCE_THRESHOLD = `Evidence threshold:
|
|
11282
|
-
Emit a recommendation only when you can do all five of these. If you cannot, say nothing.
|
|
11283
|
-
1. Name the concrete complexity this change introduced. Not "this is complicated" — the actual types, layers, files, flags or machinery.
|
|
11284
|
-
2. Explain why the current requirements and this repository do not call for it.
|
|
11285
|
-
3. Describe a credible, materially simpler implementation. If you cannot say what to do instead, you do not have a recommendation.
|
|
11286
|
-
4. Point at the changed file or files where it was introduced.
|
|
11287
|
-
5. Say what acting on it is worth, and what it would cost.
|
|
11288
|
-
Never emit a recommendation resting on naming preference, personal taste, or a requirement nobody has yet.`;
|
|
11660
|
+
//#region ../../packages/review-harness/src/domain/discussion/conversation_section.ts
|
|
11289
11661
|
/**
|
|
11290
|
-
* The
|
|
11662
|
+
* The line every state ends with.
|
|
11291
11663
|
*
|
|
11292
|
-
*
|
|
11293
|
-
*
|
|
11294
|
-
* as the gate; this block asks for the conclusion first and keeps the checks as the audit trail.
|
|
11664
|
+
* Persuasion, not the boundary: the agent's tool policy is what actually stops a fetch. This is
|
|
11665
|
+
* here so that an answer is not padded with commands the reviewer was never going to need.
|
|
11295
11666
|
*/
|
|
11296
|
-
var
|
|
11297
|
-
|
|
11298
|
-
|
|
11299
|
-
|
|
11667
|
+
var NO_FORGE_RETRIEVAL = "Do not attempt to retrieve pull request or forge data yourself, and do not suggest commands or API calls for retrieving it.";
|
|
11668
|
+
var MISSING_PREVENTS = "Where the missing conversation prevents a conclusion, say that plainly rather than guessing.";
|
|
11669
|
+
var UNAVAILABLE = {
|
|
11670
|
+
"unsupported-forge": "PR Review Buddy does not read pull request conversation on this forge yet",
|
|
11671
|
+
"cli-missing": "the forge's command-line tool is not installed on this machine",
|
|
11672
|
+
"not-authenticated": "the forge's command-line tool is not signed in on this machine",
|
|
11673
|
+
failed: "the fetch failed"
|
|
11674
|
+
};
|
|
11675
|
+
var REREAD_FAILURE = {
|
|
11676
|
+
...UNAVAILABLE,
|
|
11677
|
+
"too-slow": "the forge took longer to answer than PR Review Buddy waits before replying"
|
|
11678
|
+
};
|
|
11679
|
+
function iso(epochMs) {
|
|
11680
|
+
return Number.isFinite(epochMs) && epochMs > 0 ? new Date(epochMs).toISOString() : "an unknown time";
|
|
11681
|
+
}
|
|
11682
|
+
/** What PR Review Buddy has of the conversation, in a short paragraph that names the state outright. */
|
|
11683
|
+
function renderConversationState(state) {
|
|
11684
|
+
switch (state.kind) {
|
|
11685
|
+
case "attached":
|
|
11686
|
+
if (state.reread === "failed") return [
|
|
11687
|
+
`PR Review Buddy could not re-read the pull request conversation just now${state.rereadReason ? `: ${REREAD_FAILURE[state.rereadReason]}` : ""}.`,
|
|
11688
|
+
`What follows is the copy it fetched at ${iso(state.fetchedAt)}, and it may be out of date. Say so where that matters.`,
|
|
11689
|
+
NO_FORGE_RETRIEVAL
|
|
11690
|
+
].join(" ");
|
|
11691
|
+
return [state.reread === "fresh" ? `PR Review Buddy fetched the pull request conversation at ${iso(state.fetchedAt)}, for this request.` : `PR Review Buddy fetched the pull request conversation at ${iso(state.fetchedAt)}.`, NO_FORGE_RETRIEVAL].join(" ");
|
|
11692
|
+
case "unavailable": return [
|
|
11693
|
+
`PR Review Buddy could not retrieve the pull request conversation: ${UNAVAILABLE[state.reason]}.`,
|
|
11694
|
+
"None of it is included, so do not conclude that there are no comments.",
|
|
11695
|
+
MISSING_PREVENTS,
|
|
11696
|
+
NO_FORGE_RETRIEVAL
|
|
11697
|
+
].join(" ");
|
|
11698
|
+
case "no-request": return ["This change is not on a pull request PR Review Buddy could find, so there is no conversation to include.", NO_FORGE_RETRIEVAL].join(" ");
|
|
11699
|
+
case "not-attached": return [
|
|
11700
|
+
"No pull request conversation is attached to this review, so none is included.",
|
|
11701
|
+
MISSING_PREVENTS,
|
|
11702
|
+
NO_FORGE_RETRIEVAL
|
|
11703
|
+
].join(" ");
|
|
11704
|
+
case "withheld": return [
|
|
11705
|
+
"The reviewer has chosen not to share the pull request conversation, so none is included. Do not conclude that there are no comments.",
|
|
11706
|
+
MISSING_PREVENTS,
|
|
11707
|
+
NO_FORGE_RETRIEVAL
|
|
11708
|
+
].join(" ");
|
|
11709
|
+
}
|
|
11710
|
+
}
|
|
11711
|
+
/**
|
|
11712
|
+
* A ceiling, not a measurement: one and a half times the assistant's conversation budget
|
|
11713
|
+
* (`CHAT_DISCUSSION_BUDGET_CHARS`), so a first delta against a review made without any
|
|
11714
|
+
* conversation cannot crowd the diff out of an update prompt. Overflow is counted in the text,
|
|
11715
|
+
* so a delta that binds says so rather than reading as complete.
|
|
11716
|
+
*/
|
|
11717
|
+
var DELTA_BUDGET_CHARS = 12e3;
|
|
11718
|
+
var DELTA_HEADING = "PR DISCUSSION CHANGES SINCE THIS REVIEW WAS MADE";
|
|
11719
|
+
var BASELINE_LINE = {
|
|
11720
|
+
recorded: "Computed by PR Review Buddy by comparing the conversation this review was given with the conversation now. Times are as the forge reported them.",
|
|
11721
|
+
none: "This review was made without the pull request conversation, so all of it is new to the review.",
|
|
11722
|
+
unknown: "The conversation this review was given was not recorded, so what is listed below may include comments the review already took into account. Treat each item as possibly new, not certainly new."
|
|
11723
|
+
};
|
|
11724
|
+
/** What a delta is measured from, in the sentence the delta section opens with. */
|
|
11725
|
+
function baselineNote(baseline) {
|
|
11726
|
+
return BASELINE_LINE[baseline];
|
|
11727
|
+
}
|
|
11728
|
+
function where(thread) {
|
|
11729
|
+
return `[${thread.id}] ${locationOf(thread)}`;
|
|
11730
|
+
}
|
|
11731
|
+
/** A comment's line names the comment as well as its thread, so a finding can cite exactly what it rests on. */
|
|
11732
|
+
function whereComment(entry) {
|
|
11733
|
+
return `[${entry.thread.id}] [comment ${entry.comment.id}] ${locationOf(entry.thread)}`;
|
|
11734
|
+
}
|
|
11735
|
+
function body(text) {
|
|
11736
|
+
return truncateBody(text.trim(), 800);
|
|
11737
|
+
}
|
|
11738
|
+
function addedLine(entry) {
|
|
11739
|
+
const { comment } = entry;
|
|
11740
|
+
const kind = entry.newThread ? " (new thread)" : comment.replyToId ? " (reply)" : "";
|
|
11741
|
+
return `- ${whereComment(entry)} · @${comment.author} · ${comment.timestamp || "time unknown"}${kind}: ${body(comment.body)}`;
|
|
11742
|
+
}
|
|
11743
|
+
function editedLine(entry) {
|
|
11744
|
+
const { comment, previous } = entry;
|
|
11745
|
+
const before = previous ? ` It previously read: ${body(previous.body)}` : "";
|
|
11746
|
+
return `- ${whereComment(entry)} · @${comment.author} · written ${comment.timestamp || "at an unknown time"}, edited ${comment.editedAt}. It now reads: ${body(comment.body)}${before}`;
|
|
11747
|
+
}
|
|
11748
|
+
function removedLine(entry) {
|
|
11749
|
+
const { comment } = entry;
|
|
11750
|
+
return `- ${whereComment(entry)} · @${comment.author} · written ${comment.timestamp || "at an unknown time"}. It read: ${body(comment.body)}`;
|
|
11751
|
+
}
|
|
11752
|
+
/** Every list of a delta, in the order a reader needs them, with its heading and its line. */
|
|
11753
|
+
var SECTIONS = [
|
|
11754
|
+
{
|
|
11755
|
+
list: "added",
|
|
11756
|
+
heading: (baseline) => baseline === "recorded" ? "## Added" : "## Comments",
|
|
11757
|
+
line: addedLine
|
|
11758
|
+
},
|
|
11759
|
+
{
|
|
11760
|
+
list: "edited",
|
|
11761
|
+
heading: () => "## Edited",
|
|
11762
|
+
line: editedLine
|
|
11763
|
+
},
|
|
11764
|
+
{
|
|
11765
|
+
list: "removed",
|
|
11766
|
+
heading: () => "## Deleted",
|
|
11767
|
+
line: removedLine
|
|
11768
|
+
},
|
|
11769
|
+
{
|
|
11770
|
+
list: "vanishedThreads",
|
|
11771
|
+
heading: () => "## Review threads no longer present",
|
|
11772
|
+
line: ((thread) => `- ${where(thread)}`)
|
|
11773
|
+
},
|
|
11774
|
+
{
|
|
11775
|
+
list: "newReviews",
|
|
11776
|
+
heading: (baseline) => baseline === "recorded" ? "## New reviews submitted" : "## Reviews submitted",
|
|
11777
|
+
line: ((review) => {
|
|
11778
|
+
const text = review.body.trim();
|
|
11779
|
+
return `- ${review.id ? `[review ${review.id}] ` : ""}@${review.reviewer} · ${VERDICT_LABELS[review.verdict]} · ${review.timestamp || "time unknown"}${text ? `: ${body(text)}` : " (no summary; any comments it left on lines are listed above as comments)"}`;
|
|
11780
|
+
})
|
|
11781
|
+
},
|
|
11782
|
+
{
|
|
11783
|
+
list: "resolved",
|
|
11784
|
+
heading: () => "## Threads resolved since",
|
|
11785
|
+
line: ((thread) => `- ${where(thread)}`)
|
|
11786
|
+
},
|
|
11787
|
+
{
|
|
11788
|
+
list: "reopened",
|
|
11789
|
+
heading: () => "## Threads reopened since",
|
|
11790
|
+
line: ((thread) => `- ${where(thread)}`)
|
|
11791
|
+
}
|
|
11792
|
+
];
|
|
11793
|
+
function emptyLike(delta) {
|
|
11794
|
+
return {
|
|
11795
|
+
...delta,
|
|
11796
|
+
added: [],
|
|
11797
|
+
edited: [],
|
|
11798
|
+
removed: [],
|
|
11799
|
+
vanishedThreads: [],
|
|
11800
|
+
newReviews: [],
|
|
11801
|
+
resolved: [],
|
|
11802
|
+
reopened: []
|
|
11803
|
+
};
|
|
11804
|
+
}
|
|
11805
|
+
function headerOf(delta) {
|
|
11806
|
+
return `${DELTA_HEADING}\n${BASELINE_LINE[delta.baseline]}`;
|
|
11807
|
+
}
|
|
11300
11808
|
/**
|
|
11301
|
-
*
|
|
11809
|
+
* Split a delta at a budget, by the same lines it is rendered as.
|
|
11302
11810
|
*
|
|
11303
|
-
*
|
|
11304
|
-
*
|
|
11305
|
-
*
|
|
11306
|
-
*
|
|
11307
|
-
|
|
11811
|
+
* Its own step because the split is a fact the caller needs, not only a count in the text: an
|
|
11812
|
+
* update records as taken into account only what the model was actually shown, so a change held
|
|
11813
|
+
* back for length is still new to the next one. Whole lines only, and a line that does not fit does
|
|
11814
|
+
* not stop a shorter one after it from fitting.
|
|
11815
|
+
*/
|
|
11816
|
+
function fitDiscussionDelta(delta, budgetChars = DELTA_BUDGET_CHARS) {
|
|
11817
|
+
const shown = emptyLike(delta);
|
|
11818
|
+
const withheld = emptyLike(delta);
|
|
11819
|
+
let spent = headerOf(delta).length;
|
|
11820
|
+
for (const section of SECTIONS) {
|
|
11821
|
+
const items = delta[section.list];
|
|
11822
|
+
if (items.length === 0) continue;
|
|
11823
|
+
const heading = section.heading(delta.baseline);
|
|
11824
|
+
let opened = false;
|
|
11825
|
+
for (const item of items) {
|
|
11826
|
+
const cost = section.line(item).length + 1 + (opened ? 0 : heading.length + 2);
|
|
11827
|
+
if (spent + cost > budgetChars) {
|
|
11828
|
+
withheld[section.list].push(item);
|
|
11829
|
+
continue;
|
|
11830
|
+
}
|
|
11831
|
+
spent += cost;
|
|
11832
|
+
opened = true;
|
|
11833
|
+
shown[section.list].push(item);
|
|
11834
|
+
}
|
|
11835
|
+
}
|
|
11836
|
+
return {
|
|
11837
|
+
shown,
|
|
11838
|
+
withheld
|
|
11839
|
+
};
|
|
11840
|
+
}
|
|
11841
|
+
/** How many changes a delta lists. */
|
|
11842
|
+
function changeCount(delta) {
|
|
11843
|
+
return SECTIONS.reduce((total, section) => total + delta[section.list].length, 0);
|
|
11844
|
+
}
|
|
11845
|
+
/**
|
|
11846
|
+
* The delta as a prompt section, within a budget.
|
|
11308
11847
|
*
|
|
11309
|
-
*
|
|
11310
|
-
*
|
|
11311
|
-
*
|
|
11848
|
+
* Whole lines only, in the order a reader needs them: what was said, then what was rewritten or
|
|
11849
|
+
* withdrawn, then verdicts and resolution. Anything that did not fit is counted in a closing note,
|
|
11850
|
+
* because a delta that silently stops reads as a complete one.
|
|
11312
11851
|
*/
|
|
11313
|
-
|
|
11314
|
-
|
|
11315
|
-
|
|
11316
|
-
|
|
11317
|
-
|
|
11318
|
-
|
|
11319
|
-
|
|
11320
|
-
|
|
11321
|
-
|
|
11322
|
-
|
|
11323
|
-
|
|
11324
|
-
|
|
11325
|
-
|
|
11326
|
-
|
|
11327
|
-
|
|
11328
|
-
|
|
11329
|
-
|
|
11330
|
-
-
|
|
11331
|
-
|
|
11332
|
-
- pass-through layers adding vocabulary but no policy or isolation
|
|
11333
|
-
- indirection that makes the execution path materially harder to follow
|
|
11334
|
-
- error and retry machinery broader than the failures it faces
|
|
11335
|
-
- a large change where a materially smaller implementation looks feasible
|
|
11336
|
-
- repeated wrappers or conversions that could collapse
|
|
11337
|
-
- abstractions justified only by a hypothetical second implementation
|
|
11338
|
-
|
|
11339
|
-
Fewer lines is evidence, not the objective. A twenty-line explicit implementation can be simpler than a five-line generic one. Judge simplicity by fewer concepts, fewer states, fewer sources of truth, fewer speculative abstractions, fewer indirections and less machinery needed to understand the behaviour.
|
|
11340
|
-
This is not a style review. Naming preferences, "extract this method", small tidy-ups and repetition that costs nobody any understanding are not recommendations.
|
|
11341
|
-
|
|
11342
|
-
This is a list of things to look at, not a list of things to report. Every item here is sometimes exactly right. One implementation does not make an interface wrong: it may be a real external boundary, a testing seam, or a repository convention. More files do not make code worse. Judge what each thing is for and what it costs.`;
|
|
11343
|
-
var WORTH_KEEPING = `Complexity worth keeping:
|
|
11344
|
-
Look actively for complexity that is doing a job, and say so. This is not a politeness section — it is how the reader can tell you understood the change rather than counted its abstractions.
|
|
11345
|
-
Things that usually earn their keep: transaction boundaries protecting atomicity; write-and-rename protocols preventing corruption; security and permission boundaries; concurrency control; retry or idempotency logic against a boundary that has actually proven unreliable; compatibility adapters at a real external seam; validation at a trust boundary; abstractions with more than one real implementation, or that follow an established convention in this repository; anything an explicit requirement mandates.
|
|
11346
|
-
If nothing in this change needs the note, return an empty list. Do not pad it.`;
|
|
11347
|
-
var AUDIENCE_BLOCKS = {
|
|
11348
|
-
author: `Who is reading this:
|
|
11349
|
-
The person who wrote this change, before they ask anyone to review it. They can act on what you say immediately.
|
|
11350
|
-
Be direct and specific. Say what to collapse, remove, inline, defer or replace. "Collapse these three classes into one" and "this abstraction has one implementation, remove it for now" are the register.
|
|
11351
|
-
Give enough of the trade-off that they do not flatten something that was load-bearing, and no more. They did not ask for diplomacy.
|
|
11352
|
-
Do not write draft pull request comments and do not set commentWorthiness. Nobody is being told anything by anyone here.`,
|
|
11353
|
-
reviewer: `Who is reading this:
|
|
11354
|
-
Somebody reviewing another person's change. They are deciding what is worth raising, which is a different question from what is true.
|
|
11355
|
-
Explain why the implementation may be more complex than it needs to be, without asserting your judgement as fact. The author may know something you do not.
|
|
11356
|
-
Set commentWorthiness on every recommendation, and keep it separate from payoff: an observation can be entirely valid and still not be worth spending a review comment on.
|
|
11357
|
-
Where a comment is worth making, draft it. A good draft explains the concern, asks or recommends rather than instructs, and acknowledges the trade-off the author was probably making.
|
|
11358
|
-
Never write "bad code", "obvious", "over-engineered" or anything else that describes the author rather than the code.`
|
|
11852
|
+
function renderDiscussionDelta(delta, budgetChars = DELTA_BUDGET_CHARS) {
|
|
11853
|
+
const header = headerOf(delta);
|
|
11854
|
+
const partial = partialNote(delta);
|
|
11855
|
+
if (changeCount(delta) === 0) return `${header}\n\nNothing in the conversation has changed since this review was made.${partial ? `\n\n${partial}` : ""}`;
|
|
11856
|
+
const { shown, withheld } = fitDiscussionDelta(delta, budgetChars);
|
|
11857
|
+
const parts = [header];
|
|
11858
|
+
for (const section of SECTIONS) {
|
|
11859
|
+
const items = shown[section.list];
|
|
11860
|
+
if (items.length > 0) parts.push(`${section.heading(delta.baseline)}\n${items.map(section.line).join("\n")}`);
|
|
11861
|
+
}
|
|
11862
|
+
const omitted = changeCount(withheld);
|
|
11863
|
+
if (omitted > 0) parts.push(`(${omitted} further change${omitted === 1 ? "" : "s"} omitted for length.)`);
|
|
11864
|
+
if (partial) parts.push(partial);
|
|
11865
|
+
return parts.join("\n\n");
|
|
11866
|
+
}
|
|
11867
|
+
var COLLECTION_NAMES = {
|
|
11868
|
+
threads: "review threads",
|
|
11869
|
+
conversation: "top-level comments",
|
|
11870
|
+
reviews: "review submissions"
|
|
11359
11871
|
};
|
|
11872
|
+
/** Said when part of the conversation was past what PR Review Buddy fetches, so the lists are not read as whole. */
|
|
11873
|
+
function partialNote(delta) {
|
|
11874
|
+
if (delta.partial.length === 0) return "";
|
|
11875
|
+
const names = delta.partial.map((collection) => COLLECTION_NAMES[collection]);
|
|
11876
|
+
return `(This pull request has more ${names.length > 1 ? `${names.slice(0, -1).join(", ")} and ${names.at(-1)}` : names[0]} than PR Review Buddy fetches, so changes among the ones it did not fetch are not listed.)`;
|
|
11877
|
+
}
|
|
11360
11878
|
/**
|
|
11361
|
-
*
|
|
11879
|
+
* A ceiling, not a measurement: half the assistant's conversation budget, beside which this is
|
|
11880
|
+
* rendered. Overflow is counted in the text.
|
|
11881
|
+
*/
|
|
11882
|
+
var REVIEW_HISTORY_BUDGET_CHARS = 4e3;
|
|
11883
|
+
/**
|
|
11884
|
+
* Every review summary on the request, for a reader who may ask about any of them.
|
|
11362
11885
|
*
|
|
11363
|
-
*
|
|
11364
|
-
*
|
|
11365
|
-
*
|
|
11886
|
+
* The conversation block shows each reviewer's standing verdict, which is their latest review and
|
|
11887
|
+
* no other. An earlier summary appears in a delta only while it is new; once an analysis has taken
|
|
11888
|
+
* it into account it would otherwise be nowhere, and a reviewer asking "what did they say in their
|
|
11889
|
+
* first review?" would get a guess. So the history is stated here whole, within a budget.
|
|
11890
|
+
*
|
|
11891
|
+
* Reviews with no summary are counted rather than listed: their content is the comments they left
|
|
11892
|
+
* on lines, which the conversation already carries. Filled from the newest back, so a budget that
|
|
11893
|
+
* binds costs the oldest summaries, and shown oldest first, which is how a reader follows them.
|
|
11366
11894
|
*/
|
|
11367
|
-
|
|
11895
|
+
function renderReviewHistory(discussion, budgetChars = REVIEW_HISTORY_BUDGET_CHARS) {
|
|
11896
|
+
const human = (discussion.reviews ?? []).filter((review) => !review.reviewerIsBot);
|
|
11897
|
+
const summarised = human.filter((review) => review.body.trim());
|
|
11898
|
+
if (summarised.length === 0) return "";
|
|
11899
|
+
const heading = "## Review summaries, oldest first";
|
|
11900
|
+
let spent = 33;
|
|
11901
|
+
const kept = [];
|
|
11902
|
+
for (const review of [...summarised].reverse()) {
|
|
11903
|
+
const line = historyLine(review);
|
|
11904
|
+
if (spent + line.length + 1 > budgetChars) continue;
|
|
11905
|
+
spent += line.length + 1;
|
|
11906
|
+
kept.unshift(review);
|
|
11907
|
+
}
|
|
11908
|
+
const notes = [];
|
|
11909
|
+
const omitted = summarised.length - kept.length;
|
|
11910
|
+
if (omitted > 0) notes.push(`(${omitted} earlier summar${omitted === 1 ? "y" : "ies"} omitted for length.)`);
|
|
11911
|
+
const empty = human.length - summarised.length;
|
|
11912
|
+
if (empty > 0) notes.push(`(${empty} further review${empty === 1 ? "" : "s"} had no summary; what they said is in the comments above.)`);
|
|
11913
|
+
return [
|
|
11914
|
+
heading,
|
|
11915
|
+
...kept.map(historyLine),
|
|
11916
|
+
...notes
|
|
11917
|
+
].join("\n");
|
|
11918
|
+
}
|
|
11919
|
+
function historyLine(review) {
|
|
11920
|
+
return `- @${review.reviewer} · ${VERDICT_LABELS[review.verdict]} · ${review.timestamp || "time unknown"}: ${body(review.body)}`;
|
|
11921
|
+
}
|
|
11922
|
+
//#endregion
|
|
11923
|
+
//#region ../../packages/review-harness/src/domain/ai_review/status_history.ts
|
|
11924
|
+
/** A comment as evidence, copied as it reads now. */
|
|
11925
|
+
function commentEvidence(threadId, comment) {
|
|
11926
|
+
return {
|
|
11927
|
+
kind: "comment",
|
|
11928
|
+
threadId,
|
|
11929
|
+
commentId: comment.id,
|
|
11930
|
+
author: comment.author,
|
|
11931
|
+
body: truncateBody(comment.body.trim(), 800),
|
|
11932
|
+
...comment.url ? { url: comment.url } : {},
|
|
11933
|
+
...comment.timestamp ? { writtenAt: comment.timestamp } : {},
|
|
11934
|
+
...comment.editedAt ? { editedAt: comment.editedAt } : {}
|
|
11935
|
+
};
|
|
11936
|
+
}
|
|
11937
|
+
function reviewEvidence(review) {
|
|
11938
|
+
return {
|
|
11939
|
+
kind: "review",
|
|
11940
|
+
...review.id ? { reviewId: review.id } : {},
|
|
11941
|
+
author: review.reviewer,
|
|
11942
|
+
body: truncateBody(review.body.trim(), 800),
|
|
11943
|
+
...review.timestamp ? { writtenAt: review.timestamp } : {}
|
|
11944
|
+
};
|
|
11945
|
+
}
|
|
11946
|
+
/**
|
|
11947
|
+
* The comment a resolved finding rests on, when a comment is what resolved it. Only a comment: a
|
|
11948
|
+
* review summary cannot resolve a finding (`REVIEW_CITATION`), so no resolution rests on one.
|
|
11949
|
+
*/
|
|
11950
|
+
function conversationResolution(issue) {
|
|
11951
|
+
if (issue.reconciliationStatus !== "resolved") return void 0;
|
|
11952
|
+
const last = issue.statusHistory?.at(-1);
|
|
11953
|
+
if (!last || last.status !== "resolved" || last.basis !== "conversation") return void 0;
|
|
11954
|
+
return last.evidence?.kind === "comment" && last.evidence.commentId ? last.evidence : void 0;
|
|
11955
|
+
}
|
|
11956
|
+
/**
|
|
11957
|
+
* The findings, by index, whose resolving comment the delta edits or deletes.
|
|
11958
|
+
*
|
|
11959
|
+
* Pass the delta the model is actually shown, never the whole one: a finding flagged on an edit held
|
|
11960
|
+
* back for length would be judged again on evidence the model never saw.
|
|
11961
|
+
*/
|
|
11962
|
+
function reassessmentFlags(issues, delta) {
|
|
11963
|
+
const flags = /* @__PURE__ */ new Map();
|
|
11964
|
+
issues.forEach((issue, index) => {
|
|
11965
|
+
const resolvedBy = conversationResolution(issue);
|
|
11966
|
+
if (!resolvedBy) return;
|
|
11967
|
+
const edited = delta.edited.find((entry) => entry.comment.id === resolvedBy.commentId);
|
|
11968
|
+
if (edited) {
|
|
11969
|
+
flags.set(index, {
|
|
11970
|
+
change: "edited",
|
|
11971
|
+
resolvedBy,
|
|
11972
|
+
now: commentEvidence(edited.thread.id, edited.comment)
|
|
11973
|
+
});
|
|
11974
|
+
return;
|
|
11975
|
+
}
|
|
11976
|
+
if (delta.removed.some((entry) => entry.comment.id === resolvedBy.commentId) || delta.vanishedThreads.some((thread) => thread.id === resolvedBy.threadId)) flags.set(index, {
|
|
11977
|
+
change: "deleted",
|
|
11978
|
+
resolvedBy,
|
|
11979
|
+
now: {
|
|
11980
|
+
...resolvedBy,
|
|
11981
|
+
body: "",
|
|
11982
|
+
deleted: true
|
|
11983
|
+
}
|
|
11984
|
+
});
|
|
11985
|
+
});
|
|
11986
|
+
return flags;
|
|
11987
|
+
}
|
|
11988
|
+
function findComment(discussion, commentId) {
|
|
11989
|
+
for (const thread of discussion.discussions) {
|
|
11990
|
+
const comment = thread.comments.find((candidate) => candidate.id === commentId);
|
|
11991
|
+
if (comment) return {
|
|
11992
|
+
thread,
|
|
11993
|
+
comment
|
|
11994
|
+
};
|
|
11995
|
+
}
|
|
11996
|
+
}
|
|
11997
|
+
function resolveCitation(citation, discussion) {
|
|
11998
|
+
if (!discussion) return void 0;
|
|
11999
|
+
if ("reviewId" in citation) {
|
|
12000
|
+
const review = (discussion.reviews ?? []).find((candidate) => candidate.id === citation.reviewId);
|
|
12001
|
+
return review ? reviewEvidence(review) : void 0;
|
|
12002
|
+
}
|
|
12003
|
+
const found = findComment(discussion, citation.commentId);
|
|
12004
|
+
return found ? commentEvidence(found.thread.id, found.comment) : void 0;
|
|
12005
|
+
}
|
|
12006
|
+
/** Said, after the model's own note, when the harness would not let a resolution stand. */
|
|
12007
|
+
var UNFOUND_CITATION = "It cited a comment that is not in the pull request conversation, so it is marked for reassessment rather than resolved.";
|
|
12008
|
+
/**
|
|
12009
|
+
* A review summary may be cited, but not as what resolved a finding. Resolving is the one status a
|
|
12010
|
+
* later change to its evidence must be able to undo, and an edit to a review's summary is not yet
|
|
12011
|
+
* read, so a resolution resting on one could never be reassessed. Until it is, it is marked for
|
|
12012
|
+
* reassessment, with the review kept as its evidence.
|
|
12013
|
+
*/
|
|
12014
|
+
var REVIEW_CITATION = "It rests on a review summary, which can mark a finding for reassessment but not resolve it, so the reviewer should decide.";
|
|
12015
|
+
var NO_CITATION = "No commits were pushed and no comment was cited for it, so it is marked for reassessment rather than resolved.";
|
|
12016
|
+
function withSentence(note, sentence) {
|
|
12017
|
+
return note ? `${note} ${sentence}` : sentence;
|
|
12018
|
+
}
|
|
12019
|
+
/**
|
|
12020
|
+
* The model's citations, checked against the conversation that exists and copied from it.
|
|
12021
|
+
*
|
|
12022
|
+
* A resolution that rests on the conversation has to rest on something real. One citing a comment
|
|
12023
|
+
* that is not there, or (with no commits pushed) citing nothing, is marked "affected" with the reason
|
|
12024
|
+
* said, never left standing as resolved. A citation on any other status is evidence for the record
|
|
12025
|
+
* only, and one that is not there is dropped.
|
|
12026
|
+
*
|
|
12027
|
+
* The one resolution that needs no citation with no commits pushed: a flagged finding the model
|
|
12028
|
+
* keeps resolved, whose evidence is then the edited comment it was asked to judge. A deleted one has
|
|
12029
|
+
* nothing left to rest on.
|
|
12030
|
+
*/
|
|
12031
|
+
function groundFindings(findings, discussion, context) {
|
|
12032
|
+
return findings.map((finding) => {
|
|
12033
|
+
const { cites, ...rest } = finding;
|
|
12034
|
+
const evidence = cites ? resolveCitation(cites, discussion) : void 0;
|
|
12035
|
+
if (finding.status !== "resolved") return evidence ? {
|
|
12036
|
+
...rest,
|
|
12037
|
+
evidence
|
|
12038
|
+
} : rest;
|
|
12039
|
+
if (cites && !evidence) return {
|
|
12040
|
+
...rest,
|
|
12041
|
+
status: "affected",
|
|
12042
|
+
note: withSentence(finding.note, UNFOUND_CITATION)
|
|
12043
|
+
};
|
|
12044
|
+
if (evidence?.kind === "review") return {
|
|
12045
|
+
...rest,
|
|
12046
|
+
status: "affected",
|
|
12047
|
+
note: withSentence(finding.note, REVIEW_CITATION),
|
|
12048
|
+
evidence
|
|
12049
|
+
};
|
|
12050
|
+
if (evidence) return {
|
|
12051
|
+
...rest,
|
|
12052
|
+
evidence
|
|
12053
|
+
};
|
|
12054
|
+
if (context.codeChanged) return rest;
|
|
12055
|
+
const flag = context.flags?.get(finding.sourceIndex);
|
|
12056
|
+
if (flag?.change === "edited") return {
|
|
12057
|
+
...rest,
|
|
12058
|
+
evidence: flag.now
|
|
12059
|
+
};
|
|
12060
|
+
return {
|
|
12061
|
+
...rest,
|
|
12062
|
+
status: "affected",
|
|
12063
|
+
note: withSentence(finding.note, NO_CITATION)
|
|
12064
|
+
};
|
|
12065
|
+
});
|
|
12066
|
+
}
|
|
12067
|
+
/** Statuses that all mean "open": a finding never judged, one raised by an update, and one judged still open. */
|
|
12068
|
+
function isOpen(status) {
|
|
12069
|
+
return status === void 0 || status === "new" || status === "still-open";
|
|
12070
|
+
}
|
|
12071
|
+
/** Whether going from one status to the other is a change worth a history entry. */
|
|
12072
|
+
function isStatusChange(from, to) {
|
|
12073
|
+
return from !== to && !(isOpen(from) && isOpen(to));
|
|
12074
|
+
}
|
|
12075
|
+
/**
|
|
12076
|
+
* The history with one more entry. A finding that had a status before history was kept starts from
|
|
12077
|
+
* that status, carried with the note it had, so the first recorded change has something to move from.
|
|
12078
|
+
*/
|
|
12079
|
+
function appendStatusChange(issue, entry) {
|
|
12080
|
+
return [...issue.statusHistory ?? (issue.reconciliationStatus && !isOpen(issue.reconciliationStatus) ? [{
|
|
12081
|
+
status: issue.reconciliationStatus,
|
|
12082
|
+
carried: true,
|
|
12083
|
+
...issue.reconciliationNote ? { reason: issue.reconciliationNote } : {}
|
|
12084
|
+
}] : []), entry];
|
|
12085
|
+
}
|
|
12086
|
+
/**
|
|
12087
|
+
* The entry for a flagged finding, whatever the model said: still resolved, reopened, marked for
|
|
12088
|
+
* reassessment, or nothing, which is marked for reassessment. Recorded even when it stays resolved,
|
|
12089
|
+
* so the history shows the resolution survived the change and on what.
|
|
12090
|
+
*/
|
|
12091
|
+
function reassessedEntry(verdict, flag, record) {
|
|
12092
|
+
const status = verdict?.status ?? "affected";
|
|
12093
|
+
const byCode = status === "resolved" && !verdict?.evidence && record.basis === "code";
|
|
12094
|
+
const evidence = byCode ? void 0 : verdict?.evidence ?? flag.now;
|
|
12095
|
+
const reason = verdict?.note ?? (flag.change === "edited" ? "The comment that resolved this was edited, and the update did not say whether it still holds." : "The comment that resolved this was deleted, and the update did not say whether it still holds.");
|
|
12096
|
+
return {
|
|
12097
|
+
status,
|
|
12098
|
+
at: record.at,
|
|
12099
|
+
commitSha: record.commitSha,
|
|
12100
|
+
basis: byCode ? "code" : "conversation",
|
|
12101
|
+
reason,
|
|
12102
|
+
...evidence ? { evidence } : {},
|
|
12103
|
+
trigger: flag.change === "edited" ? "evidence-edited" : "evidence-deleted"
|
|
12104
|
+
};
|
|
12105
|
+
}
|
|
12106
|
+
/** The entry for an ordinary change of status. */
|
|
12107
|
+
function changeEntry(status, verdict, record) {
|
|
12108
|
+
return {
|
|
12109
|
+
status,
|
|
12110
|
+
at: record.at,
|
|
12111
|
+
commitSha: record.commitSha,
|
|
12112
|
+
basis: verdict?.evidence ? "conversation" : record.basis,
|
|
12113
|
+
...verdict?.note ? { reason: verdict.note } : {},
|
|
12114
|
+
...verdict?.evidence ? { evidence: verdict.evidence } : {}
|
|
12115
|
+
};
|
|
12116
|
+
}
|
|
12117
|
+
//#endregion
|
|
12118
|
+
//#region ../../packages/review-harness/src/domain/discussion/prepared_conversation.ts
|
|
12119
|
+
/**
|
|
12120
|
+
* How long a question about the review waits for the conversation to be fetched again before it
|
|
12121
|
+
* answers from the copy it has, stated as possibly out of date. The same on every surface, so the
|
|
12122
|
+
* workspace's Ask and the extension's Chat are equally patient.
|
|
12123
|
+
*/
|
|
12124
|
+
var CONVERSATION_REREAD_CEILING_MS = 3e3;
|
|
12125
|
+
/** The state as a prompt block, headed the same way on every surface. */
|
|
12126
|
+
function conversationStateBlock(state) {
|
|
12127
|
+
return `PR CONVERSATION\n${renderConversationState(state)}`;
|
|
12128
|
+
}
|
|
12129
|
+
/** Whether anything in the conversation is new to the analysis, which is what an update reconciles against. */
|
|
12130
|
+
function conversationMoved(conversation) {
|
|
12131
|
+
return conversation.delta !== void 0 && hasDiscussionChanges(conversation.delta);
|
|
12132
|
+
}
|
|
12133
|
+
/**
|
|
12134
|
+
* The conversation block of an update prompt: the state always, and what changed only when
|
|
12135
|
+
* something did, so an update the conversation did not move reads as it always has plus one line.
|
|
12136
|
+
*/
|
|
12137
|
+
function updateConversationBlock(conversation) {
|
|
12138
|
+
return [conversationStateBlock(conversation.state), ...conversationMoved(conversation) && conversation.delta ? [renderDiscussionDelta(conversation.delta)] : []].join("\n\n");
|
|
12139
|
+
}
|
|
12140
|
+
/**
|
|
12141
|
+
* The resolved findings an update judges again: those whose resolving comment the conversation it
|
|
12142
|
+
* shows edits or deletes. Read from the delta as fitted, the same split the prompt renders, so a
|
|
12143
|
+
* finding is never sent back on an edit the model was not shown. None when the conversation is only
|
|
12144
|
+
* stated, not reconciled against.
|
|
12145
|
+
*/
|
|
12146
|
+
function conversationReassessment(previous, conversation) {
|
|
12147
|
+
const { delta } = conversation;
|
|
12148
|
+
if (!delta || !previous || !hasDiscussionChanges(delta)) return /* @__PURE__ */ new Map();
|
|
12149
|
+
return reassessmentFlags(previous.issues, fitDiscussionDelta(delta).shown);
|
|
12150
|
+
}
|
|
12151
|
+
/**
|
|
12152
|
+
* The conversation an update can claim to have taken into account: what it was shown, and no more.
|
|
12153
|
+
* A change held back for length, or a conversation that was stated but not reconciled against,
|
|
12154
|
+
* stays new to the next update rather than being recorded as seen.
|
|
12155
|
+
*
|
|
12156
|
+
* `baseline` is the one the delta was measured from, read before anything moves it.
|
|
12157
|
+
*/
|
|
12158
|
+
function conversationTakenIntoAccount(conversation, baseline) {
|
|
12159
|
+
const { discussion, delta } = conversation;
|
|
12160
|
+
if (!discussion || !delta) return discussion;
|
|
12161
|
+
return deliveredSnapshot(discussion, baseline, hasDiscussionChanges(delta) ? fitDiscussionDelta(delta).withheld : delta);
|
|
12162
|
+
}
|
|
12163
|
+
/**
|
|
12164
|
+
* The conversation for a question about the review, stated whatever its state.
|
|
12165
|
+
*
|
|
12166
|
+
* Three parts: what PR Review Buddy has and how fresh it is; the conversation as it now stands; and
|
|
12167
|
+
* what changed in it since the analysis the reviewer is reading. The last only where there is a
|
|
12168
|
+
* recorded baseline to measure from. Without one, every comment would be listed twice, so the
|
|
12169
|
+
* conversation appears once and a sentence says why nothing is marked new.
|
|
12170
|
+
*/
|
|
12171
|
+
function chatConversationSection(conversation) {
|
|
12172
|
+
const parts = [conversationStateBlock(conversation.state)];
|
|
12173
|
+
if (conversation.discussion) {
|
|
12174
|
+
const full = buildChatDiscussionSection(processDiscussion(conversation.discussion));
|
|
12175
|
+
parts.push(full || "The pull request has no human comments or reviews.");
|
|
12176
|
+
const history = renderReviewHistory(conversation.discussion);
|
|
12177
|
+
if (history) parts.push(history);
|
|
12178
|
+
}
|
|
12179
|
+
if (conversation.delta) parts.push(conversation.delta.baseline === "recorded" ? renderDiscussionDelta(conversation.delta) : `WHAT IS NEW SINCE THIS REVIEW\n${baselineNote(conversation.delta.baseline)}`);
|
|
12180
|
+
return parts.join("\n\n");
|
|
12181
|
+
}
|
|
12182
|
+
//#endregion
|
|
12183
|
+
//#region ../../packages/review-harness/src/domain/ai_review/pipeline.ts
|
|
12184
|
+
var STRUCTURED_OUTPUT_INSTRUCTIONS = `In addition to the review principles above, first understand the implementation, then respond with a single JSON object (no markdown, no surrounding text) matching exactly this shape:
|
|
11368
12185
|
{
|
|
11369
|
-
"assessment": string,
|
|
11370
12186
|
"summary": string,
|
|
11371
|
-
"
|
|
12187
|
+
"motivation"?: {
|
|
12188
|
+
"status": "stated" | "inferred" | "unclear",
|
|
12189
|
+
"summary": string,
|
|
12190
|
+
"sourceQuote"?: string,
|
|
12191
|
+
"evidence"?: [{ "kind": "test" | "behaviour" | "issue" | "commit" | "docs" | "code", "detail": string, "file"?: string }],
|
|
12192
|
+
"authorQuestion"?: string
|
|
12193
|
+
},
|
|
12194
|
+
"overallRisk": "low" | "medium" | "high",
|
|
12195
|
+
"sections": [{
|
|
11372
12196
|
"id": string,
|
|
12197
|
+
"order": number,
|
|
11373
12198
|
"title": string,
|
|
11374
|
-
"
|
|
11375
|
-
"
|
|
11376
|
-
"
|
|
11377
|
-
"
|
|
11378
|
-
"
|
|
11379
|
-
"
|
|
11380
|
-
"
|
|
11381
|
-
"
|
|
11382
|
-
"
|
|
11383
|
-
"commentWorthiness": "strong" | "consider" | "leave",
|
|
11384
|
-
"draftComment": string,
|
|
11385
|
-
"locations": [{ "path": string, "line": number, "primary": boolean }]
|
|
12199
|
+
"oneLineSummary": string,
|
|
12200
|
+
"purpose": string,
|
|
12201
|
+
"files": [{ "path": string, "focus": string }],
|
|
12202
|
+
"whyReview": string,
|
|
12203
|
+
"checklist": string[],
|
|
12204
|
+
"priority": "implementation" | "supporting" | "tests" | "documentation",
|
|
12205
|
+
"reviewAttention": "high" | "medium" | "low",
|
|
12206
|
+
"reviewWeight": 1 | 2 | 3 | 4 | 5,
|
|
12207
|
+
"recommendedStartingPoint": boolean
|
|
11386
12208
|
}],
|
|
11387
|
-
"
|
|
11388
|
-
|
|
11389
|
-
|
|
11390
|
-
|
|
11391
|
-
|
|
11392
|
-
|
|
12209
|
+
"issues": [{ "id": string, "severity": "critical" | "major" | "minor" | "nit", "title": string, "description": string, "file"?: string, "line"?: number, "sectionId"?: string, "confidence"?: "high" | "medium" | "low", "existingDiscussion"?: { "threadId": string, "relation": "same" | "related" } }],
|
|
12210
|
+
"questions": [{ "id": string, "question": string, "importance"?: "high" | "medium" | "low", "whyThisMatters"?: string, "file"?: string, "sectionId"?: string }],
|
|
12211
|
+
"recommendation": string,
|
|
12212
|
+
"nextSteps": string[]
|
|
12213
|
+
}
|
|
12214
|
+
|
|
12215
|
+
Field guidance:
|
|
12216
|
+
- ${SUMMARY_GUIDANCE}
|
|
12217
|
+
- "motivation": WHY this change exists, which is a different question from what it does ("summary" covers that). Name the problem being solved or the goal being served, never the mechanics: "adds a retry loop" is what the code does, "the sync job was failing on transient network errors" is why. Weigh sources in this order: the PR Description and any issue it links; then tests and user-visible behaviour; then commit messages and docs; then the implementation itself. Choose exactly one "status":
|
|
12218
|
+
- "stated": the author's own words in the PR Description say why. "sourceQuote" is REQUIRED: a short passage (one sentence or less, at least three words) copied character for character from the PR Description, which says why. Copy it exactly as written, keeping backticks, punctuation, hyphenation and spelling; never paraphrase, shorten words, join two sentences or correct a typo. Only the PR Description counts as the author stating it: a reason that appears only in commit messages, review comments, or an issue the description merely links to is not "stated" (use "inferred" and cite it as evidence). Never use "stated" when the description only restates what the code does. If the change itself also shows the reason, include it as "evidence".
|
|
12219
|
+
- "inferred": nothing states it, but the change gives real evidence of it. "evidence" is REQUIRED and non-empty: each entry names what points to the motive, such as a test asserting the fixed behaviour, an issue reference, or a commit message, with "file" set to the changed file it is in where there is one. Never infer a motive from names alone (a branch, a function or a file being called "fix-x" is not evidence). Word the summary as a likely reading, not a fact: "This appears to..." rather than "This fixes...".
|
|
12220
|
+
- "unclear": neither the description nor the change establishes why, AND knowing why would change how this change should be reviewed (for example, whether a behaviour change is intended). "authorQuestion" is REQUIRED: one focused question for the author that would settle it. Do not also ask that question, in any wording, in "questions".
|
|
12221
|
+
If the motive is unclear and knowing it would not change the review, omit "motivation" entirely. Do not add it just to fill the field.
|
|
12222
|
+
- "sections": partition the pull request into logical implementation sections (e.g. "Authentication", "Database Migration", "API Changes"), grouped by responsibility/purpose, not by directory or file type. Identify a sensible number of sections — most PRs will naturally have between 2 and 8, but do not invent or split sections simply to reach a target; let the actual structure of the change decide the count. For each section: "oneLineSummary" is a single sentence; "purpose" explains why this part of the change exists; "whyReview" is a short note aimed at the reviewer about what to focus on; "checklist" is a short list of things to verify (review prompts, not findings — never report bugs, code smells, or recommendations inside a section, that's what "issues" is for). Assign "order" starting at 1 in the sequence a reviewer should walk through the sections (narrative order, not file count or alphabetical). "reviewWeight" is a relative time-budget indicator from 1 (skim) to 5 (spend the most time here), independent of "priority" and "reviewAttention". Set "recommendedStartingPoint": true on exactly one section — whichever one a reviewer should actually read first for the rest of the review to make sense. This is not necessarily the section with "order": 1 (e.g. a foundational database migration might naturally come first while a reviewer should still start with the authentication logic it enables). Set it to false on every other section. Every file in the diff must appear in exactly one section — the sections are the only file list the developer sees, so an omitted file is a file they never review. Files with no natural home (lockfiles, dependency bumps, generated output, formatting-only changes) go in a single final catch-all section — title it for what it contains, e.g. "Supporting changes" or "Dependency and config updates" — with "priority": "supporting" and "reviewAttention": "low". Never drop a file just because it is uninteresting; say that it is uninteresting instead.
|
|
12223
|
+
- "files" inside a section: "path" is the file path exactly as it appears in the diff. "focus" is REQUIRED on every file — if a file is worth listing in a section, it is worth explaining why the reviewer has been brought to it. Keep it to one or two short sentences, roughly 20-35 words, never more than two sentences. Make it actionable rather than descriptive: name the role this specific file plays in the section, then say what the reviewer should verify there. For example: "Introduces the new association reader. Verify it preserves the existing lookup behaviour." / "Implements the retry path. Confirm error handling matches the existing gateway implementation." / "Registers the generated behaviour with Active Record. Check the hook fires for subclasses too." Do not restate the section's summary or checklist, do not use bullet points, and do not write filler such as "Review this file carefully."
|
|
12224
|
+
- "file" on an issue or question: prefer anchoring a finding to the changed file responsible for the concern, named exactly as it appears in the diff. If an unchanged repository file is necessary context and no changed-file location accurately represents the finding, it may be referenced as an unchanged related file. Never list an unchanged file in a section's "files" array, and never describe an unchanged file as part of the pull request.
|
|
12225
|
+
- "sectionId" on issues/questions: reference the "id" of the section that item belongs to. Omit it rather than guessing if an item doesn't clearly belong to one section.
|
|
12226
|
+
- "recommendation": a 1-2 sentence merge verdict aimed at the developer (e.g. "Approve" or "Request changes — fix the unhandled rejection before merging"). Do not just restate the risk level, and do not copy Guide section content verbatim into this field.
|
|
12227
|
+
- "nextSteps": concrete actions to take before merging (specific tests to run, files to double-check), not a restatement of the Guide.
|
|
12228
|
+
- "issues": report only issues worth the reviewer's attention — do not invent issues to fill out the array, and do not omit significant issues. Let the count reflect the actual PR (a trivial PR may have zero or one issue; a large or risky PR may have several).
|
|
12229
|
+
- "existingDiscussion" on an issue: set this only when a thread in the PR DISCUSSION CONTEXT block already raises this same concern, and you have independently confirmed from the diff that it still applies. "threadId" must be copied verbatim from that thread's [T_...] marker — never invent an id, never guess, and omit the field entirely if no thread matches. Set "relation" to "same" when the thread already makes this exact point, or "related" when it discusses this area without covering the finding — a reviewer acts differently on "someone said this already" than on "someone was looking near here". Still write the issue normally; this field only records that the point was raised before, so the reviewer is not shown it as a fresh discovery. Do not use it to pad the review with issues you would otherwise have left out.
|
|
12230
|
+
- "confidence" on an issue: how sure you are the issue is real, on the same high/medium/low scale as a question's "importance". This is a separate axis from "severity", which is how much it would matter if real — a critical issue you are unsure about and a nit you are certain of are different things to act on, so do not let one field stand in for the other. Use LOW when you are reasoning from a diff excerpt without the surrounding code, and say what would confirm it in the description. Omit the field rather than guessing a middle value.
|
|
12231
|
+
- "questions": each one is a genuine open item the reviewer needs answered before they can sign off, not a rhetorical prompt. "importance" is how much attention it deserves, on the same high/medium/low scale as a section's "reviewAttention" (e.g. a question about a possibly-exposed secret is "high"; a naming preference is "low"). "whyThisMatters" is 1-2 sentences explaining the concrete consequence of the question going unanswered — not a restatement of the question itself.
|
|
12232
|
+
- Every section's "oneLineSummary"/"purpose"/"whyReview", each file's "focus", each question's "whyThisMatters", and every text field inside "motivation" are rendered as plain prose paragraphs, not markdown — write them as normal sentences. Do not use "- " bullet points or numbered lists inside these fields; if you're enumerating several points, join them into flowing sentences instead. Inline code/identifiers may use backtick spans (e.g. \`ErrorSeverity\`) — those do render.
|
|
12233
|
+
- "summary", "recommendation" and each "nextSteps" entry are rendered as Markdown, so use it where it carries meaning: backtick spans for identifiers, a fenced block for a snippet worth reading as code, a short bullet list where the content genuinely is a list. Prose is still the default and these fields are still short. Do not add headings, and do not turn a paragraph into bullets to look organised.
|
|
12234
|
+
|
|
12235
|
+
This JSON object is the only output the developer sees. The free-text sections above (Overview, Guide, Issues, Questions, Follow-up Prompts) are for your own internal reasoning — do not copy them verbatim into any JSON field.`;
|
|
12236
|
+
function buildStructuredReviewPrompt(pr, options = {}) {
|
|
12237
|
+
const { prompt, discussionOverflow, discussionThreadIds, partiallyReadFiles, partialFiles } = buildChatReviewPrompt(pr, [], {
|
|
12238
|
+
discussionSummary: options.discussionSummary,
|
|
12239
|
+
repositoryAccess: options.repositoryAccess,
|
|
12240
|
+
changeInventory: options.changeInventory,
|
|
12241
|
+
guideCoverage: options.guideCoverage
|
|
12242
|
+
});
|
|
12243
|
+
return {
|
|
12244
|
+
prompt: `${prompt}\n\n---\n\n${STRUCTURED_OUTPUT_INSTRUCTIONS}`,
|
|
12245
|
+
discussionOverflow: discussionOverflow ?? false,
|
|
12246
|
+
discussionThreadIds: discussionThreadIds ?? [],
|
|
12247
|
+
partiallyReadFiles: partiallyReadFiles ?? 0,
|
|
12248
|
+
partialFiles: partialFiles ?? []
|
|
12249
|
+
};
|
|
12250
|
+
}
|
|
12251
|
+
/**
|
|
12252
|
+
* A reply, parsed, with the motivation checked against the description the analysis read.
|
|
12253
|
+
*
|
|
12254
|
+
* The check happens here rather than after, so there is no parsed result anywhere whose motivation
|
|
12255
|
+
* has not been through it. `correction` says what the check refused, for a caller with an audit to
|
|
12256
|
+
* put it in.
|
|
12257
|
+
*/
|
|
12258
|
+
function parseReviewResponse(raw, authorDescription) {
|
|
12259
|
+
const { result, motivation } = parseUnchecked(raw);
|
|
12260
|
+
const applied = applyMotivation(result, motivation, authorDescription);
|
|
12261
|
+
return {
|
|
12262
|
+
result: applied.result,
|
|
12263
|
+
...applied.correction ? { motivationCorrection: applied.correction } : {}
|
|
12264
|
+
};
|
|
12265
|
+
}
|
|
12266
|
+
/** What tells this reply's answer from a JSON example quoted in the prose before it. */
|
|
12267
|
+
var REVIEW_ANSWER = objectWithAnyOf("summary", "recommendation", "issues", "nextSteps");
|
|
12268
|
+
function parseUnchecked(raw) {
|
|
12269
|
+
const jsonText = extractJsonText(raw, REVIEW_ANSWER);
|
|
12270
|
+
let parsed;
|
|
12271
|
+
try {
|
|
12272
|
+
parsed = JSON.parse(jsonText);
|
|
12273
|
+
} catch {
|
|
12274
|
+
throw new Error("Could not parse the AI response as JSON.");
|
|
12275
|
+
}
|
|
12276
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("AI response JSON was not an object.");
|
|
12277
|
+
const obj = parsed;
|
|
12278
|
+
const summary = asString$1(obj.summary);
|
|
12279
|
+
const recommendation = asString$1(obj.recommendation);
|
|
12280
|
+
if (!summary && !recommendation && !Array.isArray(obj.issues) && !Array.isArray(obj.nextSteps)) throw new Error("The AI returned valid JSON, but not in the expected analysis shape (missing summary/recommendation/issues/nextSteps). This usually means the model ignored the requested schema.");
|
|
12281
|
+
return {
|
|
12282
|
+
result: {
|
|
12283
|
+
schemaVersion: 1,
|
|
12284
|
+
summary,
|
|
12285
|
+
overallRisk: RISKS.includes(obj.overallRisk) ? obj.overallRisk : "medium",
|
|
12286
|
+
sections: normalizeSections(obj.sections),
|
|
12287
|
+
issues: normalizeIssues(obj.issues),
|
|
12288
|
+
questions: normalizeQuestions(obj.questions),
|
|
12289
|
+
recommendation,
|
|
12290
|
+
nextSteps: asStringArray(obj.nextSteps)
|
|
12291
|
+
},
|
|
12292
|
+
motivation: normalizeMotivation(obj.motivation)
|
|
12293
|
+
};
|
|
12294
|
+
}
|
|
12295
|
+
var EXPLAIN_SIMPLY_PROMPT = "Explain this review in plain English for someone unfamiliar with this codebase. Avoid jargon, use a simple everyday analogy if it helps, and keep it skippable — a couple of short paragraphs is enough.";
|
|
12296
|
+
//#endregion
|
|
12297
|
+
//#region ../../packages/review-harness/src/domain/kiss/prompt.ts
|
|
12298
|
+
var SCOPE_BOUNDARY = `Scope:
|
|
12299
|
+
Judge the complexity this change introduces or materially expands. That is the whole of what you are reviewing.
|
|
12300
|
+
Pre-existing complexity around the change is context you may use to understand it, and is not a finding. Do not attribute it to this author.
|
|
12301
|
+
The one exception: where this change makes existing complexity worse, or where the work could not have been done without taking it on, that is in scope and you should say which of the two it is.
|
|
12302
|
+
Do not propose refactoring architecture the change merely touches.`;
|
|
12303
|
+
var EVIDENCE_THRESHOLD = `Evidence threshold:
|
|
12304
|
+
Emit a recommendation only when you can do all five of these. If you cannot, say nothing.
|
|
12305
|
+
1. Name the concrete complexity this change introduced. Not "this is complicated" — the actual types, layers, files, flags or machinery.
|
|
12306
|
+
2. Explain why the current requirements and this repository do not call for it.
|
|
12307
|
+
3. Describe a credible, materially simpler implementation. If you cannot say what to do instead, you do not have a recommendation.
|
|
12308
|
+
4. Point at the changed file or files where it was introduced.
|
|
12309
|
+
5. Say what acting on it is worth, and what it would cost.
|
|
12310
|
+
Never emit a recommendation resting on naming preference, personal taste, or a requirement nobody has yet.`;
|
|
12311
|
+
/**
|
|
12312
|
+
* The five checks above are how to reason, not how to write.
|
|
12313
|
+
*
|
|
12314
|
+
* Schema 1 gave each check its own output field, and the result was the reasoning printed five
|
|
12315
|
+
* times over: one argument restated under five headings, with the conclusion fourth. The checks stay
|
|
12316
|
+
* as the gate; this block asks for the conclusion first and keeps the checks as the audit trail.
|
|
12317
|
+
*/
|
|
12318
|
+
var DISTIL = `How to write a recommendation:
|
|
12319
|
+
Work through all five checks first. Then distil what you found. A reviewer should understand the recommendation from "title", "gist", "simplerDirection" and "gains" alone, in a few seconds, without reading anything else.
|
|
12320
|
+
"evidence", "whyNotJustified" and "payoffAndTradeoffs" are the audit trail for somebody who wants to check or challenge you. They carry the detail. They must not restate the gist. "evidence" and "whyNotJustified" are required: a recommendation without a basis somebody can check is withheld, however good its summary.
|
|
12321
|
+
Name each piece of machinery where it matters and do not repeat the same argument in every field. One argument, stated once, then supported.`;
|
|
12322
|
+
/**
|
|
12323
|
+
* How to read the pull request conversation, for a lens that is not attributing anything to it.
|
|
12324
|
+
*
|
|
12325
|
+
* KISS needs the conversation for one reason and it is the load-bearing one: `whyNotJustified`
|
|
12326
|
+
* asks whether the requirements call for the machinery, and a thread saying "please put this
|
|
12327
|
+
* behind a flag" is the difference between a flag nobody asked for and a flag that was required.
|
|
12328
|
+
* Without it the model infers the requirements from the code it is being asked to judge, which is
|
|
12329
|
+
* circular — the change is its own justification.
|
|
12330
|
+
*
|
|
12331
|
+
* So the conversation is framed as evidence about requirements, and explicitly not as something to
|
|
12332
|
+
* cite. The standard review tags threads so a finding can point at one; here a thread is a fact
|
|
12333
|
+
* about what was asked for, and "reviewer X said so" is not a reason to keep complexity.
|
|
12334
|
+
*/
|
|
12335
|
+
var REQUIREMENTS_FROM_DISCUSSION = `How to use the conversation below:
|
|
12336
|
+
It is evidence about what was actually required, and that is the only thing you should take from it.
|
|
12337
|
+
A requirement stated in the conversation is a requirement: complexity the discussion explicitly asked for is justified, and you should say that it was asked for rather than recommending its removal.
|
|
12338
|
+
Silence is not agreement. Nobody objecting to an abstraction is not a requirement for it.
|
|
12339
|
+
Do not attribute recommendations to participants, do not quote or cite threads in your answer, and do not treat somebody's stated preference as a requirement. You are judging the change, not the discussion.`;
|
|
12340
|
+
var RUBRIC = `What to look at:
|
|
12341
|
+
- abstractions with one implementation and no boundary that presently needs one
|
|
12342
|
+
- interfaces, protocols, adapters or indirection created without a concrete need
|
|
12343
|
+
- factories, builders, managers, services, repositories or wrapper layers that do no work
|
|
12344
|
+
- file, class, type or module proliferation out of proportion to the change
|
|
12345
|
+
- generic solutions where the requirements are specific
|
|
12346
|
+
- extensibility and flexibility built for requirements that do not exist
|
|
12347
|
+
- optimisation with no measured constraint behind it
|
|
12348
|
+
- duplicated or redundant state machinery
|
|
12349
|
+
- lifecycle, state-machine, event-bus, observer or orchestration abstractions the flow does not need
|
|
12350
|
+
- defensive code for states that cannot occur at that boundary
|
|
12351
|
+
- the same validation repeated at several layers
|
|
12352
|
+
- configuration, flags, environment variables or options nobody asked for
|
|
12353
|
+
- third-party dependencies that earn less than they cost
|
|
12354
|
+
- pass-through layers adding vocabulary but no policy or isolation
|
|
12355
|
+
- indirection that makes the execution path materially harder to follow
|
|
12356
|
+
- error and retry machinery broader than the failures it faces
|
|
12357
|
+
- a large change where a materially smaller implementation looks feasible
|
|
12358
|
+
- repeated wrappers or conversions that could collapse
|
|
12359
|
+
- abstractions justified only by a hypothetical second implementation
|
|
12360
|
+
|
|
12361
|
+
Fewer lines is evidence, not the objective. A twenty-line explicit implementation can be simpler than a five-line generic one. Judge simplicity by fewer concepts, fewer states, fewer sources of truth, fewer speculative abstractions, fewer indirections and less machinery needed to understand the behaviour.
|
|
12362
|
+
This is not a style review. Naming preferences, "extract this method", small tidy-ups and repetition that costs nobody any understanding are not recommendations.
|
|
12363
|
+
|
|
12364
|
+
This is a list of things to look at, not a list of things to report. Every item here is sometimes exactly right. One implementation does not make an interface wrong: it may be a real external boundary, a testing seam, or a repository convention. More files do not make code worse. Judge what each thing is for and what it costs.`;
|
|
12365
|
+
var WORTH_KEEPING = `Complexity worth keeping:
|
|
12366
|
+
Look actively for complexity that is doing a job, and say so. This is not a politeness section — it is how the reader can tell you understood the change rather than counted its abstractions.
|
|
12367
|
+
Things that usually earn their keep: transaction boundaries protecting atomicity; write-and-rename protocols preventing corruption; security and permission boundaries; concurrency control; retry or idempotency logic against a boundary that has actually proven unreliable; compatibility adapters at a real external seam; validation at a trust boundary; abstractions with more than one real implementation, or that follow an established convention in this repository; anything an explicit requirement mandates.
|
|
12368
|
+
If nothing in this change needs the note, return an empty list. Do not pad it.`;
|
|
12369
|
+
var AUDIENCE_BLOCKS = {
|
|
12370
|
+
author: `Who is reading this:
|
|
12371
|
+
The person who wrote this change, before they ask anyone to review it. They can act on what you say immediately.
|
|
12372
|
+
Be direct and specific. Say what to collapse, remove, inline, defer or replace. "Collapse these three classes into one" and "this abstraction has one implementation, remove it for now" are the register.
|
|
12373
|
+
Give enough of the trade-off that they do not flatten something that was load-bearing, and no more. They did not ask for diplomacy.
|
|
12374
|
+
Do not write draft pull request comments and do not set commentWorthiness. Nobody is being told anything by anyone here.`,
|
|
12375
|
+
reviewer: `Who is reading this:
|
|
12376
|
+
Somebody reviewing another person's change. They are deciding what is worth raising, which is a different question from what is true.
|
|
12377
|
+
Explain why the implementation may be more complex than it needs to be, without asserting your judgement as fact. The author may know something you do not.
|
|
12378
|
+
Set commentWorthiness on every recommendation, and keep it separate from payoff: an observation can be entirely valid and still not be worth spending a review comment on.
|
|
12379
|
+
Where a comment is worth making, draft it. A good draft explains the concern, asks or recommends rather than instructs, and acknowledges the trade-off the author was probably making.
|
|
12380
|
+
Never write "bad code", "obvious", "over-engineered" or anything else that describes the author rather than the code.`
|
|
12381
|
+
};
|
|
12382
|
+
/**
|
|
12383
|
+
* The shape of the answer, written out whole.
|
|
12384
|
+
*
|
|
12385
|
+
* Never abbreviated, and never described in prose instead. A field the model is not shown is a
|
|
12386
|
+
* field it does not reliably produce, and a field the parser reads but the prompt never named is
|
|
12387
|
+
* how a result arrives parsed into nothing.
|
|
12388
|
+
*/
|
|
12389
|
+
var KISS_OUTPUT_INSTRUCTIONS = `Respond with a single JSON object, no markdown around it and no text before or after, matching exactly this shape:
|
|
12390
|
+
{
|
|
12391
|
+
"assessment": string,
|
|
12392
|
+
"summary": string,
|
|
12393
|
+
"recommendations": [{
|
|
12394
|
+
"id": string,
|
|
12395
|
+
"title": string,
|
|
12396
|
+
"payoff": "high_value" | "worth_considering" | "nit",
|
|
12397
|
+
"gist": string,
|
|
12398
|
+
"simplerDirection": string,
|
|
12399
|
+
"gains": [string],
|
|
12400
|
+
"reductionEstimate": string,
|
|
12401
|
+
"caveat": string,
|
|
12402
|
+
"evidence": string,
|
|
12403
|
+
"whyNotJustified": string,
|
|
12404
|
+
"payoffAndTradeoffs": string,
|
|
12405
|
+
"commentWorthiness": "strong" | "consider" | "leave",
|
|
12406
|
+
"draftComment": string,
|
|
12407
|
+
"locations": [{ "path": string, "line": number, "primary": boolean }]
|
|
12408
|
+
}],
|
|
12409
|
+
"justifiedComplexity": [{
|
|
12410
|
+
"id": string,
|
|
12411
|
+
"title": string,
|
|
12412
|
+
"reason": string,
|
|
12413
|
+
"rationale": string,
|
|
12414
|
+
"locations": [{ "path": string, "line": number, "primary": boolean }]
|
|
11393
12415
|
}],
|
|
11394
12416
|
"aggregateEstimate": string
|
|
11395
12417
|
}
|
|
@@ -11500,6 +12522,7 @@ ${diff}`;
|
|
|
11500
12522
|
prompt,
|
|
11501
12523
|
budget: buildPromptBudget(sections),
|
|
11502
12524
|
discussionOverflow: discussion.overflow,
|
|
12525
|
+
discussionThreadIds: discussion.includedThreadIds,
|
|
11503
12526
|
partiallyReadFiles: budgeted.partialFiles.length,
|
|
11504
12527
|
partialFiles: budgeted.partialFiles
|
|
11505
12528
|
};
|
|
@@ -11553,143 +12576,655 @@ function selectInlineDiffs(files, budget = PLUGIN_DIFF_BUDGET_CHARS) {
|
|
|
11553
12576
|
const inventoryOnly = [];
|
|
11554
12577
|
files.forEach((file, index) => (chosen.has(index) ? inline : inventoryOnly).push(file));
|
|
11555
12578
|
return {
|
|
11556
|
-
inline,
|
|
11557
|
-
inventoryOnly,
|
|
11558
|
-
usedChars: used,
|
|
11559
|
-
budgetChars: budget
|
|
12579
|
+
inline,
|
|
12580
|
+
inventoryOnly,
|
|
12581
|
+
usedChars: used,
|
|
12582
|
+
budgetChars: budget
|
|
12583
|
+
};
|
|
12584
|
+
}
|
|
12585
|
+
/**
|
|
12586
|
+
* Every file the change touches, whether or not its diff came with it.
|
|
12587
|
+
*
|
|
12588
|
+
* Deliberately never truncated, however many files there are. The inventory is the one thing that
|
|
12589
|
+
* makes the budget above a context decision rather than a coverage ceiling: the moment it starts
|
|
12590
|
+
* eliding files, the review is back to describing a change it has not been told the shape of.
|
|
12591
|
+
*/
|
|
12592
|
+
function renderChangeInventory(files, inlinePaths) {
|
|
12593
|
+
return `CHANGE INVENTORY
|
|
12594
|
+
Every file this change touches, complete. The diffs further down cover only the files marked
|
|
12595
|
+
"diff inline below"; the rest are not hidden from you and were not judged unimportant — they
|
|
12596
|
+
simply did not fit the context budget, and you have the working tree.
|
|
12597
|
+
|
|
12598
|
+
${files.map((file) => {
|
|
12599
|
+
const churn = `+${file.additions}/-${file.deletions}`;
|
|
12600
|
+
const rename = file.previousName ? ` (was ${file.previousName})` : "";
|
|
12601
|
+
const availability = inlinePaths.has(file.name) ? "diff inline below" : "diff not inline — open it in the working tree if this review needs it";
|
|
12602
|
+
return `- ${file.name}${rename} · ${file.status} · ${churn} · ${availability}`;
|
|
12603
|
+
}).join("\n")}`;
|
|
12604
|
+
}
|
|
12605
|
+
//#endregion
|
|
12606
|
+
//#region ../../packages/review-harness/src/guide/review_context.ts
|
|
12607
|
+
/**
|
|
12608
|
+
* Turn a local git change into everything Claude Code needs to write a ReviewGuide.
|
|
12609
|
+
*
|
|
12610
|
+
* Three concerns kept apart, because the extension had to fuse them and this client does not:
|
|
12611
|
+
*
|
|
12612
|
+
* inventory every changed file is named, always — the harness's job, and never truncated
|
|
12613
|
+
* priority where to start is the guide's call, made after reading; no ranking happens here
|
|
12614
|
+
* budget `PLUGIN_DIFF_BUDGET_CHARS` decides whose patch rides along, and nothing more
|
|
12615
|
+
*
|
|
12616
|
+
* A provider API given a diff cannot fetch what it was not sent, so for the extension a budget
|
|
12617
|
+
* *is* a coverage ceiling and the two collapse into one number. Here they do not, and treating
|
|
12618
|
+
* them as one is what produced "3 changed files were not analysed at all" for a client that
|
|
12619
|
+
* could have opened all three.
|
|
12620
|
+
*
|
|
12621
|
+
* Note what this still does not do: there is no planning call, and no ranking of any kind.
|
|
12622
|
+
* `processPRData` is handed exactly the files the budget already settled, so its own cap and its
|
|
12623
|
+
* largest-diff-first fallback can never bind — a second selection here would be the harness
|
|
12624
|
+
* deciding what matters, which is the one thing it must never do.
|
|
12625
|
+
*/
|
|
12626
|
+
async function buildReviewContext(options) {
|
|
12627
|
+
const changeSet = await buildChangeSet(options);
|
|
12628
|
+
const budget = options.diffBudgetChars ?? 3e5;
|
|
12629
|
+
const lens = options.lens ?? { type: "standard" };
|
|
12630
|
+
return {
|
|
12631
|
+
contextId: randomUUID(),
|
|
12632
|
+
...await assemble(changeSet, budget, lens)
|
|
12633
|
+
};
|
|
12634
|
+
}
|
|
12635
|
+
/**
|
|
12636
|
+
* The same context with the pull request's conversation folded in.
|
|
12637
|
+
*
|
|
12638
|
+
* A new context rather than a mutation of the old one, and rebuilt through the same assembly:
|
|
12639
|
+
* the model can only cite a thread whose `[T_...]` marker is in the prompt it was given, so the
|
|
12640
|
+
* prompt has to be produced again once the threads exist. Doing that through `assemble` rather
|
|
12641
|
+
* than by appending a discussion block is what keeps one prompt builder — a second, hand-written
|
|
12642
|
+
* copy is how two clients start disagreeing about what a review prompt contains.
|
|
12643
|
+
*
|
|
12644
|
+
* The change set is left exactly as git reported it. The pull request is a fact about a forge,
|
|
12645
|
+
* kept beside the change rather than written into it, so nothing downstream can mistake one for
|
|
12646
|
+
* the other.
|
|
12647
|
+
*/
|
|
12648
|
+
async function withPrContext(context, prContext) {
|
|
12649
|
+
return {
|
|
12650
|
+
...context,
|
|
12651
|
+
...await assemble(context.changeSet, context.diffBudgetChars, context.lens, prContext),
|
|
12652
|
+
contextId: context.contextId,
|
|
12653
|
+
prContext
|
|
12654
|
+
};
|
|
12655
|
+
}
|
|
12656
|
+
async function assemble(changeSet, budget, lens, prContext) {
|
|
12657
|
+
const allFiles = changeSet.data.files;
|
|
12658
|
+
const selection = selectInlineDiffs(allFiles, budget);
|
|
12659
|
+
const inlinePaths = new Set(selection.inline.map((file) => file.name));
|
|
12660
|
+
const processed = await processPRData({
|
|
12661
|
+
...changeSet.data,
|
|
12662
|
+
files: selection.inline,
|
|
12663
|
+
changedFileCount: allFiles.length,
|
|
12664
|
+
...prContext ? { pullRequest: pullRequestFor(changeSet.data.pullRequest, prContext) } : {},
|
|
12665
|
+
discussion: prContext?.discussion
|
|
12666
|
+
}, selection.inline.length, null);
|
|
12667
|
+
const shared = {
|
|
12668
|
+
repositoryAccess: "repository",
|
|
12669
|
+
changeInventory: renderChangeInventory(allFiles, inlinePaths)
|
|
12670
|
+
};
|
|
12671
|
+
const { prompt, discussionOverflow, discussionThreadIds } = lens.type === "kiss" ? buildKissPrompt(processed, {
|
|
12672
|
+
...shared,
|
|
12673
|
+
audience: lens.audience
|
|
12674
|
+
}) : buildStructuredReviewPrompt(processed, {
|
|
12675
|
+
...shared,
|
|
12676
|
+
guideCoverage: "review-path"
|
|
12677
|
+
});
|
|
12678
|
+
return {
|
|
12679
|
+
lens,
|
|
12680
|
+
instructions: prompt,
|
|
12681
|
+
diff: buildDiffText(selection.inline),
|
|
12682
|
+
changeSet,
|
|
12683
|
+
processed,
|
|
12684
|
+
fileCoverage: {
|
|
12685
|
+
...processed.fileCoverage,
|
|
12686
|
+
repositoryAvailable: true
|
|
12687
|
+
},
|
|
12688
|
+
inventoryOnlyFiles: selection.inventoryOnly.map((file) => file.name),
|
|
12689
|
+
diffBudgetChars: budget,
|
|
12690
|
+
discussionOverflow: discussionOverflow ?? false,
|
|
12691
|
+
...prContext ? { deliveredDiscussion: withoutOmittedThreads(prContext.discussion, processed.discussion.all.map((thread) => thread.id), discussionThreadIds ?? []) } : {}
|
|
12692
|
+
};
|
|
12693
|
+
}
|
|
12694
|
+
/**
|
|
12695
|
+
* The change's pull request, as the prompt should present it once a forge request is attached.
|
|
12696
|
+
*
|
|
12697
|
+
* With no description, the commit subjects stay: they are still the best account of the change
|
|
12698
|
+
* there is, and still labelled as commits rather than as the author's words.
|
|
12699
|
+
*/
|
|
12700
|
+
function pullRequestFor(local, prContext) {
|
|
12701
|
+
const body = prContext.pullRequest.body;
|
|
12702
|
+
if (!body) return local;
|
|
12703
|
+
const { bodySource: _commits, ...rest } = local;
|
|
12704
|
+
return {
|
|
12705
|
+
...rest,
|
|
12706
|
+
body
|
|
12707
|
+
};
|
|
12708
|
+
}
|
|
12709
|
+
/**
|
|
12710
|
+
* The inline patches as one blob, for grounding the assistant.
|
|
12711
|
+
*
|
|
12712
|
+
* No ceiling of its own. `selectInlineDiffs` already bounded this, and a second constant guarding
|
|
12713
|
+
* the same quantity is a number nobody can explain the day it binds.
|
|
12714
|
+
*/
|
|
12715
|
+
function buildDiffText(files) {
|
|
12716
|
+
return files.map((file) => `--- ${file.name}\n${file.patch}`).join("\n\n");
|
|
12717
|
+
}
|
|
12718
|
+
//#endregion
|
|
12719
|
+
//#region ../../packages/review-harness/src/workspace/agent_env.ts
|
|
12720
|
+
/**
|
|
12721
|
+
* The environment a review's later runs are pinned to: the job's, not the daemon's.
|
|
12722
|
+
*
|
|
12723
|
+
* A job records what it must run under at the moment it is created, and the analysis reads it back
|
|
12724
|
+
* from there. Every run that happens *after* the analysis read `captureEnv()` instead, which
|
|
12725
|
+
* answers a question about the daemon rather than about the review, and the daemon is the process
|
|
12726
|
+
* that happened to be started first by whichever shell got there first. Two things went wrong with
|
|
12727
|
+
* that. `CLAUDE_CONFIG_DIR` decides which account answers, so a review made under one and revisited
|
|
12728
|
+
* under another is served by a Claude that may not have access to the same models or settings. And
|
|
12729
|
+
* `agentId` decides which adapter runs at all, so a review made by one agent had its questions
|
|
12730
|
+
* answered, and its findings reconciled, by whichever is default today, silently, with the result
|
|
12731
|
+
* still shown as the review's.
|
|
12732
|
+
*
|
|
12733
|
+
* Three callers, and they are exactly the three things a reviewer can ask of a review after it is
|
|
12734
|
+
* written: a question in the Ask panel, bringing the analysis up to date, and re-reading the
|
|
12735
|
+
* conversation. All three put the answer on the page under the review's own name, so all three owe
|
|
12736
|
+
* the reader the review's own agent.
|
|
12737
|
+
*
|
|
12738
|
+
* Falling back to capturing now covers a workspace with no job and one whose job has aged out of
|
|
12739
|
+
* retention, and that fallback is not the rare path: jobs are finished business within hours and
|
|
12740
|
+
* reviews are kept for thirty days, so most questions asked of an older review find nothing here.
|
|
12741
|
+
*
|
|
12742
|
+
* Which is why it captures for *the review's own agent* rather than the default one. Capturing for
|
|
12743
|
+
* the default reintroduced the exact bug this module exists to fix, one retention period later and
|
|
12744
|
+
* far more quietly: a review made by Codex, asked a question a day after its job was swept, would
|
|
12745
|
+
* have been answered by Claude with the answer shown as the review's. The environment has to be
|
|
12746
|
+
* captured now, because it genuinely is all there is left to read. Who to capture it for is a fact
|
|
12747
|
+
* the review still holds.
|
|
12748
|
+
*/
|
|
12749
|
+
function agentEnvOf(workspace) {
|
|
12750
|
+
const job = workspace.jobId ? loadJob(workspace.jobId) : null;
|
|
12751
|
+
if (job?.env) return job.env;
|
|
12752
|
+
const id = agentIdOf(workspace.session);
|
|
12753
|
+
return (AGENT_IDS.includes(id) ? agentById(id) : defaultAgent()).captureEnv();
|
|
12754
|
+
}
|
|
12755
|
+
//#endregion
|
|
12756
|
+
//#region ../../packages/review-harness/src/domain/discussion/discussion_attribution.ts
|
|
12757
|
+
/**
|
|
12758
|
+
* Every surviving attribution has its author, status and link taken from the real thread — the
|
|
12759
|
+
* model's only contribution is which thread it points at. Anything it echoed back about who said
|
|
12760
|
+
* what is discarded, so a correct id can't smuggle a wrong attribution alongside it.
|
|
12761
|
+
*/
|
|
12762
|
+
function attributeIssuesToDiscussion(result, pd) {
|
|
12763
|
+
let attributed = 0;
|
|
12764
|
+
let rejected = 0;
|
|
12765
|
+
const issues = result.issues.map((issue) => {
|
|
12766
|
+
if (!issue.existingDiscussion) return issue;
|
|
12767
|
+
const thread = pd.byId[issue.existingDiscussion.threadId];
|
|
12768
|
+
if (!thread) {
|
|
12769
|
+
rejected++;
|
|
12770
|
+
const { existingDiscussion: _dropped, ...rest } = issue;
|
|
12771
|
+
return rest;
|
|
12772
|
+
}
|
|
12773
|
+
attributed++;
|
|
12774
|
+
return {
|
|
12775
|
+
...issue,
|
|
12776
|
+
existingDiscussion: {
|
|
12777
|
+
threadId: thread.id,
|
|
12778
|
+
raisedBy: thread.comments[0]?.author ?? "unknown",
|
|
12779
|
+
status: thread.resolved ? "resolved" : "unresolved",
|
|
12780
|
+
url: thread.comments[0]?.url || void 0,
|
|
12781
|
+
relation: issue.existingDiscussion.relation === "related" ? "related" : "same"
|
|
12782
|
+
}
|
|
12783
|
+
};
|
|
12784
|
+
});
|
|
12785
|
+
return {
|
|
12786
|
+
result: {
|
|
12787
|
+
...result,
|
|
12788
|
+
issues
|
|
12789
|
+
},
|
|
12790
|
+
attributed,
|
|
12791
|
+
rejected
|
|
12792
|
+
};
|
|
12793
|
+
}
|
|
12794
|
+
//#endregion
|
|
12795
|
+
//#region ../../packages/review-harness/src/workspace/discussion_diff.ts
|
|
12796
|
+
/**
|
|
12797
|
+
* What moved in a pull request conversation between two imports.
|
|
12798
|
+
*
|
|
12799
|
+
* Pure, and deliberately only counting. The workspace's job here is to say what changed, not what
|
|
12800
|
+
* it means — "two threads were resolved since you last looked" is a fact; whether that settles the
|
|
12801
|
+
* findings attached to them is a judgement, and judgement belongs to the analyser.
|
|
12802
|
+
*
|
|
12803
|
+
* A projection of `diffDiscussion`, so the sentence a re-read shows and the delta an update reasons
|
|
12804
|
+
* from are one computation and cannot disagree about what moved. That brings its rules with it:
|
|
12805
|
+
* bots are not counted, and a thread or comment is called gone only where both imports prove they
|
|
12806
|
+
* held the whole of it.
|
|
12807
|
+
*/
|
|
12808
|
+
function diffDiscussions(before, after) {
|
|
12809
|
+
return countsOf(diffDiscussion(before, after));
|
|
12810
|
+
}
|
|
12811
|
+
function countsOf(delta) {
|
|
12812
|
+
return {
|
|
12813
|
+
newThreads: new Set(delta.added.filter((entry) => entry.newThread).map((entry) => entry.thread.id)).size,
|
|
12814
|
+
vanishedThreads: delta.vanishedThreads.length,
|
|
12815
|
+
resolvedSince: delta.resolved.length,
|
|
12816
|
+
reopenedSince: delta.reopened.length,
|
|
12817
|
+
newComments: delta.added.length,
|
|
12818
|
+
editedComments: delta.edited.length,
|
|
12819
|
+
removedComments: delta.removed.length,
|
|
12820
|
+
newReviews: delta.newReviews.length
|
|
12821
|
+
};
|
|
12822
|
+
}
|
|
12823
|
+
/** Nothing moved. Worth its own answer: "checked, and nothing changed" is a useful thing to hear. */
|
|
12824
|
+
function isUnchanged(changes) {
|
|
12825
|
+
return changes.newThreads === 0 && changes.vanishedThreads === 0 && changes.resolvedSince === 0 && changes.reopenedSince === 0 && changes.newComments === 0 && !changes.editedComments && !changes.removedComments && !changes.newReviews;
|
|
12826
|
+
}
|
|
12827
|
+
/**
|
|
12828
|
+
* The change, in a sentence, newest-mattering first.
|
|
12829
|
+
*
|
|
12830
|
+
* Resolutions lead because they are the ones that make a standing claim in this review false —
|
|
12831
|
+
* an "already raised … still unresolved" line that is no longer true is the reason this whole
|
|
12832
|
+
* re-check exists.
|
|
12833
|
+
*/
|
|
12834
|
+
function describeChanges(changes) {
|
|
12835
|
+
if (isUnchanged(changes)) return "Nothing has changed in the conversation since it was imported.";
|
|
12836
|
+
const parts = [];
|
|
12837
|
+
const plural = (count, one, many) => `${count} ${count === 1 ? one : many}`;
|
|
12838
|
+
if (changes.resolvedSince > 0) parts.push(`${plural(changes.resolvedSince, "thread", "threads")} resolved since`);
|
|
12839
|
+
if (changes.reopenedSince > 0) parts.push(`${plural(changes.reopenedSince, "thread", "threads")} reopened`);
|
|
12840
|
+
if (changes.newThreads > 0) parts.push(`${plural(changes.newThreads, "new thread", "new threads")}`);
|
|
12841
|
+
if (changes.newComments > 0) parts.push(`${plural(changes.newComments, "new comment", "new comments")}`);
|
|
12842
|
+
if (changes.newReviews) parts.push(`${plural(changes.newReviews, "new review", "new reviews")}`);
|
|
12843
|
+
if (changes.editedComments) parts.push(`${plural(changes.editedComments, "comment", "comments")} edited`);
|
|
12844
|
+
if (changes.removedComments) parts.push(`${plural(changes.removedComments, "comment", "comments")} deleted`);
|
|
12845
|
+
if (changes.vanishedThreads > 0) parts.push(`${plural(changes.vanishedThreads, "thread", "threads")} no longer present`);
|
|
12846
|
+
return `${parts.join(", ")}.`;
|
|
12847
|
+
}
|
|
12848
|
+
//#endregion
|
|
12849
|
+
//#region ../../packages/review-harness/src/workspace/pr_refresh.ts
|
|
12850
|
+
/**
|
|
12851
|
+
* Re-read the pull request conversation, without re-reviewing anything.
|
|
12852
|
+
*
|
|
12853
|
+
* The problem this solves is narrow and specific. Every "Already raised by @sarah · thread still
|
|
12854
|
+
* unresolved" line in a review is a fact that was true at import and is frozen. Nothing re-checks
|
|
12855
|
+
* it, so an hour after Sarah resolves that thread the workspace is still telling the reviewer she
|
|
12856
|
+
* is waiting on it: a confident, specific, wrong claim about what a named colleague thinks.
|
|
12857
|
+
*
|
|
12858
|
+
* **This is not a re-review, and the distinction is the whole design.** Re-reading the conversation
|
|
12859
|
+
* can only update the state of findings that already exist: who raised the thread, whether it is
|
|
12860
|
+
* open, where it links to. It cannot produce a new finding, and it must not try. Deciding that a
|
|
12861
|
+
* new comment invalidates a finding, or that a new commit introduced a bug, is judgement, and
|
|
12862
|
+
* judgement belongs to the analyser. `attributeIssuesToDiscussion` is what runs here, and it is
|
|
12863
|
+
* arithmetic: the model's only surviving contribution is which thread each finding points at.
|
|
12864
|
+
*
|
|
12865
|
+
* This used to spawn an agent in the reviewer's own checkout and ask it to fetch the conversation.
|
|
12866
|
+
* It now asks the forge adapter, which is a couple of subprocess calls and answers in seconds
|
|
12867
|
+
* rather than minutes. The daemon still reaches no forge itself and still holds no credential: the
|
|
12868
|
+
* adapter drives the CLI the developer has already signed in to, exactly as the review drives the
|
|
12869
|
+
* agent they have already signed in to.
|
|
12870
|
+
*/
|
|
12871
|
+
/**
|
|
12872
|
+
* Computes; does not persist. The route saves, so a test can run the whole thing without writing
|
|
12873
|
+
* into the reviewer's own store.
|
|
12874
|
+
*/
|
|
12875
|
+
async function refreshPrContext(workspace, fetch = refetchConversation) {
|
|
12876
|
+
const startedAt = Date.now();
|
|
12877
|
+
if (!workspace.prContext) return {
|
|
12878
|
+
at: Date.now(),
|
|
12879
|
+
startedAt,
|
|
12880
|
+
ok: false,
|
|
12881
|
+
message: "This review has no pull request conversation attached, so there is nothing to re-read."
|
|
12882
|
+
};
|
|
12883
|
+
const pr = workspace.prContext.pullRequest;
|
|
12884
|
+
const outcome = await fetch(workspace.repoPath, {
|
|
12885
|
+
url: pr.url,
|
|
12886
|
+
number: pr.number
|
|
12887
|
+
}, agentEnvOf(workspace));
|
|
12888
|
+
if (outcome.kind !== "attached") return {
|
|
12889
|
+
at: Date.now(),
|
|
12890
|
+
startedAt,
|
|
12891
|
+
ok: false,
|
|
12892
|
+
message: `${whyNot(outcome)} Nothing here has changed, and the discussion shown is still the one that came with the review.`,
|
|
12893
|
+
reason: outcome.kind
|
|
12894
|
+
};
|
|
12895
|
+
const fetched = outcome.context;
|
|
12896
|
+
const before = workspace.prContext.discussion;
|
|
12897
|
+
const changes = diffDiscussions(before, fetched.discussion);
|
|
12898
|
+
const restated = reattribute(workspace, fetched.discussion);
|
|
12899
|
+
workspace.prContext = {
|
|
12900
|
+
pullRequest: {
|
|
12901
|
+
...workspace.prContext.pullRequest,
|
|
12902
|
+
...movable(fetched.pullRequest)
|
|
12903
|
+
},
|
|
12904
|
+
discussion: fetched.discussion
|
|
12905
|
+
};
|
|
12906
|
+
workspace.lastPrRefresh = {
|
|
12907
|
+
at: Date.now(),
|
|
12908
|
+
startedAt,
|
|
12909
|
+
ok: true,
|
|
12910
|
+
message: describeChanges(changes),
|
|
12911
|
+
changes,
|
|
12912
|
+
...restated
|
|
12913
|
+
};
|
|
12914
|
+
return workspace.lastPrRefresh;
|
|
12915
|
+
}
|
|
12916
|
+
/**
|
|
12917
|
+
* Why the re-check did not happen, in the tense a person clicking a button is standing in.
|
|
12918
|
+
*
|
|
12919
|
+
* The same closed union the review's own disclosure renders, and deliberately not the same
|
|
12920
|
+
* sentences. Those are written about a review that has already been made; these are written about
|
|
12921
|
+
* a button that has just been pressed and did nothing.
|
|
12922
|
+
*/
|
|
12923
|
+
function whyNot(outcome) {
|
|
12924
|
+
switch (outcome.kind) {
|
|
12925
|
+
case "no-request": return "The pull request this review is attached to could not be found.";
|
|
12926
|
+
case "unsupported-forge": return outcome.host ? `PR Review Buddy does not read pull request conversation on ${outcome.host} yet.` : "This repository has no remote to look the pull request up on.";
|
|
12927
|
+
case "cli-missing": return `\`${outcome.cli}\` is not installed here, so the pull request could not be re-read.`;
|
|
12928
|
+
case "not-authenticated": return `\`${outcome.cli}\` is not signed in, so the pull request could not be re-read. Run \`${outcome.cli} auth login\` and try again.`;
|
|
12929
|
+
case "failed": return `The pull request could not be re-read. ${outcome.detail}`;
|
|
12930
|
+
}
|
|
12931
|
+
}
|
|
12932
|
+
/**
|
|
12933
|
+
* Which fields a re-read may update, and why these.
|
|
12934
|
+
*
|
|
12935
|
+
* The head SHA and title move on their own as the request gains commits, and the two link shapes
|
|
12936
|
+
* are how this product gets from a file here to the same file there. Number, url and provider are
|
|
12937
|
+
* identity and stay as attached: a re-read does not move a review onto a different pull request.
|
|
12938
|
+
*/
|
|
12939
|
+
function movable(fetched) {
|
|
12940
|
+
const updates = {};
|
|
12941
|
+
const copy = (field) => {
|
|
12942
|
+
const value = fetched[field];
|
|
12943
|
+
if (value !== void 0 && value !== "" && value !== null) updates[field] = value;
|
|
12944
|
+
};
|
|
12945
|
+
copy("headSha");
|
|
12946
|
+
copy("title");
|
|
12947
|
+
if (typeof fetched.body === "string") updates.body = fetched.body;
|
|
12948
|
+
copy("filesUrl");
|
|
12949
|
+
copy("fileUrlTemplate");
|
|
12950
|
+
copy("author");
|
|
12951
|
+
return updates;
|
|
12952
|
+
}
|
|
12953
|
+
/**
|
|
12954
|
+
* Re-derive every attribution from the threads as they now are.
|
|
12955
|
+
*
|
|
12956
|
+
* The same function the review's own attribution pass used, run again against fresh threads. It
|
|
12957
|
+
* rebuilds author, status and link from the real thread and drops any claim whose thread is no
|
|
12958
|
+
* longer there, so nothing the model once said can outlive the conversation that justified it.
|
|
12959
|
+
*/
|
|
12960
|
+
function reattribute(workspace, discussion) {
|
|
12961
|
+
const result = workspace.session.result;
|
|
12962
|
+
if (!result) return {
|
|
12963
|
+
attributionsWithdrawn: 0,
|
|
12964
|
+
attributionsRestated: 0
|
|
12965
|
+
};
|
|
12966
|
+
const before = new Map(result.issues.map((issue) => [issue.id, issue.existingDiscussion?.status]));
|
|
12967
|
+
const outcome = attributeIssuesToDiscussion(result, processDiscussion(discussion));
|
|
12968
|
+
workspace.session = {
|
|
12969
|
+
...workspace.session,
|
|
12970
|
+
result: outcome.result
|
|
12971
|
+
};
|
|
12972
|
+
const restated = outcome.result.issues.filter((issue) => issue.existingDiscussion && before.get(issue.id) !== issue.existingDiscussion.status).length;
|
|
12973
|
+
return {
|
|
12974
|
+
attributionsWithdrawn: outcome.rejected,
|
|
12975
|
+
attributionsRestated: restated
|
|
11560
12976
|
};
|
|
11561
12977
|
}
|
|
12978
|
+
//#endregion
|
|
12979
|
+
//#region ../../packages/review-harness/src/workspace/conversation_context.ts
|
|
11562
12980
|
/**
|
|
11563
|
-
*
|
|
12981
|
+
* The pull request conversation, prepared by the harness for a model to reason over.
|
|
11564
12982
|
*
|
|
11565
|
-
*
|
|
11566
|
-
*
|
|
11567
|
-
*
|
|
12983
|
+
* Every model-facing path that can reason about a pull request starts here, so each of them gets
|
|
12984
|
+
* the same three facts the same way: whether there is a conversation, how fresh it is, and what in
|
|
12985
|
+
* it has changed since the analysis being built on. The harness answers those. What any of it
|
|
12986
|
+
* means for the review is the model's question, and nothing here tries to answer it.
|
|
12987
|
+
*
|
|
12988
|
+
* The fetch stays where it was, in the forge adapter, which returns a whole snapshot. "What
|
|
12989
|
+
* changed" is computed here between two snapshots, so no forge has to support asking for changes.
|
|
11568
12990
|
*/
|
|
11569
|
-
function renderChangeInventory(files, inlinePaths) {
|
|
11570
|
-
return `CHANGE INVENTORY
|
|
11571
|
-
Every file this change touches, complete. The diffs further down cover only the files marked
|
|
11572
|
-
"diff inline below"; the rest are not hidden from you and were not judged unimportant — they
|
|
11573
|
-
simply did not fit the context budget, and you have the working tree.
|
|
11574
|
-
|
|
11575
|
-
${files.map((file) => {
|
|
11576
|
-
const churn = `+${file.additions}/-${file.deletions}`;
|
|
11577
|
-
const rename = file.previousName ? ` (was ${file.previousName})` : "";
|
|
11578
|
-
const availability = inlinePaths.has(file.name) ? "diff inline below" : "diff not inline — open it in the working tree if this review needs it";
|
|
11579
|
-
return `- ${file.name}${rename} · ${file.status} · ${churn} · ${availability}`;
|
|
11580
|
-
}).join("\n")}`;
|
|
11581
|
-
}
|
|
11582
|
-
//#endregion
|
|
11583
|
-
//#region ../../packages/review-harness/src/guide/review_context.ts
|
|
11584
12991
|
/**
|
|
11585
|
-
*
|
|
11586
|
-
*
|
|
11587
|
-
* Three concerns kept apart, because the extension had to fuse them and this client does not:
|
|
12992
|
+
* The conversation the current analysis took into account, or why that cannot be known.
|
|
11588
12993
|
*
|
|
11589
|
-
*
|
|
11590
|
-
*
|
|
11591
|
-
*
|
|
12994
|
+
* Recorded since this was introduced. For a review made before then, the stored conversation is
|
|
12995
|
+
* still exactly what the analysis was given provided nothing has re-read it since the analysis
|
|
12996
|
+
* was made, so it is used rather than discarded. Once a re-read has replaced it, what the analysis
|
|
12997
|
+
* saw is gone, and the answer is `unknown`: offered as possibly new rather than claimed as new.
|
|
11592
12998
|
*
|
|
11593
|
-
* A
|
|
11594
|
-
*
|
|
11595
|
-
|
|
11596
|
-
|
|
12999
|
+
* A record naming a different session describes an analysis this review no longer holds, and is
|
|
13000
|
+
* unknown for the same reason. It is never taken as this one's.
|
|
13001
|
+
*/
|
|
13002
|
+
function analysedBaselineOf(workspace) {
|
|
13003
|
+
const recorded = workspace.analysedConversation;
|
|
13004
|
+
if (recorded) return recorded.sessionId === workspace.session.id ? recorded.discussion : "unknown";
|
|
13005
|
+
if (!workspace.prContext) return null;
|
|
13006
|
+
const generatedAt = workspace.session.metadata?.generatedAt;
|
|
13007
|
+
const reread = workspace.lastPrRefresh;
|
|
13008
|
+
if (typeof generatedAt !== "number") return "unknown";
|
|
13009
|
+
if (reread && reread.ok && reread.at >= generatedAt) return "unknown";
|
|
13010
|
+
return workspace.prContext.discussion;
|
|
13011
|
+
}
|
|
13012
|
+
/**
|
|
13013
|
+
* Record a legacy review's baseline before anything re-reads the conversation over it.
|
|
11597
13014
|
*
|
|
11598
|
-
*
|
|
11599
|
-
*
|
|
11600
|
-
*
|
|
11601
|
-
*
|
|
13015
|
+
* The derivation above holds only until the first re-read, which replaces the copy it reads. So
|
|
13016
|
+
* every path that re-reads calls this first, and the save that follows writes it with the re-read
|
|
13017
|
+
* (`saveRefresh`, or the update's own write). After that the review has a recorded baseline like any
|
|
13018
|
+
* other, and nothing is lost by the re-read. A no-op on a review that already has one, or whose
|
|
13019
|
+
* baseline is already unknowable.
|
|
11602
13020
|
*/
|
|
11603
|
-
|
|
11604
|
-
|
|
11605
|
-
const
|
|
11606
|
-
|
|
11607
|
-
|
|
11608
|
-
|
|
11609
|
-
|
|
13021
|
+
function adoptLegacyBaseline(workspace) {
|
|
13022
|
+
if (workspace.analysedConversation) return;
|
|
13023
|
+
const baseline = analysedBaselineOf(workspace);
|
|
13024
|
+
if (baseline === "unknown" || !workspace.prContext) return;
|
|
13025
|
+
workspace.analysedConversation = {
|
|
13026
|
+
sessionId: workspace.session.id,
|
|
13027
|
+
discussion: baseline
|
|
11610
13028
|
};
|
|
11611
13029
|
}
|
|
13030
|
+
/** What a failed re-read can say about itself in a prompt. `no-request` there means the request vanished. */
|
|
13031
|
+
function rereadReasonOf(outcome) {
|
|
13032
|
+
const reason = outcome.reason;
|
|
13033
|
+
return reason === "unsupported-forge" || reason === "cli-missing" || reason === "not-authenticated" ? reason : "failed";
|
|
13034
|
+
}
|
|
11612
13035
|
/**
|
|
11613
|
-
* The
|
|
13036
|
+
* The conversation for one model run.
|
|
11614
13037
|
*
|
|
11615
|
-
*
|
|
11616
|
-
*
|
|
11617
|
-
*
|
|
11618
|
-
*
|
|
11619
|
-
* copy is how two clients start disagreeing about what a review prompt contains.
|
|
13038
|
+
* With `reread`, the conversation is fetched again first, through the same re-read the Re-check
|
|
13039
|
+
* button uses, so a question about "the new comments" is answered from the request as it is now.
|
|
13040
|
+
* A re-read that fails leaves the stored copy in place and is stated as stale rather than hidden.
|
|
13041
|
+
* The re-read changes `workspace` in memory only; `refresh` is returned for the caller to save.
|
|
11620
13042
|
*
|
|
11621
|
-
*
|
|
11622
|
-
*
|
|
11623
|
-
* the other.
|
|
13043
|
+
* `rereadOutcome` is for a caller that has already re-read for reasons of its own (an update does,
|
|
13044
|
+
* before it decides anything): its outcome is stated rather than a second fetch made.
|
|
11624
13045
|
*/
|
|
11625
|
-
async function
|
|
13046
|
+
async function prepareConversation(workspace, options) {
|
|
13047
|
+
if (!workspace.prContext) return { prepared: { state: { kind: "not-attached" } } };
|
|
13048
|
+
let refresh = options.rereadOutcome;
|
|
13049
|
+
let reread = refresh ? refresh.ok ? "fresh" : "failed" : "skipped";
|
|
13050
|
+
if (options.reread && !refresh) {
|
|
13051
|
+
adoptLegacyBaseline(workspace);
|
|
13052
|
+
const startedAt = Date.now();
|
|
13053
|
+
try {
|
|
13054
|
+
refresh = options.fetch ? await refreshPrContext(workspace, options.fetch) : await refreshPrContext(workspace);
|
|
13055
|
+
} catch {
|
|
13056
|
+
refresh = unforeseenFailure(startedAt);
|
|
13057
|
+
}
|
|
13058
|
+
reread = refresh.ok ? "fresh" : "failed";
|
|
13059
|
+
}
|
|
11626
13060
|
return {
|
|
11627
|
-
|
|
11628
|
-
...
|
|
11629
|
-
contextId: context.contextId,
|
|
11630
|
-
prContext
|
|
13061
|
+
prepared: storedConversation(workspace, reread, refresh && !refresh.ok ? rereadReasonOf(refresh) : void 0),
|
|
13062
|
+
...refresh && !options.rereadOutcome ? { refresh } : {}
|
|
11631
13063
|
};
|
|
11632
13064
|
}
|
|
11633
|
-
|
|
11634
|
-
|
|
11635
|
-
|
|
11636
|
-
|
|
11637
|
-
|
|
11638
|
-
|
|
11639
|
-
|
|
11640
|
-
|
|
11641
|
-
|
|
11642
|
-
|
|
11643
|
-
|
|
11644
|
-
const shared = {
|
|
11645
|
-
repositoryAccess: "repository",
|
|
11646
|
-
changeInventory: renderChangeInventory(allFiles, inlinePaths)
|
|
13065
|
+
/**
|
|
13066
|
+
* Every way the fetch can fail is an outcome rather than an exception, so a throw means something
|
|
13067
|
+
* unforeseen did. The honest thing to say about it is that nothing changed.
|
|
13068
|
+
*/
|
|
13069
|
+
function unforeseenFailure(startedAt) {
|
|
13070
|
+
return {
|
|
13071
|
+
at: Date.now(),
|
|
13072
|
+
startedAt,
|
|
13073
|
+
ok: false,
|
|
13074
|
+
message: "The pull request could not be re-read. Nothing here has changed.",
|
|
13075
|
+
reason: "failed"
|
|
11647
13076
|
};
|
|
11648
|
-
|
|
11649
|
-
|
|
11650
|
-
|
|
11651
|
-
|
|
11652
|
-
|
|
11653
|
-
|
|
13077
|
+
}
|
|
13078
|
+
/**
|
|
13079
|
+
* The longest a Buddy AI turn waits for the conversation before it answers without the re-read.
|
|
13080
|
+
*
|
|
13081
|
+
* A product decision, not a measurement (2026-10-11): about two seconds is the aim and three the
|
|
13082
|
+
* hard ceiling, because an assistant that pauses before every answer feels broken however fresh
|
|
13083
|
+
* its facts. Measured against it: a small request re-reads in 0.6 to 0.75 seconds; one with
|
|
13084
|
+
* hundreds of threads and reviews takes 3.4 to 4, and so answers from the stored copy, stated as
|
|
13085
|
+
* stale, while the re-read finishes and is saved for the next turn.
|
|
13086
|
+
*/
|
|
13087
|
+
var ASK_REREAD_CEILING_MS = CONVERSATION_REREAD_CEILING_MS;
|
|
13088
|
+
/**
|
|
13089
|
+
* `prepareConversation` with a re-read that may take no longer than `ceilingMs`.
|
|
13090
|
+
*
|
|
13091
|
+
* The re-read runs on a copy of the review, so one that misses the ceiling cannot change the
|
|
13092
|
+
* review in memory while the turn is built from it. One that lands in time is taken over onto the
|
|
13093
|
+
* review and returned for the caller to save. One that lands late is saved by itself when it does,
|
|
13094
|
+
* through the same guarded save, so the next turn has it.
|
|
13095
|
+
*/
|
|
13096
|
+
async function prepareConversationWithin(workspace, ceilingMs, fetch) {
|
|
13097
|
+
if (!workspace.prContext) return { prepared: { state: { kind: "not-attached" } } };
|
|
13098
|
+
adoptLegacyBaseline(workspace);
|
|
13099
|
+
const reattributing = JSON.stringify(workspace.session.result);
|
|
13100
|
+
const copy = structuredClone(workspace);
|
|
13101
|
+
const startedAt = Date.now();
|
|
13102
|
+
const reread = (async () => {
|
|
13103
|
+
try {
|
|
13104
|
+
return fetch ? await refreshPrContext(copy, fetch) : await refreshPrContext(copy);
|
|
13105
|
+
} catch {
|
|
13106
|
+
return unforeseenFailure(startedAt);
|
|
13107
|
+
}
|
|
13108
|
+
})();
|
|
13109
|
+
let timer;
|
|
13110
|
+
const late = new Promise((resolve) => {
|
|
13111
|
+
timer = setTimeout(() => resolve("late"), ceilingMs);
|
|
11654
13112
|
});
|
|
13113
|
+
const first = await Promise.race([reread, late]);
|
|
13114
|
+
clearTimeout(timer);
|
|
13115
|
+
if (first === "late") {
|
|
13116
|
+
reread.then((outcome) => saveRefresh(copy, outcome, reattributing)).catch(() => void 0);
|
|
13117
|
+
return { prepared: storedConversation(workspace, "failed", "too-slow") };
|
|
13118
|
+
}
|
|
13119
|
+
workspace.prContext = copy.prContext;
|
|
13120
|
+
workspace.session = copy.session;
|
|
11655
13121
|
return {
|
|
11656
|
-
|
|
11657
|
-
|
|
11658
|
-
diff: buildDiffText(selection.inline),
|
|
11659
|
-
changeSet,
|
|
11660
|
-
processed,
|
|
11661
|
-
fileCoverage: {
|
|
11662
|
-
...processed.fileCoverage,
|
|
11663
|
-
repositoryAvailable: true
|
|
11664
|
-
},
|
|
11665
|
-
inventoryOnlyFiles: selection.inventoryOnly.map((file) => file.name),
|
|
11666
|
-
diffBudgetChars: budget,
|
|
11667
|
-
discussionOverflow: discussionOverflow ?? false
|
|
13122
|
+
prepared: storedConversation(workspace, first.ok ? "fresh" : "failed", first.ok ? void 0 : rereadReasonOf(first)),
|
|
13123
|
+
refresh: first
|
|
11668
13124
|
};
|
|
11669
13125
|
}
|
|
11670
13126
|
/**
|
|
11671
|
-
* The
|
|
11672
|
-
*
|
|
11673
|
-
* With no description, the commit subjects stay: they are still the best account of the change
|
|
11674
|
-
* there is, and still labelled as commits rather than as the author's words.
|
|
13127
|
+
* The conversation as the review holds it, with no fetch: what `prepareConversation` returns once
|
|
13128
|
+
* any re-read is done, and what a prompt builder states when nobody prepared one for it.
|
|
11675
13129
|
*/
|
|
11676
|
-
function
|
|
11677
|
-
|
|
11678
|
-
|
|
11679
|
-
const { bodySource: _commits, ...rest } = local;
|
|
13130
|
+
function storedConversation(workspace, reread = "skipped", rereadReason) {
|
|
13131
|
+
if (!workspace.prContext) return { state: { kind: "not-attached" } };
|
|
13132
|
+
const discussion = workspace.prContext.discussion;
|
|
11680
13133
|
return {
|
|
11681
|
-
|
|
11682
|
-
|
|
13134
|
+
state: {
|
|
13135
|
+
kind: "attached",
|
|
13136
|
+
fetchedAt: discussion.fetchedAt,
|
|
13137
|
+
reread,
|
|
13138
|
+
...reread === "failed" && rereadReason ? { rereadReason } : {}
|
|
13139
|
+
},
|
|
13140
|
+
discussion,
|
|
13141
|
+
delta: diffDiscussion(analysedBaselineOf(workspace), discussion)
|
|
11683
13142
|
};
|
|
11684
13143
|
}
|
|
11685
13144
|
/**
|
|
11686
|
-
*
|
|
13145
|
+
* Put a re-read on disk: the one save the Re-check button and the assistant both make.
|
|
11687
13146
|
*
|
|
11688
|
-
*
|
|
11689
|
-
* the
|
|
13147
|
+
* Re-reads overlap. A Buddy AI turn's can miss its ceiling and land seconds later, after the next
|
|
13148
|
+
* turn or the Re-check button has saved a newer one, so this save checks what is on disk before
|
|
13149
|
+
* writing anything over it:
|
|
13150
|
+
*
|
|
13151
|
+
* - A re-read that failed never writes the conversation or the pull request. It holds only the
|
|
13152
|
+
* copy it started from, and writing that back would restore an older conversation over a newer
|
|
13153
|
+
* one. It is recorded as the latest attempt only if it is: nothing on disk began after it did.
|
|
13154
|
+
* - A re-read that worked writes only if its fetch began no earlier than the one on disk did. A
|
|
13155
|
+
* slow one that finishes after a quicker, later one saved is older, however late it finished,
|
|
13156
|
+
* and is dropped whole. When it began is `requestedAt`; see `orderOf`.
|
|
13157
|
+
*
|
|
13158
|
+
* Its findings go only if they are still the findings on disk: an update that landed meanwhile
|
|
13159
|
+
* wrote a newer analysis, and putting these back would undo it. That update re-read the
|
|
13160
|
+
* conversation for itself, so nothing is lost by leaving its findings alone.
|
|
13161
|
+
*
|
|
13162
|
+
* `reattributing` is the findings as they stood before the re-read began, serialised.
|
|
11690
13163
|
*/
|
|
11691
|
-
function
|
|
11692
|
-
|
|
13164
|
+
function saveRefresh(workspace, outcome, reattributing) {
|
|
13165
|
+
workspace.lastPrRefresh = outcome;
|
|
13166
|
+
mutateWorkspace(workspace.id, (current) => {
|
|
13167
|
+
const adopted = workspace.analysedConversation;
|
|
13168
|
+
if (!current.analysedConversation && adopted && adopted.sessionId === current.session.id) current.analysedConversation = adopted;
|
|
13169
|
+
if (!outcome.ok) {
|
|
13170
|
+
if (!overtaken(current, outcome)) current.lastPrRefresh = outcome;
|
|
13171
|
+
return;
|
|
13172
|
+
}
|
|
13173
|
+
if (workspace.prContext) {
|
|
13174
|
+
if (!atLeastAsNew(workspace.prContext.discussion, current)) return;
|
|
13175
|
+
current.prContext = workspace.prContext;
|
|
13176
|
+
}
|
|
13177
|
+
current.lastPrRefresh = outcome;
|
|
13178
|
+
if (JSON.stringify(current.session.result) === reattributing) current.session = {
|
|
13179
|
+
...current.session,
|
|
13180
|
+
result: workspace.session.result
|
|
13181
|
+
};
|
|
13182
|
+
});
|
|
13183
|
+
}
|
|
13184
|
+
/** Whether something on disk is newer than a failed attempt: another attempt begun after it, or a conversation fetched after it began. */
|
|
13185
|
+
function overtaken(current, outcome) {
|
|
13186
|
+
const began = outcome.startedAt ?? outcome.at;
|
|
13187
|
+
const recorded = current.lastPrRefresh;
|
|
13188
|
+
if (recorded && (recorded.startedAt ?? recorded.at) > began) return true;
|
|
13189
|
+
const onDisk = current.prContext ? orderOf(current.prContext.discussion) : void 0;
|
|
13190
|
+
return onDisk !== void 0 && onDisk > began;
|
|
13191
|
+
}
|
|
13192
|
+
/**
|
|
13193
|
+
* Where a snapshot falls in time: when its fetch began. A snapshot from before that was recorded
|
|
13194
|
+
* has only when it finished, which is no earlier than when it began, so it is never taken as older
|
|
13195
|
+
* than it is.
|
|
13196
|
+
*/
|
|
13197
|
+
function orderOf(discussion) {
|
|
13198
|
+
const at = discussion.requestedAt ?? discussion.fetchedAt;
|
|
13199
|
+
return typeof at === "number" && Number.isFinite(at) ? at : void 0;
|
|
13200
|
+
}
|
|
13201
|
+
function atLeastAsNew(discussion, current) {
|
|
13202
|
+
const onDisk = current.prContext ? orderOf(current.prContext.discussion) : void 0;
|
|
13203
|
+
const ours = orderOf(discussion);
|
|
13204
|
+
return onDisk === void 0 || ours === void 0 || ours >= onDisk;
|
|
13205
|
+
}
|
|
13206
|
+
/**
|
|
13207
|
+
* What a review's own fetch came to, as the state its prompt states.
|
|
13208
|
+
*
|
|
13209
|
+
* The outcome is the job's record of the `conversation` phase. `attached` is dated by the copy the
|
|
13210
|
+
* prompt was actually built from, which a re-read may have replaced since the phase ran.
|
|
13211
|
+
*/
|
|
13212
|
+
function stateOfOutcome(outcome, prContext) {
|
|
13213
|
+
switch (outcome.kind) {
|
|
13214
|
+
case "attached": return prContext ? {
|
|
13215
|
+
kind: "attached",
|
|
13216
|
+
fetchedAt: prContext.discussion.fetchedAt,
|
|
13217
|
+
reread: "skipped"
|
|
13218
|
+
} : { kind: "not-attached" };
|
|
13219
|
+
case "no-request": return { kind: "no-request" };
|
|
13220
|
+
case "unsupported-forge":
|
|
13221
|
+
case "cli-missing":
|
|
13222
|
+
case "not-authenticated":
|
|
13223
|
+
case "failed": return {
|
|
13224
|
+
kind: "unavailable",
|
|
13225
|
+
reason: outcome.kind
|
|
13226
|
+
};
|
|
13227
|
+
}
|
|
11693
13228
|
}
|
|
11694
13229
|
//#endregion
|
|
11695
13230
|
//#region ../../packages/review-harness/src/domain/kiss/parse.ts
|
|
@@ -11932,45 +13467,6 @@ async function validateKiss(raw, changedPaths, existingPaths) {
|
|
|
11932
13467
|
};
|
|
11933
13468
|
}
|
|
11934
13469
|
//#endregion
|
|
11935
|
-
//#region ../../packages/review-harness/src/domain/discussion/discussion_attribution.ts
|
|
11936
|
-
/**
|
|
11937
|
-
* Every surviving attribution has its author, status and link taken from the real thread — the
|
|
11938
|
-
* model's only contribution is which thread it points at. Anything it echoed back about who said
|
|
11939
|
-
* what is discarded, so a correct id can't smuggle a wrong attribution alongside it.
|
|
11940
|
-
*/
|
|
11941
|
-
function attributeIssuesToDiscussion(result, pd) {
|
|
11942
|
-
let attributed = 0;
|
|
11943
|
-
let rejected = 0;
|
|
11944
|
-
const issues = result.issues.map((issue) => {
|
|
11945
|
-
if (!issue.existingDiscussion) return issue;
|
|
11946
|
-
const thread = pd.byId[issue.existingDiscussion.threadId];
|
|
11947
|
-
if (!thread) {
|
|
11948
|
-
rejected++;
|
|
11949
|
-
const { existingDiscussion: _dropped, ...rest } = issue;
|
|
11950
|
-
return rest;
|
|
11951
|
-
}
|
|
11952
|
-
attributed++;
|
|
11953
|
-
return {
|
|
11954
|
-
...issue,
|
|
11955
|
-
existingDiscussion: {
|
|
11956
|
-
threadId: thread.id,
|
|
11957
|
-
raisedBy: thread.comments[0]?.author ?? "unknown",
|
|
11958
|
-
status: thread.resolved ? "resolved" : "unresolved",
|
|
11959
|
-
url: thread.comments[0]?.url || void 0,
|
|
11960
|
-
relation: issue.existingDiscussion.relation === "related" ? "related" : "same"
|
|
11961
|
-
}
|
|
11962
|
-
};
|
|
11963
|
-
});
|
|
11964
|
-
return {
|
|
11965
|
-
result: {
|
|
11966
|
-
...result,
|
|
11967
|
-
issues
|
|
11968
|
-
},
|
|
11969
|
-
attributed,
|
|
11970
|
-
rejected
|
|
11971
|
-
};
|
|
11972
|
-
}
|
|
11973
|
-
//#endregion
|
|
11974
13470
|
//#region ../../packages/review-harness/src/git/tree.ts
|
|
11975
13471
|
/**
|
|
11976
13472
|
* Which of these paths the commit actually holds.
|
|
@@ -14734,14 +16230,14 @@ async function drive(jobId, workspaceId, runners, driver, signal) {
|
|
|
14734
16230
|
signal?.throwIfAborted();
|
|
14735
16231
|
job = update(job, "analysing", "Analysing the change");
|
|
14736
16232
|
let observedModel;
|
|
14737
|
-
const payload = await runAnalysisFor(job, workspaceId, runners, signal, (model) => {
|
|
16233
|
+
const { payload, delivered } = await runAnalysisFor(job, workspaceId, runners, signal, (model) => {
|
|
14738
16234
|
observedModel = model;
|
|
14739
16235
|
});
|
|
14740
16236
|
signal?.throwIfAborted();
|
|
14741
16237
|
if (!commitJob(job.id)) throw new AgentCancelledError("Cancelled from the workspace page.");
|
|
14742
16238
|
const versionOf = runners.agentVersion ?? installedAgentVersion$1;
|
|
14743
16239
|
if (job.reviewType === "kiss") await applyKiss(job, workspaceId, payload, versionOf, observedModel);
|
|
14744
|
-
else await applyGuide(job, workspaceId, payload, versionOf, () => measurementOf(from, startedAt, now()), observedModel);
|
|
16240
|
+
else await applyGuide(job, workspaceId, payload, delivered, versionOf, () => measurementOf(from, startedAt, now()), observedModel);
|
|
14745
16241
|
job = update(job, "done");
|
|
14746
16242
|
const completedAt = now();
|
|
14747
16243
|
recordAnalysisRuntime(job, workspaceId, from, completedAt - startedAt, completedAt, observedModel);
|
|
@@ -14946,7 +16442,13 @@ async function contextFor(job, workspaceId) {
|
|
|
14946
16442
|
...baseFor(job.target),
|
|
14947
16443
|
...expectedFor(job.target)
|
|
14948
16444
|
});
|
|
14949
|
-
|
|
16445
|
+
const context = stored.prContext ? await withPrContext(base, stored.prContext) : base;
|
|
16446
|
+
if (!job.conversation) return context;
|
|
16447
|
+
const state = stateOfOutcome(job.conversation, stored.prContext);
|
|
16448
|
+
return {
|
|
16449
|
+
...context,
|
|
16450
|
+
instructions: `${context.instructions}\n\n---\n\n${conversationStateBlock(state)}`
|
|
16451
|
+
};
|
|
14950
16452
|
}
|
|
14951
16453
|
/** As `contextFor`, starting from the workspace alone by way of the job it belongs to. */
|
|
14952
16454
|
async function contextOf(stored) {
|
|
@@ -14954,6 +16456,7 @@ async function contextOf(stored) {
|
|
|
14954
16456
|
}
|
|
14955
16457
|
async function runAnalysisFor(job, workspaceId, runners, signal, onObservedModel) {
|
|
14956
16458
|
const context = await contextFor(job, workspaceId);
|
|
16459
|
+
const delivered = context.deliveredDiscussion ?? null;
|
|
14957
16460
|
const onProgress = (text) => {
|
|
14958
16461
|
const current = loadJob(job.id);
|
|
14959
16462
|
if (current) saveJob({
|
|
@@ -14962,26 +16465,32 @@ async function runAnalysisFor(job, workspaceId, runners, signal, onObservedModel
|
|
|
14962
16465
|
updatedAt: Date.now()
|
|
14963
16466
|
});
|
|
14964
16467
|
};
|
|
14965
|
-
if (runners.analyse) return
|
|
14966
|
-
|
|
14967
|
-
|
|
14968
|
-
|
|
14969
|
-
|
|
14970
|
-
|
|
14971
|
-
|
|
16468
|
+
if (runners.analyse) return {
|
|
16469
|
+
payload: await runners.analyse({
|
|
16470
|
+
prompt: context.instructions,
|
|
16471
|
+
cwd: job.worktreePath,
|
|
16472
|
+
onProgress,
|
|
16473
|
+
...onObservedModel ? { onObservedModel } : {},
|
|
16474
|
+
signal
|
|
16475
|
+
}),
|
|
16476
|
+
delivered
|
|
16477
|
+
};
|
|
14972
16478
|
const kissRunId = job.reviewType === "kiss" ? (loadWorkspace(workspaceId)?.kissRuns ?? []).find((run) => run.jobId === job.id)?.id : void 0;
|
|
14973
|
-
return
|
|
14974
|
-
|
|
14975
|
-
|
|
14976
|
-
|
|
14977
|
-
|
|
14978
|
-
|
|
14979
|
-
|
|
14980
|
-
|
|
14981
|
-
|
|
14982
|
-
|
|
14983
|
-
|
|
14984
|
-
|
|
16479
|
+
return {
|
|
16480
|
+
payload: await withUsageRecorded(workspaceId, job.reviewType === "kiss" ? "kiss" : "analysis", job.env, (onUsage) => agentFor(job.env).run({
|
|
16481
|
+
capability: "read-code",
|
|
16482
|
+
prompt: context.instructions,
|
|
16483
|
+
cwd: job.worktreePath,
|
|
16484
|
+
originRepoPath: job.target.originRepoPath,
|
|
16485
|
+
env: job.env,
|
|
16486
|
+
...job.requestedModel ? { requestedModel: job.requestedModel } : {},
|
|
16487
|
+
onProgress,
|
|
16488
|
+
onUsage,
|
|
16489
|
+
...onObservedModel ? { onObservedModel } : {},
|
|
16490
|
+
signal
|
|
16491
|
+
}), kissRunId, job.requestedModel),
|
|
16492
|
+
delivered
|
|
16493
|
+
};
|
|
14985
16494
|
}
|
|
14986
16495
|
/**
|
|
14987
16496
|
* Write a finished KISS analysis onto the run that asked for it.
|
|
@@ -15030,7 +16539,7 @@ function failCurrentKissRun(workspaceId, job, reason, kind, rejectedAnswer) {
|
|
|
15030
16539
|
failKissRun(stored, run.id, reason, kind, rejectedAnswer);
|
|
15031
16540
|
});
|
|
15032
16541
|
}
|
|
15033
|
-
async function applyGuide(job, workspaceId, payload, versionOf, measure, observedModel) {
|
|
16542
|
+
async function applyGuide(job, workspaceId, payload, delivered, versionOf, measure, observedModel) {
|
|
15034
16543
|
const stored = loadWorkspace(workspaceId);
|
|
15035
16544
|
const context = await contextOf(stored);
|
|
15036
16545
|
let applied;
|
|
@@ -15055,7 +16564,11 @@ async function applyGuide(job, workspaceId, payload, versionOf, measure, observe
|
|
|
15055
16564
|
...fresh,
|
|
15056
16565
|
session: keepReviewerFields(fresh, session),
|
|
15057
16566
|
audit: applied.audit,
|
|
15058
|
-
referencedFiles: applied.referencedFiles
|
|
16567
|
+
referencedFiles: applied.referencedFiles,
|
|
16568
|
+
analysedConversation: {
|
|
16569
|
+
sessionId: session.id,
|
|
16570
|
+
discussion: delivered
|
|
16571
|
+
}
|
|
15059
16572
|
}));
|
|
15060
16573
|
}
|
|
15061
16574
|
/**
|
|
@@ -16096,64 +17609,27 @@ async function recordKissRun(input) {
|
|
|
16096
17609
|
...requestedModel ? { requestedModel } : {}
|
|
16097
17610
|
});
|
|
16098
17611
|
mutateWorkspace(workspace.id, (current) => {
|
|
16099
|
-
current.kissRuns = [...current.kissRuns ?? [], run];
|
|
16100
|
-
current.latestKissRunId = run.id;
|
|
16101
|
-
});
|
|
16102
|
-
return job;
|
|
16103
|
-
});
|
|
16104
|
-
if (isBusy(started)) throw new ReviewBeingDeletedError();
|
|
16105
|
-
return started;
|
|
16106
|
-
}
|
|
16107
|
-
/**
|
|
16108
|
-
* Record the run and drive it to its end.
|
|
16109
|
-
*
|
|
16110
|
-
* The lock is released before the analysis begins, exactly as `reanalyseReview` does, so a deletion
|
|
16111
|
-
* during the twenty minutes that follow is refused by the live job claim rather than made to wait
|
|
16112
|
-
* for it.
|
|
16113
|
-
*
|
|
16114
|
-
* Counted by `track` from the first await, so a restart is refused for the whole of the run rather
|
|
16115
|
-
* than only once a caller remembered to count it. Only the server calls this; the CLI records the
|
|
16116
|
-
* run and drives it in its own process.
|
|
16117
|
-
*/
|
|
16118
|
-
function startKissJob(input, runners = {}) {
|
|
16119
|
-
return track(recordKissRun(input).then((job) => runJob(job.workspaceId, runners, "kiss")));
|
|
16120
|
-
}
|
|
16121
|
-
//#endregion
|
|
16122
|
-
//#region ../../packages/review-harness/src/workspace/agent_env.ts
|
|
16123
|
-
/**
|
|
16124
|
-
* The environment a review's later runs are pinned to: the job's, not the daemon's.
|
|
16125
|
-
*
|
|
16126
|
-
* A job records what it must run under at the moment it is created, and the analysis reads it back
|
|
16127
|
-
* from there. Every run that happens *after* the analysis read `captureEnv()` instead, which
|
|
16128
|
-
* answers a question about the daemon rather than about the review, and the daemon is the process
|
|
16129
|
-
* that happened to be started first by whichever shell got there first. Two things went wrong with
|
|
16130
|
-
* that. `CLAUDE_CONFIG_DIR` decides which account answers, so a review made under one and revisited
|
|
16131
|
-
* under another is served by a Claude that may not have access to the same models or settings. And
|
|
16132
|
-
* `agentId` decides which adapter runs at all, so a review made by one agent had its questions
|
|
16133
|
-
* answered, and its findings reconciled, by whichever is default today, silently, with the result
|
|
16134
|
-
* still shown as the review's.
|
|
16135
|
-
*
|
|
16136
|
-
* Three callers, and they are exactly the three things a reviewer can ask of a review after it is
|
|
16137
|
-
* written: a question in the Ask panel, bringing the analysis up to date, and re-reading the
|
|
16138
|
-
* conversation. All three put the answer on the page under the review's own name, so all three owe
|
|
16139
|
-
* the reader the review's own agent.
|
|
17612
|
+
current.kissRuns = [...current.kissRuns ?? [], run];
|
|
17613
|
+
current.latestKissRunId = run.id;
|
|
17614
|
+
});
|
|
17615
|
+
return job;
|
|
17616
|
+
});
|
|
17617
|
+
if (isBusy(started)) throw new ReviewBeingDeletedError();
|
|
17618
|
+
return started;
|
|
17619
|
+
}
|
|
17620
|
+
/**
|
|
17621
|
+
* Record the run and drive it to its end.
|
|
16140
17622
|
*
|
|
16141
|
-
*
|
|
16142
|
-
*
|
|
16143
|
-
*
|
|
17623
|
+
* The lock is released before the analysis begins, exactly as `reanalyseReview` does, so a deletion
|
|
17624
|
+
* during the twenty minutes that follow is refused by the live job claim rather than made to wait
|
|
17625
|
+
* for it.
|
|
16144
17626
|
*
|
|
16145
|
-
*
|
|
16146
|
-
*
|
|
16147
|
-
*
|
|
16148
|
-
* have been answered by Claude with the answer shown as the review's. The environment has to be
|
|
16149
|
-
* captured now, because it genuinely is all there is left to read. Who to capture it for is a fact
|
|
16150
|
-
* the review still holds.
|
|
17627
|
+
* Counted by `track` from the first await, so a restart is refused for the whole of the run rather
|
|
17628
|
+
* than only once a caller remembered to count it. Only the server calls this; the CLI records the
|
|
17629
|
+
* run and drives it in its own process.
|
|
16151
17630
|
*/
|
|
16152
|
-
function
|
|
16153
|
-
|
|
16154
|
-
if (job?.env) return job.env;
|
|
16155
|
-
const id = agentIdOf(workspace.session);
|
|
16156
|
-
return (AGENT_IDS.includes(id) ? agentById(id) : defaultAgent()).captureEnv();
|
|
17631
|
+
function startKissJob(input, runners = {}) {
|
|
17632
|
+
return track(recordKissRun(input).then((job) => runJob(job.workspaceId, runners, "kiss")));
|
|
16157
17633
|
}
|
|
16158
17634
|
//#endregion
|
|
16159
17635
|
//#region ../../packages/review-harness/src/domain/ai_review/reconciliation.ts
|
|
@@ -16195,17 +17671,29 @@ function priorVerdict(item) {
|
|
|
16195
17671
|
return ` [previously: ${status}]`;
|
|
16196
17672
|
}
|
|
16197
17673
|
/**
|
|
17674
|
+
* A finding a comment resolved says which comment, so the model can tell it from one the code
|
|
17675
|
+
* resolved. And one whose comment has changed since says that too: it is the one settled finding
|
|
17676
|
+
* the model is asked to judge again. Every other finding reads exactly as `priorVerdict` has it.
|
|
17677
|
+
*/
|
|
17678
|
+
function priorFindingVerdict(issue, flag) {
|
|
17679
|
+
const resolvedBy = conversationResolution(issue);
|
|
17680
|
+
if (!resolvedBy) return priorVerdict(issue);
|
|
17681
|
+
const cite = `@${resolvedBy.author} in [${resolvedBy.threadId}] [comment ${resolvedBy.commentId}]`;
|
|
17682
|
+
if (!flag) return ` [previously: resolved by ${cite}]`;
|
|
17683
|
+
return ` [previously: resolved by ${cite}; that comment has since been ${flag.change}. Reassess this finding]`;
|
|
17684
|
+
}
|
|
17685
|
+
/**
|
|
16198
17686
|
* Each serialiser below states a finding's grade as the review now holds it, through `assess`: the
|
|
16199
17687
|
* reviewer may have moved it, and a model told only the analysis's grade would re-judge from where
|
|
16200
17688
|
* the review no longer stands. A caller with no ledger passes `ANALYSIS_ONLY`.
|
|
16201
17689
|
*/
|
|
16202
|
-
function formatPriorFindings(issues, assess) {
|
|
17690
|
+
function formatPriorFindings(issues, assess, reassess = /* @__PURE__ */ new Map()) {
|
|
16203
17691
|
if (!issues.length) return "(The previous analysis raised no issues.)";
|
|
16204
17692
|
return issues.map((issue, index) => {
|
|
16205
17693
|
const related = (issue.relatedFiles ?? []).map((file) => ` [related: ${file}, not changed]`).join("");
|
|
16206
17694
|
const location = (issue.file ? ` [${issue.file}${issue.line ? `:${issue.line}` : ""}]` : "") + related;
|
|
16207
17695
|
const description = issue.description.trim();
|
|
16208
|
-
return `${index}. (${promptGrade(assess("issue", issue))})${location}${
|
|
17696
|
+
return `${index}. (${promptGrade(assess("issue", issue))})${location}${priorFindingVerdict(issue, reassess.get(index))} ${issue.title.trim()}${description ? `\n ${description}` : ""}`;
|
|
16209
17697
|
}).join("\n");
|
|
16210
17698
|
}
|
|
16211
17699
|
/**
|
|
@@ -16241,9 +17729,10 @@ var RECONCILIATION_SHAPE = `{
|
|
|
16241
17729
|
* part of the job. Built rather than kept as two constants: two hand-maintained copies of a schema
|
|
16242
17730
|
* is how the one that is edited less often quietly stops matching the parser.
|
|
16243
17731
|
*/
|
|
16244
|
-
function reconciliationShape(includeQuestions) {
|
|
16245
|
-
|
|
16246
|
-
|
|
17732
|
+
function reconciliationShape(includeQuestions, withDiscussion = false) {
|
|
17733
|
+
const shape = withDiscussion ? RECONCILIATION_SHAPE.replace("\"confidence\"?: \"high\" | \"medium\" | \"low\" }]", `"confidence"?: "high" | "medium" | "low"${DISCUSSION_ISSUE_FIELD} }]`).replace("\"severityReason\"?: string }]", `"severityReason"?: string${DISCUSSION_EVIDENCE_FIELD} }]`) : RECONCILIATION_SHAPE;
|
|
17734
|
+
if (!includeQuestions) return shape;
|
|
17735
|
+
const lines = shape.split("\n");
|
|
16247
17736
|
const findingsLine = lines.findIndex((line) => line.includes("\"findings\""));
|
|
16248
17737
|
return [
|
|
16249
17738
|
...lines.slice(0, findingsLine + 1),
|
|
@@ -16270,6 +17759,41 @@ function formatPriorQuestions(questions, assess) {
|
|
|
16270
17759
|
}).join("\n");
|
|
16271
17760
|
}
|
|
16272
17761
|
/**
|
|
17762
|
+
* The shape again, with the one field a discussion-aware run may also set on a new issue. The
|
|
17763
|
+
* parser has always accepted it; it is shown only where the prompt holds threads to cite.
|
|
17764
|
+
*/
|
|
17765
|
+
var DISCUSSION_ISSUE_FIELD = ", \"existingDiscussion\"?: { \"threadId\": string, \"relation\": \"same\" | \"related\" }";
|
|
17766
|
+
/** The comment or review a finding's status rests on, on the same discussion-aware runs. */
|
|
17767
|
+
var DISCUSSION_EVIDENCE_FIELD = ", \"evidence\"?: { \"threadId\": string, \"commentId\": string } | { \"reviewId\": string }";
|
|
17768
|
+
/**
|
|
17769
|
+
* The conversation as evidence: what it may settle, what it may not, and how to say which.
|
|
17770
|
+
*
|
|
17771
|
+
* The line this draws is between evidence about reality and claims about code. A maintainer saying
|
|
17772
|
+
* the service a finding worries about is deleted next week changes the finding's premise, and may
|
|
17773
|
+
* settle it with no commit at all. Someone saying a bug is fixed is a claim about code the model
|
|
17774
|
+
* can read, and only the code settles that.
|
|
17775
|
+
*/
|
|
17776
|
+
var DISCUSSION_RULES = `THE CONVERSATION AS EVIDENCE
|
|
17777
|
+
|
|
17778
|
+
The changes in the pull request's conversation are listed above. Treat them as context, not ground truth: reviewers can be wrong, or writing about an older revision.
|
|
17779
|
+
- A comment may settle or reclassify a finding when it gives evidence that invalidates or materially changes the premise the finding rested on: for example a maintainer establishing that the code concerned is being removed, or that an assumption the finding made is false. Then "resolved" (or "affected", where it changes the finding without settling it) is right, and the note must cite that comment.
|
|
17780
|
+
- A comment saying a defect is fixed, or will be, is a claim about the code and not evidence. Without the fixing change in code you can read, that finding stays "still-open" or becomes "affected", never "resolved".
|
|
17781
|
+
- In "note" and "answer", name a comment by its author and what it establishes, for example "@sam confirms the job is being retired". Write no ids in them: the reviewer reads those sentences, and ids belong only in "evidence".
|
|
17782
|
+
- Whenever a comment or a review is the reason for a finding's status, also name it in that finding's "evidence": "threadId" and "commentId" from the comment's line (its "[T_123]" and "[comment C_9]" markers, without the brackets or the word "comment"), or "reviewId" from a review's "[review R_1]" marker. Only a comment can be the evidence for "resolved": a review summary may support "affected" or "still-open", and a resolution resting on one is marked for reassessment. A resolution that rests on the conversation without valid evidence is marked for reassessment too. Copy every id verbatim from the list above, and never invent one.
|
|
17783
|
+
- A question the conversation answers may be "answered": put the answer in "answer", naming the comment the same way.
|
|
17784
|
+
- A concern raised in the conversation that the earlier analysis did not cover belongs in "newIssues" only if you confirm it by reading the code. Then set "existingDiscussion" on it: "threadId" copied verbatim from that thread's marker, "relation" "same" when the thread makes this exact point or "related" when it is only near it. Omit the field if no listed thread matches.
|
|
17785
|
+
- A severity or importance suggestion may rest on the conversation; say which comment in the reason.`;
|
|
17786
|
+
/**
|
|
17787
|
+
* The one exception to "resolved stays resolved", stated only when a finding needs it: a resolution
|
|
17788
|
+
* that rested on a comment the conversation has since edited or deleted. Three answers, kept apart,
|
|
17789
|
+
* because "cannot tell" is not "reopened": the reviewer decides on a finding marked affected.
|
|
17790
|
+
*/
|
|
17791
|
+
var REASSESS_TASK = ` The one exception: a finding marked "that comment has since been edited" or "deleted" was resolved by a comment that has changed since. For that finding alone, judge again from the comment as it now reads; the change, and what the comment said before, are listed above.
|
|
17792
|
+
- "resolved": it remains resolved despite the change. Name in "evidence" what it now rests on.
|
|
17793
|
+
- "still-open": the change establishes that the original finding is valid again.
|
|
17794
|
+
- "affected": the evidence changed and you cannot establish whether the finding is still valid. Use this, not "still-open", whenever you are unsure.
|
|
17795
|
+
Keep it as this entry. Do not raise it again in "newIssues" unless the problem is now materially different.`;
|
|
17796
|
+
/**
|
|
16273
17797
|
* The one optional judgement about a grade: a suggestion the reviewer answers, separate from the
|
|
16274
17798
|
* status, reasoned from the grade the review holds now (each list states it), and only where the
|
|
16275
17799
|
* new changes move it. Unasked for, a model restates every grade it was shown, and each would reach
|
|
@@ -16297,33 +17821,42 @@ var GRADE_TASK = {
|
|
|
16297
17821
|
*/
|
|
16298
17822
|
function buildReconciliationInstructions(input, options = {}) {
|
|
16299
17823
|
const { previous, totalCommits, changedFiles, possiblyTruncated } = input;
|
|
17824
|
+
const reassess = input.reassess ?? /* @__PURE__ */ new Map();
|
|
16300
17825
|
const includeQuestions = options.includeQuestions === true;
|
|
16301
17826
|
const commitCount = totalCommits === 1 ? "1 new commit" : `${totalCommits} new commits`;
|
|
16302
17827
|
const fileCount = changedFiles === 1 ? "1 file" : `${changedFiles} files`;
|
|
17828
|
+
const discussion = options.discussion;
|
|
17829
|
+
const conversationOnly = discussion !== void 0 && !discussion.codeChanged;
|
|
16303
17830
|
const truncationWarning = possiblyTruncated ? `\n\nIMPORTANT: the list of changes above is INCOMPLETE — more files changed than could be retrieved. Do not mark a previous finding "resolved" on the strength of absence: a file you cannot see may or may not contain the fix. Prefer "still-open" or "affected" where you cannot see the relevant code, and say so in the note.` : "";
|
|
16304
17831
|
const questionList = includeQuestions ? `\n\nPrevious open questions, numbered — refer to each by its number in "sourceIndex":\n${formatPriorQuestions(previous.questions, input.assess)}` : "";
|
|
16305
17832
|
const tasks = [
|
|
16306
|
-
`For EVERY numbered finding above, decide what the new changes did to it:
|
|
17833
|
+
`${discussion ? `For EVERY numbered finding above, decide what ${conversationOnly ? "the changes in the conversation" : "the new commits and the changes in the conversation"} did to it:
|
|
17834
|
+
- "resolved": ${conversationOnly ? "" : "the new commits fix it, and you can point at the change that does so; or "}the conversation gives evidence that invalidates its premise, under the rules below.
|
|
17835
|
+
- "still-open": nothing since settles it. This includes findings nothing since touches.
|
|
17836
|
+
- "affected": ${conversationOnly ? "a comment bears on it" : "the code it concerns changed, or a comment bears on it,"} without clearly settling it either way. The reviewer needs to look again.` : `For EVERY numbered finding above, decide what the new changes did to it:
|
|
16307
17837
|
- "resolved" — the new changes appear to fix it. Only when you can point at the change that does so.
|
|
16308
17838
|
- "still-open" — the new changes do not address it. This includes findings the changes never touch.
|
|
16309
|
-
- "affected" — the code it concerns changed, but not in a way that clearly settles it either way. The reviewer needs to look again
|
|
17839
|
+
- "affected" — the code it concerns changed, but not in a way that clearly settles it either way. The reviewer needs to look again.`}
|
|
16310
17840
|
You MUST return one entry per numbered finding. If you are unsure, return "still-open" or "affected" with a note — do NOT omit the finding. Omitting a finding does not make it go away; it will be reported as still open regardless, without your reasoning attached.
|
|
16311
|
-
A finding marked [previously: resolved] was already fixed by an earlier commit on this branch, which is code you are NOT being shown a diff of. It stays resolved, and "still-open" is not an available answer for it — these changes not being where the fix was says nothing about whether it holds. If you can see that later code has undone that fix, report it in "newIssues" with what you saw; do not reopen the old entry.
|
|
17841
|
+
A finding marked [previously: resolved] was already fixed by an earlier commit on this branch, which is code you are NOT being shown a diff of. It stays resolved, and "still-open" is not an available answer for it — these changes not being where the fix was says nothing about whether it holds. If you can see that later code has undone that fix, report it in "newIssues" with what you saw; do not reopen the old entry.${reassess.size > 0 ? `
|
|
17842
|
+
${REASSESS_TASK}` : ""}
|
|
16312
17843
|
${GRADE_TASK.issue}`,
|
|
16313
17844
|
...includeQuestions ? [`For EVERY numbered question above, decide what the new changes did to it:
|
|
16314
|
-
- "answered" — the changes settle it. Put the answer in "answer", in a sentence or two, and name what in the code establishes it.
|
|
17845
|
+
- "answered" — the changes settle it. Put the answer in "answer", in a sentence or two, and name what in the code establishes it.${discussion ? " A comment in the conversation that answers it counts too: name it as the rules below say." : ""}
|
|
16315
17846
|
- "still-open" — the changes do not settle it. This includes questions the changes never touch, and questions only the author can answer.
|
|
16316
17847
|
- "affected" — the code the question is about changed without answering it. The reviewer should read the question again before asking it.
|
|
16317
17848
|
A question marked [previously: answered] was already settled by an earlier commit on this branch, which is code you are NOT being shown a diff of. It stays answered, and "still-open" is not an available answer for it.
|
|
16318
17849
|
The completeness rule applies here too: one entry per numbered question, and "still-open" with a note rather than an omission when you are unsure. Do not manufacture an answer out of intent you cannot see. A question about why something was done the way it was is often not answerable from code at all, and "still-open" is the correct answer to it.
|
|
16319
17850
|
${GRADE_TASK.question}`] : [],
|
|
16320
|
-
`Read the changed code above as a reviewer, not only as an auditor of the numbered list. These commits have not been reviewed by anyone. Report every genuinely NEW problem you find in them in "newIssues", whether or not it relates to anything on the list — code written to fix a finding is exactly where a regression gets introduced, and reassessing the list and stopping there is half the job. Do NOT repeat a previous finding here: if it still stands, that is what "still-open" is for.`,
|
|
17851
|
+
conversationOnly ? `No commits were pushed, so there is no new code to review. Report a problem in "newIssues" only where the conversation points at something in the code the earlier analysis missed and you have confirmed it by reading the code. Do NOT repeat a previous finding here: if it still stands, that is what "still-open" is for.` : `Read the changed code above as a reviewer, not only as an auditor of the numbered list. These commits have not been reviewed by anyone. Report every genuinely NEW problem you find in them in "newIssues", whether or not it relates to anything on the list — code written to fix a finding is exactly where a regression gets introduced, and reassessing the list and stopping there is half the job. Do NOT repeat a previous finding here: if it still stands, that is what "still-open" is for.`,
|
|
16321
17852
|
`If the new changes introduce an area the earlier analysis had no section for, add it to "newSections". Otherwise return an empty array. Do not restate or restructure the earlier analysis's existing sections.`,
|
|
16322
17853
|
`Give a fresh "summary", "overallRisk", "recommendation" and "nextSteps" for the pull request AS IT NOW STANDS, taking both the earlier findings that survive and the new changes into account. Unlike a partial review, you have been shown the earlier analysis in full and are expected to revise its verdict where the new changes justify it. ${SUMMARY_GUIDANCE}`
|
|
16323
17854
|
].map((task, index) => `${index + 1}. ${task}`).join("\n");
|
|
16324
|
-
return `You are RECONCILING an earlier analysis of this pull request against
|
|
17855
|
+
return `${conversationOnly ? `You are RECONCILING an earlier analysis of this pull request against what has changed in its conversation since that analysis was made.
|
|
17856
|
+
|
|
17857
|
+
No commits have been pushed since: the code is exactly what the earlier analysis read. What has changed is the discussion, listed above.` : `You are RECONCILING an earlier analysis of this pull request against the changes pushed since that analysis was made${discussion ? ", and against what has changed in its conversation since" : ""}.
|
|
16325
17858
|
|
|
16326
|
-
The diff above is NOT the whole pull request. It is only what changed in the ${commitCount} (${fileCount}) pushed after the earlier analysis. The earlier analysis already covered everything before that point
|
|
17859
|
+
The diff above is NOT the whole pull request. It is only what changed in the ${commitCount} (${fileCount}) pushed after the earlier analysis. The earlier analysis already covered everything before that point.`}
|
|
16327
17860
|
|
|
16328
17861
|
PREVIOUS ANALYSIS
|
|
16329
17862
|
|
|
@@ -16332,16 +17865,16 @@ Overall risk: ${previous.overallRisk}
|
|
|
16332
17865
|
Recommendation: ${previous.recommendation}
|
|
16333
17866
|
|
|
16334
17867
|
Previous findings, numbered — refer to each by its number in "sourceIndex":
|
|
16335
|
-
${formatPriorFindings(previous.issues, input.assess)}${questionList}${truncationWarning}
|
|
16336
|
-
|
|
17868
|
+
${formatPriorFindings(previous.issues, input.assess, reassess)}${questionList}${truncationWarning}
|
|
17869
|
+
${discussion ? `\n${DISCUSSION_RULES}\n` : ""}
|
|
16337
17870
|
YOUR TASK
|
|
16338
17871
|
|
|
16339
17872
|
${tasks}
|
|
16340
17873
|
|
|
16341
17874
|
Respond with a single JSON object (no markdown, no surrounding text) matching exactly this shape:
|
|
16342
|
-
${reconciliationShape(includeQuestions)}
|
|
17875
|
+
${reconciliationShape(includeQuestions, discussion !== void 0)}
|
|
16343
17876
|
|
|
16344
|
-
"note" is one short sentence on *why* the status is what it is — name the change that resolved it, or what leaves it standing. Omit it rather than restating the finding.
|
|
17877
|
+
"note" is one short sentence on *why* the status is what it is — name the change that resolved it, or what leaves it standing.${discussion ? " Where a comment is the reason, name its author." : ""} Omit it rather than restating the finding.
|
|
16345
17878
|
|
|
16346
17879
|
"severity" on a new issue is how much it would matter if real; "confidence" is how sure you are that it is real. Separate axes — do not let one stand in for the other, and omit "confidence" rather than guessing a middle. Report only what is worth the reviewer's attention: empty arrays are valid answers and are far better than padded ones.`;
|
|
16347
17880
|
}
|
|
@@ -16392,6 +17925,19 @@ function reconciliationGradeSuggestions(previous, reconciled, assess) {
|
|
|
16392
17925
|
}
|
|
16393
17926
|
return suggestions;
|
|
16394
17927
|
}
|
|
17928
|
+
/** A citation as the reply gave it: a comment by thread and comment id, or a review by its id. Anything else is no citation. */
|
|
17929
|
+
function parseCitation(value) {
|
|
17930
|
+
if (typeof value !== "object" || value === null) return void 0;
|
|
17931
|
+
const item = value;
|
|
17932
|
+
const threadId = asString$1(item.threadId).trim();
|
|
17933
|
+
const commentId = asString$1(item.commentId).trim();
|
|
17934
|
+
if (threadId && commentId) return {
|
|
17935
|
+
threadId,
|
|
17936
|
+
commentId
|
|
17937
|
+
};
|
|
17938
|
+
const reviewId = asString$1(item.reviewId).trim();
|
|
17939
|
+
return reviewId ? { reviewId } : void 0;
|
|
17940
|
+
}
|
|
16395
17941
|
function asStatus(value, allowed) {
|
|
16396
17942
|
return allowed.includes(value) ? value : null;
|
|
16397
17943
|
}
|
|
@@ -16424,6 +17970,7 @@ function parseReconciliationResult(raw, priorCount, options = {}) {
|
|
|
16424
17970
|
seen.add(sourceIndex);
|
|
16425
17971
|
const note = asString$1(item.note).trim();
|
|
16426
17972
|
const grade = parseGrade("issue", item.severity, item.severityReason);
|
|
17973
|
+
const cites = parseCitation(item.evidence);
|
|
16427
17974
|
findings.push({
|
|
16428
17975
|
sourceIndex,
|
|
16429
17976
|
status,
|
|
@@ -16431,7 +17978,8 @@ function parseReconciliationResult(raw, priorCount, options = {}) {
|
|
|
16431
17978
|
...grade ? { grade: {
|
|
16432
17979
|
to: grade.to,
|
|
16433
17980
|
reason: grade.reason
|
|
16434
|
-
} } : {}
|
|
17981
|
+
} } : {},
|
|
17982
|
+
...cites ? { cites } : {}
|
|
16435
17983
|
});
|
|
16436
17984
|
}
|
|
16437
17985
|
return {
|
|
@@ -16461,15 +18009,30 @@ function parseReconciliationResult(raw, priorCount, options = {}) {
|
|
|
16461
18009
|
* Every prior finding therefore survives into the new result, in its original order, carrying its
|
|
16462
18010
|
* original id, severity and text. Only the status is added.
|
|
16463
18011
|
*/
|
|
16464
|
-
function reconcileIssues(prior, findings) {
|
|
18012
|
+
function reconcileIssues(prior, findings, record) {
|
|
16465
18013
|
const byIndex = new Map(findings.map((f) => [f.sourceIndex, f]));
|
|
16466
18014
|
return prior.map((issue, index) => {
|
|
16467
|
-
if (issue.reconciliationStatus === "resolved") return issue;
|
|
16468
18015
|
const verdict = byIndex.get(index);
|
|
18016
|
+
const flag = issue.reconciliationStatus === "resolved" ? record?.flags?.get(index) : void 0;
|
|
18017
|
+
if (flag && record) {
|
|
18018
|
+
const entry = reassessedEntry(verdict, flag, record);
|
|
18019
|
+
return {
|
|
18020
|
+
...issue,
|
|
18021
|
+
reconciliationStatus: entry.status,
|
|
18022
|
+
...entry.reason ? { reconciliationNote: entry.reason } : {},
|
|
18023
|
+
statusHistory: appendStatusChange(issue, entry),
|
|
18024
|
+
...record.against ? { reconciledAgainst: entry.basis === "code" ? "commits" : "conversation" } : {}
|
|
18025
|
+
};
|
|
18026
|
+
}
|
|
18027
|
+
if (issue.reconciliationStatus === "resolved") return issue;
|
|
18028
|
+
const status = verdict?.status ?? "still-open";
|
|
18029
|
+
const recorded = record && isStatusChange(issue.reconciliationStatus, status) ? { statusHistory: appendStatusChange(issue, changeEntry(status, verdict, record)) } : {};
|
|
16469
18030
|
return {
|
|
16470
18031
|
...issue,
|
|
16471
|
-
reconciliationStatus:
|
|
16472
|
-
...verdict?.note ? { reconciliationNote: verdict.note } : {}
|
|
18032
|
+
reconciliationStatus: status,
|
|
18033
|
+
...verdict?.note ? { reconciliationNote: verdict.note } : {},
|
|
18034
|
+
...recorded,
|
|
18035
|
+
...record?.against ? { reconciledAgainst: verdict?.evidence ? "conversation" : record.against } : {}
|
|
16473
18036
|
};
|
|
16474
18037
|
});
|
|
16475
18038
|
}
|
|
@@ -16534,7 +18097,8 @@ function parseQuestions(value, priorCount) {
|
|
|
16534
18097
|
function alreadyAnswered(question) {
|
|
16535
18098
|
return question.reconciliationStatus === "answered" || question.reconciliationAnswer !== void 0;
|
|
16536
18099
|
}
|
|
16537
|
-
function reconcileQuestions(prior, reconciled) {
|
|
18100
|
+
function reconcileQuestions(prior, reconciled, against) {
|
|
18101
|
+
const recorded = against ? { reconciledAgainst: against } : {};
|
|
16538
18102
|
const byIndex = new Map(reconciled.map((entry) => [entry.sourceIndex, entry]));
|
|
16539
18103
|
return prior.map((question, index) => {
|
|
16540
18104
|
if (alreadyAnswered(question)) return {
|
|
@@ -16544,7 +18108,8 @@ function reconcileQuestions(prior, reconciled) {
|
|
|
16544
18108
|
const verdict = byIndex.get(index);
|
|
16545
18109
|
if (!verdict) return {
|
|
16546
18110
|
...question,
|
|
16547
|
-
reconciliationStatus: "still-open"
|
|
18111
|
+
reconciliationStatus: "still-open",
|
|
18112
|
+
...recorded
|
|
16548
18113
|
};
|
|
16549
18114
|
const answered = verdict.status === "answered" && verdict.answer !== void 0;
|
|
16550
18115
|
const status = verdict.status !== "answered" ? verdict.status : answered ? "answered" : "affected";
|
|
@@ -16552,15 +18117,17 @@ function reconcileQuestions(prior, reconciled) {
|
|
|
16552
18117
|
...question,
|
|
16553
18118
|
reconciliationStatus: status,
|
|
16554
18119
|
...verdict.note ? { reconciliationNote: verdict.note } : {},
|
|
16555
|
-
...answered ? { reconciliationAnswer: verdict.answer } : {}
|
|
18120
|
+
...answered ? { reconciliationAnswer: verdict.answer } : {},
|
|
18121
|
+
...recorded
|
|
16556
18122
|
};
|
|
16557
18123
|
});
|
|
16558
18124
|
}
|
|
16559
18125
|
/** Marks a reconciliation's own findings as new, so the UI can tell them from carried-forward ones. */
|
|
16560
|
-
function markNewIssues(issues) {
|
|
18126
|
+
function markNewIssues(issues, against) {
|
|
16561
18127
|
return issues.map((issue) => ({
|
|
16562
18128
|
...issue,
|
|
16563
|
-
reconciliationStatus: "new"
|
|
18129
|
+
reconciliationStatus: "new",
|
|
18130
|
+
...against ? { reconciledAgainst: against } : {}
|
|
16564
18131
|
}));
|
|
16565
18132
|
}
|
|
16566
18133
|
/**
|
|
@@ -16589,16 +18156,93 @@ function composeSections(parent, added) {
|
|
|
16589
18156
|
}));
|
|
16590
18157
|
}
|
|
16591
18158
|
//#endregion
|
|
16592
|
-
//#region ../../packages/review-harness/src/domain/
|
|
16593
|
-
|
|
18159
|
+
//#region ../../packages/review-harness/src/domain/ai_review/update_counts.ts
|
|
18160
|
+
/**
|
|
18161
|
+
* What an update did to a review, counted by comparing each finding and question with what it was
|
|
18162
|
+
* before. Shared by the workspace's update and the extension's, so both announce the same numbers.
|
|
18163
|
+
*/
|
|
18164
|
+
/**
|
|
18165
|
+
* Paired by position: `reconcileIssues` and `reconcileQuestions` return the previous ones in their
|
|
18166
|
+
* order, and what the update added comes after them.
|
|
18167
|
+
*/
|
|
18168
|
+
function countUpdateChanges(previous, result) {
|
|
16594
18169
|
const counts = {
|
|
16595
18170
|
resolved: 0,
|
|
16596
|
-
|
|
18171
|
+
stillOpen: 0,
|
|
16597
18172
|
affected: 0,
|
|
16598
|
-
|
|
18173
|
+
newIssues: 0,
|
|
18174
|
+
questionsAnswered: 0
|
|
18175
|
+
};
|
|
18176
|
+
let byConversation = 0;
|
|
18177
|
+
let reopened = 0;
|
|
18178
|
+
result.issues.forEach((issue, index) => {
|
|
18179
|
+
const before = previous.issues[index]?.reconciliationStatus;
|
|
18180
|
+
const now = issue.reconciliationStatus;
|
|
18181
|
+
if (now === "new") counts.newIssues++;
|
|
18182
|
+
else if (now === "resolved") {
|
|
18183
|
+
if (before === "resolved") return;
|
|
18184
|
+
counts.resolved++;
|
|
18185
|
+
if (issue.statusHistory?.at(-1)?.basis === "conversation") byConversation++;
|
|
18186
|
+
} else if (before === "resolved" && now === "still-open") reopened++;
|
|
18187
|
+
else if (now === "affected" && before !== "affected") counts.affected++;
|
|
18188
|
+
else counts.stillOpen++;
|
|
18189
|
+
});
|
|
18190
|
+
counts.questionsAnswered = result.questions.filter((question, index) => {
|
|
18191
|
+
const before = previous.questions[index];
|
|
18192
|
+
return question.reconciliationStatus === "answered" && !(before && alreadyAnswered(before));
|
|
18193
|
+
}).length;
|
|
18194
|
+
return {
|
|
18195
|
+
...counts,
|
|
18196
|
+
...byConversation ? { resolvedByConversation: byConversation } : {},
|
|
18197
|
+
...reopened ? { reopened } : {}
|
|
18198
|
+
};
|
|
18199
|
+
}
|
|
18200
|
+
//#endregion
|
|
18201
|
+
//#region ../../packages/review-harness/src/domain/ai_review/update_reconciliation.ts
|
|
18202
|
+
function reconciledAgainstOf(basis) {
|
|
18203
|
+
return basis.code ? basis.discussion ? "commits-and-conversation" : "commits" : "conversation";
|
|
18204
|
+
}
|
|
18205
|
+
function applyReconciliation(previous, parsed, context) {
|
|
18206
|
+
const { conversation } = context;
|
|
18207
|
+
const basis = {
|
|
18208
|
+
code: context.codeChanged,
|
|
18209
|
+
discussion: conversationMoved(conversation)
|
|
18210
|
+
};
|
|
18211
|
+
const against = reconciledAgainstOf(basis);
|
|
18212
|
+
const flags = conversationReassessment(previous, conversation);
|
|
18213
|
+
const findings = groundFindings(parsed.findings, conversation.discussion, {
|
|
18214
|
+
codeChanged: context.codeChanged,
|
|
18215
|
+
flags
|
|
18216
|
+
});
|
|
18217
|
+
const suggestions = reconciliationGradeSuggestions(previous, {
|
|
18218
|
+
...parsed,
|
|
18219
|
+
findings
|
|
18220
|
+
}, context.assess);
|
|
18221
|
+
const issues = [...reconcileIssues(previous.issues, findings, {
|
|
18222
|
+
at: context.at,
|
|
18223
|
+
commitSha: context.commitSha,
|
|
18224
|
+
basis: context.codeChanged ? "code" : "conversation",
|
|
18225
|
+
flags,
|
|
18226
|
+
against
|
|
18227
|
+
}), ...markNewIssues(parsed.newIssues, against)];
|
|
18228
|
+
const questions = reconcileQuestions(previous.questions, parsed.questions, against);
|
|
18229
|
+
const { motivation } = context;
|
|
18230
|
+
const composed = {
|
|
18231
|
+
schemaVersion: 1,
|
|
18232
|
+
summary: parsed.summary || previous.summary,
|
|
18233
|
+
...motivation ? { motivation } : {},
|
|
18234
|
+
overallRisk: parsed.overallRisk ?? previous.overallRisk,
|
|
18235
|
+
recommendation: parsed.recommendation || previous.recommendation,
|
|
18236
|
+
nextSteps: parsed.nextSteps.length ? parsed.nextSteps : previous.nextSteps,
|
|
18237
|
+
sections: composeSections(previous.sections, parsed.newSections),
|
|
18238
|
+
issues,
|
|
18239
|
+
questions: motivation === previous.motivation ? questions : withIntentQuestion(questions, motivation)
|
|
18240
|
+
};
|
|
18241
|
+
return {
|
|
18242
|
+
result: conversation.discussion ? attributeIssuesToDiscussion(composed, processDiscussion(conversation.discussion)).result : composed,
|
|
18243
|
+
suggestions,
|
|
18244
|
+
basis
|
|
16599
18245
|
};
|
|
16600
|
-
for (const issue of issues) if (issue.reconciliationStatus) counts[issue.reconciliationStatus] += 1;
|
|
16601
|
-
return counts;
|
|
16602
18246
|
}
|
|
16603
18247
|
//#endregion
|
|
16604
18248
|
//#region ../../packages/review-harness/src/workspace/running_jobs.ts
|
|
@@ -16795,178 +18439,6 @@ async function askCheckout(workspace) {
|
|
|
16795
18439
|
return path;
|
|
16796
18440
|
}
|
|
16797
18441
|
//#endregion
|
|
16798
|
-
//#region ../../packages/review-harness/src/workspace/discussion_diff.ts
|
|
16799
|
-
function diffDiscussions(before, after) {
|
|
16800
|
-
const oldThreads = new Map(before.discussions.map((thread) => [thread.id, thread]));
|
|
16801
|
-
const newIds = new Set(after.discussions.map((thread) => thread.id));
|
|
16802
|
-
const oldComments = new Set(before.discussions.flatMap((thread) => thread.comments.map((comment) => comment.id)));
|
|
16803
|
-
let newThreads = 0;
|
|
16804
|
-
let resolvedSince = 0;
|
|
16805
|
-
let reopenedSince = 0;
|
|
16806
|
-
let newComments = 0;
|
|
16807
|
-
for (const thread of after.discussions) {
|
|
16808
|
-
const previous = oldThreads.get(thread.id);
|
|
16809
|
-
if (!previous) newThreads++;
|
|
16810
|
-
else if (thread.resolved && !previous.resolved) resolvedSince++;
|
|
16811
|
-
else if (!thread.resolved && previous.resolved) reopenedSince++;
|
|
16812
|
-
for (const comment of thread.comments) if (!oldComments.has(comment.id)) newComments++;
|
|
16813
|
-
}
|
|
16814
|
-
return {
|
|
16815
|
-
newThreads,
|
|
16816
|
-
vanishedThreads: [...oldThreads.keys()].filter((id) => !newIds.has(id)).length,
|
|
16817
|
-
resolvedSince,
|
|
16818
|
-
reopenedSince,
|
|
16819
|
-
newComments
|
|
16820
|
-
};
|
|
16821
|
-
}
|
|
16822
|
-
/** Nothing moved. Worth its own answer: "checked, and nothing changed" is a useful thing to hear. */
|
|
16823
|
-
function isUnchanged(changes) {
|
|
16824
|
-
return changes.newThreads === 0 && changes.vanishedThreads === 0 && changes.resolvedSince === 0 && changes.reopenedSince === 0 && changes.newComments === 0;
|
|
16825
|
-
}
|
|
16826
|
-
/**
|
|
16827
|
-
* The change, in a sentence, newest-mattering first.
|
|
16828
|
-
*
|
|
16829
|
-
* Resolutions lead because they are the ones that make a standing claim in this review false —
|
|
16830
|
-
* an "already raised … still unresolved" line that is no longer true is the reason this whole
|
|
16831
|
-
* re-check exists.
|
|
16832
|
-
*/
|
|
16833
|
-
function describeChanges(changes) {
|
|
16834
|
-
if (isUnchanged(changes)) return "Nothing has changed in the conversation since it was imported.";
|
|
16835
|
-
const parts = [];
|
|
16836
|
-
const plural = (count, one, many) => `${count} ${count === 1 ? one : many}`;
|
|
16837
|
-
if (changes.resolvedSince > 0) parts.push(`${plural(changes.resolvedSince, "thread", "threads")} resolved since`);
|
|
16838
|
-
if (changes.reopenedSince > 0) parts.push(`${plural(changes.reopenedSince, "thread", "threads")} reopened`);
|
|
16839
|
-
if (changes.newThreads > 0) parts.push(`${plural(changes.newThreads, "new thread", "new threads")}`);
|
|
16840
|
-
if (changes.newComments > 0) parts.push(`${plural(changes.newComments, "new comment", "new comments")}`);
|
|
16841
|
-
if (changes.vanishedThreads > 0) parts.push(`${plural(changes.vanishedThreads, "thread", "threads")} no longer present`);
|
|
16842
|
-
return `${parts.join(", ")}.`;
|
|
16843
|
-
}
|
|
16844
|
-
//#endregion
|
|
16845
|
-
//#region ../../packages/review-harness/src/workspace/pr_refresh.ts
|
|
16846
|
-
/**
|
|
16847
|
-
* Re-read the pull request conversation, without re-reviewing anything.
|
|
16848
|
-
*
|
|
16849
|
-
* The problem this solves is narrow and specific. Every "Already raised by @sarah · thread still
|
|
16850
|
-
* unresolved" line in a review is a fact that was true at import and is frozen. Nothing re-checks
|
|
16851
|
-
* it, so an hour after Sarah resolves that thread the workspace is still telling the reviewer she
|
|
16852
|
-
* is waiting on it: a confident, specific, wrong claim about what a named colleague thinks.
|
|
16853
|
-
*
|
|
16854
|
-
* **This is not a re-review, and the distinction is the whole design.** Re-reading the conversation
|
|
16855
|
-
* can only update the state of findings that already exist: who raised the thread, whether it is
|
|
16856
|
-
* open, where it links to. It cannot produce a new finding, and it must not try. Deciding that a
|
|
16857
|
-
* new comment invalidates a finding, or that a new commit introduced a bug, is judgement, and
|
|
16858
|
-
* judgement belongs to the analyser. `attributeIssuesToDiscussion` is what runs here, and it is
|
|
16859
|
-
* arithmetic: the model's only surviving contribution is which thread each finding points at.
|
|
16860
|
-
*
|
|
16861
|
-
* This used to spawn an agent in the reviewer's own checkout and ask it to fetch the conversation.
|
|
16862
|
-
* It now asks the forge adapter, which is a couple of subprocess calls and answers in seconds
|
|
16863
|
-
* rather than minutes. The daemon still reaches no forge itself and still holds no credential: the
|
|
16864
|
-
* adapter drives the CLI the developer has already signed in to, exactly as the review drives the
|
|
16865
|
-
* agent they have already signed in to.
|
|
16866
|
-
*/
|
|
16867
|
-
/**
|
|
16868
|
-
* Computes; does not persist. The route saves, so a test can run the whole thing without writing
|
|
16869
|
-
* into the reviewer's own store.
|
|
16870
|
-
*/
|
|
16871
|
-
async function refreshPrContext(workspace, fetch = refetchConversation) {
|
|
16872
|
-
if (!workspace.prContext) return {
|
|
16873
|
-
at: Date.now(),
|
|
16874
|
-
ok: false,
|
|
16875
|
-
message: "This review has no pull request conversation attached, so there is nothing to re-read."
|
|
16876
|
-
};
|
|
16877
|
-
const pr = workspace.prContext.pullRequest;
|
|
16878
|
-
const outcome = await fetch(workspace.repoPath, {
|
|
16879
|
-
url: pr.url,
|
|
16880
|
-
number: pr.number
|
|
16881
|
-
}, agentEnvOf(workspace));
|
|
16882
|
-
if (outcome.kind !== "attached") return {
|
|
16883
|
-
at: Date.now(),
|
|
16884
|
-
ok: false,
|
|
16885
|
-
message: `${whyNot(outcome)} Nothing here has changed, and the discussion shown is still the one that came with the review.`
|
|
16886
|
-
};
|
|
16887
|
-
const fetched = outcome.context;
|
|
16888
|
-
const before = workspace.prContext.discussion;
|
|
16889
|
-
const changes = diffDiscussions(before, fetched.discussion);
|
|
16890
|
-
const restated = reattribute(workspace, fetched.discussion);
|
|
16891
|
-
workspace.prContext = {
|
|
16892
|
-
pullRequest: {
|
|
16893
|
-
...workspace.prContext.pullRequest,
|
|
16894
|
-
...movable(fetched.pullRequest)
|
|
16895
|
-
},
|
|
16896
|
-
discussion: fetched.discussion
|
|
16897
|
-
};
|
|
16898
|
-
workspace.lastPrRefresh = {
|
|
16899
|
-
at: Date.now(),
|
|
16900
|
-
ok: true,
|
|
16901
|
-
message: describeChanges(changes),
|
|
16902
|
-
changes,
|
|
16903
|
-
...restated
|
|
16904
|
-
};
|
|
16905
|
-
return workspace.lastPrRefresh;
|
|
16906
|
-
}
|
|
16907
|
-
/**
|
|
16908
|
-
* Why the re-check did not happen, in the tense a person clicking a button is standing in.
|
|
16909
|
-
*
|
|
16910
|
-
* The same closed union the review's own disclosure renders, and deliberately not the same
|
|
16911
|
-
* sentences. Those are written about a review that has already been made; these are written about
|
|
16912
|
-
* a button that has just been pressed and did nothing.
|
|
16913
|
-
*/
|
|
16914
|
-
function whyNot(outcome) {
|
|
16915
|
-
switch (outcome.kind) {
|
|
16916
|
-
case "no-request": return "The pull request this review is attached to could not be found.";
|
|
16917
|
-
case "unsupported-forge": return outcome.host ? `PR Review Buddy does not read pull request conversation on ${outcome.host} yet.` : "This repository has no remote to look the pull request up on.";
|
|
16918
|
-
case "cli-missing": return `\`${outcome.cli}\` is not installed here, so the pull request could not be re-read.`;
|
|
16919
|
-
case "not-authenticated": return `\`${outcome.cli}\` is not signed in, so the pull request could not be re-read. Run \`${outcome.cli} auth login\` and try again.`;
|
|
16920
|
-
case "failed": return `The pull request could not be re-read. ${outcome.detail}`;
|
|
16921
|
-
}
|
|
16922
|
-
}
|
|
16923
|
-
/**
|
|
16924
|
-
* Which fields a re-read may update, and why these.
|
|
16925
|
-
*
|
|
16926
|
-
* The head SHA and title move on their own as the request gains commits, and the two link shapes
|
|
16927
|
-
* are how this product gets from a file here to the same file there. Number, url and provider are
|
|
16928
|
-
* identity and stay as attached: a re-read does not move a review onto a different pull request.
|
|
16929
|
-
*/
|
|
16930
|
-
function movable(fetched) {
|
|
16931
|
-
const updates = {};
|
|
16932
|
-
const copy = (field) => {
|
|
16933
|
-
const value = fetched[field];
|
|
16934
|
-
if (value !== void 0 && value !== "" && value !== null) updates[field] = value;
|
|
16935
|
-
};
|
|
16936
|
-
copy("headSha");
|
|
16937
|
-
copy("title");
|
|
16938
|
-
if (typeof fetched.body === "string") updates.body = fetched.body;
|
|
16939
|
-
copy("filesUrl");
|
|
16940
|
-
copy("fileUrlTemplate");
|
|
16941
|
-
copy("author");
|
|
16942
|
-
return updates;
|
|
16943
|
-
}
|
|
16944
|
-
/**
|
|
16945
|
-
* Re-derive every attribution from the threads as they now are.
|
|
16946
|
-
*
|
|
16947
|
-
* The same function the review's own attribution pass used, run again against fresh threads. It
|
|
16948
|
-
* rebuilds author, status and link from the real thread and drops any claim whose thread is no
|
|
16949
|
-
* longer there, so nothing the model once said can outlive the conversation that justified it.
|
|
16950
|
-
*/
|
|
16951
|
-
function reattribute(workspace, discussion) {
|
|
16952
|
-
const result = workspace.session.result;
|
|
16953
|
-
if (!result) return {
|
|
16954
|
-
attributionsWithdrawn: 0,
|
|
16955
|
-
attributionsRestated: 0
|
|
16956
|
-
};
|
|
16957
|
-
const before = new Map(result.issues.map((issue) => [issue.id, issue.existingDiscussion?.status]));
|
|
16958
|
-
const outcome = attributeIssuesToDiscussion(result, processDiscussion(discussion));
|
|
16959
|
-
workspace.session = {
|
|
16960
|
-
...workspace.session,
|
|
16961
|
-
result: outcome.result
|
|
16962
|
-
};
|
|
16963
|
-
const restated = outcome.result.issues.filter((issue) => issue.existingDiscussion && before.get(issue.id) !== issue.existingDiscussion.status).length;
|
|
16964
|
-
return {
|
|
16965
|
-
attributionsWithdrawn: outcome.rejected,
|
|
16966
|
-
attributionsRestated: restated
|
|
16967
|
-
};
|
|
16968
|
-
}
|
|
16969
|
-
//#endregion
|
|
16970
18442
|
//#region ../../packages/review-harness/src/workspace/review_update.ts
|
|
16971
18443
|
/**
|
|
16972
18444
|
* Make this review describe the code that is on the ref it follows.
|
|
@@ -17156,34 +18628,44 @@ async function bringForward(workspace, runners, reviewedSha, held, step, signal,
|
|
|
17156
18628
|
const remote = await remoteHead(workspace, signal, runners);
|
|
17157
18629
|
signal.throwIfAborted();
|
|
17158
18630
|
if (!remote.sha) return refuse(reviewedSha, remote.fetchFailed, remote.refusal ?? noRemoteHeadMessage(workspace));
|
|
18631
|
+
adoptLegacyBaseline(workspace);
|
|
17159
18632
|
if (workspace.prContext) step("conversation");
|
|
17160
18633
|
const conversationOk = await reReadConversation(workspace, runners.runRefresh);
|
|
17161
18634
|
signal.throwIfAborted();
|
|
18635
|
+
const { prepared } = await prepareConversation(workspace, {
|
|
18636
|
+
reread: false,
|
|
18637
|
+
...conversationOk !== null && workspace.lastPrRefresh ? { rereadOutcome: workspace.lastPrRefresh } : {}
|
|
18638
|
+
});
|
|
18639
|
+
const discussionMoved = conversationMoved(prepared);
|
|
17162
18640
|
if (reviewedSha && !await commitPresent(workspace.repoPath, reviewedSha)) return settled(reviewedSha, remote.sha, remote.fetchFailed, conversationOk, "baseline-gone", BASELINE_GONE_MESSAGE);
|
|
17163
|
-
if (!reviewedSha || reviewedSha === remote.sha) return settled(reviewedSha, remote.sha, remote.fetchFailed, conversationOk, "already-current", alreadyCurrentMessage(conversationOk));
|
|
18641
|
+
if (!reviewedSha || reviewedSha === remote.sha && !discussionMoved) return settled(reviewedSha, remote.sha, remote.fetchFailed, conversationOk, "already-current", alreadyCurrentMessage(conversationOk));
|
|
17164
18642
|
step("context");
|
|
17165
18643
|
let worktreePath;
|
|
17166
18644
|
try {
|
|
17167
18645
|
({path: worktreePath} = await pinCheckout(workspace, remote.sha, held));
|
|
17168
18646
|
} catch (error) {
|
|
17169
18647
|
if (error instanceof ReviewBeingDeletedError) throw error;
|
|
17170
|
-
return settled(reviewedSha, remote.sha, remote.fetchFailed, conversationOk, "analysis-failed", "A checkout of the
|
|
18648
|
+
return settled(reviewedSha, remote.sha, remote.fetchFailed, conversationOk, "analysis-failed", "A checkout of the latest commit could not be prepared, so nothing was reassessed.");
|
|
17171
18649
|
}
|
|
17172
18650
|
signal.throwIfAborted();
|
|
17173
|
-
return reconcile(workspace, runners, reviewedSha, remote, conversationOk, worktreePath, step, held.job.id, signal, clock);
|
|
18651
|
+
return reconcile(workspace, runners, reviewedSha, remote, conversationOk, worktreePath, step, held.job.id, signal, clock, prepared, discussionMoved);
|
|
17174
18652
|
}
|
|
17175
|
-
async function reconcile(workspace, runners, reviewedSha, remote, conversationOk, worktreePath, step, jobId, signal, clock) {
|
|
18653
|
+
async function reconcile(workspace, runners, reviewedSha, remote, conversationOk, worktreePath, step, jobId, signal, clock, prepared, discussionMoved) {
|
|
17176
18654
|
const newHead = remote.sha;
|
|
17177
|
-
let delta;
|
|
17178
|
-
try {
|
|
18655
|
+
let delta = null;
|
|
18656
|
+
if (reviewedSha !== newHead) try {
|
|
17179
18657
|
delta = await buildReviewContext({
|
|
17180
18658
|
repoPath: worktreePath,
|
|
17181
18659
|
base: reviewedSha
|
|
17182
18660
|
});
|
|
17183
18661
|
} catch (error) {
|
|
17184
|
-
if (error instanceof EmptyChangeError) return settled(reviewedSha, newHead, remote.fetchFailed, conversationOk, "
|
|
17185
|
-
return settled(reviewedSha, newHead, remote.fetchFailed, conversationOk, "baseline-gone", BASELINE_GONE_MESSAGE);
|
|
18662
|
+
if (!(error instanceof EmptyChangeError)) return settled(reviewedSha, newHead, remote.fetchFailed, conversationOk, "baseline-gone", BASELINE_GONE_MESSAGE);
|
|
17186
18663
|
}
|
|
18664
|
+
if (!delta && !discussionMoved) return settled(reviewedSha, newHead, remote.fetchFailed, conversationOk, "already-current", alreadyCurrentMessage(conversationOk));
|
|
18665
|
+
const basis = {
|
|
18666
|
+
code: delta !== null,
|
|
18667
|
+
discussion: discussionMoved
|
|
18668
|
+
};
|
|
17187
18669
|
const previous = workspace.session.result;
|
|
17188
18670
|
if (!previous) return settled(reviewedSha, newHead, remote.fetchFailed, conversationOk, "no-result", "This review has no analysis to bring up to date.");
|
|
17189
18671
|
step("analysing");
|
|
@@ -17202,50 +18684,45 @@ async function reconcile(workspace, runners, reviewedSha, remote, conversationOk
|
|
|
17202
18684
|
const requestedModel = resolveModel({ review: { session: workspace.session } }) ?? void 0;
|
|
17203
18685
|
let raw;
|
|
17204
18686
|
try {
|
|
17205
|
-
const prompt = buildUpdatePrompt(workspace, delta, reviewedSha, newHead, worktreePath);
|
|
18687
|
+
const prompt = buildUpdatePrompt(workspace, delta, prepared, reviewedSha, newHead, worktreePath);
|
|
17206
18688
|
raw = runners.run ? await runners.run(prompt, worktreePath, agentEnvOf(workspace), onProgress, onObservedModel, signal) : await runUpdate(workspace.id, prompt, worktreePath, workspace.repoPath, agentEnvOf(workspace), onProgress, requestedModel, onObservedModel, signal);
|
|
17207
18689
|
} catch (error) {
|
|
17208
18690
|
return settled(reviewedSha, newHead, remote.fetchFailed, conversationOk, "analysis-failed", explainUpdateFailure(error, agentEnvOf(workspace), requestedModel));
|
|
17209
18691
|
}
|
|
17210
18692
|
const parsed = parseReconciliationResult(raw, previous.issues.length, { priorQuestionCount: previous.questions.length });
|
|
17211
18693
|
if (!parsed) return settled(reviewedSha, newHead, remote.fetchFailed, conversationOk, "analysis-failed", "The reassessment came back in a form this could not read, so nothing was changed.");
|
|
17212
|
-
const suggestions = reconciliationGradeSuggestions(previous, parsed, assessmentLookup(workspace.session));
|
|
17213
|
-
const issues = [...reconcileIssues(previous.issues, parsed.findings), ...markNewIssues(parsed.newIssues)];
|
|
17214
18694
|
const motivation = carryMotivation(previous.motivation, workspace.session.metadata?.authorDescription, workspace.prContext?.pullRequest.body);
|
|
17215
|
-
const
|
|
17216
|
-
|
|
17217
|
-
|
|
17218
|
-
|
|
17219
|
-
|
|
17220
|
-
|
|
17221
|
-
|
|
17222
|
-
|
|
17223
|
-
sections: composeSections(previous.sections, parsed.newSections),
|
|
17224
|
-
issues,
|
|
17225
|
-
questions: motivation === previous.motivation ? questions : withIntentQuestion(questions, motivation)
|
|
17226
|
-
};
|
|
18695
|
+
const { result, suggestions } = applyReconciliation(previous, parsed, {
|
|
18696
|
+
at: clock.now(),
|
|
18697
|
+
commitSha: newHead,
|
|
18698
|
+
codeChanged: delta !== null,
|
|
18699
|
+
conversation: prepared,
|
|
18700
|
+
assess: assessmentLookup(workspace.session),
|
|
18701
|
+
motivation
|
|
18702
|
+
});
|
|
17227
18703
|
signal.throwIfAborted();
|
|
17228
18704
|
cancellers.delete(jobId);
|
|
17229
18705
|
const gradeChanges = await applyUpdate(workspace, result, previous, newHead, worktreePath, {
|
|
17230
18706
|
...requestedModel ? { requestedModel } : {},
|
|
17231
18707
|
...observedModel ? { observedModel } : {},
|
|
17232
18708
|
...await (runners.agentVersion ?? installedAgentVersion)(agentEnvOf(workspace))
|
|
17233
|
-
}, clock, remote.base, suggestions);
|
|
18709
|
+
}, clock, remote.base, suggestions, conversationTakenIntoAccount(prepared, analysedBaselineOf(workspace)));
|
|
17234
18710
|
const counts = {
|
|
17235
|
-
...
|
|
18711
|
+
...countUpdateChanges(previous, result),
|
|
17236
18712
|
...gradeChanges ? { gradeChanges } : {}
|
|
17237
18713
|
};
|
|
17238
18714
|
return {
|
|
17239
18715
|
at: Date.now(),
|
|
17240
18716
|
ok: true,
|
|
17241
|
-
message: describeUpdate(counts, newHead),
|
|
18717
|
+
message: describeUpdate(counts, newHead, basis),
|
|
17242
18718
|
stop: null,
|
|
17243
18719
|
fetchFailed: remote.fetchFailed,
|
|
17244
18720
|
moved: reviewedSha !== newHead,
|
|
17245
18721
|
...conversationOk === null ? {} : { conversationOk },
|
|
17246
18722
|
fromSha: reviewedSha,
|
|
17247
18723
|
toSha: newHead,
|
|
17248
|
-
counts
|
|
18724
|
+
counts,
|
|
18725
|
+
reassessed: basis
|
|
17249
18726
|
};
|
|
17250
18727
|
}
|
|
17251
18728
|
var BASELINE_GONE_MESSAGE = "The commit this review was made from is no longer in this repository, so there is nothing to measure the new code against. This one needs starting fresh: run `prreviewbuddy review --fresh`.";
|
|
@@ -17398,6 +18875,7 @@ var UPDATE_OWNED_FIELDS = [
|
|
|
17398
18875
|
"reviewedPaths",
|
|
17399
18876
|
"previousResult",
|
|
17400
18877
|
"prContext",
|
|
18878
|
+
"analysedConversation",
|
|
17401
18879
|
"lastPrRefresh",
|
|
17402
18880
|
"dismissedFreshnessSha",
|
|
17403
18881
|
"lastUpdate",
|
|
@@ -17460,7 +18938,7 @@ function carryUpdateSuggestions(merged, updated) {
|
|
|
17460
18938
|
merged.lastUpdate = {
|
|
17461
18939
|
...outcome,
|
|
17462
18940
|
counts,
|
|
17463
|
-
message: describeUpdate(counts, outcome.toSha)
|
|
18941
|
+
message: describeUpdate(counts, outcome.toSha, outcome.reassessed)
|
|
17464
18942
|
};
|
|
17465
18943
|
}
|
|
17466
18944
|
/**
|
|
@@ -17471,8 +18949,9 @@ function carryUpdateSuggestions(merged, updated) {
|
|
|
17471
18949
|
* to browse the review's past. There is no restore button yet, and that is fine. Insurance that is
|
|
17472
18950
|
* only written once somebody has already built the claim form is not insurance.
|
|
17473
18951
|
*/
|
|
17474
|
-
async function applyUpdate(workspace, result, replaced, newHead, worktreePath, run, clock, freshBase, suggestions = []) {
|
|
18952
|
+
async function applyUpdate(workspace, result, replaced, newHead, worktreePath, run, clock, freshBase, suggestions = [], conversation) {
|
|
17475
18953
|
workspace.previousResult = {
|
|
18954
|
+
...workspace.analysedConversation ? { analysedConversation: workspace.analysedConversation } : {},
|
|
17476
18955
|
result: replaced,
|
|
17477
18956
|
commitSha: workspace.session.metadata?.commitSha ?? "",
|
|
17478
18957
|
replacedAt: Date.now(),
|
|
@@ -17505,6 +18984,10 @@ async function applyUpdate(workspace, result, replaced, newHead, worktreePath, r
|
|
|
17505
18984
|
}
|
|
17506
18985
|
});
|
|
17507
18986
|
delete workspace.dismissedFreshnessSha;
|
|
18987
|
+
workspace.analysedConversation = {
|
|
18988
|
+
sessionId: workspace.session.id,
|
|
18989
|
+
discussion: conversation ?? null
|
|
18990
|
+
};
|
|
17508
18991
|
return proposeGradeSuggestions(workspace.session, suggestions, {
|
|
17509
18992
|
kind: "update",
|
|
17510
18993
|
commitSha: newHead
|
|
@@ -17599,11 +19082,13 @@ function legacyBaseCandidates(recorded) {
|
|
|
17599
19082
|
/** Null when there is no conversation attached, which is neither success nor failure. */
|
|
17600
19083
|
async function reReadConversation(workspace, fetch) {
|
|
17601
19084
|
if (!workspace.prContext) return null;
|
|
19085
|
+
const startedAt = Date.now();
|
|
17602
19086
|
try {
|
|
17603
19087
|
const outcome = fetch ? await refreshPrContext(workspace, fetch) : await refreshPrContext(workspace);
|
|
17604
19088
|
workspace.lastPrRefresh = outcome;
|
|
17605
19089
|
return outcome.ok;
|
|
17606
19090
|
} catch {
|
|
19091
|
+
workspace.lastPrRefresh = unforeseenFailure(startedAt);
|
|
17607
19092
|
return false;
|
|
17608
19093
|
}
|
|
17609
19094
|
}
|
|
@@ -17625,16 +19110,6 @@ function alreadyCurrentMessage(conversationOk) {
|
|
|
17625
19110
|
if (conversationOk === null) return code;
|
|
17626
19111
|
return conversationOk ? `${code} The pull request conversation was re-read.` : `${code} The pull request conversation could not be re-read, so the notes about who raised what are unchanged.`;
|
|
17627
19112
|
}
|
|
17628
|
-
function summariseCounts(result) {
|
|
17629
|
-
const byStatus = countByStatus(result.issues);
|
|
17630
|
-
return {
|
|
17631
|
-
resolved: byStatus.resolved,
|
|
17632
|
-
stillOpen: byStatus["still-open"],
|
|
17633
|
-
affected: byStatus.affected,
|
|
17634
|
-
newIssues: byStatus.new,
|
|
17635
|
-
questionsAnswered: result.questions.filter((question) => question.reconciliationStatus === "answered").length
|
|
17636
|
-
};
|
|
17637
|
-
}
|
|
17638
19113
|
/**
|
|
17639
19114
|
* What happened, in the order the reviewer cares about.
|
|
17640
19115
|
*
|
|
@@ -17642,18 +19117,35 @@ function summariseCounts(result) {
|
|
|
17642
19117
|
* they need. Still-present is the number that is usually largest and almost never interesting, so
|
|
17643
19118
|
* it goes last and only when there is something to sit beside.
|
|
17644
19119
|
*/
|
|
17645
|
-
function describeUpdate(counts, sha
|
|
19120
|
+
function describeUpdate(counts, sha, basis = {
|
|
19121
|
+
code: true,
|
|
19122
|
+
discussion: false
|
|
19123
|
+
}) {
|
|
17646
19124
|
const parts = [];
|
|
17647
|
-
|
|
19125
|
+
const byConversation = counts.resolvedByConversation ?? 0;
|
|
19126
|
+
const fixed = counts.resolved - byConversation;
|
|
19127
|
+
if (fixed) parts.push(`${fixed} fixed`);
|
|
19128
|
+
if (byConversation) parts.push(`${byConversation} resolved by the conversation`);
|
|
19129
|
+
if (counts.reopened) parts.push(`${counts.reopened} reopened`);
|
|
17648
19130
|
if (counts.newIssues) parts.push(`${counts.newIssues} new`);
|
|
17649
19131
|
if (counts.affected) parts.push(`${counts.affected} to look at again`);
|
|
17650
19132
|
if (counts.questionsAnswered) parts.push(`${counts.questionsAnswered} question${counts.questionsAnswered === 1 ? "" : "s"} answered`);
|
|
17651
19133
|
if (counts.stillOpen && parts.length) parts.push(`${counts.stillOpen} still present`);
|
|
17652
19134
|
const grades = counts.gradeChanges ? ` ${counts.gradeChanges} grade ${counts.gradeChanges === 1 ? "change" : "changes"} suggested.` : "";
|
|
17653
|
-
|
|
17654
|
-
|
|
19135
|
+
if (!basis.code) {
|
|
19136
|
+
const at = "Reassessed against the pull request conversation; no new commits";
|
|
19137
|
+
if (!parts.length) return `${at}. The conversation changed nothing about the findings${stillPresent(counts)}.${grades}`;
|
|
19138
|
+
return `${at}: ${parts.join(", ")}.${grades}`;
|
|
19139
|
+
}
|
|
19140
|
+
const at = `Updated to ${sha.slice(0, 7)}${basis.discussion ? ", with the conversation" : ""}`;
|
|
19141
|
+
if (!parts.length) return `${at}. The new commits${basis.discussion ? " and comments" : ""} changed nothing about the findings${stillPresent(counts)}.${grades}`;
|
|
17655
19142
|
return `${at}: ${parts.join(", ")}.${grades}`;
|
|
17656
19143
|
}
|
|
19144
|
+
/** The open findings an update left as they were, after a sentence saying it changed nothing. */
|
|
19145
|
+
function stillPresent(counts) {
|
|
19146
|
+
if (!counts.stillOpen) return "";
|
|
19147
|
+
return `: ${counts.stillOpen} ${counts.stillOpen === 1 ? "is" : "are"} still present`;
|
|
19148
|
+
}
|
|
17657
19149
|
/**
|
|
17658
19150
|
* The reconciliation, asked for as a review of new code rather than as a tidy-up of an old list.
|
|
17659
19151
|
*
|
|
@@ -17663,19 +19155,26 @@ function describeUpdate(counts, sha) {
|
|
|
17663
19155
|
* Everything after it is the shared builder, so the two clients cannot drift into asking for
|
|
17664
19156
|
* different shapes.
|
|
17665
19157
|
*/
|
|
17666
|
-
function buildUpdatePrompt(workspace, delta, reviewedSha, newHead, worktreePath) {
|
|
17667
|
-
const files = delta.changeSet.data.files;
|
|
19158
|
+
function buildUpdatePrompt(workspace, delta, conversation, reviewedSha, newHead, worktreePath) {
|
|
17668
19159
|
const previous = workspace.session.result;
|
|
17669
|
-
const
|
|
17670
|
-
|
|
19160
|
+
const discussionChanged = conversationMoved(conversation);
|
|
19161
|
+
const conversationBlock = updateConversationBlock(conversation);
|
|
19162
|
+
const opening = delta ? `An earlier review of this branch is being brought up to date with the commits pushed since it was written${discussionChanged ? ", and with what has changed in its pull request conversation since" : ""}.` : "An earlier review of this branch is being brought up to date with what has changed in its pull request conversation since it was written. No commits have been pushed since.";
|
|
19163
|
+
const code = delta ? `The change below is what happened between ${reviewedSha} and ${newHead}. It is not the whole branch: the earlier review already covered everything up to ${reviewedSha}.
|
|
19164
|
+
|
|
19165
|
+
${renderChangeInventory(delta.changeSet.data.files, inlinePathsOf(delta))}
|
|
19166
|
+
|
|
19167
|
+
${delta.diff}` : `The code is unchanged since the earlier review: ${newHead} is the commit it read.`;
|
|
19168
|
+
const commits = delta ? (delta.changeSet.authors ?? []).reduce((total, author) => total + author.commits, 0) : 0;
|
|
19169
|
+
return `${opening}
|
|
17671
19170
|
|
|
17672
19171
|
The working tree at ${worktreePath} is checked out at ${newHead}, which is the code as it now stands. You may read any file in it, and you should: the point of doing this here rather than from a diff alone is that you can open the code and see whether a fix actually holds. Read only. Change nothing, commit nothing, and run no command that writes.
|
|
17673
19172
|
|
|
17674
|
-
|
|
19173
|
+
${code}
|
|
17675
19174
|
|
|
17676
|
-
|
|
19175
|
+
---
|
|
17677
19176
|
|
|
17678
|
-
${
|
|
19177
|
+
${conversationBlock}
|
|
17679
19178
|
|
|
17680
19179
|
---
|
|
17681
19180
|
|
|
@@ -17685,9 +19184,17 @@ ${buildReconciliationInstructions({
|
|
|
17685
19184
|
fromSha: reviewedSha,
|
|
17686
19185
|
toSha: newHead,
|
|
17687
19186
|
totalCommits: commits,
|
|
17688
|
-
changedFiles: files.length,
|
|
17689
|
-
possiblyTruncated: false
|
|
17690
|
-
|
|
19187
|
+
changedFiles: delta ? delta.changeSet.data.files.length : 0,
|
|
19188
|
+
possiblyTruncated: false,
|
|
19189
|
+
reassess: conversationReassessment(previous, conversation)
|
|
19190
|
+
}, {
|
|
19191
|
+
includeQuestions: true,
|
|
19192
|
+
...discussionChanged ? { discussion: { codeChanged: delta !== null } } : {}
|
|
19193
|
+
})}`;
|
|
19194
|
+
}
|
|
19195
|
+
/** The files whose patch travelled inline, as against those only named in the inventory. */
|
|
19196
|
+
function inlinePathsOf(delta) {
|
|
19197
|
+
return new Set(delta.changeSet.data.files.map((file) => file.name).filter((name) => !delta.inventoryOnlyFiles.includes(name)));
|
|
17691
19198
|
}
|
|
17692
19199
|
/**
|
|
17693
19200
|
* The production run: a `read-code` run standing in the pinned worktree.
|
|
@@ -17906,4 +19413,4 @@ function lineageIds(id) {
|
|
|
17906
19413
|
return [.../* @__PURE__ */ new Set([id, ...lineage.map((review) => review.id)])];
|
|
17907
19414
|
}
|
|
17908
19415
|
//#endregion
|
|
17909
|
-
export {
|
|
19416
|
+
export { resolveWorkspacePort as $, PHASES as $n, explanationOf as $r, readNotification as $t, parsePullRequestLink as A, readAgentPreference as Ai, MANAGED_ROOT as An, activityLabel as Ar, storeArchitecture as At, ensureServer as B, checkCodeFreshness as Bn, queueRemove as Br, branchOf as Bt, completeRun as C, DetachedHeadError as Ci, renderMarkdown as Cn, reviewedRepositories as Cr, describeRuntimes as Ct, relativeTime as D, resolveAgent as Di, renderInlineText as Dn, workspaceStamps as Dr, partFiles as Dt, stopRun as E, git as Ei, escapeHtml as En, workspaceRevision as Er, loadArchitecture as Et, queueForReview as F, agentById as Fi, removeWorktree as Fn, offersLifecycle as Fr, REPOSITORY_SCOPE as Ft, stopChild as G, forgeResolver as Gn, isQuestionOutstanding as Gr, createPrUpdate as Gt, reviewsUrl as H, followedBranch as Hn, readQueue as Hr, categoryOf as Ht, settleQueue as I, agentFor as Ii, requestJobStop as In, parseLifecycle as Ir, ancestorsOf as It, writeServerRecord as J, endedAt as Jn, resolvedByConversation as Jr, markAllRead as Jt, stopServer as K, fillFileUrlTemplate as Kn, issuesOutstanding as Kr, eventName as Kt, readIndexToken as L, detectAgents as Li, describeMovement as Ln, sectionOf as Lr, isArchitectureId as Lt, runBatch as M, CONFIG_PATH as Mi, holdWorktree as Mn, lifecycleChip as Mr, trackedFiles as Mt, activateQueued as N, STORE_ROOT as Ni, isWorktreeHeld as Nn, lifecycleItemLabel as Nr, ownersOf as Nt, batchIdentity as O, resolveModel as Oi, modelHelpLines as On, LIFECYCLES as Or, readArchitecture as Ot, compactQueue as P, AGENT_IDS as Pi, readMarker as Pn, lifecyclePill as Pr, QUOTE_LIMITS as Pt, parseWorkspacePort as Q, saveJob as Qn, explanationActionOf as Qr, pruneNotifications as Qt, bootstrapUrl as R, AgentCancelledError as Ri, describeSubject as Rn, queueAdd as Rr, writeJsonAtomically as Rt, beginRun as S, originRemote as Si, GITHUB_MARKDOWN as Sn, reviewedCommit as Sr, RUNTIME_WORDS as St, reportRun as T, resolveBase as Ti, CODE_COPY_CLASS as Tn, touchWorkspace as Tr, checkArchitecture as Tt, serverEntryExists as U, reviewedShaOf as Un, doneVerb as Ur, createArchitectureNotification as Ut, readServerRecord as V, checkReview as Vn, queueStamp as Vr, subjectOf as Vt, spawnServer as W, fetchHead as Wn, isIssueOutstanding as Wr, createPrState as Wt, MAX_WORKSPACE_PORT as X, isTerminal as Xn, processDiscussion as Xr, markReadForReview as Xt, DEFAULT_WORKSPACE_PORT as Y, fail as Yn, reviewerDispositions as Yr, markRead as Yt, MIN_WORKSPACE_PORT as Z, loadJob as Zn, recommendationsOf as Zr, moveIds as Zt, recordKissRun as _, objectWithAnyOf as _i, EXPLAIN_SIMPLY_PROMPT as _n, mutateWorkspace as _r, PACKAGE_NAME as _t, wasBlocked as a, currentGradeIn as ai, unreadCount as an, isClaimed as ar, reanalyseReview as at, track as b, kissOutcome as bi, authorPreview as bn, previousKissResult as br, mapRuntimes as bt, RefreshUnavailableError as c, gradeScale as ci, ASK_REREAD_CEILING_MS as cn, groupByLineage as cr, append as ct, mergeUpdateInto as d, reassessIn as di, saveRefresh as dn, kissDecisionRefusal as dr, stamp as dt, isExplanationAction as ei, readStateOf as en, isLiveJob as er, statedWorkspacePort as et, updateReview as f, sourcePhrase as fi, storedConversation as fn, kissDecisionsOf as fr, deliver as ft, runningJobs as g, extractJsonText as gi, agentEnvOf as gn, matchingWorkspaceIds as gr, BUILD_VERSION as gt, liveUpdateFor as h, asString$1 as hi, isUnchanged as hn, loadWorkspace as hr, telemetryUploadEnabled as ht, liveJobsFor as i, assessmentLookup as ii, unreadAround as in, clearClaim as ir, settleReview as it, unsupportedReason as j, writeAgentPreference as ji, createWorktree as jn, isLifecycle as jr, storedScopes as jt, extractPullRequestLinks as k, clearAgentPreference as ki, ReviewBeingDeletedError as kn, activityAttributes as kr, repositoryKey as kt, cancelUpdate as l, isGradeFor as li, adoptLegacyBaseline as ln, indexKeptIds as lr, readEvents as lt, UpdateAlreadyRunningError as m, followUpFindingId as mi, refreshPrContext as mn, lineagePosition as mr, telemetryUploadConsent as mt, deleteReview as n, ANALYSIS_ONLY as ni, retireNotification as nn, progressSteps as nr, failureOf as nt, discardJob as o, findingAssessment as oi, withUsageRecorded as on, decideKissRecommendation as or, runJob as ot, askCheckout as p, wasReassessed as pi, unforeseenFailure as pn, latestKissRun as pr, recordTelemetryUploadConsent as pt, workspaceUrl as q, allJobs as qn, questionsOutstanding as qr, listNotifications as qt, lineageIds as r, analysisRefOf as ri, toneOf as rn, purposeOf as rr, prepareReview as rt, FETCH_TIMEOUT_MS as s, findingIn as si, existingPaths as sn, followedRefName as sr, startJob as st, deleteLineage as t, withExplanation as ti, removeNotification as tn, phasesFor as tr, writePortPreference as tt, cancellableHere as u, promptGrade as ui, prepareConversationWithin as un, indexReviewGroups as ur, record as ut, startKissJob as v, describeAuthorship as vi, chatConversationSection as vn, positionInLineage as vr, feedbackUrl as vt, endRun as w, isCommitReview as wi, CODE_BLOCK_CLASS as wn, summarise as wr, ARCHITECTURE_DIR as wt, RUN_KINDS as x, resolveTarget as xi, hasAuthorContent as xn, recentWorkspaces as xr, recordRuntimeBestEffort as xt, isDriving as y, WITHHELD_REASON as yi, UNESTABLISHED_MOTIVATION as yn, presentedKissRunOf as yr, waitingHistory as yt, clearServerRecordOwnedBy as z, AgentUnavailableError as zi, findExistingReview as zn, queueAddMany as zr, announceJob as zt };
|