@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,510 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic state machine for the async Copilot review/fix loop.
|
|
3
|
+
*
|
|
4
|
+
* This module provides:
|
|
5
|
+
* - STATE: stable state name constants
|
|
6
|
+
* - TRANSITIONS: legal next-state graph for each state
|
|
7
|
+
* - normalizeSnapshot: validate and canonicalize a raw loop-state snapshot
|
|
8
|
+
* - interpretLoopState: map a snapshot to one current state + allowed transitions + next action
|
|
9
|
+
*
|
|
10
|
+
* The state machine owns workflow control.
|
|
11
|
+
* Agent judgment (accept/defer a comment, confirm a fix, decide on another Copilot pass)
|
|
12
|
+
* becomes an explicit bounded input (agentFixStatus) rather than hidden orchestration behavior.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { normalizeStatusCheckRollupContract } from "./copilot-ci-status.mjs";
|
|
16
|
+
|
|
17
|
+
/** Stable state name constants for the async Copilot review/fix loop. */
|
|
18
|
+
export const STATE = Object.freeze({
|
|
19
|
+
/** No open PR exists for the current work. */
|
|
20
|
+
NO_PR: "no_pr",
|
|
21
|
+
/** PR exists but is in draft state. */
|
|
22
|
+
PR_DRAFT: "pr_draft",
|
|
23
|
+
/** PR is ready-for-review; no Copilot review has been requested or received yet. */
|
|
24
|
+
PR_READY_NO_FEEDBACK: "pr_ready_no_feedback",
|
|
25
|
+
/** Copilot review was requested and is in requested_reviewers; waiting for review activity. */
|
|
26
|
+
WAITING_FOR_COPILOT_REVIEW: "waiting_for_copilot_review",
|
|
27
|
+
/** Unresolved review threads exist that require a fix and/or reply/resolve action. */
|
|
28
|
+
UNRESOLVED_FEEDBACK_PRESENT: "unresolved_feedback_present",
|
|
29
|
+
/**
|
|
30
|
+
* Agent has applied a fix; unresolved threads still exist on GitHub and need
|
|
31
|
+
* reply/resolve before another Copilot pass or re-request is appropriate.
|
|
32
|
+
*/
|
|
33
|
+
ALREADY_FIXED_NEEDS_REPLY_RESOLVE: "already_fixed_needs_reply_resolve",
|
|
34
|
+
/**
|
|
35
|
+
* All threads are resolved; Copilot has reviewed at least once and is not
|
|
36
|
+
* currently requested. Ready to re-request a new Copilot pass or confirm done.
|
|
37
|
+
*/
|
|
38
|
+
READY_TO_REREQUEST_REVIEW: "ready_to_rerequest_review",
|
|
39
|
+
/**
|
|
40
|
+
* Low-signal heuristic stopped the re-request loop. Round count exceeded
|
|
41
|
+
* threshold with only minimal actionable feedback per round.
|
|
42
|
+
*/
|
|
43
|
+
LOW_SIGNAL_CONVERGED: "low_signal_converged",
|
|
44
|
+
/**
|
|
45
|
+
* Copilot review request returned `unavailable`. Must stop/report.
|
|
46
|
+
* Do not sleep or watch as if review were requested.
|
|
47
|
+
*/
|
|
48
|
+
REVIEW_REQUEST_UNAVAILABLE: "review_request_unavailable",
|
|
49
|
+
/** CI checks are in progress or no usable CI readiness signal exists yet; wait before proceeding. */
|
|
50
|
+
WAITING_FOR_CI: "waiting_for_ci",
|
|
51
|
+
/**
|
|
52
|
+
* An unexpected failure occurred (bad review-request result, CI failure, etc.)
|
|
53
|
+
* that requires user decision before the loop can continue.
|
|
54
|
+
*/
|
|
55
|
+
BLOCKED_NEEDS_USER_DECISION: "blocked_needs_user_decision",
|
|
56
|
+
/** PR has been merged or closed. Loop is complete. */
|
|
57
|
+
DONE: "done",
|
|
58
|
+
/** Round cap reached with unresolved threads or failing CI; explicit stop. */
|
|
59
|
+
ROUND_CAP_REACHED: "round_cap_reached",
|
|
60
|
+
/** Round cap reached with clean threads and green CI; eligible for pre_approval_gate fallback. */
|
|
61
|
+
ROUND_CAP_CLEAN_FALLBACK: "round_cap_clean_fallback",
|
|
62
|
+
/** Internal tooling only PR; Copilot external review skipped, proceed directly to pre_approval_gate. */
|
|
63
|
+
INTERNAL_TOOLING_DIRECT_GATE: "internal_tooling_direct_gate",
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
/** Stable high-level loop dispositions for completion vs follow-up decisions. */
|
|
67
|
+
export const DISPOSITION = Object.freeze({
|
|
68
|
+
PENDING: "pending",
|
|
69
|
+
UNRESOLVED_FEEDBACK: "unresolved_feedback",
|
|
70
|
+
CLEAN_CONVERGED: "clean_converged",
|
|
71
|
+
BLOCKED: "blocked",
|
|
72
|
+
ACTION_REQUIRED: "action_required",
|
|
73
|
+
DONE: "done",
|
|
74
|
+
/** Internal tooling only: Copilot skipped, ready for pre_approval_gate. */
|
|
75
|
+
DIRECT_GATE: "direct_gate",
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Legal transitions for each state.
|
|
80
|
+
* Each entry lists the states that are reachable from the given state.
|
|
81
|
+
* The agent layer selects among allowed transitions; the state machine enforces the graph.
|
|
82
|
+
*/
|
|
83
|
+
export const TRANSITIONS = Object.freeze({
|
|
84
|
+
[STATE.NO_PR]: [],
|
|
85
|
+
[STATE.PR_DRAFT]: [STATE.PR_READY_NO_FEEDBACK],
|
|
86
|
+
[STATE.PR_READY_NO_FEEDBACK]: [STATE.WAITING_FOR_COPILOT_REVIEW],
|
|
87
|
+
[STATE.WAITING_FOR_COPILOT_REVIEW]: [
|
|
88
|
+
STATE.UNRESOLVED_FEEDBACK_PRESENT,
|
|
89
|
+
STATE.READY_TO_REREQUEST_REVIEW,
|
|
90
|
+
STATE.WAITING_FOR_CI,
|
|
91
|
+
],
|
|
92
|
+
[STATE.UNRESOLVED_FEEDBACK_PRESENT]: [
|
|
93
|
+
STATE.ALREADY_FIXED_NEEDS_REPLY_RESOLVE,
|
|
94
|
+
STATE.UNRESOLVED_FEEDBACK_PRESENT,
|
|
95
|
+
],
|
|
96
|
+
[STATE.ALREADY_FIXED_NEEDS_REPLY_RESOLVE]: [
|
|
97
|
+
STATE.READY_TO_REREQUEST_REVIEW,
|
|
98
|
+
],
|
|
99
|
+
[STATE.READY_TO_REREQUEST_REVIEW]: [
|
|
100
|
+
STATE.WAITING_FOR_COPILOT_REVIEW,
|
|
101
|
+
STATE.REVIEW_REQUEST_UNAVAILABLE,
|
|
102
|
+
STATE.DONE,
|
|
103
|
+
],
|
|
104
|
+
[STATE.REVIEW_REQUEST_UNAVAILABLE]: [],
|
|
105
|
+
[STATE.WAITING_FOR_CI]: [
|
|
106
|
+
STATE.PR_READY_NO_FEEDBACK,
|
|
107
|
+
STATE.READY_TO_REREQUEST_REVIEW,
|
|
108
|
+
STATE.BLOCKED_NEEDS_USER_DECISION,
|
|
109
|
+
],
|
|
110
|
+
[STATE.BLOCKED_NEEDS_USER_DECISION]: [],
|
|
111
|
+
[STATE.DONE]: [],
|
|
112
|
+
[STATE.LOW_SIGNAL_CONVERGED]: [],
|
|
113
|
+
[STATE.ROUND_CAP_REACHED]: [],
|
|
114
|
+
[STATE.ROUND_CAP_CLEAN_FALLBACK]: [],
|
|
115
|
+
[STATE.INTERNAL_TOOLING_DIRECT_GATE]: [STATE.DONE],
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
/** Recommended next action for each state. */
|
|
119
|
+
export const NEXT_ACTIONS = Object.freeze({
|
|
120
|
+
[STATE.NO_PR]: "Create a PR or hand work to Copilot",
|
|
121
|
+
[STATE.PR_DRAFT]: "Move the PR from draft to ready-for-review",
|
|
122
|
+
[STATE.PR_READY_NO_FEEDBACK]: "Request Copilot review via scripts/github/request-copilot-review.mjs",
|
|
123
|
+
[STATE.WAITING_FOR_COPILOT_REVIEW]: "Wait for Copilot review via scripts/github/probe-copilot-review.mjs",
|
|
124
|
+
[STATE.UNRESOLVED_FEEDBACK_PRESENT]: "Address unresolved review feedback, then reply to and resolve each thread on GitHub",
|
|
125
|
+
[STATE.ALREADY_FIXED_NEEDS_REPLY_RESOLVE]: "Reply to and resolve addressed threads on GitHub via scripts/github/reply-resolve-review-thread.mjs before re-requesting review",
|
|
126
|
+
[STATE.READY_TO_REREQUEST_REVIEW]: "Re-request Copilot review via scripts/github/request-copilot-review.mjs only after smallest honest local validation is green and no known fixable CI-red state remains, or confirm the PR is done",
|
|
127
|
+
[STATE.REVIEW_REQUEST_UNAVAILABLE]: "Report that Copilot review is unavailable and stop; do not sleep or watch as if review were requested",
|
|
128
|
+
[STATE.WAITING_FOR_CI]: "Wait for CI checks to complete or become available",
|
|
129
|
+
[STATE.BLOCKED_NEEDS_USER_DECISION]: "Report the blocked state to the user and stop; do not proceed without explicit authorization",
|
|
130
|
+
[STATE.LOW_SIGNAL_CONVERGED]: "Low-signal heuristic stopped re-request loop: round count exceeded threshold with only minimal actionable feedback; treat as converged",
|
|
131
|
+
[STATE.ROUND_CAP_REACHED]: "Stop: Copilot review round limit reached with unresolved threads or failing CI; do not re-request review",
|
|
132
|
+
[STATE.ROUND_CAP_CLEAN_FALLBACK]: "Round cap reached with clean PR; continue to pre_approval_gate instead of re-requesting Copilot review",
|
|
133
|
+
[STATE.INTERNAL_TOOLING_DIRECT_GATE]: "Internal tooling PR — Copilot external review suppressed; run pre_approval_gate directly",
|
|
134
|
+
[STATE.DONE]: "Loop is complete; confirm merge-readiness or close",
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const SAME_HEAD_CLEAN_CONVERGED_NEXT_ACTION = "Current head already has a clean submitted Copilot review; suppress automatic same-head re-request unless a meaningful remediation event occurs, or explicitly request another Copilot pass";
|
|
138
|
+
|
|
139
|
+
const VALID_REVIEW_REQUEST_STATUSES = new Set(["requested", "already-requested", "unavailable", "none", "failed"]);
|
|
140
|
+
const VALID_CI_STATUSES = new Set(["success", "failure", "pending", "none", "crediblyGreen"]);
|
|
141
|
+
const ACTIVE_REQUEST_STATUSES = new Set(["requested", "already-requested"]);
|
|
142
|
+
|
|
143
|
+
function isWaitingCiStatus(status) {
|
|
144
|
+
return status === "pending" || status === "none";
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function isBlockedCiStatus(status) {
|
|
148
|
+
return status === "failure";
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function normalizeCiStatus(rollup) {
|
|
152
|
+
return normalizeStatusCheckRollupContract(rollup).overallStatus;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function buildSnapshotFromPrFacts({
|
|
156
|
+
prData,
|
|
157
|
+
prNumber,
|
|
158
|
+
copilotReviewRequestStatus = "none",
|
|
159
|
+
copilotReviewPresent = false,
|
|
160
|
+
copilotReviewOnCurrentHead = false,
|
|
161
|
+
unresolvedThreadCount = 0,
|
|
162
|
+
actionableThreadCount = 0,
|
|
163
|
+
copilotReviewRoundCount = 0,
|
|
164
|
+
ciStatus,
|
|
165
|
+
lastCopilotRoundMaxSignal = null,
|
|
166
|
+
failureDetails = [],
|
|
167
|
+
excludedFailureDetails = [],
|
|
168
|
+
}) {
|
|
169
|
+
const prState = typeof prData?.state === "string" ? prData.state.toUpperCase() : "OPEN";
|
|
170
|
+
const prMerged = prState === "MERGED";
|
|
171
|
+
const prClosed = prState === "CLOSED";
|
|
172
|
+
|
|
173
|
+
return normalizeSnapshot({
|
|
174
|
+
prExists: true,
|
|
175
|
+
prNumber: typeof prData?.number === "number" ? prData.number : prNumber,
|
|
176
|
+
prDraft: Boolean(prData?.isDraft),
|
|
177
|
+
prMerged,
|
|
178
|
+
prClosed,
|
|
179
|
+
copilotReviewRequestStatus,
|
|
180
|
+
copilotReviewPresent,
|
|
181
|
+
copilotReviewOnCurrentHead,
|
|
182
|
+
unresolvedThreadCount,
|
|
183
|
+
actionableThreadCount,
|
|
184
|
+
copilotReviewRoundCount,
|
|
185
|
+
lastCopilotRoundMaxSignal,
|
|
186
|
+
ciStatus: ciStatus ?? normalizeCiStatus(prData?.statusCheckRollup),
|
|
187
|
+
failureDetails,
|
|
188
|
+
excludedFailureDetails,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function isAutoRerequestEligible(snapshot, state) {
|
|
193
|
+
if (state !== STATE.READY_TO_REREQUEST_REVIEW) return false;
|
|
194
|
+
// A fresh submitted Copilot review on the current head with no unresolved feedback
|
|
195
|
+
// is converged for that head on the automatic path. Auto re-request eligibility
|
|
196
|
+
// re-opens only when the head advances (i.e. review is no longer on current head).
|
|
197
|
+
return !snapshot.copilotReviewOnCurrentHead;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Normalize a raw snapshot object into a validated, canonical snapshot.
|
|
202
|
+
*
|
|
203
|
+
* Unknown or invalid field values are replaced with safe defaults.
|
|
204
|
+
* Throws if `raw` is not a non-null object.
|
|
205
|
+
*
|
|
206
|
+
* Snapshot schema:
|
|
207
|
+
* - prExists {boolean} — whether a PR was found
|
|
208
|
+
* - prNumber {number|null} — PR number if prExists, otherwise null
|
|
209
|
+
* - prDraft {boolean} — whether the PR is in draft state
|
|
210
|
+
* - prMerged {boolean} — whether the PR has been merged
|
|
211
|
+
* - prClosed {boolean} — whether the PR has been closed without merge
|
|
212
|
+
* - copilotReviewRequestStatus {"requested"|"already-requested"|"unavailable"|"none"|"failed"}
|
|
213
|
+
* — current known Copilot review-request state, or "none" if unknown
|
|
214
|
+
* - copilotReviewPresent {boolean} — whether at least one Copilot review exists on the PR
|
|
215
|
+
* - copilotReviewOnCurrentHead {boolean} — whether a submitted (non-PENDING) Copilot review
|
|
216
|
+
* exists for the current head commit; this alone does not prove the current-head
|
|
217
|
+
* review-request lifecycle is settled, so callers must still check request-state fields
|
|
218
|
+
* - unresolvedThreadCount {number} — total unresolved review-thread count
|
|
219
|
+
* - actionableThreadCount {number} — unresolved threads with non-bot actionable comments
|
|
220
|
+
* - copilotReviewRoundCount {number} — completed Copilot review rounds observed on the PR
|
|
221
|
+
* - ciStatus {"success"|"failure"|"pending"|"none"|"crediblyGreen"} — current CI check rollup status
|
|
222
|
+
* - lastCopilotRoundMaxSignal {"high"|"mid"|"low"|null} — highest signal level across Copilot-authored threads
|
|
223
|
+
* - agentFixStatus {"applied"|null} — agent-provided input: "applied" when code has been fixed
|
|
224
|
+
* - failureDetails {Array<string>} — names of failing visible check-runs from refreshed head-scoped CI evidence
|
|
225
|
+
* - excludedFailureDetails {Array<string>} — names of failing check-runs filtered out by PR-visibility intersection
|
|
226
|
+
*
|
|
227
|
+
* @param {object} raw - raw snapshot input
|
|
228
|
+
* @returns {object} normalized snapshot
|
|
229
|
+
*/
|
|
230
|
+
const VALID_SIGNAL_LEVELS = new Set(["high", "mid", "low"]);
|
|
231
|
+
|
|
232
|
+
function hasExplicitCurrentHeadReviewSignal(raw) {
|
|
233
|
+
return Boolean(raw)
|
|
234
|
+
&& typeof raw === "object"
|
|
235
|
+
&& Object.prototype.hasOwnProperty.call(raw, "copilotReviewOnCurrentHead");
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function normalizeSnapshot(raw) {
|
|
239
|
+
if (!raw || typeof raw !== "object") {
|
|
240
|
+
throw new Error("Snapshot must be a non-null object");
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const prExists = Boolean(raw.prExists);
|
|
244
|
+
|
|
245
|
+
const copilotReviewOnCurrentHead = Boolean(raw.copilotReviewOnCurrentHead);
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
prExists,
|
|
249
|
+
prNumber: prExists && typeof raw.prNumber === "number" && raw.prNumber > 0
|
|
250
|
+
? Math.floor(raw.prNumber)
|
|
251
|
+
: null,
|
|
252
|
+
prDraft: Boolean(raw.prDraft),
|
|
253
|
+
prMerged: Boolean(raw.prMerged),
|
|
254
|
+
prClosed: Boolean(raw.prClosed),
|
|
255
|
+
copilotReviewRequestStatus: VALID_REVIEW_REQUEST_STATUSES.has(raw.copilotReviewRequestStatus)
|
|
256
|
+
? raw.copilotReviewRequestStatus
|
|
257
|
+
: "none",
|
|
258
|
+
copilotReviewPresent: Boolean(raw.copilotReviewPresent) || copilotReviewOnCurrentHead,
|
|
259
|
+
copilotReviewOnCurrentHead,
|
|
260
|
+
unresolvedThreadCount: typeof raw.unresolvedThreadCount === "number" && raw.unresolvedThreadCount >= 0
|
|
261
|
+
? Math.floor(raw.unresolvedThreadCount)
|
|
262
|
+
: 0,
|
|
263
|
+
actionableThreadCount: typeof raw.actionableThreadCount === "number" && raw.actionableThreadCount >= 0
|
|
264
|
+
? Math.floor(raw.actionableThreadCount)
|
|
265
|
+
: 0,
|
|
266
|
+
copilotReviewRoundCount: typeof raw.copilotReviewRoundCount === "number" && raw.copilotReviewRoundCount >= 0
|
|
267
|
+
? Math.floor(raw.copilotReviewRoundCount)
|
|
268
|
+
: 0,
|
|
269
|
+
ciStatus: VALID_CI_STATUSES.has(raw.ciStatus) ? raw.ciStatus : "none",
|
|
270
|
+
lastCopilotRoundMaxSignal: VALID_SIGNAL_LEVELS.has(raw.lastCopilotRoundMaxSignal) ? raw.lastCopilotRoundMaxSignal : null,
|
|
271
|
+
agentFixStatus: raw.agentFixStatus === "applied" ? "applied" : null,
|
|
272
|
+
failureDetails: Array.isArray(raw.failureDetails) ? raw.failureDetails : [],
|
|
273
|
+
excludedFailureDetails: Array.isArray(raw.excludedFailureDetails) ? raw.excludedFailureDetails : [],
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Return the post-request snapshot that should drive the next wait-cycle interpretation
|
|
279
|
+
* once a Copilot review request has been explicitly issued or confirmed.
|
|
280
|
+
*
|
|
281
|
+
* This keeps the handoff helper on the same shared state-machine contract instead of
|
|
282
|
+
* emitting a watch action that contradicts a same-head clean-convergence interpretation.
|
|
283
|
+
* A confirmed request starts a new wait cycle for the current head, so prior
|
|
284
|
+
* current-head clean-review convergence is cleared for handoff purposes while
|
|
285
|
+
* preserving whether a submitted Copilot review has ever been observed on the PR.
|
|
286
|
+
*
|
|
287
|
+
* @param {object} snapshot
|
|
288
|
+
* @param {string} reviewRequestStatus
|
|
289
|
+
* @returns {object}
|
|
290
|
+
*/
|
|
291
|
+
export function applyConfirmedReviewRequest(snapshot, reviewRequestStatus) {
|
|
292
|
+
const s = normalizeSnapshot(snapshot);
|
|
293
|
+
|
|
294
|
+
if (!ACTIVE_REQUEST_STATUSES.has(reviewRequestStatus)) {
|
|
295
|
+
return normalizeSnapshot({ ...s, copilotReviewRequestStatus: reviewRequestStatus });
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return normalizeSnapshot({
|
|
299
|
+
...s,
|
|
300
|
+
copilotReviewRequestStatus: reviewRequestStatus,
|
|
301
|
+
copilotReviewOnCurrentHead: false,
|
|
302
|
+
copilotReviewPresent: s.copilotReviewPresent,
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Interpret a loop-state snapshot into one current state, allowed next transitions,
|
|
308
|
+
* and a recommended next action.
|
|
309
|
+
*
|
|
310
|
+
* Interpretation is deterministic: the same snapshot always yields the same result.
|
|
311
|
+
* The function normalizes the snapshot before interpreting, so raw inputs are accepted.
|
|
312
|
+
*
|
|
313
|
+
* Key routing guarantees:
|
|
314
|
+
* - unresolvedThreadCount > 0 always routes into fix/reply-resolve flow, never into wait
|
|
315
|
+
* - "unavailable" or "failed" review-request status routes into stop/report states
|
|
316
|
+
* - agentFixStatus "applied" distinguishes fix-needed from already-fixed-needs-reply/resolve
|
|
317
|
+
* - Copilot review request still active (via requested_reviewers or a PENDING current-head Copilot review)
|
|
318
|
+
* routes into waiting_for_copilot_review until that request is conclusively settled for this head
|
|
319
|
+
*
|
|
320
|
+
* @param {object} snapshot - raw or normalized snapshot
|
|
321
|
+
* @param {object} [refinementConfig] - optional refinement config with low-signal heuristic fields
|
|
322
|
+
* @param {boolean} [refinementConfig.stopOnLowSignal]
|
|
323
|
+
* @param {number} [refinementConfig.lowSignalRoundThreshold]
|
|
324
|
+
* @param {number} [refinementConfig.lowSignalMaxComments]
|
|
325
|
+
* @param {number} [refinementConfig.maxCopilotRounds]
|
|
326
|
+
* @returns {{
|
|
327
|
+
* state: string,
|
|
328
|
+
* allowedTransitions: string[],
|
|
329
|
+
* nextAction: string,
|
|
330
|
+
* autoRerequestEligible: boolean,
|
|
331
|
+
* sameHeadCleanConverged: boolean,
|
|
332
|
+
* roundCapCleanEligible: boolean
|
|
333
|
+
* }}
|
|
334
|
+
*/
|
|
335
|
+
export function interpretLoopState(snapshot, refinementConfig) {
|
|
336
|
+
const s = normalizeSnapshot(snapshot);
|
|
337
|
+
|
|
338
|
+
let state;
|
|
339
|
+
|
|
340
|
+
if (!s.prExists) {
|
|
341
|
+
state = STATE.NO_PR;
|
|
342
|
+
} else if (s.prMerged || s.prClosed) {
|
|
343
|
+
state = STATE.DONE;
|
|
344
|
+
} else if (s.prDraft) {
|
|
345
|
+
state = STATE.PR_DRAFT;
|
|
346
|
+
} else if (s.copilotReviewRequestStatus === "unavailable") {
|
|
347
|
+
state = STATE.REVIEW_REQUEST_UNAVAILABLE;
|
|
348
|
+
} else if (s.copilotReviewRequestStatus === "failed") {
|
|
349
|
+
state = STATE.BLOCKED_NEEDS_USER_DECISION;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Round-cap enforcement: when maxCopilotRounds is configured and the review-round
|
|
353
|
+
// count has been exhausted, stop re-requests before entering fix/reply-resolve routing.
|
|
354
|
+
// Gating here (before unresolved-thread checks) ensures round cap takes priority over
|
|
355
|
+
// the normal fix loop, including unresolved threads, pending CI, and CI failures.
|
|
356
|
+
// Clean PRs are usually eligible for pre_approval_gate fallback; the only automatic
|
|
357
|
+
// exception is when the head has advanced since the last submitted Copilot review,
|
|
358
|
+
// all prior feedback is resolved, and CI is green/credibly green again. In that case
|
|
359
|
+
// the state re-opens to READY_TO_REREQUEST_REVIEW instead of terminating as clean fallback.
|
|
360
|
+
// Does NOT interrupt an in-flight review request (requested/already-requested).
|
|
361
|
+
const maxRounds = refinementConfig?.maxCopilotRounds;
|
|
362
|
+
const reviewInFlight = s.copilotReviewRequestStatus === "requested"
|
|
363
|
+
|| s.copilotReviewRequestStatus === "already-requested";
|
|
364
|
+
if (typeof maxRounds === "number" && maxRounds > 0
|
|
365
|
+
&& s.copilotReviewRoundCount >= maxRounds
|
|
366
|
+
&& !reviewInFlight
|
|
367
|
+
&& state !== STATE.NO_PR && state !== STATE.DONE
|
|
368
|
+
&& state !== STATE.PR_DRAFT && state !== STATE.REVIEW_REQUEST_UNAVAILABLE
|
|
369
|
+
&& state !== STATE.BLOCKED_NEEDS_USER_DECISION) {
|
|
370
|
+
const ciClean = s.ciStatus === "success" || s.ciStatus === "crediblyGreen";
|
|
371
|
+
const cleanThreads = s.unresolvedThreadCount === 0;
|
|
372
|
+
const headAdvancedSinceLastSubmittedCopilotReview = s.copilotReviewPresent
|
|
373
|
+
&& hasExplicitCurrentHeadReviewSignal(snapshot)
|
|
374
|
+
&& !s.copilotReviewOnCurrentHead;
|
|
375
|
+
if (cleanThreads && ciClean && headAdvancedSinceLastSubmittedCopilotReview) {
|
|
376
|
+
state = STATE.READY_TO_REREQUEST_REVIEW;
|
|
377
|
+
} else if (cleanThreads && ciClean) {
|
|
378
|
+
state = STATE.ROUND_CAP_CLEAN_FALLBACK;
|
|
379
|
+
} else {
|
|
380
|
+
state = STATE.ROUND_CAP_REACHED;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (state === undefined) {
|
|
385
|
+
if (s.unresolvedThreadCount > 0 && s.agentFixStatus === "applied") {
|
|
386
|
+
// Agent has fixed the code; threads still need reply/resolve on GitHub
|
|
387
|
+
state = STATE.ALREADY_FIXED_NEEDS_REPLY_RESOLVE;
|
|
388
|
+
} else if (s.unresolvedThreadCount > 0) {
|
|
389
|
+
// Unresolved feedback exists — do not wait; enter fix/reply-resolve handling
|
|
390
|
+
state = STATE.UNRESOLVED_FEEDBACK_PRESENT;
|
|
391
|
+
} else if (s.copilotReviewRequestStatus === "requested" || s.copilotReviewRequestStatus === "already-requested") {
|
|
392
|
+
// A current-head Copilot request is still active/pending and must settle before gate progression.
|
|
393
|
+
state = STATE.WAITING_FOR_COPILOT_REVIEW;
|
|
394
|
+
} else if (s.copilotReviewPresent) {
|
|
395
|
+
// Copilot has reviewed at least once; all threads resolved
|
|
396
|
+
if (isBlockedCiStatus(s.ciStatus)) {
|
|
397
|
+
state = STATE.BLOCKED_NEEDS_USER_DECISION;
|
|
398
|
+
} else if (isWaitingCiStatus(s.ciStatus)) {
|
|
399
|
+
state = STATE.WAITING_FOR_CI;
|
|
400
|
+
} else {
|
|
401
|
+
state = STATE.READY_TO_REREQUEST_REVIEW;
|
|
402
|
+
}
|
|
403
|
+
} else {
|
|
404
|
+
// No Copilot review yet; not currently requested
|
|
405
|
+
if (isBlockedCiStatus(s.ciStatus)) {
|
|
406
|
+
state = STATE.BLOCKED_NEEDS_USER_DECISION;
|
|
407
|
+
} else if (isWaitingCiStatus(s.ciStatus)) {
|
|
408
|
+
state = STATE.WAITING_FOR_CI;
|
|
409
|
+
} else {
|
|
410
|
+
state = STATE.PR_READY_NO_FEEDBACK;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
// Low-signal heuristic: when configured and last Copilot round signal
|
|
417
|
+
// classification is mid or low (not high), suppress re-request.
|
|
418
|
+
// Falls back to actionableThreadCount heuristic when signal data is null.
|
|
419
|
+
const lowSignalApplied =
|
|
420
|
+
refinementConfig?.stopOnLowSignal === true
|
|
421
|
+
&& state === STATE.READY_TO_REREQUEST_REVIEW
|
|
422
|
+
&& s.copilotReviewRoundCount > (refinementConfig.lowSignalRoundThreshold ?? 3)
|
|
423
|
+
&& s.actionableThreadCount <= (refinementConfig.lowSignalMaxComments ?? 2)
|
|
424
|
+
&& (
|
|
425
|
+
s.lastCopilotRoundMaxSignal === null
|
|
426
|
+
|| s.lastCopilotRoundMaxSignal !== "high"
|
|
427
|
+
);
|
|
428
|
+
|
|
429
|
+
if (lowSignalApplied) {
|
|
430
|
+
state = STATE.LOW_SIGNAL_CONVERGED;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const autoRerequestEligible = isAutoRerequestEligible(s, state);
|
|
434
|
+
const sameHeadCleanConverged = state === STATE.READY_TO_REREQUEST_REVIEW
|
|
435
|
+
&& s.copilotReviewOnCurrentHead
|
|
436
|
+
&& s.unresolvedThreadCount === 0
|
|
437
|
+
&& s.actionableThreadCount === 0;
|
|
438
|
+
|
|
439
|
+
let nextAction = NEXT_ACTIONS[state];
|
|
440
|
+
if (sameHeadCleanConverged) {
|
|
441
|
+
nextAction = SAME_HEAD_CLEAN_CONVERGED_NEXT_ACTION;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const roundCapCleanEligible = state === STATE.ROUND_CAP_CLEAN_FALLBACK;
|
|
445
|
+
|
|
446
|
+
return {
|
|
447
|
+
state,
|
|
448
|
+
allowedTransitions: [...TRANSITIONS[state]],
|
|
449
|
+
nextAction,
|
|
450
|
+
autoRerequestEligible,
|
|
451
|
+
sameHeadCleanConverged,
|
|
452
|
+
roundCapCleanEligible,
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Classify a loop interpretation into a higher-level disposition and whether the
|
|
458
|
+
* loop is terminal/stoppable for this head.
|
|
459
|
+
*
|
|
460
|
+
* @param {object} snapshotOrInterpretation - raw snapshot, normalized snapshot, or interpretLoopState() output
|
|
461
|
+
* @returns {{ loopDisposition: string, terminal: boolean }}
|
|
462
|
+
*/
|
|
463
|
+
export function summarizeLoopInterpretation(snapshotOrInterpretation, refinementConfig) {
|
|
464
|
+
const interpretation = Array.isArray(snapshotOrInterpretation?.allowedTransitions)
|
|
465
|
+
&& typeof snapshotOrInterpretation?.state === "string"
|
|
466
|
+
&& typeof snapshotOrInterpretation?.nextAction === "string"
|
|
467
|
+
? snapshotOrInterpretation
|
|
468
|
+
: interpretLoopState(snapshotOrInterpretation, refinementConfig);
|
|
469
|
+
|
|
470
|
+
let loopDisposition;
|
|
471
|
+
|
|
472
|
+
switch (interpretation.state) {
|
|
473
|
+
case STATE.WAITING_FOR_COPILOT_REVIEW:
|
|
474
|
+
case STATE.WAITING_FOR_CI:
|
|
475
|
+
loopDisposition = DISPOSITION.PENDING;
|
|
476
|
+
break;
|
|
477
|
+
case STATE.UNRESOLVED_FEEDBACK_PRESENT:
|
|
478
|
+
case STATE.ALREADY_FIXED_NEEDS_REPLY_RESOLVE:
|
|
479
|
+
loopDisposition = DISPOSITION.UNRESOLVED_FEEDBACK;
|
|
480
|
+
break;
|
|
481
|
+
case STATE.REVIEW_REQUEST_UNAVAILABLE:
|
|
482
|
+
case STATE.BLOCKED_NEEDS_USER_DECISION:
|
|
483
|
+
case STATE.ROUND_CAP_REACHED:
|
|
484
|
+
loopDisposition = DISPOSITION.BLOCKED;
|
|
485
|
+
break;
|
|
486
|
+
case STATE.INTERNAL_TOOLING_DIRECT_GATE:
|
|
487
|
+
loopDisposition = DISPOSITION.DIRECT_GATE;
|
|
488
|
+
break;
|
|
489
|
+
case STATE.LOW_SIGNAL_CONVERGED:
|
|
490
|
+
case STATE.ROUND_CAP_CLEAN_FALLBACK:
|
|
491
|
+
case STATE.DONE:
|
|
492
|
+
loopDisposition = DISPOSITION.DONE;
|
|
493
|
+
break;
|
|
494
|
+
case STATE.READY_TO_REREQUEST_REVIEW:
|
|
495
|
+
loopDisposition = interpretation.sameHeadCleanConverged
|
|
496
|
+
? DISPOSITION.CLEAN_CONVERGED
|
|
497
|
+
: DISPOSITION.ACTION_REQUIRED;
|
|
498
|
+
break;
|
|
499
|
+
default:
|
|
500
|
+
loopDisposition = DISPOSITION.ACTION_REQUIRED;
|
|
501
|
+
break;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
return {
|
|
505
|
+
loopDisposition,
|
|
506
|
+
terminal: loopDisposition === DISPOSITION.CLEAN_CONVERGED
|
|
507
|
+
|| loopDisposition === DISPOSITION.BLOCKED
|
|
508
|
+
|| loopDisposition === DISPOSITION.DONE,
|
|
509
|
+
};
|
|
510
|
+
}
|