@dev-loops/core 1.0.3 → 1.0.4-pre.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/package.json +1 -1
- package/src/analysis/change-classifier.mjs +49 -28
- package/src/analysis/diff-analyzer.mjs +127 -31
- package/src/claude/asset-generation.mjs +36 -1
- package/src/claude/hook-decisions.mjs +117 -18
- package/src/config/config.mjs +198 -86
- package/src/config/extension-defaults.yaml +66 -6
- package/src/github/copilot-helpers.mjs +85 -20
- package/src/github/gh.mjs +14 -1
- package/src/github/review-threads.mjs +6 -0
- package/src/loop/bash-command-classify.mjs +251 -14
- package/src/loop/copilot-ci-status.mjs +116 -6
- package/src/loop/copilot-loop-state.mjs +38 -2
- package/src/loop/finding-cluster.mjs +30 -11
- package/src/loop/gate-carry-forward.mjs +39 -6
- package/src/loop/gate-fanin.mjs +58 -3
- package/src/loop/issue-refinement-artifact.mjs +117 -9
- package/src/loop/main-checkout-ff.mjs +169 -17
- package/src/loop/merge-approval.mjs +186 -5
- package/src/loop/pr-gate-coordination.mjs +339 -67
- package/src/loop/run-inspection.mjs +6 -0
- package/src/loop/spec-authority.mjs +40 -6
- package/src/loop/ui-e2e-scoping.mjs +1 -0
- package/src/projects/move-queue-item.mjs +5 -79
- package/src/projects/projects-access.mjs +124 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Main-checkout fast-forward
|
|
2
|
+
* Main-checkout fast-forward flow.
|
|
3
3
|
*
|
|
4
4
|
* The dev-loop merges remotely (`gh pr merge` → origin/main) but neither the merge
|
|
5
5
|
* procedure nor the post-merge hooks fast-forwarded the main checkout's local
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* code — re-introducing the CI-wait stall every PR (e.g. #1531's fix was invisible
|
|
9
9
|
* until the main checkout caught up).
|
|
10
10
|
*
|
|
11
|
-
* This module owns the shared, dependency-free
|
|
11
|
+
* This module owns the shared, dependency-free step-wise flow both harness hooks
|
|
12
12
|
* (Pi `post-merge-update`, Claude `post-tool-use-merge`) run after a successful
|
|
13
13
|
* merge. It is best-effort and NON-BLOCKING: `--ff-only` refuses a diverged `main`
|
|
14
14
|
* without rewriting history, so a diverged checkout fails the merge step cleanly and
|
|
@@ -16,10 +16,11 @@
|
|
|
16
16
|
* push). `mainCheckout` is POSIX single-quoted so consumer checkout paths containing
|
|
17
17
|
* spaces or shell metacharacters cannot break or inject into the shell string.
|
|
18
18
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
19
|
+
* `syncMainCheckout` inspects the current ref BEFORE submitting any merge command, so
|
|
20
|
+
* a non-`main` checkout (detached HEAD, or another branch checked out) is classified
|
|
21
|
+
* and reported as an action-required diagnostic instead of fast-forwarding the wrong
|
|
22
|
+
* branch. No `git switch`/`checkout`/`reset` command is ever submitted for a non-main
|
|
23
|
+
* checkout — only read-only inspection commands run.
|
|
23
24
|
*
|
|
24
25
|
* No imports so this file vendors into the `.claude/hooks/` bundle unchanged
|
|
25
26
|
* (vendored modules may only import `node:` builtins or relative paths).
|
|
@@ -27,15 +28,18 @@
|
|
|
27
28
|
import path from "node:path";
|
|
28
29
|
|
|
29
30
|
/**
|
|
30
|
-
* Timeout (ms) for the `git worktree list` resolution step (the
|
|
31
|
-
*
|
|
32
|
-
*
|
|
31
|
+
* Timeout (ms) for the `git worktree list` resolution step (the harness hook's own
|
|
32
|
+
* budget; `syncMainCheckout`'s step-wise `fetch`/`rev-parse`/`merge` commands each
|
|
33
|
+
* run under `MAIN_CHECKOUT_FF_MERGE_TIMEOUT_MS` instead).
|
|
33
34
|
*/
|
|
34
35
|
export const MAIN_CHECKOUT_FF_FETCH_TIMEOUT_MS = 60_000;
|
|
35
36
|
|
|
36
|
-
/** Timeout (ms) for
|
|
37
|
+
/** Timeout (ms) for each step of `syncMainCheckout`'s fetch/rev-parse/merge flow. */
|
|
37
38
|
export const MAIN_CHECKOUT_FF_MERGE_TIMEOUT_MS = 60_000;
|
|
38
39
|
|
|
40
|
+
/** Stable diagnostic kind for a main checkout proven not to be on `main`. */
|
|
41
|
+
export const MAIN_CHECKOUT_NOT_ON_MAIN_KIND = "main_checkout_not_on_main";
|
|
42
|
+
|
|
39
43
|
/**
|
|
40
44
|
* POSIX single-quote a path so spaces/shell metacharacters in a consumer's checkout
|
|
41
45
|
* path cannot break or inject into the shell string.
|
|
@@ -45,17 +49,165 @@ function shellQuotePath(value) {
|
|
|
45
49
|
}
|
|
46
50
|
|
|
47
51
|
/**
|
|
48
|
-
*
|
|
52
|
+
* Classify a main checkout's current full symbolic ref (`git rev-parse
|
|
53
|
+
* --symbolic-full-name HEAD` output) into one of the four states `syncMainCheckout`
|
|
54
|
+
* acts on. `--symbolic-full-name` (unlike `--abbrev-ref`) is never subject to
|
|
55
|
+
* `core.warnAmbiguousRefs` renaming a branch to `heads/<name>` when a tag or other ref
|
|
56
|
+
* shares its short name.
|
|
57
|
+
*
|
|
58
|
+
* @param {unknown} symbolicFullName - Raw (already-trimmed or not) `--symbolic-full-name
|
|
59
|
+
* HEAD` output.
|
|
60
|
+
* @returns {"main" | "other_branch" | "detached" | "unreadable"}
|
|
61
|
+
*/
|
|
62
|
+
export function classifyMainCheckoutRef(symbolicFullName) {
|
|
63
|
+
if (typeof symbolicFullName !== "string") {
|
|
64
|
+
return "unreadable";
|
|
65
|
+
}
|
|
66
|
+
const trimmed = symbolicFullName.trim();
|
|
67
|
+
if (!trimmed) {
|
|
68
|
+
return "unreadable";
|
|
69
|
+
}
|
|
70
|
+
if (trimmed === "HEAD") {
|
|
71
|
+
return "detached";
|
|
72
|
+
}
|
|
73
|
+
if (trimmed === "refs/heads/main") {
|
|
74
|
+
return "main";
|
|
75
|
+
}
|
|
76
|
+
if (trimmed.startsWith("refs/heads/")) {
|
|
77
|
+
return "other_branch";
|
|
78
|
+
}
|
|
79
|
+
return "unreadable";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Build the `main_checkout_not_on_main` diagnostic, or `null` when any required field
|
|
84
|
+
* is missing or invalid. The one shared rendered `message` names the kind, the
|
|
85
|
+
* absolute checkout path, the current ref, the behind count, that no fast-forward
|
|
86
|
+
* happened, that the action is non-fatal, and the manual recovery — never a
|
|
87
|
+
* reset/force suggestion.
|
|
88
|
+
*
|
|
89
|
+
* @param {{ mainCheckout: unknown, ref: unknown, behindCount: unknown }} fields
|
|
90
|
+
* @returns {{ kind: string, severity: "error", mainCheckout: string, ref: string, behindCount: number, message: string } | null}
|
|
91
|
+
*/
|
|
92
|
+
export function buildMainCheckoutNotOnMainDiagnostic({ mainCheckout, ref, behindCount } = {}) {
|
|
93
|
+
if (typeof mainCheckout !== "string" || !path.isAbsolute(mainCheckout)) {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
if (typeof ref !== "string" || !ref.trim()) {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
if (!Number.isInteger(behindCount) || behindCount < 0) {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
const message =
|
|
103
|
+
`[dev-loops] post-merge: ${MAIN_CHECKOUT_NOT_ON_MAIN_KIND} — the main checkout at '${mainCheckout}' ` +
|
|
104
|
+
`is on ${ref}, ${behindCount} commit(s) behind origin/main after fetch. No fast-forward was performed; ` +
|
|
105
|
+
`this is non-fatal. Reconcile manually: preserve any local-only commits, then check out main and ` +
|
|
106
|
+
`fast-forward it to origin/main.`;
|
|
107
|
+
return { kind: MAIN_CHECKOUT_NOT_ON_MAIN_KIND, severity: "error", mainCheckout, ref, behindCount, message };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Run one shell-command step through the harness-supplied `run` adapter, normalizing
|
|
112
|
+
* a thrown/rejected `run` into the same `{ ok: false, reason }` shape as an adapter
|
|
113
|
+
* that resolves a failure — callers never need a second failure path.
|
|
114
|
+
*
|
|
115
|
+
* @param {(command: string) => Promise<{ ok: boolean, stdout?: string, reason?: string }>} run
|
|
116
|
+
* @param {string} command
|
|
117
|
+
* @returns {Promise<{ ok: true, stdout: string } | { ok: false, reason: string }>}
|
|
118
|
+
*/
|
|
119
|
+
async function runStep(run, command) {
|
|
120
|
+
try {
|
|
121
|
+
const result = await run(command);
|
|
122
|
+
if (result?.ok) {
|
|
123
|
+
return { ok: true, stdout: typeof result.stdout === "string" ? result.stdout : "" };
|
|
124
|
+
}
|
|
125
|
+
return { ok: false, reason: result?.reason || "command failed" };
|
|
126
|
+
} catch (error) {
|
|
127
|
+
return { ok: false, reason: error?.message || String(error) };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Best-effort, step-wise main-checkout sync.
|
|
133
|
+
*
|
|
134
|
+
* After a successful `fetch origin main`, inspects the checkout's current ref BEFORE
|
|
135
|
+
* submitting any merge command:
|
|
136
|
+
* - `main` → `git merge --ff-only origin/main`; ok → `fast_forwarded`; a diverged
|
|
137
|
+
* main fails the merge step cleanly → `skipped`.
|
|
138
|
+
* - detached HEAD or another named branch → never merged/switched/reset; instead
|
|
139
|
+
* the post-fetch `HEAD..origin/main` behind count is measured and reported as a
|
|
140
|
+
* `not_on_main` diagnostic (see `buildMainCheckoutNotOnMainDiagnostic`).
|
|
141
|
+
* Any step that cannot prove its outcome (fetch failure, unreadable ref, a failed or
|
|
142
|
+
* empty short-SHA resolution, a failed or non-numeric behind-count read) is reported
|
|
143
|
+
* as `skipped` instead of a partial diagnostic.
|
|
49
144
|
*
|
|
50
145
|
* @param {string} mainCheckout - Absolute path to the main (primary) git checkout.
|
|
51
|
-
* @
|
|
146
|
+
* @param {(command: string) => Promise<{ ok: boolean, stdout?: string, reason?: string }>} run
|
|
147
|
+
* Harness adapter that executes one shell command and resolves its outcome (may also
|
|
148
|
+
* throw — treated as a failed step).
|
|
149
|
+
* @returns {Promise<
|
|
150
|
+
* | { status: "fast_forwarded" }
|
|
151
|
+
* | { status: "skipped", reason: string }
|
|
152
|
+
* | { status: "not_on_main", diagnostic: ReturnType<typeof buildMainCheckoutNotOnMainDiagnostic> }
|
|
153
|
+
* >}
|
|
52
154
|
*/
|
|
53
|
-
export function
|
|
155
|
+
export async function syncMainCheckout(mainCheckout, run) {
|
|
54
156
|
const quoted = shellQuotePath(mainCheckout);
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
157
|
+
|
|
158
|
+
const fetchResult = await runStep(run, `git -C ${quoted} fetch origin main`);
|
|
159
|
+
if (!fetchResult.ok) {
|
|
160
|
+
return { status: "skipped", reason: fetchResult.reason };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const refResult = await runStep(run, `git -C ${quoted} rev-parse --symbolic-full-name HEAD`);
|
|
164
|
+
if (!refResult.ok) {
|
|
165
|
+
return { status: "skipped", reason: refResult.reason };
|
|
166
|
+
}
|
|
167
|
+
const symbolicRef = refResult.stdout.trim();
|
|
168
|
+
const classification = classifyMainCheckoutRef(symbolicRef);
|
|
169
|
+
if (classification === "unreadable") {
|
|
170
|
+
return { status: "skipped", reason: `could not determine the current branch (got: ${JSON.stringify(symbolicRef)})` };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (classification === "main") {
|
|
174
|
+
const mergeResult = await runStep(run, `git -C ${quoted} merge --ff-only origin/main`);
|
|
175
|
+
if (!mergeResult.ok) {
|
|
176
|
+
return { status: "skipped", reason: mergeResult.reason };
|
|
177
|
+
}
|
|
178
|
+
return { status: "fast_forwarded" };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
let ref;
|
|
182
|
+
if (classification === "detached") {
|
|
183
|
+
const shortShaResult = await runStep(run, `git -C ${quoted} rev-parse --short HEAD`);
|
|
184
|
+
if (!shortShaResult.ok) {
|
|
185
|
+
return { status: "skipped", reason: shortShaResult.reason };
|
|
186
|
+
}
|
|
187
|
+
const shortSha = shortShaResult.stdout.trim();
|
|
188
|
+
if (!shortSha) {
|
|
189
|
+
return { status: "skipped", reason: "could not resolve a short SHA for the detached HEAD" };
|
|
190
|
+
}
|
|
191
|
+
ref = `detached@${shortSha}`;
|
|
192
|
+
} else {
|
|
193
|
+
ref = symbolicRef.slice("refs/heads/".length);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const behindResult = await runStep(run, `git -C ${quoted} rev-list --count HEAD..origin/main`);
|
|
197
|
+
if (!behindResult.ok) {
|
|
198
|
+
return { status: "skipped", reason: behindResult.reason };
|
|
199
|
+
}
|
|
200
|
+
const behindStdout = behindResult.stdout.trim();
|
|
201
|
+
if (!/^\d+$/.test(behindStdout)) {
|
|
202
|
+
return { status: "skipped", reason: `could not read the behind count (got: ${JSON.stringify(behindStdout)})` };
|
|
203
|
+
}
|
|
204
|
+
const behindCount = Number.parseInt(behindStdout, 10);
|
|
205
|
+
|
|
206
|
+
const diagnostic = buildMainCheckoutNotOnMainDiagnostic({ mainCheckout, ref, behindCount });
|
|
207
|
+
if (!diagnostic) {
|
|
208
|
+
return { status: "skipped", reason: "incomplete main_checkout_not_on_main diagnostic" };
|
|
209
|
+
}
|
|
210
|
+
return { status: "not_on_main", diagnostic };
|
|
59
211
|
}
|
|
60
212
|
|
|
61
213
|
/**
|
|
@@ -11,13 +11,16 @@
|
|
|
11
11
|
*
|
|
12
12
|
* It reuses, never re-derives, the existing precondition set:
|
|
13
13
|
* `resolveSizeBudgetHumanApprovalRequired` (size-budget-merge-gate),
|
|
14
|
-
* `findBlockingTitleMarkers` (pr-title-markers),
|
|
14
|
+
* `findBlockingTitleMarkers` (pr-title-markers), the detect-checkpoint-evidence
|
|
15
15
|
* `preMergeGateCheck` bundle (draft/pre-approval verdicts, threads, runner lock,
|
|
16
|
-
* fan-out provenance)
|
|
17
|
-
*
|
|
16
|
+
* fan-out provenance), and `classifyCopilotReviewBodyDisposition` (copilot-helpers,
|
|
17
|
+
* the same current-head Copilot disposition detection the loop's
|
|
18
|
+
* `copilotBodyFeedbackUnresolved` reads). This module adds ONLY the
|
|
19
|
+
* human-approver identity, the merge-class split, the Copilot-convergence
|
|
20
|
+
* precondition, and the aggregate naming.
|
|
18
21
|
*/
|
|
19
22
|
|
|
20
|
-
import { isCopilotLogin } from "../github/copilot-helpers.mjs";
|
|
23
|
+
import { isCopilotLogin, classifyCopilotReviewBodyDisposition, COPILOT_DISPOSITION, SUBMITTED_REVIEW_STATES, extractReviewCommitSha } from "../github/copilot-helpers.mjs";
|
|
21
24
|
import { findBlockingTitleMarkers } from "./pr-title-markers.mjs";
|
|
22
25
|
import { resolveSizeBudgetHumanApprovalRequired } from "./size-budget-merge-gate.mjs";
|
|
23
26
|
import { deriveLoopCiStatusFromRollup } from "./copilot-ci-status.mjs";
|
|
@@ -81,6 +84,15 @@ function reviewCommit(entry) {
|
|
|
81
84
|
return null;
|
|
82
85
|
}
|
|
83
86
|
|
|
87
|
+
// Copilot-only login extraction for evaluateCopilotConvergence: accepts the
|
|
88
|
+
// `gh pr view` GraphQL shape (author.login) in addition to the REST shape
|
|
89
|
+
// (user.login | login) reviewLogin reads. Scoped to the Copilot-convergence
|
|
90
|
+
// eval so verifyFreshHumanApproval's human-approval matching is unchanged.
|
|
91
|
+
function copilotConvergenceReviewLogin(entry) {
|
|
92
|
+
if (typeof entry?.author?.login === "string" && entry.author.login.length > 0) return entry.author.login;
|
|
93
|
+
return reviewLogin(entry);
|
|
94
|
+
}
|
|
95
|
+
|
|
84
96
|
/**
|
|
85
97
|
* Verify a fresh, agent-unforgeable, head-pinned human approval by `approvedBy`.
|
|
86
98
|
*
|
|
@@ -150,6 +162,147 @@ export function verifyFreshHumanApproval({ approvedBy, currentHeadSha, reviews =
|
|
|
150
162
|
};
|
|
151
163
|
}
|
|
152
164
|
|
|
165
|
+
export const COPILOT_CONVERGENCE_STATE = Object.freeze({
|
|
166
|
+
CURRENT_HEAD_CLEAN: "current_head_clean",
|
|
167
|
+
CURRENT_HEAD_FINDINGS: "current_head_findings",
|
|
168
|
+
NO_CURRENT_HEAD_REVIEW: "no_current_head_review",
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
/** ADR 0012 end states that may satisfy convergence without a current-head review. */
|
|
172
|
+
export const COPILOT_ABSENT_REVIEW_DISPOSITION = Object.freeze({
|
|
173
|
+
ROUND_CAP_CLEAN_FALLBACK: "round_cap_clean_fallback",
|
|
174
|
+
DOCS_ONLY_SUPPRESSION: "docs_only_suppression",
|
|
175
|
+
COPILOT_GATE_DISABLED: "copilot_gate_disabled",
|
|
176
|
+
});
|
|
177
|
+
const SANCTIONED_ABSENT_REVIEW_DISPOSITIONS = new Set(Object.values(COPILOT_ABSENT_REVIEW_DISPOSITION));
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Shared Copilot-convergence evaluation for pre-approval entry and merge.
|
|
181
|
+
* Both treat a thread-clean 🔵 as conductor-overridable; unresolved threads
|
|
182
|
+
* still block independently. Fail-closed.
|
|
183
|
+
*
|
|
184
|
+
* Only the LATEST Copilot review pinned to `currentHeadSha` is judged, so a
|
|
185
|
+
* stale non-approval at an earlier head never blocks and a later same-head 🟢
|
|
186
|
+
* clears an earlier same-head finding.
|
|
187
|
+
*
|
|
188
|
+
* Policy (operator-resolved during the v1.0.4 drain):
|
|
189
|
+
* 🟡 "Changes recommended" on the current head -> BLOCK (actionable; the review
|
|
190
|
+
* body is unresolved feedback even with zero inline threads — the exact
|
|
191
|
+
* body-only fail-open the loop detects and this precondition enforces at merge).
|
|
192
|
+
* 🔵 "Needs a closer look" -> conductor-OVERRIDABLE (soft), NOT blocked here.
|
|
193
|
+
* Unresolved threads still gate it (detect-checkpoint-evidence refuses any
|
|
194
|
+
* unresolved review thread), so a 🔵 merges only with zero unresolved
|
|
195
|
+
* threads — the conductor's override is choosing to run the merge on a
|
|
196
|
+
* thread-clean 🔵.
|
|
197
|
+
* unrecognized disposition -> BLOCK (fail closed on a Copilot format change).
|
|
198
|
+
* 🟢 clean / headerless -> PASS.
|
|
199
|
+
*
|
|
200
|
+
* Three distinct states (`state` on the result):
|
|
201
|
+
* current_head_clean a current-head review passes (🟢 / headerless / 🔵);
|
|
202
|
+
* current_head_findings a current-head review blocks (🟡 / unrecognized);
|
|
203
|
+
* no_current_head_review no current-head Copilot review exists (none yet, or
|
|
204
|
+
* every review is on an earlier head). This state
|
|
205
|
+
* passes ONLY through a sanctioned disposition recorded
|
|
206
|
+
* for the current head (`absentReviewDisposition`
|
|
207
|
+
* `{ kind, headSha }`, kind in
|
|
208
|
+
* COPILOT_ABSENT_REVIEW_DISPOSITION). Absence alone never
|
|
209
|
+
* converges, and a stale earlier-head review is never
|
|
210
|
+
* inherited as the current-head verdict.
|
|
211
|
+
*
|
|
212
|
+
* @returns {{ ok: boolean, state: string|null, disposition: string|null, reason: string|null }}
|
|
213
|
+
*/
|
|
214
|
+
export function evaluateCopilotConvergence({ currentHeadSha = null, reviews = [], absentReviewDisposition = null } = {}) {
|
|
215
|
+
const head = typeof currentHeadSha === "string" ? currentHeadSha.trim() : "";
|
|
216
|
+
// Head unknown: no current-head review can be pinned. Fail closed (matches
|
|
217
|
+
// verifyFreshHumanApproval), so this precondition can never pass without a
|
|
218
|
+
// known head to pin the Copilot disposition to.
|
|
219
|
+
if (head.length === 0) return { ok: false, state: null, disposition: null, reason: "current head SHA is unknown; cannot pin a Copilot review to it" };
|
|
220
|
+
|
|
221
|
+
// Mirror summarizeCopilotReviews' current-head finding selection BYTE-FOR-BYTE
|
|
222
|
+
// so the merge gate and the loop can never diverge (they already share
|
|
223
|
+
// classifyCopilotReviewBodyDisposition at the detection layer). Use the SAME
|
|
224
|
+
// comparison the loop uses — the RAW submittedAt string (a non-string is null),
|
|
225
|
+
// compared with `>`/`===`, NOT a parsed timestamp: parsing would diverge on a
|
|
226
|
+
// malformed/mixed-offset submittedAt (an invalid-timestamp 🟡 that the loop
|
|
227
|
+
// keeps as latest could otherwise be superseded here — a fail-open). Consider
|
|
228
|
+
// only SUBMITTED current-head reviews, skip PENDING drafts (a PENDING never
|
|
229
|
+
// sets the finding), pick the latest by submittedAt string, and on an
|
|
230
|
+
// equal-string tie (or both-null) fold toward the most-blocking disposition so
|
|
231
|
+
// array order never silently drops a finding.
|
|
232
|
+
let latestDisposition = null;
|
|
233
|
+
let latestAt = null;
|
|
234
|
+
for (const entry of Array.isArray(reviews) ? reviews : []) {
|
|
235
|
+
// Shape-tolerant Copilot-login + commit extraction: the merge gate feeds
|
|
236
|
+
// REST-shaped reviews (user.login/commit_id) while the gate-ENTRY detector
|
|
237
|
+
// feeds `gh pr view` GraphQL-shaped reviews (author.login/commit.oid). Both
|
|
238
|
+
// call sites route through this one evaluateCopilotConvergence, so it must
|
|
239
|
+
// recognize BOTH shapes to produce ONE convergence verdict. Only Copilot
|
|
240
|
+
// reviews matter here, so broadening the login read to author.login cannot
|
|
241
|
+
// affect verifyFreshHumanApproval (which keeps its own REST-only
|
|
242
|
+
// reviewLogin/reviewCommit).
|
|
243
|
+
const login = copilotConvergenceReviewLogin(entry);
|
|
244
|
+
if (login === null || !isCopilotLogin(login)) continue;
|
|
245
|
+
if (extractReviewCommitSha(entry) !== head) continue; // only current-head reviews
|
|
246
|
+
const state = typeof entry?.state === "string" ? entry.state.toUpperCase() : "";
|
|
247
|
+
if (state === "PENDING" || !SUBMITTED_REVIEW_STATES.has(state)) continue; // PENDING/unknown never sets the finding
|
|
248
|
+
const disposition = classifyCopilotReviewBodyDisposition(state, entry?.body);
|
|
249
|
+
const submittedAt = typeof entry?.submittedAt === "string"
|
|
250
|
+
? entry.submittedAt
|
|
251
|
+
: (typeof entry?.submitted_at === "string" ? entry.submitted_at : null);
|
|
252
|
+
if (submittedAt !== null && (latestAt === null || submittedAt > latestAt)) {
|
|
253
|
+
latestDisposition = disposition; // a lexicographically-later submittedAt supersedes (matches summarize)
|
|
254
|
+
latestAt = submittedAt;
|
|
255
|
+
} else if (submittedAt !== null && submittedAt === latestAt) {
|
|
256
|
+
latestDisposition = latestDisposition === null ? disposition : moreBlockingDisposition(latestDisposition, disposition);
|
|
257
|
+
} else if (submittedAt === null && latestAt === null) {
|
|
258
|
+
latestDisposition = latestDisposition === null ? disposition : moreBlockingDisposition(latestDisposition, disposition);
|
|
259
|
+
}
|
|
260
|
+
// a null submittedAt once a non-null latest exists is ignored (mirrors summarize)
|
|
261
|
+
}
|
|
262
|
+
if (latestDisposition === null) {
|
|
263
|
+
const absent = COPILOT_CONVERGENCE_STATE.NO_CURRENT_HEAD_REVIEW;
|
|
264
|
+
const kind = absentReviewDisposition?.kind;
|
|
265
|
+
if (SANCTIONED_ABSENT_REVIEW_DISPOSITIONS.has(kind) && absentReviewDisposition?.headSha === head) {
|
|
266
|
+
return { ok: true, state: absent, disposition: kind, reason: null };
|
|
267
|
+
}
|
|
268
|
+
return {
|
|
269
|
+
ok: false,
|
|
270
|
+
state: absent,
|
|
271
|
+
disposition: null,
|
|
272
|
+
reason: `no current-head Copilot review exists on head ${head}; convergence requires a clean current-head review or a sanctioned disposition recorded for this head (${[...SANCTIONED_ABSENT_REVIEW_DISPOSITIONS].join(", ")})`,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const findings = COPILOT_CONVERGENCE_STATE.CURRENT_HEAD_FINDINGS;
|
|
277
|
+
if (latestDisposition === COPILOT_DISPOSITION.CHANGES_RECOMMENDED) {
|
|
278
|
+
return { ok: false, state: findings, disposition: latestDisposition, reason: `current-head Copilot review is "Changes recommended" (🟡, actionable non-approval); converge to "Approval recommended" (🟢) or resolve the feedback before merge` };
|
|
279
|
+
}
|
|
280
|
+
if (latestDisposition === COPILOT_DISPOSITION.UNRECOGNIZED) {
|
|
281
|
+
return { ok: false, state: findings, disposition: latestDisposition, reason: `current-head Copilot review carries an unrecognized disposition header (fail closed); a recognized "Approval recommended" (🟢) is required` };
|
|
282
|
+
}
|
|
283
|
+
// CLEAN, NONE, and NEEDS_CLOSER_LOOK (🔵, conductor-overridable) pass.
|
|
284
|
+
return { ok: true, state: COPILOT_CONVERGENCE_STATE.CURRENT_HEAD_CLEAN, disposition: latestDisposition, reason: null };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Disposition blocking precedence, most-blocking first. Used to fold an
|
|
288
|
+
// equal-timestamp same-head tie toward the most-blocking disposition so a tied
|
|
289
|
+
// 🟡/unrecognized is never silently dropped by a co-timestamped 🟢/🔵.
|
|
290
|
+
const COPILOT_DISPOSITION_BLOCKING_ORDER = [
|
|
291
|
+
COPILOT_DISPOSITION.CHANGES_RECOMMENDED,
|
|
292
|
+
COPILOT_DISPOSITION.UNRECOGNIZED,
|
|
293
|
+
COPILOT_DISPOSITION.NEEDS_CLOSER_LOOK,
|
|
294
|
+
COPILOT_DISPOSITION.CLEAN,
|
|
295
|
+
COPILOT_DISPOSITION.NONE,
|
|
296
|
+
];
|
|
297
|
+
function moreBlockingDisposition(a, b) {
|
|
298
|
+
const ia = COPILOT_DISPOSITION_BLOCKING_ORDER.indexOf(a);
|
|
299
|
+
const ib = COPILOT_DISPOSITION_BLOCKING_ORDER.indexOf(b);
|
|
300
|
+
// A value absent from the order (defensive) sorts last.
|
|
301
|
+
const ra = ia === -1 ? COPILOT_DISPOSITION_BLOCKING_ORDER.length : ia;
|
|
302
|
+
const rb = ib === -1 ? COPILOT_DISPOSITION_BLOCKING_ORDER.length : ib;
|
|
303
|
+
return ra <= rb ? a : b;
|
|
304
|
+
}
|
|
305
|
+
|
|
153
306
|
/**
|
|
154
307
|
* Decide whether merge is authorized given the class, the standing
|
|
155
308
|
* authorization signal, and any fresh per-merge approval.
|
|
@@ -227,6 +380,8 @@ export function evaluateMergePreconditions({
|
|
|
227
380
|
comments = [],
|
|
228
381
|
standingAuthorized = false,
|
|
229
382
|
stableRelease = false,
|
|
383
|
+
copilotAbsentReviewDisposition = null,
|
|
384
|
+
copilotBodyDisposition = null,
|
|
230
385
|
} = {}) {
|
|
231
386
|
const failures = [];
|
|
232
387
|
|
|
@@ -273,11 +428,37 @@ export function evaluateMergePreconditions({
|
|
|
273
428
|
failures.push({ precondition: "size_budget_human_approval", reason: "size-budget requires a human APPROVED review OR a head-pinned \"approve merge <headSha>\" operator comment, with zero unresolved CHANGES_REQUESTED, for this escalated/T1 PR" });
|
|
274
429
|
}
|
|
275
430
|
|
|
431
|
+
// Copilot-convergence precondition: refuse a current-head Copilot non-approval
|
|
432
|
+
// body disposition, mirroring the loop's copilotBodyFeedbackUnresolved.
|
|
433
|
+
// A trusted copilot-body-disposition record resolved for the current head
|
|
434
|
+
// (`{ headSha, reviewId, ... }`) clears a current-head finding, as it does
|
|
435
|
+
// at gate entry.
|
|
436
|
+
const copilotConvergence = evaluateCopilotConvergence({ currentHeadSha, reviews, absentReviewDisposition: copilotAbsentReviewDisposition });
|
|
437
|
+
const head = typeof currentHeadSha === "string" ? currentHeadSha.trim().toLowerCase() : "";
|
|
438
|
+
const bodyCleared = copilotConvergence.state === COPILOT_CONVERGENCE_STATE.CURRENT_HEAD_FINDINGS
|
|
439
|
+
&& head.length > 0
|
|
440
|
+
&& typeof copilotBodyDisposition?.headSha === "string" && copilotBodyDisposition.headSha.toLowerCase() === head;
|
|
441
|
+
if (!copilotConvergence.ok && !bodyCleared) {
|
|
442
|
+
failures.push({ precondition: "copilot_convergence", reason: copilotConvergence.reason });
|
|
443
|
+
}
|
|
444
|
+
|
|
276
445
|
const mergeClass = resolveMergeClass({ sizeOutcome, touchesT1, stableRelease });
|
|
277
446
|
const decision = resolveMergeApprovalDecision({ mergeClass, standingAuthorized, freshApproval });
|
|
278
447
|
if (!decision.authorized) {
|
|
279
448
|
failures.push({ precondition: "merge_approval", reason: decision.reason });
|
|
280
449
|
}
|
|
281
450
|
|
|
282
|
-
return {
|
|
451
|
+
return {
|
|
452
|
+
ok: failures.length === 0,
|
|
453
|
+
failures,
|
|
454
|
+
mergeClass,
|
|
455
|
+
approvalVia: decision.authorized ? decision.via : null,
|
|
456
|
+
// Audit trace: which convergence state applied and what settled it (the
|
|
457
|
+
// current-head review disposition, or the sanctioned disposition kind when
|
|
458
|
+
// no current-head review exists), so a merge on a conductor-overridable 🔵
|
|
459
|
+
// or without a current-head review is recorded, never invisible.
|
|
460
|
+
copilotConvergenceState: copilotConvergence.state,
|
|
461
|
+
copilotDisposition: copilotConvergence.disposition,
|
|
462
|
+
copilotBodyDisposition: bodyCleared ? copilotBodyDisposition : null,
|
|
463
|
+
};
|
|
283
464
|
}
|