@dev-loops/core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/capture-deep-persona-signals.mjs +143 -0
- package/bin/ensure-phase-files.mjs +7 -0
- package/bin/log-bash-exit-1.mjs +7 -0
- package/bin/parse-review-threads.mjs +7 -0
- package/package.json +78 -0
- package/src/analysis/change-classifier.mjs +146 -0
- package/src/analysis/diff-analyzer.mjs +285 -0
- package/src/bash-exit-one.mjs +130 -0
- package/src/cli/helpers.mjs +22 -0
- package/src/cli/primitives.mjs +70 -0
- package/src/cli/retry-wrapper.mjs +169 -0
- package/src/cli/subcommand-runner.mjs +246 -0
- package/src/config/config.mjs +965 -0
- package/src/debt/cluster.mjs +240 -0
- package/src/debt/debt-finding.mjs +68 -0
- package/src/debt/debt-signal.mjs +46 -0
- package/src/debt/deep-persona-signals.mjs +266 -0
- package/src/debt/remediation-to-issue.mjs +121 -0
- package/src/debt/score.mjs +127 -0
- package/src/debt/shape.mjs +214 -0
- package/src/github/copilot-helpers.mjs +343 -0
- package/src/github/repo-slug.mjs +105 -0
- package/src/github/review-threads.mjs +343 -0
- package/src/harness/adapter.mjs +57 -0
- package/src/harness/index.mjs +3 -0
- package/src/harness/noop-adapter.mjs +22 -0
- package/src/harness/pi-adapter.mjs +47 -0
- package/src/loop/async-start-contract.mjs +170 -0
- package/src/loop/conductor-routing.mjs +817 -0
- package/src/loop/copilot-ci-status.mjs +255 -0
- package/src/loop/copilot-loop-iterations.mjs +161 -0
- package/src/loop/copilot-loop-state.mjs +510 -0
- package/src/loop/handoff-envelope.mjs +800 -0
- package/src/loop/issue-refinement-artifact.mjs +268 -0
- package/src/loop/lifecycle-state.mjs +342 -0
- package/src/loop/phase-files.mjs +187 -0
- package/src/loop/policy-constants.mjs +17 -0
- package/src/loop/pr-gate-coordination.mjs +1278 -0
- package/src/loop/public-dev-loop-routing-contract.mjs +277 -0
- package/src/loop/public-dev-loop-routing.mjs +1746 -0
- package/src/loop/queue-board-ordering.mjs +38 -0
- package/src/loop/queue-board-sync.mjs +223 -0
- package/src/loop/queue-driver.mjs +164 -0
- package/src/loop/queue-parallel.mjs +190 -0
- package/src/loop/queue-state.mjs +230 -0
- package/src/loop/retrospective-checkpoint.mjs +178 -0
- package/src/loop/reviewer-loop-state.mjs +456 -0
- package/src/loop/run-inspection.mjs +604 -0
- package/src/loop/steering.mjs +793 -0
- package/src/loop/timeout-policy.mjs +73 -0
- package/src/loop/tracker-first-loop-state.mjs +87 -0
- package/src/loop/tracker-pr-state.mjs +301 -0
- package/src/loop/worktree-guard.mjs +141 -0
- package/src/refinement/ac-dod-matrix.mjs +95 -0
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic state machine and bounded planning/merge contracts for reviewer-side PR loops.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export const REVIEWER_STATE = Object.freeze({
|
|
6
|
+
WAITING_FOR_REVIEW_REQUEST: "waiting_for_review_request",
|
|
7
|
+
REVIEW_REQUESTED: "review_requested",
|
|
8
|
+
DETERMINE_REVIEW_PLAN: "determine_review_plan",
|
|
9
|
+
REVIEWS_RUNNING: "reviews_running",
|
|
10
|
+
MERGE_RESULTS: "merge_results",
|
|
11
|
+
DRAFT_REVIEW_READY: "draft_review_ready",
|
|
12
|
+
DRAFT_REVIEW_POSTED: "draft_review_posted",
|
|
13
|
+
WAITING_FOR_USER_SUBMIT: "waiting_for_user_submit",
|
|
14
|
+
SUBMITTED_REVIEW: "submitted_review",
|
|
15
|
+
WAITING_FOR_AUTHOR_FOLLOWUP: "waiting_for_author_followup",
|
|
16
|
+
WAITING_FOR_RE_REQUEST: "waiting_for_re_request",
|
|
17
|
+
REVIEW_INVALIDATED: "review_invalidated",
|
|
18
|
+
BLOCKED_NEEDS_USER_DECISION: "blocked_needs_user_decision",
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
export const REVIEWER_TRANSITIONS = Object.freeze({
|
|
22
|
+
[REVIEWER_STATE.WAITING_FOR_REVIEW_REQUEST]: [REVIEWER_STATE.REVIEW_REQUESTED],
|
|
23
|
+
[REVIEWER_STATE.REVIEW_REQUESTED]: [
|
|
24
|
+
REVIEWER_STATE.DETERMINE_REVIEW_PLAN,
|
|
25
|
+
REVIEWER_STATE.BLOCKED_NEEDS_USER_DECISION,
|
|
26
|
+
],
|
|
27
|
+
[REVIEWER_STATE.DETERMINE_REVIEW_PLAN]: [
|
|
28
|
+
REVIEWER_STATE.REVIEWS_RUNNING,
|
|
29
|
+
REVIEWER_STATE.BLOCKED_NEEDS_USER_DECISION,
|
|
30
|
+
],
|
|
31
|
+
[REVIEWER_STATE.REVIEWS_RUNNING]: [
|
|
32
|
+
REVIEWER_STATE.MERGE_RESULTS,
|
|
33
|
+
REVIEWER_STATE.BLOCKED_NEEDS_USER_DECISION,
|
|
34
|
+
],
|
|
35
|
+
[REVIEWER_STATE.MERGE_RESULTS]: [
|
|
36
|
+
REVIEWER_STATE.DRAFT_REVIEW_READY,
|
|
37
|
+
REVIEWER_STATE.BLOCKED_NEEDS_USER_DECISION,
|
|
38
|
+
],
|
|
39
|
+
[REVIEWER_STATE.DRAFT_REVIEW_READY]: [
|
|
40
|
+
REVIEWER_STATE.DRAFT_REVIEW_POSTED,
|
|
41
|
+
REVIEWER_STATE.BLOCKED_NEEDS_USER_DECISION,
|
|
42
|
+
],
|
|
43
|
+
[REVIEWER_STATE.DRAFT_REVIEW_POSTED]: [
|
|
44
|
+
REVIEWER_STATE.WAITING_FOR_USER_SUBMIT,
|
|
45
|
+
REVIEWER_STATE.REVIEW_INVALIDATED,
|
|
46
|
+
REVIEWER_STATE.SUBMITTED_REVIEW,
|
|
47
|
+
],
|
|
48
|
+
[REVIEWER_STATE.WAITING_FOR_USER_SUBMIT]: [
|
|
49
|
+
REVIEWER_STATE.SUBMITTED_REVIEW,
|
|
50
|
+
REVIEWER_STATE.REVIEW_INVALIDATED,
|
|
51
|
+
],
|
|
52
|
+
[REVIEWER_STATE.SUBMITTED_REVIEW]: [
|
|
53
|
+
REVIEWER_STATE.REVIEW_REQUESTED,
|
|
54
|
+
REVIEWER_STATE.WAITING_FOR_REVIEW_REQUEST,
|
|
55
|
+
],
|
|
56
|
+
[REVIEWER_STATE.WAITING_FOR_AUTHOR_FOLLOWUP]: [
|
|
57
|
+
REVIEWER_STATE.SUBMITTED_REVIEW,
|
|
58
|
+
REVIEWER_STATE.REVIEW_REQUESTED,
|
|
59
|
+
REVIEWER_STATE.WAITING_FOR_REVIEW_REQUEST,
|
|
60
|
+
],
|
|
61
|
+
[REVIEWER_STATE.WAITING_FOR_RE_REQUEST]: [
|
|
62
|
+
REVIEWER_STATE.REVIEW_REQUESTED,
|
|
63
|
+
REVIEWER_STATE.SUBMITTED_REVIEW,
|
|
64
|
+
],
|
|
65
|
+
[REVIEWER_STATE.REVIEW_INVALIDATED]: [REVIEWER_STATE.REVIEW_REQUESTED],
|
|
66
|
+
[REVIEWER_STATE.BLOCKED_NEEDS_USER_DECISION]: [],
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const REVIEWER_NEXT_ACTIONS = Object.freeze({
|
|
70
|
+
[REVIEWER_STATE.WAITING_FOR_REVIEW_REQUEST]: "Wait for an explicit review request on the PR",
|
|
71
|
+
[REVIEWER_STATE.REVIEW_REQUESTED]: "Capture PR context and start deterministic reviewer planning",
|
|
72
|
+
[REVIEWER_STATE.DETERMINE_REVIEW_PLAN]: "Select a bounded review-angle plan and prepare local runs",
|
|
73
|
+
[REVIEWER_STATE.REVIEWS_RUNNING]: "Wait for all bounded local review runs to complete",
|
|
74
|
+
[REVIEWER_STATE.MERGE_RESULTS]: "Merge completed review results into one coherent review package",
|
|
75
|
+
[REVIEWER_STATE.DRAFT_REVIEW_READY]: "Create a pending GitHub draft review from merged findings",
|
|
76
|
+
[REVIEWER_STATE.DRAFT_REVIEW_POSTED]: "Share the draft review URL and move to submit wait state",
|
|
77
|
+
[REVIEWER_STATE.WAITING_FOR_USER_SUBMIT]: "Wait for review submission through Pi or directly on GitHub",
|
|
78
|
+
[REVIEWER_STATE.SUBMITTED_REVIEW]: "Review outcome submitted; hand off to remediation/fix follow-up until explicit re-request",
|
|
79
|
+
[REVIEWER_STATE.WAITING_FOR_AUTHOR_FOLLOWUP]: "Legacy external wait: author/Copilot follow-up boundary after submitted review",
|
|
80
|
+
[REVIEWER_STATE.WAITING_FOR_RE_REQUEST]: "Legacy external wait: explicit re-request boundary after submitted review",
|
|
81
|
+
[REVIEWER_STATE.REVIEW_INVALIDATED]: "Discard stale pending draft review and restart at review_requested",
|
|
82
|
+
[REVIEWER_STATE.BLOCKED_NEEDS_USER_DECISION]: "Stop and request explicit user direction",
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const VALID_LOCAL_PLANNING_STATUSES = new Set(["none", "determining", "complete", "failed"]);
|
|
86
|
+
const VALID_LOCAL_RUN_STATUSES = new Set(["none", "running", "completed", "failed"]);
|
|
87
|
+
const VALID_LOCAL_MERGE_STATUSES = new Set(["none", "ready", "failed"]);
|
|
88
|
+
const VALID_DRAFT_NOTIFICATION_STATUSES = new Set(["none", "notified"]);
|
|
89
|
+
const VALID_SUBMISSION_STATUSES = new Set(["none", "submitted", "failed"]);
|
|
90
|
+
const VALID_SUBMITTED_REVIEW_STATES = new Set(["APPROVED", "CHANGES_REQUESTED", "COMMENTED", "DISMISSED"]);
|
|
91
|
+
|
|
92
|
+
const SUPPORTED_REVIEW_ANGLES = Object.freeze([
|
|
93
|
+
"correctness",
|
|
94
|
+
"tests",
|
|
95
|
+
"maintainability",
|
|
96
|
+
"security",
|
|
97
|
+
"scope",
|
|
98
|
+
]);
|
|
99
|
+
const DEFAULT_REVIEW_MAX_PARALLEL = 3;
|
|
100
|
+
const HARD_REVIEW_MAX_PARALLEL = 4;
|
|
101
|
+
|
|
102
|
+
function normalizeSha(value) {
|
|
103
|
+
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function normalizePositiveInt(value) {
|
|
107
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0
|
|
108
|
+
? Math.floor(value)
|
|
109
|
+
: null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function normalizeStatus(value, allowed, fallback) {
|
|
113
|
+
return allowed.has(value) ? value : fallback;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function normalizeReviewerLogin(value) {
|
|
117
|
+
return typeof value === "string" && value.trim().length > 0
|
|
118
|
+
? value.trim().toLowerCase()
|
|
119
|
+
: null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function normalizeSubmittedReviewState(value) {
|
|
123
|
+
if (typeof value !== "string") {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const normalized = value.trim().toUpperCase();
|
|
128
|
+
return VALID_SUBMITTED_REVIEW_STATES.has(normalized) ? normalized : null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Normalize reviewer-loop snapshot data into a canonical, deterministic shape.
|
|
133
|
+
*
|
|
134
|
+
* @param {object} raw
|
|
135
|
+
* @returns {object}
|
|
136
|
+
*/
|
|
137
|
+
export function normalizeReviewerSnapshot(raw) {
|
|
138
|
+
if (!raw || typeof raw !== "object") {
|
|
139
|
+
throw new Error("Snapshot must be a non-null object");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const prExists = Boolean(raw.prExists);
|
|
143
|
+
const reviewerLogin = normalizeReviewerLogin(raw.reviewerLogin);
|
|
144
|
+
const reviewerScope = reviewerLogin !== null ? "single_reviewer" : "all_reviewers";
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
prExists,
|
|
148
|
+
prNumber: prExists ? normalizePositiveInt(raw.prNumber) : null,
|
|
149
|
+
prDraft: Boolean(raw.prDraft),
|
|
150
|
+
prMerged: Boolean(raw.prMerged),
|
|
151
|
+
prClosed: Boolean(raw.prClosed),
|
|
152
|
+
prHeadSha: prExists ? normalizeSha(raw.prHeadSha) : null,
|
|
153
|
+
|
|
154
|
+
reviewerScope,
|
|
155
|
+
reviewerLogin: reviewerScope === "single_reviewer" ? reviewerLogin : null,
|
|
156
|
+
reviewRequested: Boolean(raw.reviewRequested),
|
|
157
|
+
|
|
158
|
+
localPlanningStatus: normalizeStatus(raw.localPlanningStatus, VALID_LOCAL_PLANNING_STATUSES, "none"),
|
|
159
|
+
localReviewRunsStatus: normalizeStatus(raw.localReviewRunsStatus, VALID_LOCAL_RUN_STATUSES, "none"),
|
|
160
|
+
localMergeStatus: normalizeStatus(raw.localMergeStatus, VALID_LOCAL_MERGE_STATUSES, "none"),
|
|
161
|
+
draftReviewPrepared: Boolean(raw.draftReviewPrepared),
|
|
162
|
+
|
|
163
|
+
draftReviewPosted: Boolean(raw.draftReviewPosted),
|
|
164
|
+
draftReviewId: normalizePositiveInt(raw.draftReviewId),
|
|
165
|
+
draftReviewUrl: typeof raw.draftReviewUrl === "string" && raw.draftReviewUrl.trim().length > 0
|
|
166
|
+
? raw.draftReviewUrl.trim()
|
|
167
|
+
: null,
|
|
168
|
+
draftReviewCommitSha: normalizeSha(raw.draftReviewCommitSha),
|
|
169
|
+
draftReviewNotificationStatus: normalizeStatus(
|
|
170
|
+
raw.draftReviewNotificationStatus,
|
|
171
|
+
VALID_DRAFT_NOTIFICATION_STATUSES,
|
|
172
|
+
"none",
|
|
173
|
+
),
|
|
174
|
+
|
|
175
|
+
submittedReviewPresent: Boolean(raw.submittedReviewPresent),
|
|
176
|
+
submittedReviewCommitSha: normalizeSha(raw.submittedReviewCommitSha),
|
|
177
|
+
submittedReviewState: normalizeSubmittedReviewState(raw.submittedReviewState),
|
|
178
|
+
reviewSubmissionStatus: normalizeStatus(raw.reviewSubmissionStatus, VALID_SUBMISSION_STATUSES, "none"),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Deterministically interpret current reviewer-loop state from a snapshot.
|
|
184
|
+
*
|
|
185
|
+
* @param {object} snapshot
|
|
186
|
+
* @returns {{state: string, allowedTransitions: string[], nextAction: string}}
|
|
187
|
+
*/
|
|
188
|
+
export function interpretReviewerLoopState(snapshot) {
|
|
189
|
+
const s = normalizeReviewerSnapshot(snapshot);
|
|
190
|
+
|
|
191
|
+
const draftIsStale = s.draftReviewPosted
|
|
192
|
+
&& s.prHeadSha !== null
|
|
193
|
+
&& s.draftReviewCommitSha !== null
|
|
194
|
+
&& s.prHeadSha !== s.draftReviewCommitSha;
|
|
195
|
+
|
|
196
|
+
const authorPushedSinceSubmit = s.submittedReviewPresent
|
|
197
|
+
&& s.prHeadSha !== null
|
|
198
|
+
&& s.submittedReviewCommitSha !== null
|
|
199
|
+
&& s.prHeadSha !== s.submittedReviewCommitSha;
|
|
200
|
+
|
|
201
|
+
let state;
|
|
202
|
+
|
|
203
|
+
if (!s.prExists || s.prMerged || s.prClosed) {
|
|
204
|
+
state = REVIEWER_STATE.WAITING_FOR_REVIEW_REQUEST;
|
|
205
|
+
} else if (s.prDraft) {
|
|
206
|
+
state = REVIEWER_STATE.WAITING_FOR_REVIEW_REQUEST;
|
|
207
|
+
} else if (s.reviewSubmissionStatus === "failed"
|
|
208
|
+
|| s.localPlanningStatus === "failed"
|
|
209
|
+
|| s.localReviewRunsStatus === "failed"
|
|
210
|
+
|| s.localMergeStatus === "failed") {
|
|
211
|
+
state = REVIEWER_STATE.BLOCKED_NEEDS_USER_DECISION;
|
|
212
|
+
} else if (draftIsStale) {
|
|
213
|
+
state = REVIEWER_STATE.REVIEW_INVALIDATED;
|
|
214
|
+
} else if (s.draftReviewPosted) {
|
|
215
|
+
if (s.draftReviewNotificationStatus === "notified") {
|
|
216
|
+
state = REVIEWER_STATE.WAITING_FOR_USER_SUBMIT;
|
|
217
|
+
} else {
|
|
218
|
+
state = REVIEWER_STATE.DRAFT_REVIEW_POSTED;
|
|
219
|
+
}
|
|
220
|
+
} else if (s.submittedReviewPresent) {
|
|
221
|
+
state = authorPushedSinceSubmit && s.reviewRequested
|
|
222
|
+
? REVIEWER_STATE.REVIEW_REQUESTED
|
|
223
|
+
: REVIEWER_STATE.SUBMITTED_REVIEW;
|
|
224
|
+
} else if (s.reviewSubmissionStatus === "submitted") {
|
|
225
|
+
state = REVIEWER_STATE.SUBMITTED_REVIEW;
|
|
226
|
+
} else if (s.draftReviewPrepared || s.localMergeStatus === "ready") {
|
|
227
|
+
state = REVIEWER_STATE.DRAFT_REVIEW_READY;
|
|
228
|
+
} else if (s.localReviewRunsStatus === "completed") {
|
|
229
|
+
state = REVIEWER_STATE.MERGE_RESULTS;
|
|
230
|
+
} else if (s.localReviewRunsStatus === "running") {
|
|
231
|
+
state = REVIEWER_STATE.REVIEWS_RUNNING;
|
|
232
|
+
} else if (s.localPlanningStatus === "determining") {
|
|
233
|
+
state = REVIEWER_STATE.DETERMINE_REVIEW_PLAN;
|
|
234
|
+
} else if (s.reviewRequested) {
|
|
235
|
+
state = REVIEWER_STATE.REVIEW_REQUESTED;
|
|
236
|
+
} else {
|
|
237
|
+
state = REVIEWER_STATE.WAITING_FOR_REVIEW_REQUEST;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return {
|
|
241
|
+
state,
|
|
242
|
+
allowedTransitions: [...REVIEWER_TRANSITIONS[state]],
|
|
243
|
+
nextAction: REVIEWER_NEXT_ACTIONS[state],
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Build a bounded deterministic review-angle plan for parallel local runs.
|
|
249
|
+
*
|
|
250
|
+
* @param {{ requestedAngles?: string[], maxParallel?: number }} [options]
|
|
251
|
+
* @returns {{ maxParallel: number, angles: string[], runs: {runId:string, angle:string}[] }}
|
|
252
|
+
*/
|
|
253
|
+
export function selectReviewerPlan(options = {}) {
|
|
254
|
+
const requestedAngles = Array.isArray(options.requestedAngles)
|
|
255
|
+
? options.requestedAngles
|
|
256
|
+
.filter((entry) => typeof entry === "string")
|
|
257
|
+
.map((entry) => entry.trim().toLowerCase())
|
|
258
|
+
.filter((entry) => SUPPORTED_REVIEW_ANGLES.includes(entry))
|
|
259
|
+
: [];
|
|
260
|
+
|
|
261
|
+
const maxParallel = typeof options.maxParallel === "number"
|
|
262
|
+
&& Number.isFinite(options.maxParallel)
|
|
263
|
+
&& options.maxParallel > 0
|
|
264
|
+
? Math.min(HARD_REVIEW_MAX_PARALLEL, Math.floor(options.maxParallel))
|
|
265
|
+
: DEFAULT_REVIEW_MAX_PARALLEL;
|
|
266
|
+
|
|
267
|
+
const chosenAngles = [];
|
|
268
|
+
const source = requestedAngles.length > 0 ? requestedAngles : SUPPORTED_REVIEW_ANGLES;
|
|
269
|
+
|
|
270
|
+
for (const angle of source) {
|
|
271
|
+
if (!chosenAngles.includes(angle)) {
|
|
272
|
+
chosenAngles.push(angle);
|
|
273
|
+
}
|
|
274
|
+
if (chosenAngles.length >= maxParallel) {
|
|
275
|
+
break;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return {
|
|
280
|
+
maxParallel,
|
|
281
|
+
angles: chosenAngles,
|
|
282
|
+
runs: chosenAngles.map((angle, index) => ({
|
|
283
|
+
runId: `review-angle-${String(index + 1).padStart(2, "0")}`,
|
|
284
|
+
angle,
|
|
285
|
+
})),
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Normalize free-form severity text to the bounded set:
|
|
291
|
+
* critical | high | medium | low | note.
|
|
292
|
+
* Unknown values are conservatively treated as medium.
|
|
293
|
+
*
|
|
294
|
+
* @param {unknown} value
|
|
295
|
+
* @returns {"critical"|"high"|"medium"|"low"|"note"}
|
|
296
|
+
*/
|
|
297
|
+
function normalizeFindingSeverity(value) {
|
|
298
|
+
// Unknown severities are treated as medium so merge synthesis stays conservative.
|
|
299
|
+
// Valid severities: critical, high, medium, low, note.
|
|
300
|
+
// (non-empty findings remain reviewable and can still produce COMMENT/REQUEST_CHANGES).
|
|
301
|
+
const severity = typeof value === "string" ? value.trim().toLowerCase() : "";
|
|
302
|
+
if (["critical", "high", "medium", "low", "note"].includes(severity)) {
|
|
303
|
+
return severity;
|
|
304
|
+
}
|
|
305
|
+
return "medium";
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Build a deterministic dedup key from finding location and message.
|
|
310
|
+
*
|
|
311
|
+
* @param {object} finding
|
|
312
|
+
* @returns {string}
|
|
313
|
+
*/
|
|
314
|
+
function findingDedupKey(finding) {
|
|
315
|
+
const pathPart = typeof finding.path === "string" ? finding.path.trim() : "";
|
|
316
|
+
const linePart = typeof finding.line === "number" && finding.line > 0 ? String(Math.floor(finding.line)) : "";
|
|
317
|
+
const messagePart = typeof finding.message === "string" ? finding.message.trim().toLowerCase() : "";
|
|
318
|
+
return `${pathPart}|${linePart}|${messagePart}`;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Deterministically merge bounded parallel review-run outputs into one review package.
|
|
323
|
+
*
|
|
324
|
+
* @param {{headSha?: string|null, runResults?: Array<{runId?:string, angle?:string, findings?:Array<object>, verdictHint?:string}>}} input
|
|
325
|
+
* @returns {{headSha:string|null, verdict:string, inlineComments:object[], summaryFindings:object[], totalFindings:number, runsMerged:number}}
|
|
326
|
+
*/
|
|
327
|
+
export function mergeReviewerResults(input = {}) {
|
|
328
|
+
const headSha = normalizeSha(input.headSha);
|
|
329
|
+
const runResults = Array.isArray(input.runResults) ? input.runResults : [];
|
|
330
|
+
|
|
331
|
+
const deduped = [];
|
|
332
|
+
const seen = new Set();
|
|
333
|
+
let hintRequestsChanges = false;
|
|
334
|
+
|
|
335
|
+
for (const run of runResults) {
|
|
336
|
+
if (run?.verdictHint === "REQUEST_CHANGES") {
|
|
337
|
+
hintRequestsChanges = true;
|
|
338
|
+
}
|
|
339
|
+
const findings = Array.isArray(run?.findings) ? run.findings : [];
|
|
340
|
+
|
|
341
|
+
for (const finding of findings) {
|
|
342
|
+
const key = findingDedupKey(finding);
|
|
343
|
+
if (seen.has(key)) {
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
seen.add(key);
|
|
347
|
+
deduped.push({
|
|
348
|
+
path: typeof finding.path === "string" ? finding.path.trim() : null,
|
|
349
|
+
line: typeof finding.line === "number" && finding.line > 0 ? Math.floor(finding.line) : null,
|
|
350
|
+
message: typeof finding.message === "string" ? finding.message.trim() : "",
|
|
351
|
+
severity: normalizeFindingSeverity(finding.severity),
|
|
352
|
+
angle: typeof run?.angle === "string" ? run.angle : null,
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const inlineComments = deduped.filter((finding) => finding.path && finding.line && finding.message.length > 0);
|
|
358
|
+
const summaryFindings = deduped.filter((finding) => !finding.path || !finding.line);
|
|
359
|
+
|
|
360
|
+
const verdict = determineReviewVerdict(deduped, hintRequestsChanges);
|
|
361
|
+
|
|
362
|
+
return {
|
|
363
|
+
headSha,
|
|
364
|
+
verdict,
|
|
365
|
+
inlineComments,
|
|
366
|
+
summaryFindings,
|
|
367
|
+
totalFindings: deduped.length,
|
|
368
|
+
runsMerged: runResults.length,
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
export function buildDraftReviewPayload(mergedResult = {}) {
|
|
373
|
+
const headSha = normalizeSha(mergedResult.headSha);
|
|
374
|
+
const verdict = normalizeDraftVerdict(mergedResult.verdict);
|
|
375
|
+
const inlineComments = Array.isArray(mergedResult.inlineComments) ? mergedResult.inlineComments : [];
|
|
376
|
+
const summaryFindings = Array.isArray(mergedResult.summaryFindings) ? mergedResult.summaryFindings : [];
|
|
377
|
+
|
|
378
|
+
const comments = inlineComments
|
|
379
|
+
.filter((finding) => finding && typeof finding === "object")
|
|
380
|
+
.map((finding) => ({
|
|
381
|
+
path: typeof finding.path === "string" && finding.path.trim().length > 0 ? finding.path.trim() : null,
|
|
382
|
+
line: typeof finding.line === "number" && finding.line > 0 ? Math.floor(finding.line) : null,
|
|
383
|
+
body: typeof finding.message === "string" ? finding.message.trim() : "",
|
|
384
|
+
side: "RIGHT",
|
|
385
|
+
}))
|
|
386
|
+
.filter((comment) => comment.path && comment.line && comment.body.length > 0);
|
|
387
|
+
|
|
388
|
+
return {
|
|
389
|
+
commit_id: headSha,
|
|
390
|
+
body: buildDraftReviewBody({
|
|
391
|
+
verdict,
|
|
392
|
+
summaryFindings,
|
|
393
|
+
totalFindings: typeof mergedResult.totalFindings === "number"
|
|
394
|
+
? Math.max(0, Math.floor(mergedResult.totalFindings))
|
|
395
|
+
: comments.length + summaryFindings.length,
|
|
396
|
+
runsMerged: typeof mergedResult.runsMerged === "number"
|
|
397
|
+
? Math.max(0, Math.floor(mergedResult.runsMerged))
|
|
398
|
+
: 0,
|
|
399
|
+
}),
|
|
400
|
+
comments,
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
export const REVIEWER_SUPPORTED_ANGLES = Object.freeze([...SUPPORTED_REVIEW_ANGLES]);
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Determine one merged review verdict from deduplicated findings.
|
|
408
|
+
* - APPROVE when there are no findings
|
|
409
|
+
* - REQUEST_CHANGES when a run explicitly hints it, or high/critical severity exists
|
|
410
|
+
* - COMMENT otherwise
|
|
411
|
+
*
|
|
412
|
+
* @param {Array<{severity:string}>} dedupedFindings
|
|
413
|
+
* @param {boolean} hintRequestsChanges
|
|
414
|
+
* @returns {"APPROVE"|"REQUEST_CHANGES"|"COMMENT"}
|
|
415
|
+
*/
|
|
416
|
+
function determineReviewVerdict(dedupedFindings, hintRequestsChanges) {
|
|
417
|
+
if (dedupedFindings.length === 0) {
|
|
418
|
+
return "APPROVE";
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const hasBlockingSeverity = dedupedFindings
|
|
422
|
+
.some((finding) => finding.severity === "critical" || finding.severity === "high");
|
|
423
|
+
|
|
424
|
+
if (hintRequestsChanges || hasBlockingSeverity) {
|
|
425
|
+
return "REQUEST_CHANGES";
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
return "COMMENT";
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function normalizeDraftVerdict(value) {
|
|
432
|
+
return ["APPROVE", "COMMENT", "REQUEST_CHANGES"].includes(value) ? value : "COMMENT";
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function buildDraftReviewBody({ verdict, summaryFindings, totalFindings, runsMerged }) {
|
|
436
|
+
const lines = [
|
|
437
|
+
`Reviewer-loop draft verdict: ${verdict}`,
|
|
438
|
+
`Total findings: ${totalFindings}`,
|
|
439
|
+
`Review runs merged: ${runsMerged}`,
|
|
440
|
+
];
|
|
441
|
+
|
|
442
|
+
if (summaryFindings.length > 0) {
|
|
443
|
+
lines.push("", "Summary findings:");
|
|
444
|
+
for (const finding of summaryFindings) {
|
|
445
|
+
const message = typeof finding?.message === "string" && finding.message.trim().length > 0
|
|
446
|
+
? finding.message.trim()
|
|
447
|
+
: "Review finding";
|
|
448
|
+
const severity = normalizeFindingSeverity(finding?.severity);
|
|
449
|
+
lines.push(`- [${severity}] ${message}`);
|
|
450
|
+
}
|
|
451
|
+
} else if (totalFindings === 0) {
|
|
452
|
+
lines.push("", "No summary-only findings were produced by the deterministic reviewer loop.");
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
return `${lines.join("\n")}\n`;
|
|
456
|
+
}
|