prreviewbuddy 0.52.2 → 0.55.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 +99 -0
- package/dist/{delete_review-BiV1aBpx.js → delete_review-B2bqhgtx.js} +557 -115
- package/dist/main.js +1 -1
- package/dist/server.js +567 -66
- package/package.json +1 -1
- package/static/chunk-BeqgAIrQ.js +4 -4
- package/static/chunk-BpiqRF0Y.js +1 -0
- package/static/chunk-RriGSD6H.js +1 -0
- package/static/notifications.js +1 -1
- package/static/reviews.js +1 -1
- package/static/shell.css +147 -22
- package/static/sidebar.css +137 -0
- package/static/workspace.js +44 -36
- package/static/chunk-CofqJ-Qn.js +0 -1
|
@@ -1643,7 +1643,8 @@ function completeReviewSession(session, result, context, options = {}) {
|
|
|
1643
1643
|
...session.metadata,
|
|
1644
1644
|
...context ? reviewFacts(result, context) : {
|
|
1645
1645
|
fileCoverage: void 0,
|
|
1646
|
-
discussionStats: void 0
|
|
1646
|
+
discussionStats: void 0,
|
|
1647
|
+
authorDescription: void 0
|
|
1647
1648
|
},
|
|
1648
1649
|
commitSha: options.commitSha ?? session.metadata.commitSha,
|
|
1649
1650
|
durationMs: Math.max(0, now - session.metadata.generatedAt)
|
|
@@ -1664,7 +1665,8 @@ function reviewFacts(result, context) {
|
|
|
1664
1665
|
reviewedFiles: sectionedFiles(result, context),
|
|
1665
1666
|
totalChangedFiles: context.fileCoverage.totalChangedFiles
|
|
1666
1667
|
},
|
|
1667
|
-
...context.prContext ? { discussionStats: countDiscussion(context) } : {}
|
|
1668
|
+
...context.prContext ? { discussionStats: countDiscussion(context) } : {},
|
|
1669
|
+
authorDescription: context.prContext?.pullRequest.body
|
|
1668
1670
|
};
|
|
1669
1671
|
}
|
|
1670
1672
|
/**
|
|
@@ -2237,6 +2239,7 @@ async function buildChangeSet(options) {
|
|
|
2237
2239
|
number: 0,
|
|
2238
2240
|
title: changeTitle(subjects, headRef),
|
|
2239
2241
|
body: subjects.length > 1 ? subjects.map((s) => `- ${s}`).join("\n") : null,
|
|
2242
|
+
bodySource: "commit-subjects",
|
|
2240
2243
|
base: {
|
|
2241
2244
|
ref: base.display,
|
|
2242
2245
|
sha: mergeBase
|
|
@@ -3245,6 +3248,30 @@ function failSession(session, error, kind, rejectedAnswer) {
|
|
|
3245
3248
|
};
|
|
3246
3249
|
}
|
|
3247
3250
|
//#endregion
|
|
3251
|
+
//#region ../../packages/review-harness/src/domain/kiss/identity.ts
|
|
3252
|
+
function distinctIds(items) {
|
|
3253
|
+
const taken = new Set(items.map((item) => item.id));
|
|
3254
|
+
const kept = /* @__PURE__ */ new Set();
|
|
3255
|
+
return items.map((item) => {
|
|
3256
|
+
if (!kept.has(item.id)) {
|
|
3257
|
+
kept.add(item.id);
|
|
3258
|
+
return item;
|
|
3259
|
+
}
|
|
3260
|
+
let n = 2;
|
|
3261
|
+
while (taken.has(`${item.id}~${n}`)) n += 1;
|
|
3262
|
+
const id = `${item.id}~${n}`;
|
|
3263
|
+
taken.add(id);
|
|
3264
|
+
return {
|
|
3265
|
+
...item,
|
|
3266
|
+
id
|
|
3267
|
+
};
|
|
3268
|
+
});
|
|
3269
|
+
}
|
|
3270
|
+
/** A run's recommendations with the ids decisions are stored against. Read through this, always. */
|
|
3271
|
+
function recommendationsOf(result) {
|
|
3272
|
+
return distinctIds(result?.recommendations ?? []);
|
|
3273
|
+
}
|
|
3274
|
+
//#endregion
|
|
3248
3275
|
//#region ../../packages/review-harness/src/domain/content/github/diff_parser.ts
|
|
3249
3276
|
var CONTEXT_LINES = 2;
|
|
3250
3277
|
/**
|
|
@@ -4326,6 +4353,19 @@ var WORKSPACES_DIR = join(STORE_ROOT, "workspaces");
|
|
|
4326
4353
|
* in the minutes it took. `handleState` is the only writer and writes nothing else.
|
|
4327
4354
|
*/
|
|
4328
4355
|
var REVIEWER_SESSION_FIELDS = ["answeredQuestionIds", "dismissedIssues"];
|
|
4356
|
+
/**
|
|
4357
|
+
* A session a long run is about to write, with the reviewer's own fields taken from disk instead.
|
|
4358
|
+
*
|
|
4359
|
+
* The run built `session` from a copy it loaded before awaiting something, and the reviewer may have
|
|
4360
|
+
* answered or dismissed something in that window. Absent on disk means absent here too.
|
|
4361
|
+
*/
|
|
4362
|
+
function keepReviewerFields(fresh, session) {
|
|
4363
|
+
const kept = { ...session };
|
|
4364
|
+
const current = fresh.session;
|
|
4365
|
+
for (const field of REVIEWER_SESSION_FIELDS) if (field in fresh.session) kept[field] = current[field];
|
|
4366
|
+
else delete kept[field];
|
|
4367
|
+
return kept;
|
|
4368
|
+
}
|
|
4329
4369
|
function workspacePath(id) {
|
|
4330
4370
|
return join(WORKSPACES_DIR, `${id}.json`);
|
|
4331
4371
|
}
|
|
@@ -4945,6 +4985,17 @@ function previousKissResult(workspace) {
|
|
|
4945
4985
|
for (let index = before.length - 1; index >= 0; index -= 1) if (before[index].result) return before[index];
|
|
4946
4986
|
return null;
|
|
4947
4987
|
}
|
|
4988
|
+
/**
|
|
4989
|
+
* The run whose answer the page presents: the latest when it produced one, otherwise the last that
|
|
4990
|
+
* did. The store's form of `presentedKissRun` in the view, for callers holding a workspace rather
|
|
4991
|
+
* than a view model. Freshness must be measured from this run's commit: measured from a failed
|
|
4992
|
+
* rerun's instead, an answer about older commits would read as current, and could be handed off.
|
|
4993
|
+
*/
|
|
4994
|
+
function presentedKissRunOf(workspace) {
|
|
4995
|
+
const latest = latestKissRun(workspace);
|
|
4996
|
+
if (latest?.result) return latest;
|
|
4997
|
+
return previousKissResult(workspace);
|
|
4998
|
+
}
|
|
4948
4999
|
/** Appends a run and points the page at it. The caller saves: this is one half of a larger write. */
|
|
4949
5000
|
function startKissRun(workspace, start) {
|
|
4950
5001
|
const run = {
|
|
@@ -5004,6 +5055,38 @@ function failKissRun(workspace, runId, error, kind, rejectedAnswer) {
|
|
|
5004
5055
|
return run;
|
|
5005
5056
|
}
|
|
5006
5057
|
/**
|
|
5058
|
+
* Whether this review holds the recommendation a decision names. Asked before writing, so a refused
|
|
5059
|
+
* decision leaves the file untouched, and again inside the write, where the answer is final.
|
|
5060
|
+
*/
|
|
5061
|
+
function kissDecisionRefusal(workspace, input) {
|
|
5062
|
+
const run = (workspace.kissRuns ?? []).find((candidate) => candidate.id === input.runId);
|
|
5063
|
+
if (!run?.result) return "unknown-run";
|
|
5064
|
+
return recommendationsOf(run.result).some((recommendation) => recommendation.id === input.recommendationId) ? null : "unknown-recommendation";
|
|
5065
|
+
}
|
|
5066
|
+
/**
|
|
5067
|
+
* Record one decision, replacing whatever was decided about that recommendation before.
|
|
5068
|
+
*
|
|
5069
|
+
* One entry per request rather than the page's whole list, so two cards decided in quick
|
|
5070
|
+
* succession cannot overwrite each other: each request changes only the entry it names.
|
|
5071
|
+
*/
|
|
5072
|
+
function decideKissRecommendation(workspace, input, now = Date.now()) {
|
|
5073
|
+
const refusal = kissDecisionRefusal(workspace, input);
|
|
5074
|
+
if (refusal) return refusal;
|
|
5075
|
+
const others = (workspace.kissDecisions ?? []).filter((entry) => !(entry.runId === input.runId && entry.recommendationId === input.recommendationId));
|
|
5076
|
+
workspace.kissDecisions = input.decision === "undecided" ? others : [...others, {
|
|
5077
|
+
runId: input.runId,
|
|
5078
|
+
recommendationId: input.recommendationId,
|
|
5079
|
+
decision: input.decision,
|
|
5080
|
+
decidedAt: now
|
|
5081
|
+
}];
|
|
5082
|
+
return null;
|
|
5083
|
+
}
|
|
5084
|
+
/** The decisions made against one run. Empty for a run nobody has decided anything about. */
|
|
5085
|
+
function kissDecisionsOf(workspace, runId) {
|
|
5086
|
+
if (!runId) return [];
|
|
5087
|
+
return (workspace.kissDecisions ?? []).filter((entry) => entry.runId === runId);
|
|
5088
|
+
}
|
|
5089
|
+
/**
|
|
5007
5090
|
* Record that a workspace was opened.
|
|
5008
5091
|
*
|
|
5009
5092
|
* Best-effort and deliberately silent: a review that cannot record having been looked at is still
|
|
@@ -6216,6 +6299,7 @@ function sanitisePullRequest(value) {
|
|
|
6216
6299
|
};
|
|
6217
6300
|
const title = typeof value.title === "string" ? value.title.trim() : "";
|
|
6218
6301
|
if (title) pr.title = title.slice(0, 200);
|
|
6302
|
+
if (typeof value.body === "string" && value.body.trim()) pr.body = value.body;
|
|
6219
6303
|
const headSha = label(value.headSha);
|
|
6220
6304
|
if (headSha) pr.headSha = headSha;
|
|
6221
6305
|
const headRef = label(value.headRef);
|
|
@@ -6771,7 +6855,7 @@ function slug(repo) {
|
|
|
6771
6855
|
* `baseRefOid` is a diff taken against the wrong *commit*, which is worse because the branch name
|
|
6772
6856
|
* is right and everything reads as normal. A list nobody asserts is a list somebody tidies.
|
|
6773
6857
|
*/
|
|
6774
|
-
var PR_FIELDS = "number,url,title,headRefName,headRefOid,baseRefName,baseRefOid,changedFiles,author";
|
|
6858
|
+
var PR_FIELDS = "number,url,title,body,headRefName,headRefOid,baseRefName,baseRefOid,changedFiles,author";
|
|
6775
6859
|
async function viewRequest(repo, reference, cwd, env) {
|
|
6776
6860
|
return await ghJson([
|
|
6777
6861
|
"pr",
|
|
@@ -6783,7 +6867,7 @@ async function viewRequest(repo, reference, cwd, env) {
|
|
|
6783
6867
|
PR_FIELDS
|
|
6784
6868
|
], cwd, env);
|
|
6785
6869
|
}
|
|
6786
|
-
function text(value) {
|
|
6870
|
+
function text$1(value) {
|
|
6787
6871
|
return typeof value === "string" ? value.trim() : "";
|
|
6788
6872
|
}
|
|
6789
6873
|
/**
|
|
@@ -6804,30 +6888,31 @@ function fileUrlTemplate(prUrl) {
|
|
|
6804
6888
|
* email address all live in `pr_context.ts` and in nothing else.
|
|
6805
6889
|
*/
|
|
6806
6890
|
function identity(raw) {
|
|
6807
|
-
const url = text(raw.url);
|
|
6891
|
+
const url = text$1(raw.url);
|
|
6808
6892
|
const author = raw.author ?? null;
|
|
6809
6893
|
return {
|
|
6810
6894
|
provider: GITHUB_FORGE_ID,
|
|
6811
6895
|
number: typeof raw.number === "number" ? raw.number : 0,
|
|
6812
6896
|
url,
|
|
6813
|
-
title: text(raw.title),
|
|
6814
|
-
|
|
6815
|
-
|
|
6816
|
-
|
|
6897
|
+
title: text$1(raw.title),
|
|
6898
|
+
body: raw.body,
|
|
6899
|
+
headSha: text$1(raw.headRefOid),
|
|
6900
|
+
headRef: text$1(raw.headRefName),
|
|
6901
|
+
baseRef: text$1(raw.baseRefName),
|
|
6817
6902
|
filesUrl: url ? `${url}/files` : "",
|
|
6818
6903
|
fileUrlTemplate: fileUrlTemplate(url),
|
|
6819
6904
|
author: author ? {
|
|
6820
|
-
name: text(author.name),
|
|
6821
|
-
handle: text(author.login)
|
|
6905
|
+
name: text$1(author.name),
|
|
6906
|
+
handle: text$1(author.login)
|
|
6822
6907
|
} : null
|
|
6823
6908
|
};
|
|
6824
6909
|
}
|
|
6825
6910
|
/**
|
|
6826
6911
|
* The watcher's query: where the branch is, and whether the request is still open.
|
|
6827
6912
|
*
|
|
6828
|
-
* Deliberately not `PR_FIELDS`. That list exists because a review needs all
|
|
6913
|
+
* Deliberately not `PR_FIELDS`. That list exists because a review needs all ten of them, and its
|
|
6829
6914
|
* docblock warns that trimming it fails silently rather than loudly. Sharing it here would put a
|
|
6830
|
-
*
|
|
6915
|
+
* ten-field query on a five-minute timer across the whole working set, and would put the watcher
|
|
6831
6916
|
* in a position to "optimise" a list that must never shrink.
|
|
6832
6917
|
*
|
|
6833
6918
|
* `state` rides on the same call rather than being a second one, which keeps the watcher's rate
|
|
@@ -6850,7 +6935,7 @@ var STATES = /* @__PURE__ */ new Map([
|
|
|
6850
6935
|
* about a request GitHub declined to describe, once per cycle, indefinitely.
|
|
6851
6936
|
*/
|
|
6852
6937
|
function readHeadSha(raw) {
|
|
6853
|
-
const sha = text(raw?.headRefOid);
|
|
6938
|
+
const sha = text$1(raw?.headRefOid);
|
|
6854
6939
|
if (!sha) return {
|
|
6855
6940
|
kind: "failed",
|
|
6856
6941
|
detail: "GitHub reported no head commit for this request."
|
|
@@ -6858,7 +6943,7 @@ function readHeadSha(raw) {
|
|
|
6858
6943
|
return {
|
|
6859
6944
|
kind: "head",
|
|
6860
6945
|
sha,
|
|
6861
|
-
state: STATES.get(text(raw?.state)) ?? null
|
|
6946
|
+
state: STATES.get(text$1(raw?.state)) ?? null
|
|
6862
6947
|
};
|
|
6863
6948
|
}
|
|
6864
6949
|
async function headOf(repo, number, env, cwd) {
|
|
@@ -6893,8 +6978,8 @@ async function resolve$1(repo, reference, env, cwd) {
|
|
|
6893
6978
|
} catch (error) {
|
|
6894
6979
|
throw new TargetError(`${reference} could not be looked up on GitHub: ${error.message}`);
|
|
6895
6980
|
}
|
|
6896
|
-
const headRef = text(raw.headRefName);
|
|
6897
|
-
const headSha = text(raw.headRefOid);
|
|
6981
|
+
const headRef = text$1(raw.headRefName);
|
|
6982
|
+
const headSha = text$1(raw.headRefOid);
|
|
6898
6983
|
if (!headRef || !headSha) throw new TargetError(`${reference} was found on GitHub but reported no branch and commit.`);
|
|
6899
6984
|
const resolved = {
|
|
6900
6985
|
reference,
|
|
@@ -6902,12 +6987,12 @@ async function resolve$1(repo, reference, env, cwd) {
|
|
|
6902
6987
|
headSha,
|
|
6903
6988
|
provider: GITHUB_FORGE_ID
|
|
6904
6989
|
};
|
|
6905
|
-
const baseRef = text(raw.baseRefName);
|
|
6990
|
+
const baseRef = text$1(raw.baseRefName);
|
|
6906
6991
|
if (baseRef) resolved.baseRef = baseRef;
|
|
6907
|
-
const baseSha = text(raw.baseRefOid);
|
|
6992
|
+
const baseSha = text$1(raw.baseRefOid);
|
|
6908
6993
|
if (baseSha) resolved.baseSha = baseSha;
|
|
6909
6994
|
if (typeof raw.changedFiles === "number") resolved.changedFiles = raw.changedFiles;
|
|
6910
|
-
const url = text(raw.url);
|
|
6995
|
+
const url = text$1(raw.url);
|
|
6911
6996
|
if (url) resolved.url = url;
|
|
6912
6997
|
if (typeof raw.number === "number") resolved.number = raw.number;
|
|
6913
6998
|
return resolved;
|
|
@@ -9001,7 +9086,7 @@ Follow-up Prompts:
|
|
|
9001
9086
|
${buildFollowUpPromptsSpec()}
|
|
9002
9087
|
|
|
9003
9088
|
PR Title: ${raw.pullRequest.title}
|
|
9004
|
-
${raw.pullRequest.body ? `\
|
|
9089
|
+
${raw.pullRequest.body ? `\n${raw.pullRequest.bodySource === "commit-subjects" ? "Commit subjects (there is no PR Description)" : "PR Description"}:\n${raw.pullRequest.body}\n` : ""}
|
|
9005
9090
|
${diff}`;
|
|
9006
9091
|
const overheadChars = assemble({
|
|
9007
9092
|
diff: "",
|
|
@@ -9310,10 +9395,304 @@ function normalizeSections(value) {
|
|
|
9310
9395
|
return sections;
|
|
9311
9396
|
}
|
|
9312
9397
|
//#endregion
|
|
9398
|
+
//#region ../../packages/review-harness/src/domain/ai_review/author_description.ts
|
|
9399
|
+
/**
|
|
9400
|
+
* Reading a pull request's description for whether it says anything, without ever changing it.
|
|
9401
|
+
*
|
|
9402
|
+
* Nothing here returns a copy to store or show. The description is shown verbatim, so a cleaned
|
|
9403
|
+
* copy is only ever a means of asking a question of it: whether it says anything, and whether a
|
|
9404
|
+
* quote is something the author said.
|
|
9405
|
+
*/
|
|
9406
|
+
/**
|
|
9407
|
+
* Lines that are a pull request template's scaffolding when they stand alone.
|
|
9408
|
+
*
|
|
9409
|
+
* Matched against a whole line only, after its heading marker, bold and trailing colon are gone.
|
|
9410
|
+
* A heading that says something of its own, `## Fix startup crashes`, is not a label and survives.
|
|
9411
|
+
*/
|
|
9412
|
+
var TEMPLATE_LABELS = /* @__PURE__ */ new Set([
|
|
9413
|
+
"description",
|
|
9414
|
+
"summary",
|
|
9415
|
+
"changes",
|
|
9416
|
+
"what",
|
|
9417
|
+
"why",
|
|
9418
|
+
"how",
|
|
9419
|
+
"context",
|
|
9420
|
+
"motivation",
|
|
9421
|
+
"motivation and context",
|
|
9422
|
+
"what changed",
|
|
9423
|
+
"what does this pr do",
|
|
9424
|
+
"why is this needed",
|
|
9425
|
+
"type of change",
|
|
9426
|
+
"testing",
|
|
9427
|
+
"tests",
|
|
9428
|
+
"test plan",
|
|
9429
|
+
"how to test",
|
|
9430
|
+
"how has this been tested",
|
|
9431
|
+
"screenshots",
|
|
9432
|
+
"notes",
|
|
9433
|
+
"checklist",
|
|
9434
|
+
"related issues",
|
|
9435
|
+
"related issue",
|
|
9436
|
+
"links"
|
|
9437
|
+
]);
|
|
9438
|
+
var HTML_COMMENT = /<!--[\s\S]*?-->/g;
|
|
9439
|
+
var HEADING_MARKER = /^#{1,6}\s*/;
|
|
9440
|
+
var UNCHECKED_TASK = /^[-*+]\s+\[ \]/;
|
|
9441
|
+
var EMPTY_LIST_ITEM = /^(?:[-*+]|\d+[.)])\s*$/;
|
|
9442
|
+
function asLabel(line) {
|
|
9443
|
+
return line.replace(HEADING_MARKER, "").replace(/^\*\*(.*)\*\*$/, "$1").replace(/^__(.*)__$/, "$1").replace(/:\s*$/, "").trim().toLowerCase();
|
|
9444
|
+
}
|
|
9445
|
+
/** One line, reduced to whatever in it is the author's own words. Empty when none are. */
|
|
9446
|
+
function substance(line) {
|
|
9447
|
+
const trimmed = line.trim();
|
|
9448
|
+
if (!trimmed || UNCHECKED_TASK.test(trimmed) || EMPTY_LIST_ITEM.test(trimmed)) return "";
|
|
9449
|
+
if (TEMPLATE_LABELS.has(asLabel(trimmed))) return "";
|
|
9450
|
+
return trimmed.replace(HEADING_MARKER, "");
|
|
9451
|
+
}
|
|
9452
|
+
/**
|
|
9453
|
+
* The parts of a description the author wrote, one line each, with the template's scaffolding gone.
|
|
9454
|
+
*
|
|
9455
|
+
* Only HTML comments, unticked checklist items, empty list items and bare template labels are
|
|
9456
|
+
* discounted. A ticked item is the author asserting something, so it stays.
|
|
9457
|
+
*/
|
|
9458
|
+
function authorText(description) {
|
|
9459
|
+
if (!description) return "";
|
|
9460
|
+
return description.replace(HTML_COMMENT, "").split("\n").map(substance).filter((line) => line !== "").join("\n");
|
|
9461
|
+
}
|
|
9462
|
+
/**
|
|
9463
|
+
* Whether a description has anything in it beyond an unfilled template.
|
|
9464
|
+
*
|
|
9465
|
+
* Conservative by design: any line that survives counts, so a sparse description is shown rather
|
|
9466
|
+
* than hidden.
|
|
9467
|
+
*/
|
|
9468
|
+
function hasAuthorContent(description) {
|
|
9469
|
+
return authorText(description) !== "";
|
|
9470
|
+
}
|
|
9471
|
+
function isLongDescription(description) {
|
|
9472
|
+
const trimmed = description.trim();
|
|
9473
|
+
return trimmed.split("\n").length > 10 || trimmed.length > 700;
|
|
9474
|
+
}
|
|
9475
|
+
//#endregion
|
|
9476
|
+
//#region ../../packages/review-harness/src/domain/ai_review/motivation.ts
|
|
9477
|
+
var MOTIVATION_STATUSES = [
|
|
9478
|
+
"stated",
|
|
9479
|
+
"inferred",
|
|
9480
|
+
"unclear"
|
|
9481
|
+
];
|
|
9482
|
+
var MOTIVATION_EVIDENCE_KINDS = [
|
|
9483
|
+
"test",
|
|
9484
|
+
"behaviour",
|
|
9485
|
+
"issue",
|
|
9486
|
+
"commit",
|
|
9487
|
+
"docs",
|
|
9488
|
+
"code"
|
|
9489
|
+
];
|
|
9490
|
+
/** Ours, not the model's: what an unsupported claim is replaced with when a question remains. */
|
|
9491
|
+
var UNESTABLISHED_MOTIVATION = "The reason for this change isn't established by the description or the code.";
|
|
9492
|
+
/** Stable, so "Mark answered" on it survives a reload like any other question's. */
|
|
9493
|
+
var INTENT_QUESTION_ID = "intent-motivation";
|
|
9494
|
+
/**
|
|
9495
|
+
* Fewer words than this cannot attribute a reason to anyone: "the cache" is in most descriptions
|
|
9496
|
+
* that mention a cache. An initial setting, not a measured one.
|
|
9497
|
+
*/
|
|
9498
|
+
var MIN_QUOTE_WORDS = 3;
|
|
9499
|
+
function text(value) {
|
|
9500
|
+
if (typeof value !== "string") return void 0;
|
|
9501
|
+
return value.trim() || void 0;
|
|
9502
|
+
}
|
|
9503
|
+
function normalizeEvidence(value) {
|
|
9504
|
+
if (!Array.isArray(value)) return [];
|
|
9505
|
+
return value.flatMap((raw) => {
|
|
9506
|
+
const item = raw ?? {};
|
|
9507
|
+
const kind = item.kind;
|
|
9508
|
+
const detail = text(item.detail);
|
|
9509
|
+
if (!MOTIVATION_EVIDENCE_KINDS.includes(kind) || !detail) return [];
|
|
9510
|
+
const file = text(item.file);
|
|
9511
|
+
return [{
|
|
9512
|
+
kind,
|
|
9513
|
+
detail,
|
|
9514
|
+
...file ? { file } : {}
|
|
9515
|
+
}];
|
|
9516
|
+
});
|
|
9517
|
+
}
|
|
9518
|
+
/** The model's `motivation`, if it is one at all. Absent, or the wrong shape, is no motivation. */
|
|
9519
|
+
function normalizeMotivation(value) {
|
|
9520
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
|
|
9521
|
+
const item = value;
|
|
9522
|
+
const status = item.status;
|
|
9523
|
+
const summary = text(item.summary);
|
|
9524
|
+
if (!MOTIVATION_STATUSES.includes(status) || !summary) return void 0;
|
|
9525
|
+
const sourceQuote = text(item.sourceQuote);
|
|
9526
|
+
const authorQuestion = text(item.authorQuestion);
|
|
9527
|
+
return {
|
|
9528
|
+
status,
|
|
9529
|
+
summary,
|
|
9530
|
+
evidence: normalizeEvidence(item.evidence),
|
|
9531
|
+
...sourceQuote ? { sourceQuote } : {},
|
|
9532
|
+
...authorQuestion ? { authorQuestion } : {}
|
|
9533
|
+
};
|
|
9534
|
+
}
|
|
9535
|
+
/** Whitespace is the one thing a faithful quote may differ in: line wrapping is not wording. */
|
|
9536
|
+
function collapse(value) {
|
|
9537
|
+
return value.replace(/\s+/g, " ").trim();
|
|
9538
|
+
}
|
|
9539
|
+
/**
|
|
9540
|
+
* Whether the author wrote this, in these words, in the description the analysis read.
|
|
9541
|
+
*
|
|
9542
|
+
* Searched in the author's own text only. A template's instructions are in the description too, and
|
|
9543
|
+
* "Explain why this change is needed" would otherwise pass as the author explaining why.
|
|
9544
|
+
*/
|
|
9545
|
+
function isAuthorQuote(quote, description) {
|
|
9546
|
+
if (!quote) return false;
|
|
9547
|
+
const needle = collapse(quote);
|
|
9548
|
+
if (needle.split(" ").length < MIN_QUOTE_WORDS) return false;
|
|
9549
|
+
return collapse(authorText(description)).includes(needle);
|
|
9550
|
+
}
|
|
9551
|
+
/**
|
|
9552
|
+
* The checks, applied to one candidate.
|
|
9553
|
+
*
|
|
9554
|
+
* Each status has one thing it cannot stand without: a verified quote, evidence, a question. The
|
|
9555
|
+
* fields that belong to the other statuses are dropped rather than carried, so a `stated` motive
|
|
9556
|
+
* cannot also show a guess and an `unclear` one cannot also show a quote.
|
|
9557
|
+
*
|
|
9558
|
+
* A claim that fails becomes unclear only if the model also asked the question that would settle
|
|
9559
|
+
* it, and then in our words rather than its own. Otherwise there is nothing to show that the
|
|
9560
|
+
* reviewer could act on, and nothing is shown.
|
|
9561
|
+
*/
|
|
9562
|
+
function verifyMotivation(candidate, authorDescription) {
|
|
9563
|
+
if (!candidate) return {};
|
|
9564
|
+
const base = {
|
|
9565
|
+
contractVersion: 1,
|
|
9566
|
+
summary: candidate.summary
|
|
9567
|
+
};
|
|
9568
|
+
if (candidate.status === "stated" && isAuthorQuote(candidate.sourceQuote, authorDescription)) return { motivation: {
|
|
9569
|
+
...base,
|
|
9570
|
+
status: "stated",
|
|
9571
|
+
sourceQuote: candidate.sourceQuote
|
|
9572
|
+
} };
|
|
9573
|
+
if (candidate.status === "inferred" && candidate.evidence.length > 0) return { motivation: {
|
|
9574
|
+
...base,
|
|
9575
|
+
status: "inferred",
|
|
9576
|
+
evidence: candidate.evidence
|
|
9577
|
+
} };
|
|
9578
|
+
if (candidate.status === "unclear" && candidate.authorQuestion) return { motivation: {
|
|
9579
|
+
...base,
|
|
9580
|
+
status: "unclear",
|
|
9581
|
+
authorQuestion: candidate.authorQuestion
|
|
9582
|
+
} };
|
|
9583
|
+
const reason = candidate.status === "stated" ? candidate.sourceQuote ? "unverified-quote" : "missing-quote" : candidate.status === "inferred" ? "no-evidence" : "no-question";
|
|
9584
|
+
const correction = {
|
|
9585
|
+
status: candidate.status,
|
|
9586
|
+
summary: candidate.summary,
|
|
9587
|
+
reason
|
|
9588
|
+
};
|
|
9589
|
+
if (candidate.status !== "unclear" && candidate.authorQuestion) return {
|
|
9590
|
+
motivation: {
|
|
9591
|
+
contractVersion: 1,
|
|
9592
|
+
status: "unclear",
|
|
9593
|
+
summary: UNESTABLISHED_MOTIVATION,
|
|
9594
|
+
authorQuestion: candidate.authorQuestion
|
|
9595
|
+
},
|
|
9596
|
+
correction: {
|
|
9597
|
+
...correction,
|
|
9598
|
+
outcome: "downgraded"
|
|
9599
|
+
}
|
|
9600
|
+
};
|
|
9601
|
+
return { correction: {
|
|
9602
|
+
...correction,
|
|
9603
|
+
outcome: "withheld"
|
|
9604
|
+
} };
|
|
9605
|
+
}
|
|
9606
|
+
/**
|
|
9607
|
+
* The one evidence-link policy, for both hosts.
|
|
9608
|
+
*
|
|
9609
|
+
* A changed file is a diff link and a real unchanged file a source link. A path the host can show
|
|
9610
|
+
* does not exist is removed and returned for the audit. A path the host cannot check keeps its
|
|
9611
|
+
* text and gets no state, so it is never presented as a link the reader can trust.
|
|
9612
|
+
*/
|
|
9613
|
+
function placeEvidenceFiles(motivation, paths) {
|
|
9614
|
+
if (!motivation?.evidence) return {
|
|
9615
|
+
motivation,
|
|
9616
|
+
removed: []
|
|
9617
|
+
};
|
|
9618
|
+
const removed = [];
|
|
9619
|
+
const evidence = motivation.evidence.map(({ fileState: _ignored, ...item }) => {
|
|
9620
|
+
if (!item.file) return item;
|
|
9621
|
+
if (paths.changed(item.file)) return {
|
|
9622
|
+
...item,
|
|
9623
|
+
fileState: "changed"
|
|
9624
|
+
};
|
|
9625
|
+
if (!paths.exists) return item;
|
|
9626
|
+
if (paths.exists(item.file)) return {
|
|
9627
|
+
...item,
|
|
9628
|
+
fileState: "unchanged"
|
|
9629
|
+
};
|
|
9630
|
+
removed.push(item.file);
|
|
9631
|
+
const { file: _gone, ...rest } = item;
|
|
9632
|
+
return rest;
|
|
9633
|
+
});
|
|
9634
|
+
return {
|
|
9635
|
+
motivation: {
|
|
9636
|
+
...motivation,
|
|
9637
|
+
evidence
|
|
9638
|
+
},
|
|
9639
|
+
removed
|
|
9640
|
+
};
|
|
9641
|
+
}
|
|
9642
|
+
/** Two questions are the same question when they differ only in case, spacing or end punctuation. */
|
|
9643
|
+
function sameQuestion(a, b) {
|
|
9644
|
+
const key = (value) => collapse(value).toLowerCase().replace(/[?.!\s]+$/, "");
|
|
9645
|
+
return key(a) === key(b);
|
|
9646
|
+
}
|
|
9647
|
+
/**
|
|
9648
|
+
* File an unclear motive's question with the rest, as the one Intent question.
|
|
9649
|
+
*
|
|
9650
|
+
* A question the model already asked in the same words is tagged rather than repeated, so the
|
|
9651
|
+
* reader meets it once, in the Intent group. Telling two differently worded questions apart is
|
|
9652
|
+
* judgement and is left to the prompt.
|
|
9653
|
+
*/
|
|
9654
|
+
function withIntentQuestion(questions, motivation) {
|
|
9655
|
+
if (motivation?.status !== "unclear" || !motivation.authorQuestion) return questions;
|
|
9656
|
+
const asked = motivation.authorQuestion;
|
|
9657
|
+
const existing = questions.findIndex((q) => sameQuestion(q.question, asked));
|
|
9658
|
+
if (existing >= 0) return questions.map((q, index) => index === existing ? {
|
|
9659
|
+
...q,
|
|
9660
|
+
category: "intent"
|
|
9661
|
+
} : q);
|
|
9662
|
+
const taken = new Set(questions.map((q) => q.id));
|
|
9663
|
+
let id = INTENT_QUESTION_ID;
|
|
9664
|
+
for (let n = 2; taken.has(id); n += 1) id = `${INTENT_QUESTION_ID}-${n}`;
|
|
9665
|
+
return [...questions, {
|
|
9666
|
+
id,
|
|
9667
|
+
question: asked,
|
|
9668
|
+
category: "intent"
|
|
9669
|
+
}];
|
|
9670
|
+
}
|
|
9671
|
+
/** The candidate checked and, when it ends unclear, its question filed. */
|
|
9672
|
+
function applyMotivation(result, candidate, authorDescription) {
|
|
9673
|
+
const { motivation, correction } = verifyMotivation(candidate, authorDescription);
|
|
9674
|
+
const { motivation: _unverified, ...rest } = result;
|
|
9675
|
+
return {
|
|
9676
|
+
result: {
|
|
9677
|
+
...rest,
|
|
9678
|
+
...motivation ? { motivation } : {},
|
|
9679
|
+
questions: withIntentQuestion(result.questions, motivation)
|
|
9680
|
+
},
|
|
9681
|
+
...correction ? { correction } : {}
|
|
9682
|
+
};
|
|
9683
|
+
}
|
|
9684
|
+
//#endregion
|
|
9313
9685
|
//#region ../../packages/review-harness/src/domain/ai_review/pipeline.ts
|
|
9314
9686
|
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:
|
|
9315
9687
|
{
|
|
9316
9688
|
"summary": string,
|
|
9689
|
+
"motivation"?: {
|
|
9690
|
+
"status": "stated" | "inferred" | "unclear",
|
|
9691
|
+
"summary": string,
|
|
9692
|
+
"sourceQuote"?: string,
|
|
9693
|
+
"evidence"?: [{ "kind": "test" | "behaviour" | "issue" | "commit" | "docs" | "code", "detail": string, "file"?: string }],
|
|
9694
|
+
"authorQuestion"?: string
|
|
9695
|
+
},
|
|
9317
9696
|
"overallRisk": "low" | "medium" | "high",
|
|
9318
9697
|
"sections": [{
|
|
9319
9698
|
"id": string,
|
|
@@ -9336,6 +9715,11 @@ var STRUCTURED_OUTPUT_INSTRUCTIONS = `In addition to the review principles above
|
|
|
9336
9715
|
}
|
|
9337
9716
|
|
|
9338
9717
|
Field guidance:
|
|
9718
|
+
- "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":
|
|
9719
|
+
- "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. Only the PR Description counts as the author stating it. Never use "stated" when the description only restates what the code does.
|
|
9720
|
+
- "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...".
|
|
9721
|
+
- "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".
|
|
9722
|
+
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.
|
|
9339
9723
|
- "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.
|
|
9340
9724
|
- "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."
|
|
9341
9725
|
- "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.
|
|
@@ -9346,7 +9730,7 @@ Field guidance:
|
|
|
9346
9730
|
- "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.
|
|
9347
9731
|
- "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.
|
|
9348
9732
|
- "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.
|
|
9349
|
-
- Every section's "oneLineSummary"/"purpose"/"whyReview", each file's "focus",
|
|
9733
|
+
- 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.
|
|
9350
9734
|
- "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.
|
|
9351
9735
|
|
|
9352
9736
|
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.`;
|
|
@@ -9364,7 +9748,22 @@ function buildStructuredReviewPrompt(pr, options = {}) {
|
|
|
9364
9748
|
partialFiles: partialFiles ?? []
|
|
9365
9749
|
};
|
|
9366
9750
|
}
|
|
9367
|
-
|
|
9751
|
+
/**
|
|
9752
|
+
* A reply, parsed, with the motivation checked against the description the analysis read.
|
|
9753
|
+
*
|
|
9754
|
+
* The check happens here rather than after, so there is no parsed result anywhere whose motivation
|
|
9755
|
+
* has not been through it. `correction` says what the check refused, for a caller with an audit to
|
|
9756
|
+
* put it in.
|
|
9757
|
+
*/
|
|
9758
|
+
function parseReviewResponse(raw, authorDescription) {
|
|
9759
|
+
const { result, motivation } = parseUnchecked(raw);
|
|
9760
|
+
const applied = applyMotivation(result, motivation, authorDescription);
|
|
9761
|
+
return {
|
|
9762
|
+
result: applied.result,
|
|
9763
|
+
...applied.correction ? { motivationCorrection: applied.correction } : {}
|
|
9764
|
+
};
|
|
9765
|
+
}
|
|
9766
|
+
function parseUnchecked(raw) {
|
|
9368
9767
|
const jsonText = extractJsonText(raw);
|
|
9369
9768
|
let parsed;
|
|
9370
9769
|
try {
|
|
@@ -9378,14 +9777,17 @@ function parseReviewResult(raw) {
|
|
|
9378
9777
|
const recommendation = asString(obj.recommendation);
|
|
9379
9778
|
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.");
|
|
9380
9779
|
return {
|
|
9381
|
-
|
|
9382
|
-
|
|
9383
|
-
|
|
9384
|
-
|
|
9385
|
-
|
|
9386
|
-
|
|
9387
|
-
|
|
9388
|
-
|
|
9780
|
+
result: {
|
|
9781
|
+
schemaVersion: 1,
|
|
9782
|
+
summary,
|
|
9783
|
+
overallRisk: RISKS.includes(obj.overallRisk) ? obj.overallRisk : "medium",
|
|
9784
|
+
sections: normalizeSections(obj.sections),
|
|
9785
|
+
issues: normalizeIssues(obj.issues),
|
|
9786
|
+
questions: normalizeQuestions(obj.questions),
|
|
9787
|
+
recommendation,
|
|
9788
|
+
nextSteps: asStringArray(obj.nextSteps)
|
|
9789
|
+
},
|
|
9790
|
+
motivation: normalizeMotivation(obj.motivation)
|
|
9389
9791
|
};
|
|
9390
9792
|
}
|
|
9391
9793
|
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.";
|
|
@@ -9505,6 +9907,7 @@ var KISS_OUTPUT_INSTRUCTIONS = `Respond with a single JSON object, no markdown a
|
|
|
9505
9907
|
"justifiedComplexity": [{
|
|
9506
9908
|
"id": string,
|
|
9507
9909
|
"title": string,
|
|
9910
|
+
"reason": string,
|
|
9508
9911
|
"rationale": string,
|
|
9509
9912
|
"locations": [{ "path": string, "line": number, "primary": boolean }]
|
|
9510
9913
|
}],
|
|
@@ -9530,6 +9933,9 @@ Field guidance:
|
|
|
9530
9933
|
- "draftComment": REVIEWER FRAMING ONLY, and only where a comment is worth making. Omit it otherwise. A short, constructive pull request comment in the reviewer's voice.
|
|
9531
9934
|
- "locations": required, at least one. Where this complexity lives. Prefer the changed files responsible for it, named exactly as they appear in the diff. If an unchanged repository file is where the complexity actually sits and no changed file represents it, cite that file too, but never describe an unchanged file as part of this change. "line" is optional. Set "primary": true on exactly one location — the one place a reader should open first, which should be a changed file wherever one represents the concern — and false on the rest.
|
|
9532
9935
|
- "justifiedComplexity": complexity in this change that is doing a real job. Empty list when there is nothing worth the note.
|
|
9936
|
+
- "title": what is kept, as the decision: "Keep atomic replacement", "Keep the schema 1 fallback".
|
|
9937
|
+
- "reason": required. Why it stays, in one sentence of at most 15 words: "Prevents concurrent writers from corrupting persisted state." Shown without expanding anything, so it must carry the point on its own. Do not restate the title.
|
|
9938
|
+
- "rationale": the full supporting explanation, in detail. Point at the code.
|
|
9533
9939
|
- "aggregateEstimate": optional, one phrase, across the whole change. Omit it unless it is defensible.`;
|
|
9534
9940
|
function buildKissPrompt(pr, options) {
|
|
9535
9941
|
const { stats, raw } = pr;
|
|
@@ -9752,6 +10158,7 @@ async function assemble(changeSet, budget, lens, prContext) {
|
|
|
9752
10158
|
...changeSet.data,
|
|
9753
10159
|
files: selection.inline,
|
|
9754
10160
|
changedFileCount: allFiles.length,
|
|
10161
|
+
...prContext ? { pullRequest: pullRequestFor(changeSet.data.pullRequest, prContext) } : {},
|
|
9755
10162
|
discussion: prContext?.discussion
|
|
9756
10163
|
}, selection.inline.length, null);
|
|
9757
10164
|
const shared = {
|
|
@@ -9781,6 +10188,21 @@ async function assemble(changeSet, budget, lens, prContext) {
|
|
|
9781
10188
|
};
|
|
9782
10189
|
}
|
|
9783
10190
|
/**
|
|
10191
|
+
* The change's pull request, as the prompt should present it once a forge request is attached.
|
|
10192
|
+
*
|
|
10193
|
+
* With no description, the commit subjects stay: they are still the best account of the change
|
|
10194
|
+
* there is, and still labelled as commits rather than as the author's words.
|
|
10195
|
+
*/
|
|
10196
|
+
function pullRequestFor(local, prContext) {
|
|
10197
|
+
const body = prContext.pullRequest.body;
|
|
10198
|
+
if (!body) return local;
|
|
10199
|
+
const { bodySource: _commits, ...rest } = local;
|
|
10200
|
+
return {
|
|
10201
|
+
...rest,
|
|
10202
|
+
body
|
|
10203
|
+
};
|
|
10204
|
+
}
|
|
10205
|
+
/**
|
|
9784
10206
|
* The inline patches as one blob, for grounding the assistant.
|
|
9785
10207
|
*
|
|
9786
10208
|
* No ceiling of its own. `selectInlineDiffs` already bounded this, and a second constant guarding
|
|
@@ -9828,10 +10250,10 @@ function parseKissAnswer(raw) {
|
|
|
9828
10250
|
const { kept, withheld } = normalizeRecommendations(obj.recommendations);
|
|
9829
10251
|
return {
|
|
9830
10252
|
result: {
|
|
9831
|
-
schemaVersion:
|
|
10253
|
+
schemaVersion: 3,
|
|
9832
10254
|
assessment,
|
|
9833
10255
|
summary,
|
|
9834
|
-
recommendations: kept,
|
|
10256
|
+
recommendations: distinctIds(kept),
|
|
9835
10257
|
justifiedComplexity: normalizeJustified(obj.justifiedComplexity),
|
|
9836
10258
|
...optional("aggregateEstimate", asString(obj.aggregateEstimate))
|
|
9837
10259
|
},
|
|
@@ -9896,6 +10318,7 @@ function normalizeJustified(value) {
|
|
|
9896
10318
|
return value.filter((item) => typeof item === "object" && item !== null).map((item, index) => ({
|
|
9897
10319
|
id: asString(item.id) || `keep-${index + 1}`,
|
|
9898
10320
|
title: asString(item.title),
|
|
10321
|
+
...optional("reason", unlessPlaceholder(asString(item.reason))),
|
|
9899
10322
|
rationale: asString(item.rationale),
|
|
9900
10323
|
locations: normalizeLocations(item.locations)
|
|
9901
10324
|
})).filter((entry) => entry.title !== "" || entry.rationale !== "");
|
|
@@ -10114,13 +10537,14 @@ async function existingPaths(repoPath, paths, commit = "HEAD") {
|
|
|
10114
10537
|
* from the change set, and it costs one git call, or none when the guide cites nothing off-diff.
|
|
10115
10538
|
*/
|
|
10116
10539
|
async function validateGuide(rawResponse, context) {
|
|
10117
|
-
const result =
|
|
10540
|
+
const { result, motivationCorrection } = parseReviewResponse(rawResponse, context.prContext?.pullRequest.body);
|
|
10118
10541
|
const changedPaths = new Set(context.changeSet.data.files.map((file) => file.name));
|
|
10119
10542
|
const sectionIds = new Set(result.sections.map((section) => section.id));
|
|
10120
10543
|
const findings = [...result.issues, ...result.questions];
|
|
10121
10544
|
const offDiff = /* @__PURE__ */ new Set();
|
|
10122
10545
|
for (const section of result.sections) for (const file of section.files) if (!changedPaths.has(file.path)) offDiff.add(file.path);
|
|
10123
10546
|
for (const finding of findings) if (finding.file && !changedPaths.has(finding.file)) offDiff.add(finding.file);
|
|
10547
|
+
for (const evidence of result.motivation?.evidence ?? []) if (evidence.file && !changedPaths.has(evidence.file)) offDiff.add(evidence.file);
|
|
10124
10548
|
const realButUnchanged = await existingPaths(context.changeSet.repoPath, [...offDiff]);
|
|
10125
10549
|
const unknownPaths = /* @__PURE__ */ new Set();
|
|
10126
10550
|
const sectioned = /* @__PURE__ */ new Set();
|
|
@@ -10161,6 +10585,13 @@ async function validateGuide(rawResponse, context) {
|
|
|
10161
10585
|
orphanedSectionRefs += 1;
|
|
10162
10586
|
}
|
|
10163
10587
|
}
|
|
10588
|
+
const placed = placeEvidenceFiles(result.motivation, {
|
|
10589
|
+
changed: (path) => changedPaths.has(path),
|
|
10590
|
+
exists: (path) => realButUnchanged.has(path)
|
|
10591
|
+
});
|
|
10592
|
+
if (placed.motivation) result.motivation = placed.motivation;
|
|
10593
|
+
for (const path of placed.removed) unknownPaths.add(path);
|
|
10594
|
+
for (const evidence of placed.motivation?.evidence ?? []) if (evidence.fileState === "unchanged" && evidence.file) noteReferenced(evidence.file);
|
|
10164
10595
|
const attribution = attributeIssuesToDiscussion(result, context.processed.discussion);
|
|
10165
10596
|
return {
|
|
10166
10597
|
result: attribution.result,
|
|
@@ -10168,7 +10599,8 @@ async function validateGuide(rawResponse, context) {
|
|
|
10168
10599
|
unknownPaths: [...unknownPaths].sort(),
|
|
10169
10600
|
relocatedFindings,
|
|
10170
10601
|
orphanedSectionRefs,
|
|
10171
|
-
rejectedAttributions: attribution.rejected
|
|
10602
|
+
rejectedAttributions: attribution.rejected,
|
|
10603
|
+
...motivationCorrection ? { motivationCorrection } : {}
|
|
10172
10604
|
},
|
|
10173
10605
|
referencedFiles: [...referenced].map(([path, sections]) => ({
|
|
10174
10606
|
path,
|
|
@@ -10849,16 +11281,17 @@ async function startJob(input) {
|
|
|
10849
11281
|
} : {}
|
|
10850
11282
|
});
|
|
10851
11283
|
saveJob(job);
|
|
10852
|
-
|
|
10853
|
-
|
|
10854
|
-
|
|
10855
|
-
|
|
10856
|
-
|
|
10857
|
-
|
|
10858
|
-
|
|
11284
|
+
mutateWorkspace(workspace.id, (fresh) => {
|
|
11285
|
+
if (review.type === "kiss") startKissRun(fresh, {
|
|
11286
|
+
jobId: job.id,
|
|
11287
|
+
audience: review.audience,
|
|
11288
|
+
reviewedSha: target.sha,
|
|
11289
|
+
promptVersion: "3",
|
|
11290
|
+
agentId: agent.id,
|
|
11291
|
+
...input.requestedModel ? { requestedModel: input.requestedModel } : {}
|
|
11292
|
+
});
|
|
11293
|
+
else fresh.jobId = job.id;
|
|
10859
11294
|
});
|
|
10860
|
-
else workspace.jobId = job.id;
|
|
10861
|
-
saveWorkspace(workspace);
|
|
10862
11295
|
});
|
|
10863
11296
|
return workspace.id;
|
|
10864
11297
|
}
|
|
@@ -10881,37 +11314,41 @@ async function reanalyseReview(workspaceId, agentId, onStarted, requestedModel)
|
|
|
10881
11314
|
if (!sha) throw new Error("This review does not record which commit it describes, so it cannot be analysed again.");
|
|
10882
11315
|
const agent = agentId ? agentById(agentId) : agentById(agentIdOf(workspace.session));
|
|
10883
11316
|
const started = await withReviewLock(workspaceId, () => {
|
|
10884
|
-
|
|
10885
|
-
|
|
10886
|
-
|
|
10887
|
-
|
|
10888
|
-
|
|
10889
|
-
|
|
10890
|
-
|
|
10891
|
-
|
|
10892
|
-
|
|
10893
|
-
|
|
10894
|
-
|
|
10895
|
-
|
|
10896
|
-
|
|
10897
|
-
|
|
10898
|
-
|
|
10899
|
-
|
|
10900
|
-
|
|
10901
|
-
|
|
10902
|
-
|
|
10903
|
-
|
|
10904
|
-
|
|
10905
|
-
|
|
10906
|
-
|
|
10907
|
-
|
|
11317
|
+
const created = {};
|
|
11318
|
+
mutateWorkspace(workspaceId, (workspace) => {
|
|
11319
|
+
if (workspace.session.result) workspace.previousResult = {
|
|
11320
|
+
result: workspace.session.result,
|
|
11321
|
+
commitSha: sha,
|
|
11322
|
+
replacedAt: Date.now(),
|
|
11323
|
+
agentId: agentIdOf(workspace.session),
|
|
11324
|
+
...workspace.session.requestedModel ? { requestedModel: workspace.session.requestedModel } : {}
|
|
11325
|
+
};
|
|
11326
|
+
const target = {
|
|
11327
|
+
originRepoPath: workspace.repoPath,
|
|
11328
|
+
branch: workspace.changeSet.headRef,
|
|
11329
|
+
sha,
|
|
11330
|
+
...baseOf(workspace.changeSet)
|
|
11331
|
+
};
|
|
11332
|
+
const model = resolveModel({
|
|
11333
|
+
...requestedModel ? { flag: requestedModel } : {},
|
|
11334
|
+
review: { session: workspace.session }
|
|
11335
|
+
});
|
|
11336
|
+
const job = createJob({
|
|
11337
|
+
workspaceId: workspace.id,
|
|
11338
|
+
target,
|
|
11339
|
+
env: agent.captureEnv(),
|
|
11340
|
+
purpose: "review",
|
|
11341
|
+
reanalysis: true,
|
|
11342
|
+
...model ? { requestedModel: model } : {}
|
|
11343
|
+
});
|
|
11344
|
+
saveJob(job);
|
|
11345
|
+
workspace.jobId = job.id;
|
|
11346
|
+
workspace.session = beginReviewSession(sha, agent, void 0, model ?? void 0);
|
|
11347
|
+
dropDerivedFromAnalysis(workspace);
|
|
11348
|
+
created.job = job;
|
|
10908
11349
|
});
|
|
10909
|
-
|
|
10910
|
-
|
|
10911
|
-
workspace.session = beginReviewSession(sha, agent, void 0, model ?? void 0);
|
|
10912
|
-
dropDerivedFromAnalysis(workspace);
|
|
10913
|
-
saveWorkspace(workspace);
|
|
10914
|
-
return job;
|
|
11350
|
+
if (!created.job) throw new Error("No such review.");
|
|
11351
|
+
return created.job;
|
|
10915
11352
|
});
|
|
10916
11353
|
if (isBusy(started)) throw new ReviewBeingDeletedError();
|
|
10917
11354
|
onStarted?.(started.id);
|
|
@@ -11038,27 +11475,27 @@ async function drive(jobId, workspaceId, runners, driver, signal) {
|
|
|
11038
11475
|
...job.target.pullRequest ? { requestNumber: job.target.pullRequest.number } : {},
|
|
11039
11476
|
...expectedFor(job.target)
|
|
11040
11477
|
});
|
|
11041
|
-
const stored = loadWorkspace(workspaceId);
|
|
11042
11478
|
const isLinkedWorktree = await linkedWorktree(job.target.originRepoPath);
|
|
11043
11479
|
const checkoutName = basename(job.target.originRepoPath);
|
|
11044
|
-
|
|
11480
|
+
const target = job.target;
|
|
11481
|
+
mutateWorkspace(workspaceId, (stored) => ({
|
|
11045
11482
|
...stored,
|
|
11046
11483
|
changeSet: {
|
|
11047
11484
|
...context.changeSet,
|
|
11048
|
-
repoPath:
|
|
11049
|
-
headRef:
|
|
11050
|
-
...
|
|
11051
|
-
...commitIdentity(
|
|
11485
|
+
repoPath: target.originRepoPath,
|
|
11486
|
+
headRef: target.branch,
|
|
11487
|
+
...target.ref ? { headResolvedRef: target.ref } : {},
|
|
11488
|
+
...commitIdentity(target, stored.changeSet),
|
|
11052
11489
|
checkoutName,
|
|
11053
11490
|
isLinkedWorktree,
|
|
11054
11491
|
data: {
|
|
11055
11492
|
...context.changeSet.data,
|
|
11056
11493
|
pullRequest: {
|
|
11057
11494
|
...context.changeSet.data.pullRequest,
|
|
11058
|
-
number:
|
|
11495
|
+
number: target.pullRequest?.number ?? context.changeSet.data.pullRequest.number,
|
|
11059
11496
|
head: {
|
|
11060
11497
|
...context.changeSet.data.pullRequest.head,
|
|
11061
|
-
ref:
|
|
11498
|
+
ref: target.branch
|
|
11062
11499
|
},
|
|
11063
11500
|
repo: checkoutName
|
|
11064
11501
|
}
|
|
@@ -11066,7 +11503,7 @@ async function drive(jobId, workspaceId, runners, driver, signal) {
|
|
|
11066
11503
|
},
|
|
11067
11504
|
diff: context.diff,
|
|
11068
11505
|
reviewedPaths: context.changeSet.data.files.map((file) => file.name)
|
|
11069
|
-
});
|
|
11506
|
+
}));
|
|
11070
11507
|
job = {
|
|
11071
11508
|
...job,
|
|
11072
11509
|
contextId: context.contextId
|
|
@@ -11099,10 +11536,12 @@ async function drive(jobId, workspaceId, runners, driver, signal) {
|
|
|
11099
11536
|
detail: message(error)
|
|
11100
11537
|
};
|
|
11101
11538
|
}
|
|
11102
|
-
if (outcome.kind === "attached")
|
|
11103
|
-
|
|
11104
|
-
|
|
11105
|
-
|
|
11539
|
+
if (outcome.kind === "attached") {
|
|
11540
|
+
const context = outcome.context;
|
|
11541
|
+
mutateWorkspace(workspaceId, (fresh) => {
|
|
11542
|
+
fresh.prContext = context;
|
|
11543
|
+
});
|
|
11544
|
+
}
|
|
11106
11545
|
}
|
|
11107
11546
|
job = {
|
|
11108
11547
|
...job,
|
|
@@ -11338,15 +11777,17 @@ async function runAnalysisFor(job, workspaceId, runners, signal, onObservedModel
|
|
|
11338
11777
|
*/
|
|
11339
11778
|
async function applyKiss(job, workspaceId, payload, observedModel) {
|
|
11340
11779
|
const stored = loadWorkspace(workspaceId);
|
|
11341
|
-
|
|
11780
|
+
runOfJob(stored, job.id);
|
|
11342
11781
|
const validated = await validateKiss(payload, stored.reviewedPaths, (paths) => existingPaths(job.worktreePath, paths));
|
|
11343
|
-
|
|
11344
|
-
|
|
11345
|
-
|
|
11346
|
-
|
|
11347
|
-
|
|
11782
|
+
const provenance = await runProvenanceOf(job, observedModel);
|
|
11783
|
+
mutateWorkspace(workspaceId, (fresh) => {
|
|
11784
|
+
completeKissRun(fresh, runOfJob(fresh, job.id).id, {
|
|
11785
|
+
result: validated.result,
|
|
11786
|
+
audit: validated.audit,
|
|
11787
|
+
...job.env.agentId ? { agentId: job.env.agentId } : {},
|
|
11788
|
+
...provenance
|
|
11789
|
+
});
|
|
11348
11790
|
});
|
|
11349
|
-
saveWorkspace(stored);
|
|
11350
11791
|
}
|
|
11351
11792
|
/**
|
|
11352
11793
|
* Record the failure on the run, if there is still a run in flight to record it on.
|
|
@@ -11358,12 +11799,11 @@ async function applyKiss(job, workspaceId, payload, observedModel) {
|
|
|
11358
11799
|
* declines instead of turning a delivered answer into a failure.
|
|
11359
11800
|
*/
|
|
11360
11801
|
function failCurrentKissRun(workspaceId, job, reason, kind, rejectedAnswer) {
|
|
11361
|
-
|
|
11362
|
-
|
|
11363
|
-
|
|
11364
|
-
|
|
11365
|
-
|
|
11366
|
-
saveWorkspace(stored);
|
|
11802
|
+
mutateWorkspace(workspaceId, (stored) => {
|
|
11803
|
+
const run = (stored.kissRuns ?? []).find((candidate) => candidate.jobId === job.id);
|
|
11804
|
+
if (!run || run.status === "completed" || run.status === "failed") return;
|
|
11805
|
+
failKissRun(stored, run.id, reason, kind, rejectedAnswer);
|
|
11806
|
+
});
|
|
11367
11807
|
}
|
|
11368
11808
|
async function applyGuide(job, workspaceId, payload, observedModel) {
|
|
11369
11809
|
const stored = loadWorkspace(workspaceId);
|
|
@@ -11372,18 +11812,22 @@ async function applyGuide(job, workspaceId, payload, observedModel) {
|
|
|
11372
11812
|
try {
|
|
11373
11813
|
applied = await applyGuideToContext(context, payload);
|
|
11374
11814
|
} catch (error) {
|
|
11375
|
-
if (error instanceof GuideRejected)
|
|
11376
|
-
|
|
11377
|
-
|
|
11378
|
-
|
|
11815
|
+
if (error instanceof GuideRejected) {
|
|
11816
|
+
const failed = failSession(stored.session, error.message, "agent_output_invalid", error.answer);
|
|
11817
|
+
mutateWorkspace(workspaceId, (fresh) => ({
|
|
11818
|
+
...fresh,
|
|
11819
|
+
session: keepReviewerFields(fresh, failed)
|
|
11820
|
+
}));
|
|
11821
|
+
}
|
|
11379
11822
|
throw error;
|
|
11380
11823
|
}
|
|
11381
|
-
|
|
11382
|
-
|
|
11383
|
-
|
|
11824
|
+
const session = completeReviewSession(stored.session, applied.result, context, { run: await runProvenanceOf(job, observedModel) });
|
|
11825
|
+
mutateWorkspace(workspaceId, (fresh) => ({
|
|
11826
|
+
...fresh,
|
|
11827
|
+
session: keepReviewerFields(fresh, session),
|
|
11384
11828
|
audit: applied.audit,
|
|
11385
11829
|
referencedFiles: applied.referencedFiles
|
|
11386
|
-
});
|
|
11830
|
+
}));
|
|
11387
11831
|
}
|
|
11388
11832
|
/**
|
|
11389
11833
|
* How the run that just finished was made, as the session should record it.
|
|
@@ -11458,7 +11902,7 @@ async function recordKissRun(input) {
|
|
|
11458
11902
|
jobId: job.id,
|
|
11459
11903
|
audience: input.audience,
|
|
11460
11904
|
reviewedSha: sha,
|
|
11461
|
-
promptVersion: "
|
|
11905
|
+
promptVersion: "3",
|
|
11462
11906
|
agentId: agent.id,
|
|
11463
11907
|
...input.requestedModel ? { requestedModel: input.requestedModel } : {}
|
|
11464
11908
|
});
|
|
@@ -12220,6 +12664,7 @@ function movable(fetched) {
|
|
|
12220
12664
|
};
|
|
12221
12665
|
copy("headSha");
|
|
12222
12666
|
copy("title");
|
|
12667
|
+
copy("body");
|
|
12223
12668
|
copy("filesUrl");
|
|
12224
12669
|
copy("fileUrlTemplate");
|
|
12225
12670
|
copy("author");
|
|
@@ -12429,6 +12874,7 @@ async function reconcile(workspace, runners, reviewedSha, remote, conversationOk
|
|
|
12429
12874
|
const result = {
|
|
12430
12875
|
schemaVersion: 1,
|
|
12431
12876
|
summary: parsed.summary || previous.summary,
|
|
12877
|
+
...previous.motivation ? { motivation: previous.motivation } : {},
|
|
12432
12878
|
overallRisk: parsed.overallRisk ?? previous.overallRisk,
|
|
12433
12879
|
recommendation: parsed.recommendation || previous.recommendation,
|
|
12434
12880
|
nextSteps: parsed.nextSteps.length ? parsed.nextSteps : previous.nextSteps,
|
|
@@ -12557,11 +13003,7 @@ function mergeUpdateInto(fresh, updated) {
|
|
|
12557
13003
|
const source = updated;
|
|
12558
13004
|
for (const field of UPDATE_OWNED_FIELDS) if (field in updated) target[field] = source[field];
|
|
12559
13005
|
else delete target[field];
|
|
12560
|
-
|
|
12561
|
-
const current = fresh.session;
|
|
12562
|
-
for (const field of REVIEWER_SESSION_FIELDS) if (field in fresh.session) session[field] = current[field];
|
|
12563
|
-
else delete session[field];
|
|
12564
|
-
merged.session = session;
|
|
13006
|
+
merged.session = keepReviewerFields(fresh, updated.session);
|
|
12565
13007
|
dropStaleChangeMap(merged);
|
|
12566
13008
|
return merged;
|
|
12567
13009
|
}
|
|
@@ -12991,4 +13433,4 @@ function lineageIds(id) {
|
|
|
12991
13433
|
return [.../* @__PURE__ */ new Set([id, ...lineage.map((review) => review.id)])];
|
|
12992
13434
|
}
|
|
12993
13435
|
//#endregion
|
|
12994
|
-
export {
|
|
13436
|
+
export { relativeTime as $, AGENT_IDS as $n, kissDecisionRefusal as $t, createPrState as A, readQueue as An, checkCodeFreshness as At, toneOf as B, WITHHELD_REASON as Bn, PHASES as Bt, readEvents as C, offersLifecycle as Cn, statedWorkspacePort as Ct, announceJob as D, queueAddMany as Dn, forgeFor as Dt, telemetryUploadConsent as E, queueAdd as En, forgeResolver as Et, markAllRead as F, questionsOutstanding as Fn, endedAt as Ft, isLongDescription as G, git as Gn, clearClaim as Gt, withUsageRecorded as H, resolveTarget as Hn, phasesFor as Ht, markRead as I, reviewerDispositions as In, fail as It, ReviewBeingDeletedError as J, clearAgentPreference as Jn, decideKissRecommendation as Jt, asString as K, resolveAgent as Kn, driverGone as Kt, pruneNotifications as L, processDiscussion as Ln, isTerminal as Lt, eventName as M, isIssueOutstanding as Mn, followedBranch as Mt, headline as N, isQuestionOutstanding as Nn, reviewedShaOf as Nt, branchOf as O, queueRemove as On, remoteHost as Ot, listNotifications as P, issuesOutstanding as Pn, allJobs as Pt, modelHelpLines as Q, STORE_ROOT as Qn, indexReviewGroups as Qt, readNotification as R, recommendationsOf as Rn, loadJob as Rt, startJob as S, lifecyclePill as Sn, resolveWorkspacePort as St, recordTelemetryUploadConsent as T, sectionOf as Tn, claimRequestUrl as Tt, EXPLAIN_SIMPLY_PROMPT as U, displayRef as Un, progressSteps as Ut, unreadCount as V, kissOutcome as Vn, isLiveJob as Vt, hasAuthorContent as W, isCommitReview as Wn, purposeOf as Wt, readMarker as X, writeAgentPreference as Xn, groupByLineage as Xt, MANAGED_ROOT as Y, readAgentPreference as Yn, followedRefName as Yt, removeWorktree as Z, CONFIG_PATH as Zn, indexKeptIds as Zt, agentEnvOf as _, LIFECYCLES as _n, feedbackUrl as _t, wasBlocked as a, matchingWorkspaceIds as an, AgentUnavailableError as ar, bootstrapUrl as at, reanalyseReview as b, lifecycleAction as bn, MIN_WORKSPACE_PORT as bt, RefreshUnavailableError as c, presentedKissRunOf as cn, readServerRecord as ct, refreshPrContext as d, reviewedCommit as dn, spawnServer as dt, kissDecisionsOf as en, DEFAULT_AGENT_ID as er, activateQueued as et, isUnchanged as f, reviewedRepositories as fn, stopServer as ft, runningJobs as g, workspaceStamps as gn, PACKAGE_NAME as gt, liveUpdateFor as h, workspaceRevision as hn, BUILD_VERSION as ht, liveJobsFor as i, loadWorkspace as in, AgentCancelledError as ir, readIndexToken as it, createPrUpdate as j, doneVerb as jn, checkReview as jt, subjectOf as k, queueStamp as kn, fillFileUrlTemplate as kt, mergeUpdateInto as l, previousKissResult as ln, reviewsUrl as lt, UpdateAlreadyRunningError as m, touchWorkspace as mn, writeServerRecord as mt, deleteReview as n, lineageKeyFor as nn, agentFor as nr, queueForReview as nt, discardJob as o, mutateWorkspace as on, clearServerRecord as ot, askCheckout as p, summarise as pn, workspaceUrl as pt, extractJsonText as q, resolveModel as qn, isClaimed as qt, lineageIds as r, lineagePosition as rn, detectAgents as rr, settleQueue as rt, FETCH_TIMEOUT_MS as s, positionInLineage as sn, ensureServer as st, deleteLineage as t, latestKissRun as tn, agentById as tr, compactQueue as tt, updateReview as u, recentWorkspaces as un, serverEntryExists as ut, recordKissRun as v, activityLabel as vn, DEFAULT_WORKSPACE_PORT as vt, record as w, parseLifecycle as wn, writePortPreference as wt, runJob as x, lifecycleChip as xn, parseWorkspacePort as xt, startKissJob as y, isLifecycle as yn, MAX_WORKSPACE_PORT as yt, removeNotification as z, describeAuthorship as zn, saveJob as zt };
|