prreviewbuddy 0.75.0 → 0.77.1
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 +164 -0
- package/dist/{delete_review-BWKGCqwI.js → delete_review-BHOzMQ0_.js} +2257 -702
- package/dist/main.js +1 -1
- package/dist/server.js +529 -153
- package/package.json +1 -1
- package/static/{chunk-egI_P3oB.js → chunk-BoQsVuYd.js} +1 -1
- package/static/chunk-CEQv4zVy.js +1 -0
- package/static/help.js +1 -1
- package/static/notifications.js +1 -1
- package/static/reviews.js +1 -1
- package/static/settings.js +1 -1
- package/static/shell.css +73 -4
- package/static/sidebar.css +13 -0
- package/static/workspace.js +3 -3
- package/static/chunk-C0lM8IBt.js +0 -1
|
@@ -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,123 +11430,874 @@ 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
|
-
|
|
11277
|
-
|
|
11278
|
-
|
|
11660
|
+
//#region ../../packages/review-harness/src/domain/discussion/conversation_section.ts
|
|
11661
|
+
/**
|
|
11662
|
+
* The line every state ends with.
|
|
11663
|
+
*
|
|
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.
|
|
11666
|
+
*/
|
|
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
|
+
}
|
|
11808
|
+
/**
|
|
11809
|
+
* Split a delta at a budget, by the same lines it is rendered as.
|
|
11810
|
+
*
|
|
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.
|
|
11847
|
+
*
|
|
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.
|
|
11851
|
+
*/
|
|
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"
|
|
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
|
+
}
|
|
11878
|
+
/**
|
|
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.
|
|
11885
|
+
*
|
|
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.
|
|
11894
|
+
*/
|
|
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:
|
|
12185
|
+
{
|
|
12186
|
+
"summary": string,
|
|
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": [{
|
|
12196
|
+
"id": string,
|
|
12197
|
+
"order": number,
|
|
12198
|
+
"title": string,
|
|
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
|
|
12208
|
+
}],
|
|
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.
|
|
11279
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.
|
|
11280
12302
|
Do not propose refactoring architecture the change merely touches.`;
|
|
11281
12303
|
var EVIDENCE_THRESHOLD = `Evidence threshold:
|
|
@@ -11497,199 +12519,712 @@ ${diff}`;
|
|
|
11497
12519
|
text: "x".repeat(instructionsChars)
|
|
11498
12520
|
});
|
|
11499
12521
|
return {
|
|
11500
|
-
prompt,
|
|
11501
|
-
budget: buildPromptBudget(sections),
|
|
11502
|
-
discussionOverflow: discussion.overflow,
|
|
11503
|
-
|
|
11504
|
-
|
|
12522
|
+
prompt,
|
|
12523
|
+
budget: buildPromptBudget(sections),
|
|
12524
|
+
discussionOverflow: discussion.overflow,
|
|
12525
|
+
discussionThreadIds: discussion.includedThreadIds,
|
|
12526
|
+
partiallyReadFiles: budgeted.partialFiles.length,
|
|
12527
|
+
partialFiles: budgeted.partialFiles
|
|
12528
|
+
};
|
|
12529
|
+
}
|
|
12530
|
+
//#endregion
|
|
12531
|
+
//#region ../../packages/review-harness/src/guide/diff_inventory.ts
|
|
12532
|
+
/**
|
|
12533
|
+
* How much diff text this client may put inline in one review context.
|
|
12534
|
+
*
|
|
12535
|
+
* A Claude Code context budget, not a coverage limit — the two were the same number in the
|
|
12536
|
+
* extension only because a provider API cannot go and fetch what it was not sent. Here every
|
|
12537
|
+
* changed file is named in the inventory whatever this is set to, and the reader holds the
|
|
12538
|
+
* working tree, so all this decides is how much reading it is saved rather than how much of the
|
|
12539
|
+
* change it knows about.
|
|
12540
|
+
*
|
|
12541
|
+
* 300k characters is roughly 75k tokens, which is already a large share of a live session that
|
|
12542
|
+
* has other work in it. It is a starting point chosen against that constraint rather than a
|
|
12543
|
+
* measured optimum; change it freely once real reviews say something.
|
|
12544
|
+
*/
|
|
12545
|
+
var PLUGIN_DIFF_BUDGET_CHARS = 3e5;
|
|
12546
|
+
/**
|
|
12547
|
+
* Decide whose patch travels inline, whole or not at all.
|
|
12548
|
+
*
|
|
12549
|
+
* The extension truncates an oversized patch and marks how far it read, which is the best
|
|
12550
|
+
* available answer when the reader cannot go and get the rest. This client's reader can, and a
|
|
12551
|
+
* truncated patch is strictly worse than a pointer to the real file: it invites a conclusion
|
|
12552
|
+
* drawn from a prefix, where the pointer invites a `git diff`. So there is no partial state here.
|
|
12553
|
+
*
|
|
12554
|
+
* Files are served smallest-first, the same progressive filling `domain/prompts/diff_budget` uses
|
|
12555
|
+
* and for the same reason: a claim that fits within what is left is granted in full, so one
|
|
12556
|
+
* enormous file is left out by itself instead of taking a slice off everything else.
|
|
12557
|
+
*
|
|
12558
|
+
* The consequence is worth stating rather than hiding — the *largest* files are the likeliest to
|
|
12559
|
+
* be left out. That is not a ranking. Size is not importance, a large change is conspicuous in
|
|
12560
|
+
* the inventory whether or not its diff is inline, and deciding which large file mattered is the
|
|
12561
|
+
* model's job, not this function's.
|
|
12562
|
+
*/
|
|
12563
|
+
function selectInlineDiffs(files, budget = PLUGIN_DIFF_BUDGET_CHARS) {
|
|
12564
|
+
const candidates = files.map((file, index) => ({
|
|
12565
|
+
file,
|
|
12566
|
+
index
|
|
12567
|
+
})).filter((entry) => !isGeneratedOrVendored(entry.file));
|
|
12568
|
+
const chosen = /* @__PURE__ */ new Set();
|
|
12569
|
+
let used = 0;
|
|
12570
|
+
for (const entry of [...candidates].sort((a, b) => a.file.patch.length - b.file.patch.length || a.index - b.index)) {
|
|
12571
|
+
if (used + entry.file.patch.length > budget) break;
|
|
12572
|
+
chosen.add(entry.index);
|
|
12573
|
+
used += entry.file.patch.length;
|
|
12574
|
+
}
|
|
12575
|
+
const inline = [];
|
|
12576
|
+
const inventoryOnly = [];
|
|
12577
|
+
files.forEach((file, index) => (chosen.has(index) ? inline : inventoryOnly).push(file));
|
|
12578
|
+
return {
|
|
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
|
|
11505
12792
|
};
|
|
11506
12793
|
}
|
|
11507
12794
|
//#endregion
|
|
11508
|
-
//#region ../../packages/review-harness/src/
|
|
12795
|
+
//#region ../../packages/review-harness/src/workspace/discussion_diff.ts
|
|
11509
12796
|
/**
|
|
11510
|
-
*
|
|
12797
|
+
* What moved in a pull request conversation between two imports.
|
|
11511
12798
|
*
|
|
11512
|
-
*
|
|
11513
|
-
*
|
|
11514
|
-
*
|
|
11515
|
-
* working tree, so all this decides is how much reading it is saved rather than how much of the
|
|
11516
|
-
* change it knows about.
|
|
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.
|
|
11517
12802
|
*
|
|
11518
|
-
*
|
|
11519
|
-
*
|
|
11520
|
-
*
|
|
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.
|
|
11521
12807
|
*/
|
|
11522
|
-
|
|
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
|
+
}
|
|
11523
12827
|
/**
|
|
11524
|
-
*
|
|
12828
|
+
* The change, in a sentence, newest-mattering first.
|
|
11525
12829
|
*
|
|
11526
|
-
*
|
|
11527
|
-
*
|
|
11528
|
-
*
|
|
11529
|
-
|
|
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.
|
|
11530
12852
|
*
|
|
11531
|
-
*
|
|
11532
|
-
*
|
|
11533
|
-
*
|
|
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.
|
|
11534
12857
|
*
|
|
11535
|
-
*
|
|
11536
|
-
*
|
|
11537
|
-
*
|
|
11538
|
-
*
|
|
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.
|
|
11539
12870
|
*/
|
|
11540
|
-
|
|
11541
|
-
|
|
11542
|
-
|
|
11543
|
-
|
|
11544
|
-
|
|
11545
|
-
const
|
|
11546
|
-
|
|
11547
|
-
|
|
11548
|
-
|
|
11549
|
-
|
|
11550
|
-
|
|
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}`;
|
|
11551
12930
|
}
|
|
11552
|
-
|
|
11553
|
-
|
|
11554
|
-
|
|
11555
|
-
|
|
11556
|
-
|
|
11557
|
-
|
|
11558
|
-
|
|
11559
|
-
|
|
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;
|
|
11560
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;
|
|
11561
12952
|
}
|
|
11562
12953
|
/**
|
|
11563
|
-
*
|
|
12954
|
+
* Re-derive every attribution from the threads as they now are.
|
|
11564
12955
|
*
|
|
11565
|
-
*
|
|
11566
|
-
*
|
|
11567
|
-
*
|
|
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.
|
|
11568
12959
|
*/
|
|
11569
|
-
function
|
|
11570
|
-
|
|
11571
|
-
|
|
11572
|
-
|
|
11573
|
-
|
|
11574
|
-
|
|
11575
|
-
|
|
11576
|
-
|
|
11577
|
-
|
|
11578
|
-
|
|
11579
|
-
|
|
11580
|
-
}
|
|
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
|
|
12976
|
+
};
|
|
11581
12977
|
}
|
|
11582
12978
|
//#endregion
|
|
11583
|
-
//#region ../../packages/review-harness/src/
|
|
12979
|
+
//#region ../../packages/review-harness/src/workspace/conversation_context.ts
|
|
11584
12980
|
/**
|
|
11585
|
-
*
|
|
12981
|
+
* The pull request conversation, prepared by the harness for a model to reason over.
|
|
11586
12982
|
*
|
|
11587
|
-
*
|
|
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.
|
|
11588
12987
|
*
|
|
11589
|
-
*
|
|
11590
|
-
*
|
|
11591
|
-
|
|
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.
|
|
12990
|
+
*/
|
|
12991
|
+
/**
|
|
12992
|
+
* The conversation the current analysis took into account, or why that cannot be known.
|
|
11592
12993
|
*
|
|
11593
|
-
*
|
|
11594
|
-
*
|
|
11595
|
-
*
|
|
11596
|
-
*
|
|
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.
|
|
11597
12998
|
*
|
|
11598
|
-
*
|
|
11599
|
-
*
|
|
11600
|
-
* largest-diff-first fallback can never bind — a second selection here would be the harness
|
|
11601
|
-
* deciding what matters, which is the one thing it must never do.
|
|
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.
|
|
11602
13001
|
*/
|
|
11603
|
-
|
|
11604
|
-
const
|
|
11605
|
-
|
|
11606
|
-
|
|
11607
|
-
|
|
11608
|
-
|
|
11609
|
-
|
|
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.
|
|
13014
|
+
*
|
|
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.
|
|
13020
|
+
*/
|
|
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
|
/**
|
|
@@ -15192,6 +16705,54 @@ async function settleReview(workspaceId, reviewType = "standard", signal) {
|
|
|
15192
16705
|
job
|
|
15193
16706
|
};
|
|
15194
16707
|
}
|
|
16708
|
+
var realReads = {
|
|
16709
|
+
job: (workspaceId) => {
|
|
16710
|
+
const jobId = loadWorkspace(workspaceId)?.jobId;
|
|
16711
|
+
return jobId ? loadJob(jobId) : null;
|
|
16712
|
+
},
|
|
16713
|
+
hasResult: (workspaceId) => Boolean(loadWorkspace(workspaceId)?.session?.result),
|
|
16714
|
+
driverGone,
|
|
16715
|
+
wait: (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
16716
|
+
};
|
|
16717
|
+
/**
|
|
16718
|
+
* Wait for a review that something else is analysing, and say how it ended, running nothing.
|
|
16719
|
+
*
|
|
16720
|
+
* `settleReview` is the wrong tool for a review already in progress: it drives the job, and a
|
|
16721
|
+
* second driver on a running job is the duplicate analysis this exists to avoid. This only reads
|
|
16722
|
+
* the job record until it ends, with the same outcomes `settleReview` reports. A driver that went
|
|
16723
|
+
* away mid-run ends the wait too, as a failure, rather than leaving it to the sweep half an hour on.
|
|
16724
|
+
*/
|
|
16725
|
+
async function observeReview(workspaceId, reads = realReads, everyMs = 2e3) {
|
|
16726
|
+
for (;;) {
|
|
16727
|
+
const job = reads.job(workspaceId);
|
|
16728
|
+
if (!job) return {
|
|
16729
|
+
ok: false,
|
|
16730
|
+
reason: "The review’s analysis record could not be read.",
|
|
16731
|
+
stopped: false
|
|
16732
|
+
};
|
|
16733
|
+
if (isTerminal(job)) {
|
|
16734
|
+
if (job.phase !== "done") return {
|
|
16735
|
+
ok: false,
|
|
16736
|
+
reason: failureMessage(job),
|
|
16737
|
+
stopped: job.failure?.stopped === true
|
|
16738
|
+
};
|
|
16739
|
+
return reads.hasResult(workspaceId) ? {
|
|
16740
|
+
ok: true,
|
|
16741
|
+
job
|
|
16742
|
+
} : {
|
|
16743
|
+
ok: false,
|
|
16744
|
+
reason: "The analysis finished without recording a review.",
|
|
16745
|
+
stopped: false
|
|
16746
|
+
};
|
|
16747
|
+
}
|
|
16748
|
+
if (reads.driverGone(job.id)) return {
|
|
16749
|
+
ok: false,
|
|
16750
|
+
reason: `The analysis stopped: ${DRIVER_GONE}`,
|
|
16751
|
+
stopped: false
|
|
16752
|
+
};
|
|
16753
|
+
await reads.wait(everyMs);
|
|
16754
|
+
}
|
|
16755
|
+
}
|
|
15195
16756
|
/**
|
|
15196
16757
|
* Why a run ended, when it did not end by finishing.
|
|
15197
16758
|
*
|
|
@@ -16096,64 +17657,27 @@ async function recordKissRun(input) {
|
|
|
16096
17657
|
...requestedModel ? { requestedModel } : {}
|
|
16097
17658
|
});
|
|
16098
17659
|
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.
|
|
17660
|
+
current.kissRuns = [...current.kissRuns ?? [], run];
|
|
17661
|
+
current.latestKissRunId = run.id;
|
|
17662
|
+
});
|
|
17663
|
+
return job;
|
|
17664
|
+
});
|
|
17665
|
+
if (isBusy(started)) throw new ReviewBeingDeletedError();
|
|
17666
|
+
return started;
|
|
17667
|
+
}
|
|
17668
|
+
/**
|
|
17669
|
+
* Record the run and drive it to its end.
|
|
16140
17670
|
*
|
|
16141
|
-
*
|
|
16142
|
-
*
|
|
16143
|
-
*
|
|
17671
|
+
* The lock is released before the analysis begins, exactly as `reanalyseReview` does, so a deletion
|
|
17672
|
+
* during the twenty minutes that follow is refused by the live job claim rather than made to wait
|
|
17673
|
+
* for it.
|
|
16144
17674
|
*
|
|
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.
|
|
17675
|
+
* Counted by `track` from the first await, so a restart is refused for the whole of the run rather
|
|
17676
|
+
* than only once a caller remembered to count it. Only the server calls this; the CLI records the
|
|
17677
|
+
* run and drives it in its own process.
|
|
16151
17678
|
*/
|
|
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();
|
|
17679
|
+
function startKissJob(input, runners = {}) {
|
|
17680
|
+
return track(recordKissRun(input).then((job) => runJob(job.workspaceId, runners, "kiss")));
|
|
16157
17681
|
}
|
|
16158
17682
|
//#endregion
|
|
16159
17683
|
//#region ../../packages/review-harness/src/domain/ai_review/reconciliation.ts
|
|
@@ -16195,17 +17719,29 @@ function priorVerdict(item) {
|
|
|
16195
17719
|
return ` [previously: ${status}]`;
|
|
16196
17720
|
}
|
|
16197
17721
|
/**
|
|
17722
|
+
* A finding a comment resolved says which comment, so the model can tell it from one the code
|
|
17723
|
+
* resolved. And one whose comment has changed since says that too: it is the one settled finding
|
|
17724
|
+
* the model is asked to judge again. Every other finding reads exactly as `priorVerdict` has it.
|
|
17725
|
+
*/
|
|
17726
|
+
function priorFindingVerdict(issue, flag) {
|
|
17727
|
+
const resolvedBy = conversationResolution(issue);
|
|
17728
|
+
if (!resolvedBy) return priorVerdict(issue);
|
|
17729
|
+
const cite = `@${resolvedBy.author} in [${resolvedBy.threadId}] [comment ${resolvedBy.commentId}]`;
|
|
17730
|
+
if (!flag) return ` [previously: resolved by ${cite}]`;
|
|
17731
|
+
return ` [previously: resolved by ${cite}; that comment has since been ${flag.change}. Reassess this finding]`;
|
|
17732
|
+
}
|
|
17733
|
+
/**
|
|
16198
17734
|
* Each serialiser below states a finding's grade as the review now holds it, through `assess`: the
|
|
16199
17735
|
* reviewer may have moved it, and a model told only the analysis's grade would re-judge from where
|
|
16200
17736
|
* the review no longer stands. A caller with no ledger passes `ANALYSIS_ONLY`.
|
|
16201
17737
|
*/
|
|
16202
|
-
function formatPriorFindings(issues, assess) {
|
|
17738
|
+
function formatPriorFindings(issues, assess, reassess = /* @__PURE__ */ new Map()) {
|
|
16203
17739
|
if (!issues.length) return "(The previous analysis raised no issues.)";
|
|
16204
17740
|
return issues.map((issue, index) => {
|
|
16205
17741
|
const related = (issue.relatedFiles ?? []).map((file) => ` [related: ${file}, not changed]`).join("");
|
|
16206
17742
|
const location = (issue.file ? ` [${issue.file}${issue.line ? `:${issue.line}` : ""}]` : "") + related;
|
|
16207
17743
|
const description = issue.description.trim();
|
|
16208
|
-
return `${index}. (${promptGrade(assess("issue", issue))})${location}${
|
|
17744
|
+
return `${index}. (${promptGrade(assess("issue", issue))})${location}${priorFindingVerdict(issue, reassess.get(index))} ${issue.title.trim()}${description ? `\n ${description}` : ""}`;
|
|
16209
17745
|
}).join("\n");
|
|
16210
17746
|
}
|
|
16211
17747
|
/**
|
|
@@ -16241,9 +17777,10 @@ var RECONCILIATION_SHAPE = `{
|
|
|
16241
17777
|
* part of the job. Built rather than kept as two constants: two hand-maintained copies of a schema
|
|
16242
17778
|
* is how the one that is edited less often quietly stops matching the parser.
|
|
16243
17779
|
*/
|
|
16244
|
-
function reconciliationShape(includeQuestions) {
|
|
16245
|
-
|
|
16246
|
-
|
|
17780
|
+
function reconciliationShape(includeQuestions, withDiscussion = false) {
|
|
17781
|
+
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;
|
|
17782
|
+
if (!includeQuestions) return shape;
|
|
17783
|
+
const lines = shape.split("\n");
|
|
16247
17784
|
const findingsLine = lines.findIndex((line) => line.includes("\"findings\""));
|
|
16248
17785
|
return [
|
|
16249
17786
|
...lines.slice(0, findingsLine + 1),
|
|
@@ -16270,6 +17807,41 @@ function formatPriorQuestions(questions, assess) {
|
|
|
16270
17807
|
}).join("\n");
|
|
16271
17808
|
}
|
|
16272
17809
|
/**
|
|
17810
|
+
* The shape again, with the one field a discussion-aware run may also set on a new issue. The
|
|
17811
|
+
* parser has always accepted it; it is shown only where the prompt holds threads to cite.
|
|
17812
|
+
*/
|
|
17813
|
+
var DISCUSSION_ISSUE_FIELD = ", \"existingDiscussion\"?: { \"threadId\": string, \"relation\": \"same\" | \"related\" }";
|
|
17814
|
+
/** The comment or review a finding's status rests on, on the same discussion-aware runs. */
|
|
17815
|
+
var DISCUSSION_EVIDENCE_FIELD = ", \"evidence\"?: { \"threadId\": string, \"commentId\": string } | { \"reviewId\": string }";
|
|
17816
|
+
/**
|
|
17817
|
+
* The conversation as evidence: what it may settle, what it may not, and how to say which.
|
|
17818
|
+
*
|
|
17819
|
+
* The line this draws is between evidence about reality and claims about code. A maintainer saying
|
|
17820
|
+
* the service a finding worries about is deleted next week changes the finding's premise, and may
|
|
17821
|
+
* settle it with no commit at all. Someone saying a bug is fixed is a claim about code the model
|
|
17822
|
+
* can read, and only the code settles that.
|
|
17823
|
+
*/
|
|
17824
|
+
var DISCUSSION_RULES = `THE CONVERSATION AS EVIDENCE
|
|
17825
|
+
|
|
17826
|
+
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.
|
|
17827
|
+
- 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.
|
|
17828
|
+
- 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".
|
|
17829
|
+
- 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".
|
|
17830
|
+
- 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.
|
|
17831
|
+
- A question the conversation answers may be "answered": put the answer in "answer", naming the comment the same way.
|
|
17832
|
+
- 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.
|
|
17833
|
+
- A severity or importance suggestion may rest on the conversation; say which comment in the reason.`;
|
|
17834
|
+
/**
|
|
17835
|
+
* The one exception to "resolved stays resolved", stated only when a finding needs it: a resolution
|
|
17836
|
+
* that rested on a comment the conversation has since edited or deleted. Three answers, kept apart,
|
|
17837
|
+
* because "cannot tell" is not "reopened": the reviewer decides on a finding marked affected.
|
|
17838
|
+
*/
|
|
17839
|
+
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.
|
|
17840
|
+
- "resolved": it remains resolved despite the change. Name in "evidence" what it now rests on.
|
|
17841
|
+
- "still-open": the change establishes that the original finding is valid again.
|
|
17842
|
+
- "affected": the evidence changed and you cannot establish whether the finding is still valid. Use this, not "still-open", whenever you are unsure.
|
|
17843
|
+
Keep it as this entry. Do not raise it again in "newIssues" unless the problem is now materially different.`;
|
|
17844
|
+
/**
|
|
16273
17845
|
* The one optional judgement about a grade: a suggestion the reviewer answers, separate from the
|
|
16274
17846
|
* status, reasoned from the grade the review holds now (each list states it), and only where the
|
|
16275
17847
|
* new changes move it. Unasked for, a model restates every grade it was shown, and each would reach
|
|
@@ -16297,33 +17869,42 @@ var GRADE_TASK = {
|
|
|
16297
17869
|
*/
|
|
16298
17870
|
function buildReconciliationInstructions(input, options = {}) {
|
|
16299
17871
|
const { previous, totalCommits, changedFiles, possiblyTruncated } = input;
|
|
17872
|
+
const reassess = input.reassess ?? /* @__PURE__ */ new Map();
|
|
16300
17873
|
const includeQuestions = options.includeQuestions === true;
|
|
16301
17874
|
const commitCount = totalCommits === 1 ? "1 new commit" : `${totalCommits} new commits`;
|
|
16302
17875
|
const fileCount = changedFiles === 1 ? "1 file" : `${changedFiles} files`;
|
|
17876
|
+
const discussion = options.discussion;
|
|
17877
|
+
const conversationOnly = discussion !== void 0 && !discussion.codeChanged;
|
|
16303
17878
|
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
17879
|
const questionList = includeQuestions ? `\n\nPrevious open questions, numbered — refer to each by its number in "sourceIndex":\n${formatPriorQuestions(previous.questions, input.assess)}` : "";
|
|
16305
17880
|
const tasks = [
|
|
16306
|
-
`For EVERY numbered finding above, decide what the new changes did to it:
|
|
17881
|
+
`${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:
|
|
17882
|
+
- "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.
|
|
17883
|
+
- "still-open": nothing since settles it. This includes findings nothing since touches.
|
|
17884
|
+
- "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
17885
|
- "resolved" — the new changes appear to fix it. Only when you can point at the change that does so.
|
|
16308
17886
|
- "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
|
|
17887
|
+
- "affected" — the code it concerns changed, but not in a way that clearly settles it either way. The reviewer needs to look again.`}
|
|
16310
17888
|
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.
|
|
17889
|
+
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 ? `
|
|
17890
|
+
${REASSESS_TASK}` : ""}
|
|
16312
17891
|
${GRADE_TASK.issue}`,
|
|
16313
17892
|
...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.
|
|
17893
|
+
- "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
17894
|
- "still-open" — the changes do not settle it. This includes questions the changes never touch, and questions only the author can answer.
|
|
16316
17895
|
- "affected" — the code the question is about changed without answering it. The reviewer should read the question again before asking it.
|
|
16317
17896
|
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
17897
|
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
17898
|
${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.`,
|
|
17899
|
+
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
17900
|
`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
17901
|
`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
17902
|
].map((task, index) => `${index + 1}. ${task}`).join("\n");
|
|
16324
|
-
return `You are RECONCILING an earlier analysis of this pull request against
|
|
17903
|
+
return `${conversationOnly ? `You are RECONCILING an earlier analysis of this pull request against what has changed in its conversation since that analysis was made.
|
|
17904
|
+
|
|
17905
|
+
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
17906
|
|
|
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
|
|
17907
|
+
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
17908
|
|
|
16328
17909
|
PREVIOUS ANALYSIS
|
|
16329
17910
|
|
|
@@ -16332,16 +17913,16 @@ Overall risk: ${previous.overallRisk}
|
|
|
16332
17913
|
Recommendation: ${previous.recommendation}
|
|
16333
17914
|
|
|
16334
17915
|
Previous findings, numbered — refer to each by its number in "sourceIndex":
|
|
16335
|
-
${formatPriorFindings(previous.issues, input.assess)}${questionList}${truncationWarning}
|
|
16336
|
-
|
|
17916
|
+
${formatPriorFindings(previous.issues, input.assess, reassess)}${questionList}${truncationWarning}
|
|
17917
|
+
${discussion ? `\n${DISCUSSION_RULES}\n` : ""}
|
|
16337
17918
|
YOUR TASK
|
|
16338
17919
|
|
|
16339
17920
|
${tasks}
|
|
16340
17921
|
|
|
16341
17922
|
Respond with a single JSON object (no markdown, no surrounding text) matching exactly this shape:
|
|
16342
|
-
${reconciliationShape(includeQuestions)}
|
|
17923
|
+
${reconciliationShape(includeQuestions, discussion !== void 0)}
|
|
16343
17924
|
|
|
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.
|
|
17925
|
+
"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
17926
|
|
|
16346
17927
|
"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
17928
|
}
|
|
@@ -16392,6 +17973,19 @@ function reconciliationGradeSuggestions(previous, reconciled, assess) {
|
|
|
16392
17973
|
}
|
|
16393
17974
|
return suggestions;
|
|
16394
17975
|
}
|
|
17976
|
+
/** 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. */
|
|
17977
|
+
function parseCitation(value) {
|
|
17978
|
+
if (typeof value !== "object" || value === null) return void 0;
|
|
17979
|
+
const item = value;
|
|
17980
|
+
const threadId = asString$1(item.threadId).trim();
|
|
17981
|
+
const commentId = asString$1(item.commentId).trim();
|
|
17982
|
+
if (threadId && commentId) return {
|
|
17983
|
+
threadId,
|
|
17984
|
+
commentId
|
|
17985
|
+
};
|
|
17986
|
+
const reviewId = asString$1(item.reviewId).trim();
|
|
17987
|
+
return reviewId ? { reviewId } : void 0;
|
|
17988
|
+
}
|
|
16395
17989
|
function asStatus(value, allowed) {
|
|
16396
17990
|
return allowed.includes(value) ? value : null;
|
|
16397
17991
|
}
|
|
@@ -16424,6 +18018,7 @@ function parseReconciliationResult(raw, priorCount, options = {}) {
|
|
|
16424
18018
|
seen.add(sourceIndex);
|
|
16425
18019
|
const note = asString$1(item.note).trim();
|
|
16426
18020
|
const grade = parseGrade("issue", item.severity, item.severityReason);
|
|
18021
|
+
const cites = parseCitation(item.evidence);
|
|
16427
18022
|
findings.push({
|
|
16428
18023
|
sourceIndex,
|
|
16429
18024
|
status,
|
|
@@ -16431,7 +18026,8 @@ function parseReconciliationResult(raw, priorCount, options = {}) {
|
|
|
16431
18026
|
...grade ? { grade: {
|
|
16432
18027
|
to: grade.to,
|
|
16433
18028
|
reason: grade.reason
|
|
16434
|
-
} } : {}
|
|
18029
|
+
} } : {},
|
|
18030
|
+
...cites ? { cites } : {}
|
|
16435
18031
|
});
|
|
16436
18032
|
}
|
|
16437
18033
|
return {
|
|
@@ -16461,15 +18057,30 @@ function parseReconciliationResult(raw, priorCount, options = {}) {
|
|
|
16461
18057
|
* Every prior finding therefore survives into the new result, in its original order, carrying its
|
|
16462
18058
|
* original id, severity and text. Only the status is added.
|
|
16463
18059
|
*/
|
|
16464
|
-
function reconcileIssues(prior, findings) {
|
|
18060
|
+
function reconcileIssues(prior, findings, record) {
|
|
16465
18061
|
const byIndex = new Map(findings.map((f) => [f.sourceIndex, f]));
|
|
16466
18062
|
return prior.map((issue, index) => {
|
|
16467
|
-
if (issue.reconciliationStatus === "resolved") return issue;
|
|
16468
18063
|
const verdict = byIndex.get(index);
|
|
18064
|
+
const flag = issue.reconciliationStatus === "resolved" ? record?.flags?.get(index) : void 0;
|
|
18065
|
+
if (flag && record) {
|
|
18066
|
+
const entry = reassessedEntry(verdict, flag, record);
|
|
18067
|
+
return {
|
|
18068
|
+
...issue,
|
|
18069
|
+
reconciliationStatus: entry.status,
|
|
18070
|
+
...entry.reason ? { reconciliationNote: entry.reason } : {},
|
|
18071
|
+
statusHistory: appendStatusChange(issue, entry),
|
|
18072
|
+
...record.against ? { reconciledAgainst: entry.basis === "code" ? "commits" : "conversation" } : {}
|
|
18073
|
+
};
|
|
18074
|
+
}
|
|
18075
|
+
if (issue.reconciliationStatus === "resolved") return issue;
|
|
18076
|
+
const status = verdict?.status ?? "still-open";
|
|
18077
|
+
const recorded = record && isStatusChange(issue.reconciliationStatus, status) ? { statusHistory: appendStatusChange(issue, changeEntry(status, verdict, record)) } : {};
|
|
16469
18078
|
return {
|
|
16470
18079
|
...issue,
|
|
16471
|
-
reconciliationStatus:
|
|
16472
|
-
...verdict?.note ? { reconciliationNote: verdict.note } : {}
|
|
18080
|
+
reconciliationStatus: status,
|
|
18081
|
+
...verdict?.note ? { reconciliationNote: verdict.note } : {},
|
|
18082
|
+
...recorded,
|
|
18083
|
+
...record?.against ? { reconciledAgainst: verdict?.evidence ? "conversation" : record.against } : {}
|
|
16473
18084
|
};
|
|
16474
18085
|
});
|
|
16475
18086
|
}
|
|
@@ -16534,7 +18145,8 @@ function parseQuestions(value, priorCount) {
|
|
|
16534
18145
|
function alreadyAnswered(question) {
|
|
16535
18146
|
return question.reconciliationStatus === "answered" || question.reconciliationAnswer !== void 0;
|
|
16536
18147
|
}
|
|
16537
|
-
function reconcileQuestions(prior, reconciled) {
|
|
18148
|
+
function reconcileQuestions(prior, reconciled, against) {
|
|
18149
|
+
const recorded = against ? { reconciledAgainst: against } : {};
|
|
16538
18150
|
const byIndex = new Map(reconciled.map((entry) => [entry.sourceIndex, entry]));
|
|
16539
18151
|
return prior.map((question, index) => {
|
|
16540
18152
|
if (alreadyAnswered(question)) return {
|
|
@@ -16544,7 +18156,8 @@ function reconcileQuestions(prior, reconciled) {
|
|
|
16544
18156
|
const verdict = byIndex.get(index);
|
|
16545
18157
|
if (!verdict) return {
|
|
16546
18158
|
...question,
|
|
16547
|
-
reconciliationStatus: "still-open"
|
|
18159
|
+
reconciliationStatus: "still-open",
|
|
18160
|
+
...recorded
|
|
16548
18161
|
};
|
|
16549
18162
|
const answered = verdict.status === "answered" && verdict.answer !== void 0;
|
|
16550
18163
|
const status = verdict.status !== "answered" ? verdict.status : answered ? "answered" : "affected";
|
|
@@ -16552,15 +18165,17 @@ function reconcileQuestions(prior, reconciled) {
|
|
|
16552
18165
|
...question,
|
|
16553
18166
|
reconciliationStatus: status,
|
|
16554
18167
|
...verdict.note ? { reconciliationNote: verdict.note } : {},
|
|
16555
|
-
...answered ? { reconciliationAnswer: verdict.answer } : {}
|
|
18168
|
+
...answered ? { reconciliationAnswer: verdict.answer } : {},
|
|
18169
|
+
...recorded
|
|
16556
18170
|
};
|
|
16557
18171
|
});
|
|
16558
18172
|
}
|
|
16559
18173
|
/** Marks a reconciliation's own findings as new, so the UI can tell them from carried-forward ones. */
|
|
16560
|
-
function markNewIssues(issues) {
|
|
18174
|
+
function markNewIssues(issues, against) {
|
|
16561
18175
|
return issues.map((issue) => ({
|
|
16562
18176
|
...issue,
|
|
16563
|
-
reconciliationStatus: "new"
|
|
18177
|
+
reconciliationStatus: "new",
|
|
18178
|
+
...against ? { reconciledAgainst: against } : {}
|
|
16564
18179
|
}));
|
|
16565
18180
|
}
|
|
16566
18181
|
/**
|
|
@@ -16589,16 +18204,93 @@ function composeSections(parent, added) {
|
|
|
16589
18204
|
}));
|
|
16590
18205
|
}
|
|
16591
18206
|
//#endregion
|
|
16592
|
-
//#region ../../packages/review-harness/src/domain/
|
|
16593
|
-
|
|
18207
|
+
//#region ../../packages/review-harness/src/domain/ai_review/update_counts.ts
|
|
18208
|
+
/**
|
|
18209
|
+
* What an update did to a review, counted by comparing each finding and question with what it was
|
|
18210
|
+
* before. Shared by the workspace's update and the extension's, so both announce the same numbers.
|
|
18211
|
+
*/
|
|
18212
|
+
/**
|
|
18213
|
+
* Paired by position: `reconcileIssues` and `reconcileQuestions` return the previous ones in their
|
|
18214
|
+
* order, and what the update added comes after them.
|
|
18215
|
+
*/
|
|
18216
|
+
function countUpdateChanges(previous, result) {
|
|
16594
18217
|
const counts = {
|
|
16595
18218
|
resolved: 0,
|
|
16596
|
-
|
|
18219
|
+
stillOpen: 0,
|
|
16597
18220
|
affected: 0,
|
|
16598
|
-
|
|
18221
|
+
newIssues: 0,
|
|
18222
|
+
questionsAnswered: 0
|
|
18223
|
+
};
|
|
18224
|
+
let byConversation = 0;
|
|
18225
|
+
let reopened = 0;
|
|
18226
|
+
result.issues.forEach((issue, index) => {
|
|
18227
|
+
const before = previous.issues[index]?.reconciliationStatus;
|
|
18228
|
+
const now = issue.reconciliationStatus;
|
|
18229
|
+
if (now === "new") counts.newIssues++;
|
|
18230
|
+
else if (now === "resolved") {
|
|
18231
|
+
if (before === "resolved") return;
|
|
18232
|
+
counts.resolved++;
|
|
18233
|
+
if (issue.statusHistory?.at(-1)?.basis === "conversation") byConversation++;
|
|
18234
|
+
} else if (before === "resolved" && now === "still-open") reopened++;
|
|
18235
|
+
else if (now === "affected" && before !== "affected") counts.affected++;
|
|
18236
|
+
else counts.stillOpen++;
|
|
18237
|
+
});
|
|
18238
|
+
counts.questionsAnswered = result.questions.filter((question, index) => {
|
|
18239
|
+
const before = previous.questions[index];
|
|
18240
|
+
return question.reconciliationStatus === "answered" && !(before && alreadyAnswered(before));
|
|
18241
|
+
}).length;
|
|
18242
|
+
return {
|
|
18243
|
+
...counts,
|
|
18244
|
+
...byConversation ? { resolvedByConversation: byConversation } : {},
|
|
18245
|
+
...reopened ? { reopened } : {}
|
|
18246
|
+
};
|
|
18247
|
+
}
|
|
18248
|
+
//#endregion
|
|
18249
|
+
//#region ../../packages/review-harness/src/domain/ai_review/update_reconciliation.ts
|
|
18250
|
+
function reconciledAgainstOf(basis) {
|
|
18251
|
+
return basis.code ? basis.discussion ? "commits-and-conversation" : "commits" : "conversation";
|
|
18252
|
+
}
|
|
18253
|
+
function applyReconciliation(previous, parsed, context) {
|
|
18254
|
+
const { conversation } = context;
|
|
18255
|
+
const basis = {
|
|
18256
|
+
code: context.codeChanged,
|
|
18257
|
+
discussion: conversationMoved(conversation)
|
|
18258
|
+
};
|
|
18259
|
+
const against = reconciledAgainstOf(basis);
|
|
18260
|
+
const flags = conversationReassessment(previous, conversation);
|
|
18261
|
+
const findings = groundFindings(parsed.findings, conversation.discussion, {
|
|
18262
|
+
codeChanged: context.codeChanged,
|
|
18263
|
+
flags
|
|
18264
|
+
});
|
|
18265
|
+
const suggestions = reconciliationGradeSuggestions(previous, {
|
|
18266
|
+
...parsed,
|
|
18267
|
+
findings
|
|
18268
|
+
}, context.assess);
|
|
18269
|
+
const issues = [...reconcileIssues(previous.issues, findings, {
|
|
18270
|
+
at: context.at,
|
|
18271
|
+
commitSha: context.commitSha,
|
|
18272
|
+
basis: context.codeChanged ? "code" : "conversation",
|
|
18273
|
+
flags,
|
|
18274
|
+
against
|
|
18275
|
+
}), ...markNewIssues(parsed.newIssues, against)];
|
|
18276
|
+
const questions = reconcileQuestions(previous.questions, parsed.questions, against);
|
|
18277
|
+
const { motivation } = context;
|
|
18278
|
+
const composed = {
|
|
18279
|
+
schemaVersion: 1,
|
|
18280
|
+
summary: parsed.summary || previous.summary,
|
|
18281
|
+
...motivation ? { motivation } : {},
|
|
18282
|
+
overallRisk: parsed.overallRisk ?? previous.overallRisk,
|
|
18283
|
+
recommendation: parsed.recommendation || previous.recommendation,
|
|
18284
|
+
nextSteps: parsed.nextSteps.length ? parsed.nextSteps : previous.nextSteps,
|
|
18285
|
+
sections: composeSections(previous.sections, parsed.newSections),
|
|
18286
|
+
issues,
|
|
18287
|
+
questions: motivation === previous.motivation ? questions : withIntentQuestion(questions, motivation)
|
|
18288
|
+
};
|
|
18289
|
+
return {
|
|
18290
|
+
result: conversation.discussion ? attributeIssuesToDiscussion(composed, processDiscussion(conversation.discussion)).result : composed,
|
|
18291
|
+
suggestions,
|
|
18292
|
+
basis
|
|
16599
18293
|
};
|
|
16600
|
-
for (const issue of issues) if (issue.reconciliationStatus) counts[issue.reconciliationStatus] += 1;
|
|
16601
|
-
return counts;
|
|
16602
18294
|
}
|
|
16603
18295
|
//#endregion
|
|
16604
18296
|
//#region ../../packages/review-harness/src/workspace/running_jobs.ts
|
|
@@ -16795,178 +18487,6 @@ async function askCheckout(workspace) {
|
|
|
16795
18487
|
return path;
|
|
16796
18488
|
}
|
|
16797
18489
|
//#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
18490
|
//#region ../../packages/review-harness/src/workspace/review_update.ts
|
|
16971
18491
|
/**
|
|
16972
18492
|
* Make this review describe the code that is on the ref it follows.
|
|
@@ -17156,34 +18676,44 @@ async function bringForward(workspace, runners, reviewedSha, held, step, signal,
|
|
|
17156
18676
|
const remote = await remoteHead(workspace, signal, runners);
|
|
17157
18677
|
signal.throwIfAborted();
|
|
17158
18678
|
if (!remote.sha) return refuse(reviewedSha, remote.fetchFailed, remote.refusal ?? noRemoteHeadMessage(workspace));
|
|
18679
|
+
adoptLegacyBaseline(workspace);
|
|
17159
18680
|
if (workspace.prContext) step("conversation");
|
|
17160
18681
|
const conversationOk = await reReadConversation(workspace, runners.runRefresh);
|
|
17161
18682
|
signal.throwIfAborted();
|
|
18683
|
+
const { prepared } = await prepareConversation(workspace, {
|
|
18684
|
+
reread: false,
|
|
18685
|
+
...conversationOk !== null && workspace.lastPrRefresh ? { rereadOutcome: workspace.lastPrRefresh } : {}
|
|
18686
|
+
});
|
|
18687
|
+
const discussionMoved = conversationMoved(prepared);
|
|
17162
18688
|
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));
|
|
18689
|
+
if (!reviewedSha || reviewedSha === remote.sha && !discussionMoved) return settled(reviewedSha, remote.sha, remote.fetchFailed, conversationOk, "already-current", alreadyCurrentMessage(conversationOk));
|
|
17164
18690
|
step("context");
|
|
17165
18691
|
let worktreePath;
|
|
17166
18692
|
try {
|
|
17167
18693
|
({path: worktreePath} = await pinCheckout(workspace, remote.sha, held));
|
|
17168
18694
|
} catch (error) {
|
|
17169
18695
|
if (error instanceof ReviewBeingDeletedError) throw error;
|
|
17170
|
-
return settled(reviewedSha, remote.sha, remote.fetchFailed, conversationOk, "analysis-failed", "A checkout of the
|
|
18696
|
+
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
18697
|
}
|
|
17172
18698
|
signal.throwIfAborted();
|
|
17173
|
-
return reconcile(workspace, runners, reviewedSha, remote, conversationOk, worktreePath, step, held.job.id, signal, clock);
|
|
18699
|
+
return reconcile(workspace, runners, reviewedSha, remote, conversationOk, worktreePath, step, held.job.id, signal, clock, prepared, discussionMoved);
|
|
17174
18700
|
}
|
|
17175
|
-
async function reconcile(workspace, runners, reviewedSha, remote, conversationOk, worktreePath, step, jobId, signal, clock) {
|
|
18701
|
+
async function reconcile(workspace, runners, reviewedSha, remote, conversationOk, worktreePath, step, jobId, signal, clock, prepared, discussionMoved) {
|
|
17176
18702
|
const newHead = remote.sha;
|
|
17177
|
-
let delta;
|
|
17178
|
-
try {
|
|
18703
|
+
let delta = null;
|
|
18704
|
+
if (reviewedSha !== newHead) try {
|
|
17179
18705
|
delta = await buildReviewContext({
|
|
17180
18706
|
repoPath: worktreePath,
|
|
17181
18707
|
base: reviewedSha
|
|
17182
18708
|
});
|
|
17183
18709
|
} 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);
|
|
18710
|
+
if (!(error instanceof EmptyChangeError)) return settled(reviewedSha, newHead, remote.fetchFailed, conversationOk, "baseline-gone", BASELINE_GONE_MESSAGE);
|
|
17186
18711
|
}
|
|
18712
|
+
if (!delta && !discussionMoved) return settled(reviewedSha, newHead, remote.fetchFailed, conversationOk, "already-current", alreadyCurrentMessage(conversationOk));
|
|
18713
|
+
const basis = {
|
|
18714
|
+
code: delta !== null,
|
|
18715
|
+
discussion: discussionMoved
|
|
18716
|
+
};
|
|
17187
18717
|
const previous = workspace.session.result;
|
|
17188
18718
|
if (!previous) return settled(reviewedSha, newHead, remote.fetchFailed, conversationOk, "no-result", "This review has no analysis to bring up to date.");
|
|
17189
18719
|
step("analysing");
|
|
@@ -17202,50 +18732,45 @@ async function reconcile(workspace, runners, reviewedSha, remote, conversationOk
|
|
|
17202
18732
|
const requestedModel = resolveModel({ review: { session: workspace.session } }) ?? void 0;
|
|
17203
18733
|
let raw;
|
|
17204
18734
|
try {
|
|
17205
|
-
const prompt = buildUpdatePrompt(workspace, delta, reviewedSha, newHead, worktreePath);
|
|
18735
|
+
const prompt = buildUpdatePrompt(workspace, delta, prepared, reviewedSha, newHead, worktreePath);
|
|
17206
18736
|
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
18737
|
} catch (error) {
|
|
17208
18738
|
return settled(reviewedSha, newHead, remote.fetchFailed, conversationOk, "analysis-failed", explainUpdateFailure(error, agentEnvOf(workspace), requestedModel));
|
|
17209
18739
|
}
|
|
17210
18740
|
const parsed = parseReconciliationResult(raw, previous.issues.length, { priorQuestionCount: previous.questions.length });
|
|
17211
18741
|
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
18742
|
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
|
-
};
|
|
18743
|
+
const { result, suggestions } = applyReconciliation(previous, parsed, {
|
|
18744
|
+
at: clock.now(),
|
|
18745
|
+
commitSha: newHead,
|
|
18746
|
+
codeChanged: delta !== null,
|
|
18747
|
+
conversation: prepared,
|
|
18748
|
+
assess: assessmentLookup(workspace.session),
|
|
18749
|
+
motivation
|
|
18750
|
+
});
|
|
17227
18751
|
signal.throwIfAborted();
|
|
17228
18752
|
cancellers.delete(jobId);
|
|
17229
18753
|
const gradeChanges = await applyUpdate(workspace, result, previous, newHead, worktreePath, {
|
|
17230
18754
|
...requestedModel ? { requestedModel } : {},
|
|
17231
18755
|
...observedModel ? { observedModel } : {},
|
|
17232
18756
|
...await (runners.agentVersion ?? installedAgentVersion)(agentEnvOf(workspace))
|
|
17233
|
-
}, clock, remote.base, suggestions);
|
|
18757
|
+
}, clock, remote.base, suggestions, conversationTakenIntoAccount(prepared, analysedBaselineOf(workspace)));
|
|
17234
18758
|
const counts = {
|
|
17235
|
-
...
|
|
18759
|
+
...countUpdateChanges(previous, result),
|
|
17236
18760
|
...gradeChanges ? { gradeChanges } : {}
|
|
17237
18761
|
};
|
|
17238
18762
|
return {
|
|
17239
18763
|
at: Date.now(),
|
|
17240
18764
|
ok: true,
|
|
17241
|
-
message: describeUpdate(counts, newHead),
|
|
18765
|
+
message: describeUpdate(counts, newHead, basis),
|
|
17242
18766
|
stop: null,
|
|
17243
18767
|
fetchFailed: remote.fetchFailed,
|
|
17244
18768
|
moved: reviewedSha !== newHead,
|
|
17245
18769
|
...conversationOk === null ? {} : { conversationOk },
|
|
17246
18770
|
fromSha: reviewedSha,
|
|
17247
18771
|
toSha: newHead,
|
|
17248
|
-
counts
|
|
18772
|
+
counts,
|
|
18773
|
+
reassessed: basis
|
|
17249
18774
|
};
|
|
17250
18775
|
}
|
|
17251
18776
|
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 +18923,7 @@ var UPDATE_OWNED_FIELDS = [
|
|
|
17398
18923
|
"reviewedPaths",
|
|
17399
18924
|
"previousResult",
|
|
17400
18925
|
"prContext",
|
|
18926
|
+
"analysedConversation",
|
|
17401
18927
|
"lastPrRefresh",
|
|
17402
18928
|
"dismissedFreshnessSha",
|
|
17403
18929
|
"lastUpdate",
|
|
@@ -17460,7 +18986,7 @@ function carryUpdateSuggestions(merged, updated) {
|
|
|
17460
18986
|
merged.lastUpdate = {
|
|
17461
18987
|
...outcome,
|
|
17462
18988
|
counts,
|
|
17463
|
-
message: describeUpdate(counts, outcome.toSha)
|
|
18989
|
+
message: describeUpdate(counts, outcome.toSha, outcome.reassessed)
|
|
17464
18990
|
};
|
|
17465
18991
|
}
|
|
17466
18992
|
/**
|
|
@@ -17471,8 +18997,9 @@ function carryUpdateSuggestions(merged, updated) {
|
|
|
17471
18997
|
* to browse the review's past. There is no restore button yet, and that is fine. Insurance that is
|
|
17472
18998
|
* only written once somebody has already built the claim form is not insurance.
|
|
17473
18999
|
*/
|
|
17474
|
-
async function applyUpdate(workspace, result, replaced, newHead, worktreePath, run, clock, freshBase, suggestions = []) {
|
|
19000
|
+
async function applyUpdate(workspace, result, replaced, newHead, worktreePath, run, clock, freshBase, suggestions = [], conversation) {
|
|
17475
19001
|
workspace.previousResult = {
|
|
19002
|
+
...workspace.analysedConversation ? { analysedConversation: workspace.analysedConversation } : {},
|
|
17476
19003
|
result: replaced,
|
|
17477
19004
|
commitSha: workspace.session.metadata?.commitSha ?? "",
|
|
17478
19005
|
replacedAt: Date.now(),
|
|
@@ -17505,6 +19032,10 @@ async function applyUpdate(workspace, result, replaced, newHead, worktreePath, r
|
|
|
17505
19032
|
}
|
|
17506
19033
|
});
|
|
17507
19034
|
delete workspace.dismissedFreshnessSha;
|
|
19035
|
+
workspace.analysedConversation = {
|
|
19036
|
+
sessionId: workspace.session.id,
|
|
19037
|
+
discussion: conversation ?? null
|
|
19038
|
+
};
|
|
17508
19039
|
return proposeGradeSuggestions(workspace.session, suggestions, {
|
|
17509
19040
|
kind: "update",
|
|
17510
19041
|
commitSha: newHead
|
|
@@ -17599,11 +19130,13 @@ function legacyBaseCandidates(recorded) {
|
|
|
17599
19130
|
/** Null when there is no conversation attached, which is neither success nor failure. */
|
|
17600
19131
|
async function reReadConversation(workspace, fetch) {
|
|
17601
19132
|
if (!workspace.prContext) return null;
|
|
19133
|
+
const startedAt = Date.now();
|
|
17602
19134
|
try {
|
|
17603
19135
|
const outcome = fetch ? await refreshPrContext(workspace, fetch) : await refreshPrContext(workspace);
|
|
17604
19136
|
workspace.lastPrRefresh = outcome;
|
|
17605
19137
|
return outcome.ok;
|
|
17606
19138
|
} catch {
|
|
19139
|
+
workspace.lastPrRefresh = unforeseenFailure(startedAt);
|
|
17607
19140
|
return false;
|
|
17608
19141
|
}
|
|
17609
19142
|
}
|
|
@@ -17625,16 +19158,6 @@ function alreadyCurrentMessage(conversationOk) {
|
|
|
17625
19158
|
if (conversationOk === null) return code;
|
|
17626
19159
|
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
19160
|
}
|
|
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
19161
|
/**
|
|
17639
19162
|
* What happened, in the order the reviewer cares about.
|
|
17640
19163
|
*
|
|
@@ -17642,18 +19165,35 @@ function summariseCounts(result) {
|
|
|
17642
19165
|
* they need. Still-present is the number that is usually largest and almost never interesting, so
|
|
17643
19166
|
* it goes last and only when there is something to sit beside.
|
|
17644
19167
|
*/
|
|
17645
|
-
function describeUpdate(counts, sha
|
|
19168
|
+
function describeUpdate(counts, sha, basis = {
|
|
19169
|
+
code: true,
|
|
19170
|
+
discussion: false
|
|
19171
|
+
}) {
|
|
17646
19172
|
const parts = [];
|
|
17647
|
-
|
|
19173
|
+
const byConversation = counts.resolvedByConversation ?? 0;
|
|
19174
|
+
const fixed = counts.resolved - byConversation;
|
|
19175
|
+
if (fixed) parts.push(`${fixed} fixed`);
|
|
19176
|
+
if (byConversation) parts.push(`${byConversation} resolved by the conversation`);
|
|
19177
|
+
if (counts.reopened) parts.push(`${counts.reopened} reopened`);
|
|
17648
19178
|
if (counts.newIssues) parts.push(`${counts.newIssues} new`);
|
|
17649
19179
|
if (counts.affected) parts.push(`${counts.affected} to look at again`);
|
|
17650
19180
|
if (counts.questionsAnswered) parts.push(`${counts.questionsAnswered} question${counts.questionsAnswered === 1 ? "" : "s"} answered`);
|
|
17651
19181
|
if (counts.stillOpen && parts.length) parts.push(`${counts.stillOpen} still present`);
|
|
17652
19182
|
const grades = counts.gradeChanges ? ` ${counts.gradeChanges} grade ${counts.gradeChanges === 1 ? "change" : "changes"} suggested.` : "";
|
|
17653
|
-
|
|
17654
|
-
|
|
19183
|
+
if (!basis.code) {
|
|
19184
|
+
const at = "Reassessed against the pull request conversation; no new commits";
|
|
19185
|
+
if (!parts.length) return `${at}. The conversation changed nothing about the findings${stillPresent(counts)}.${grades}`;
|
|
19186
|
+
return `${at}: ${parts.join(", ")}.${grades}`;
|
|
19187
|
+
}
|
|
19188
|
+
const at = `Updated to ${sha.slice(0, 7)}${basis.discussion ? ", with the conversation" : ""}`;
|
|
19189
|
+
if (!parts.length) return `${at}. The new commits${basis.discussion ? " and comments" : ""} changed nothing about the findings${stillPresent(counts)}.${grades}`;
|
|
17655
19190
|
return `${at}: ${parts.join(", ")}.${grades}`;
|
|
17656
19191
|
}
|
|
19192
|
+
/** The open findings an update left as they were, after a sentence saying it changed nothing. */
|
|
19193
|
+
function stillPresent(counts) {
|
|
19194
|
+
if (!counts.stillOpen) return "";
|
|
19195
|
+
return `: ${counts.stillOpen} ${counts.stillOpen === 1 ? "is" : "are"} still present`;
|
|
19196
|
+
}
|
|
17657
19197
|
/**
|
|
17658
19198
|
* The reconciliation, asked for as a review of new code rather than as a tidy-up of an old list.
|
|
17659
19199
|
*
|
|
@@ -17663,19 +19203,26 @@ function describeUpdate(counts, sha) {
|
|
|
17663
19203
|
* Everything after it is the shared builder, so the two clients cannot drift into asking for
|
|
17664
19204
|
* different shapes.
|
|
17665
19205
|
*/
|
|
17666
|
-
function buildUpdatePrompt(workspace, delta, reviewedSha, newHead, worktreePath) {
|
|
17667
|
-
const files = delta.changeSet.data.files;
|
|
19206
|
+
function buildUpdatePrompt(workspace, delta, conversation, reviewedSha, newHead, worktreePath) {
|
|
17668
19207
|
const previous = workspace.session.result;
|
|
17669
|
-
const
|
|
17670
|
-
|
|
19208
|
+
const discussionChanged = conversationMoved(conversation);
|
|
19209
|
+
const conversationBlock = updateConversationBlock(conversation);
|
|
19210
|
+
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.";
|
|
19211
|
+
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}.
|
|
19212
|
+
|
|
19213
|
+
${renderChangeInventory(delta.changeSet.data.files, inlinePathsOf(delta))}
|
|
19214
|
+
|
|
19215
|
+
${delta.diff}` : `The code is unchanged since the earlier review: ${newHead} is the commit it read.`;
|
|
19216
|
+
const commits = delta ? (delta.changeSet.authors ?? []).reduce((total, author) => total + author.commits, 0) : 0;
|
|
19217
|
+
return `${opening}
|
|
17671
19218
|
|
|
17672
19219
|
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
19220
|
|
|
17674
|
-
|
|
19221
|
+
${code}
|
|
17675
19222
|
|
|
17676
|
-
|
|
19223
|
+
---
|
|
17677
19224
|
|
|
17678
|
-
${
|
|
19225
|
+
${conversationBlock}
|
|
17679
19226
|
|
|
17680
19227
|
---
|
|
17681
19228
|
|
|
@@ -17685,9 +19232,17 @@ ${buildReconciliationInstructions({
|
|
|
17685
19232
|
fromSha: reviewedSha,
|
|
17686
19233
|
toSha: newHead,
|
|
17687
19234
|
totalCommits: commits,
|
|
17688
|
-
changedFiles: files.length,
|
|
17689
|
-
possiblyTruncated: false
|
|
17690
|
-
|
|
19235
|
+
changedFiles: delta ? delta.changeSet.data.files.length : 0,
|
|
19236
|
+
possiblyTruncated: false,
|
|
19237
|
+
reassess: conversationReassessment(previous, conversation)
|
|
19238
|
+
}, {
|
|
19239
|
+
includeQuestions: true,
|
|
19240
|
+
...discussionChanged ? { discussion: { codeChanged: delta !== null } } : {}
|
|
19241
|
+
})}`;
|
|
19242
|
+
}
|
|
19243
|
+
/** The files whose patch travelled inline, as against those only named in the inventory. */
|
|
19244
|
+
function inlinePathsOf(delta) {
|
|
19245
|
+
return new Set(delta.changeSet.data.files.map((file) => file.name).filter((name) => !delta.inventoryOnlyFiles.includes(name)));
|
|
17691
19246
|
}
|
|
17692
19247
|
/**
|
|
17693
19248
|
* The production run: a `read-code` run standing in the pinned worktree.
|
|
@@ -17906,4 +19461,4 @@ function lineageIds(id) {
|
|
|
17906
19461
|
return [.../* @__PURE__ */ new Set([id, ...lineage.map((review) => review.id)])];
|
|
17907
19462
|
}
|
|
17908
19463
|
//#endregion
|
|
17909
|
-
export {
|
|
19464
|
+
export { resolveWorkspacePort as $, saveJob as $n, explanationActionOf as $r, pruneNotifications as $t, parsePullRequestLink as A, git as Ai, ReviewBeingDeletedError as An, activityAttributes as Ar, repositoryKey as At, ensureServer as B, agentById as Bi, findExistingReview as Bn, queueAddMany as Br, announceJob as Bt, completeRun as C, originRemote as Ci, GITHUB_MARKDOWN as Cn, reviewedCommit as Cr, RUNTIME_WORDS as Ct, relativeTime as D, AmbiguousRefError as Di, escapeHtml as Dn, workspaceRevision as Dr, loadArchitecture as Dt, stopRun as E, resolveBase as Ei, CODE_COPY_CLASS as En, touchWorkspace as Er, checkArchitecture as Et, queueForReview as F, readAgentPreference as Fi, readMarker as Fn, lifecyclePill as Fr, QUOTE_LIMITS as Ft, stopChild as G, fetchHead as Gn, isIssueOutstanding as Gr, createPrState as Gt, reviewsUrl as H, detectAgents as Hi, checkReview as Hn, queueStamp as Hr, subjectOf as Ht, settleQueue as I, writeAgentPreference as Ii, removeWorktree as In, offersLifecycle as Ir, REPOSITORY_SCOPE as It, writeServerRecord as J, allJobs as Jn, questionsOutstanding as Jr, listNotifications as Jt, stopServer as K, forgeResolver as Kn, isQuestionOutstanding as Kr, createPrUpdate as Kt, readIndexToken as L, CONFIG_PATH as Li, requestJobStop as Ln, parseLifecycle as Lr, ancestorsOf as Lt, runBatch as M, resolveAgent as Mi, createWorktree as Mn, isLifecycle as Mr, storedScopes as Mt, activateQueued as N, resolveModel as Ni, holdWorktree as Nn, lifecycleChip as Nr, trackedFiles as Nt, batchIdentity as O, shortNameOf as Oi, renderInlineText as On, workspaceStamps as Or, partFiles as Ot, compactQueue as P, clearAgentPreference as Pi, isWorktreeHeld as Pn, lifecycleItemLabel as Pr, ownersOf as Pt, parseWorkspacePort as Q, loadJob as Qn, recommendationsOf as Qr, moveIds as Qt, bootstrapUrl as R, STORE_ROOT as Ri, describeMovement as Rn, sectionOf as Rr, isArchitectureId as Rt, beginRun as S, resolveTarget as Si, hasAuthorContent as Sn, recentWorkspaces as Sr, recordRuntimeBestEffort as St, reportRun as T, isCommitReview as Ti, CODE_BLOCK_CLASS as Tn, summarise as Tr, ARCHITECTURE_DIR as Tt, serverEntryExists as U, AgentCancelledError as Ui, followedBranch as Un, readQueue as Ur, categoryOf as Ut, readServerRecord as V, agentFor as Vi, checkCodeFreshness as Vn, queueRemove as Vr, branchOf as Vt, spawnServer as W, AgentUnavailableError as Wi, reviewedShaOf as Wn, doneVerb as Wr, createArchitectureNotification as Wt, MAX_WORKSPACE_PORT as X, fail as Xn, reviewerDispositions as Xr, markRead as Xt, DEFAULT_WORKSPACE_PORT as Y, endedAt as Yn, resolvedByConversation as Yr, markAllRead as Yt, MIN_WORKSPACE_PORT as Z, isTerminal as Zn, processDiscussion as Zr, markReadForReview as Zt, recordKissRun as _, extractJsonText as _i, agentEnvOf as _n, matchingWorkspaceIds as _r, BUILD_VERSION as _t, wasBlocked as a, assessmentLookup as ai, unreadAround as an, clearClaim as ar, settleReview as at, track as b, WITHHELD_REASON as bi, UNESTABLISHED_MOTIVATION as bn, presentedKissRunOf as br, waitingHistory as bt, RefreshUnavailableError as c, findingIn as ci, existingPaths as cn, followedRefName as cr, startJob as ct, mergeUpdateInto as d, promptGrade as di, prepareConversationWithin as dn, indexReviewGroups as dr, record as dt, explanationOf as ei, readNotification as en, PHASES as er, statedWorkspacePort as et, updateReview as f, reassessIn as fi, saveRefresh as fn, kissDecisionRefusal as fr, stamp as ft, runningJobs as g, asString$1 as gi, isUnchanged as gn, loadWorkspace as gr, telemetryUploadEnabled as gt, liveUpdateFor as h, followUpFindingId as hi, refreshPrContext as hn, lineagePosition as hr, telemetryUploadConsent as ht, liveJobsFor as i, analysisRefOf as ii, toneOf as in, purposeOf as ir, prepareReview as it, unsupportedReason as j, refExists as ji, MANAGED_ROOT as jn, activityLabel as jr, storeArchitecture as jt, extractPullRequestLinks as k, shorthandOf as ki, modelHelpLines as kn, LIFECYCLES as kr, readArchitecture as kt, cancelUpdate as l, gradeScale as li, ASK_REREAD_CEILING_MS as ln, groupByLineage as lr, append as lt, UpdateAlreadyRunningError as m, wasReassessed as mi, unforeseenFailure as mn, latestKissRun as mr, recordTelemetryUploadConsent as mt, deleteReview as n, withExplanation as ni, removeNotification as nn, phasesFor as nr, failureOf as nt, discardJob as o, currentGradeIn as oi, unreadCount as on, isClaimed as or, reanalyseReview as ot, askCheckout as p, sourcePhrase as pi, storedConversation as pn, kissDecisionsOf as pr, deliver as pt, workspaceUrl as q, fillFileUrlTemplate as qn, issuesOutstanding as qr, eventName as qt, lineageIds as r, ANALYSIS_ONLY as ri, retireNotification as rn, progressSteps as rr, observeReview as rt, FETCH_TIMEOUT_MS as s, findingAssessment as si, withUsageRecorded as sn, decideKissRecommendation as sr, runJob as st, deleteLineage as t, isExplanationAction as ti, readStateOf as tn, isLiveJob as tr, writePortPreference as tt, cancellableHere as u, isGradeFor as ui, adoptLegacyBaseline as un, indexKeptIds as ur, readEvents as ut, startKissJob as v, objectWithAnyOf as vi, EXPLAIN_SIMPLY_PROMPT as vn, mutateWorkspace as vr, PACKAGE_NAME as vt, endRun as w, DetachedHeadError as wi, renderMarkdown as wn, reviewedRepositories as wr, describeRuntimes as wt, RUN_KINDS as x, kissOutcome as xi, authorPreview as xn, previousKissResult as xr, mapRuntimes as xt, isDriving as y, describeAuthorship as yi, chatConversationSection as yn, positionInLineage as yr, feedbackUrl as yt, clearServerRecordOwnedBy as z, AGENT_IDS as zi, describeSubject as zn, queueAdd as zr, writeJsonAtomically as zt };
|