@dev-loops/core 1.0.0-rc.2 → 1.0.0-rc.4
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 +7 -1
- package/src/analysis/diff-analyzer.mjs +31 -5
- package/src/claude/hook-decisions.mjs +14 -0
- package/src/cli/primitives.mjs +10 -2
- package/src/cli/retry-wrapper.mjs +14 -6
- package/src/config/config.mjs +1125 -240
- package/src/config/extension-defaults.yaml +217 -426
- package/src/github/copilot-helpers.mjs +139 -18
- package/src/github/issue-ops.mjs +556 -0
- package/src/github/ownership-helpers.mjs +79 -0
- package/src/github/review-threads.mjs +44 -3
- package/src/loop/bash-command-classify.mjs +35 -5
- package/src/loop/conductor-routing.mjs +1 -1
- package/src/loop/copilot-ci-status.mjs +76 -0
- package/src/loop/copilot-loop-iterations.mjs +1 -2
- package/src/loop/copilot-loop-state.mjs +9 -5
- package/src/loop/default-branch-guard.mjs +380 -0
- package/src/loop/gate-carry-forward.mjs +29 -2
- package/src/loop/gate-fanin.mjs +481 -31
- package/src/loop/handoff-envelope.mjs +43 -23
- package/src/loop/main-checkout-ff.mjs +58 -0
- package/src/loop/pr-gate-coordination.mjs +204 -47
- package/src/loop/pr-title-markers.mjs +76 -15
- package/src/loop/queue-board-sync.mjs +26 -9
- package/src/loop/reviewer-loop-state.mjs +2 -2
- package/src/loop/ui-e2e-scoping.mjs +2 -0
- package/src/loop/ui-review-drive.mjs +23 -0
- package/src/loop/ui-review-provision.mjs +36 -0
- package/src/projects/resolve-project.mjs +14 -7
- package/src/tracker/adapter.mjs +127 -0
- package/src/tracker/github-adapter.mjs +150 -0
- package/src/tracker/index.mjs +50 -0
- package/src/tracker/noop-adapter.mjs +35 -0
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
import { normalizeRepoSlug } from "../github/repo-slug.mjs";
|
|
22
22
|
import { COPILOT_REVIEW_WAIT_TIMEOUT_MS } from "./policy-constants.mjs";
|
|
23
23
|
import { resolveEffectiveAsyncStartMode } from "./async-start-contract.mjs";
|
|
24
|
-
import { resolveHumanMergeOnly } from "../config/config.mjs";
|
|
24
|
+
import { resolveGateAngleContract, resolveGateAngles, resolveGateConfig, resolveHumanMergeOnly } from "../config/config.mjs";
|
|
25
25
|
|
|
26
26
|
// ---------------------------------------------------------------------------
|
|
27
27
|
// Constants
|
|
@@ -95,7 +95,7 @@ register(INTERNAL_DEV_LOOP_STRATEGY.COPILOT_PR_FOLLOWUP, "watch", {
|
|
|
95
95
|
register(INTERNAL_DEV_LOOP_STRATEGY.COPILOT_PR_FOLLOWUP, "pre-approval", {
|
|
96
96
|
criteria: [
|
|
97
97
|
{ id: "full-gate-chain", must: "Complete pre-approval gate chain with all configured review angles.", severity: "required" },
|
|
98
|
-
{ id: "clean-verdict", must: "Pre-approval gate must return clean verdict (no
|
|
98
|
+
{ id: "clean-verdict", must: "Pre-approval gate must return clean verdict (no findings at a severity in the gate's configured blockCleanOnFindingSeverities, high by default).", severity: "required" },
|
|
99
99
|
{ id: "unresolved-threads", must: "All review threads must be resolved before pre-approval gate runs.", severity: "required" },
|
|
100
100
|
{ id: "ci-green", must: "CI must be green on the current head SHA.", severity: "required" },
|
|
101
101
|
],
|
|
@@ -354,20 +354,30 @@ function applySpecSourceVariant(criteria, specSource) {
|
|
|
354
354
|
|
|
355
355
|
function deriveGateConfig(settings, subGate) {
|
|
356
356
|
const gateKey = subGate === "pre-approval" ? "preApproval" : subGate;
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
357
|
+
if (!settings?.gates?.[gateKey]) return undefined;
|
|
358
|
+
|
|
359
|
+
// Route through the canonical resolvers rather than re-parsing
|
|
360
|
+
// gates.<gate>.angles by hand: resolveGateConfig folds the unified
|
|
361
|
+
// angle-entry shape (mandatory/enabled per-entry, D3) into excludeAngles/
|
|
362
|
+
// blockCleanOnFindingSeverities/requireCi, the envelope contract's
|
|
363
|
+
// long-standing shape. `angles` is the RUN-set (the configured angles the
|
|
364
|
+
// orchestrator is told to dispatch) with every validator-MANDATORY angle
|
|
365
|
+
// merged in — never resolveGateAngleContract's `pool`, which is the
|
|
366
|
+
// enforcement CEILING and deliberately widens to the whole lens catalog
|
|
367
|
+
// under gates.<gate>.dynamic.additive (advertising that as the run-set
|
|
368
|
+
// would tell the orchestrator to dispatch 20+ angles). The parity contract
|
|
369
|
+
// (test/contracts/envelope-validator-angle-parity.test.mjs) pins both
|
|
370
|
+
// invariants: everything advertised is within the validator pool, and
|
|
371
|
+
// every mandatory angle is advertised.
|
|
372
|
+
const resolved = resolveGateConfig(settings, gateKey);
|
|
373
|
+
const { mandatoryAngles } = resolveGateAngleContract(settings, gateKey);
|
|
374
|
+
const runSet = resolveGateAngles(settings, gateKey) ?? [];
|
|
375
|
+
const angles = [...new Set([...runSet, ...mandatoryAngles])];
|
|
364
376
|
return {
|
|
365
|
-
angles
|
|
366
|
-
excludeAngles: excludeAngles.length > 0 ? excludeAngles : undefined,
|
|
367
|
-
blockCleanOnFindingSeverities:
|
|
368
|
-
|
|
369
|
-
: ["must-fix"],
|
|
370
|
-
requireCi: gateSettings.requireCi ?? true,
|
|
377
|
+
angles,
|
|
378
|
+
excludeAngles: resolved.excludeAngles.length > 0 ? resolved.excludeAngles : undefined,
|
|
379
|
+
blockCleanOnFindingSeverities: resolved.blockCleanOnFindingSeverities,
|
|
380
|
+
requireCi: resolved.requireCi,
|
|
371
381
|
};
|
|
372
382
|
}
|
|
373
383
|
|
|
@@ -614,14 +624,9 @@ export function buildDevLoopHandoffEnvelope(resolverOutput, settings, gateState
|
|
|
614
624
|
|
|
615
625
|
const envelope = {
|
|
616
626
|
handoffVersion: ENVELOPE_HANDOFF_VERSION,
|
|
617
|
-
derivedAt: (now ?? new Date()).toISOString(),
|
|
618
627
|
|
|
619
628
|
target,
|
|
620
629
|
currentGate: subGate,
|
|
621
|
-
currentHeadSha: gs.currentHeadSha,
|
|
622
|
-
ciStatus: gs.ciStatus,
|
|
623
|
-
unresolvedThreadCount: gs.unresolvedThreadCount,
|
|
624
|
-
copilotRoundCount: gs.copilotRoundCount,
|
|
625
630
|
maxCopilotRounds: settings?.refinement?.maxCopilotRounds ?? 5,
|
|
626
631
|
executionMode,
|
|
627
632
|
|
|
@@ -675,6 +680,20 @@ export function buildDevLoopHandoffEnvelope(resolverOutput, settings, gateState
|
|
|
675
680
|
envelope.specSource = specSource;
|
|
676
681
|
}
|
|
677
682
|
|
|
683
|
+
// #1462: the ONLY per-round-varying block, kept LAST. Every field here changes
|
|
684
|
+
// between builds/rounds (the timestamp, the head SHA, CI status, thread/round
|
|
685
|
+
// counts); isolating them as the envelope's tail keeps everything above a
|
|
686
|
+
// byte-stable prefix that a fresh reviewer spawn can cache-READ instead of
|
|
687
|
+
// re-billing the full contract scaffolding each round. Consumers must treat
|
|
688
|
+
// gateState as volatile — read it last, or re-derive it fresh via detectors.
|
|
689
|
+
envelope.gateState = {
|
|
690
|
+
derivedAt: (now ?? new Date()).toISOString(),
|
|
691
|
+
currentHeadSha: gs.currentHeadSha,
|
|
692
|
+
ciStatus: gs.ciStatus,
|
|
693
|
+
unresolvedThreadCount: gs.unresolvedThreadCount,
|
|
694
|
+
copilotRoundCount: gs.copilotRoundCount,
|
|
695
|
+
};
|
|
696
|
+
|
|
678
697
|
return deepFreeze(envelope);
|
|
679
698
|
}
|
|
680
699
|
|
|
@@ -941,9 +960,10 @@ export function validateHandoffEnvelope(envelope) {
|
|
|
941
960
|
}
|
|
942
961
|
}
|
|
943
962
|
|
|
944
|
-
// ----- derivedAt (informational, warn on missing)
|
|
945
|
-
|
|
946
|
-
|
|
963
|
+
// ----- gateState.derivedAt (informational, warn on missing) — #1462 moved the
|
|
964
|
+
// volatile timestamp into the gateState tail so the rest stays byte-stable -----
|
|
965
|
+
if (typeof envelope.gateState?.derivedAt !== "string" || !envelope.gateState.derivedAt.trim()) {
|
|
966
|
+
warnings.push({ field: "gateState.derivedAt", reason: "should be an ISO 8601 timestamp" });
|
|
947
967
|
}
|
|
948
968
|
|
|
949
969
|
return {
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Main-checkout fast-forward command shape (#1596).
|
|
3
|
+
*
|
|
4
|
+
* The dev-loop merges remotely (`gh pr merge` → origin/main) but neither the merge
|
|
5
|
+
* procedure nor the post-merge hooks fast-forwarded the main checkout's local
|
|
6
|
+
* `main`. Read-only gate scripts (`probe-ci-status.mjs`, `detect-copilot-loop-state.mjs`,
|
|
7
|
+
* …) run from the main checkout, so a stale local `main` made them execute pre-merge
|
|
8
|
+
* code — re-introducing the CI-wait stall every PR (e.g. #1531's fix was invisible
|
|
9
|
+
* until the main checkout caught up).
|
|
10
|
+
*
|
|
11
|
+
* This module owns the shared, dependency-free command string both harness hooks
|
|
12
|
+
* (Pi `post-merge-update`, Claude `post-tool-use-merge`) run after a successful
|
|
13
|
+
* merge. It is best-effort and NON-BLOCKING: `--ff-only` refuses a diverged `main`
|
|
14
|
+
* without rewriting history, so a diverged checkout fails the merge step cleanly and
|
|
15
|
+
* the caller treats that as warn-and-continue (never a hard failure, never a force
|
|
16
|
+
* push). `mainCheckout` is POSIX single-quoted so consumer checkout paths containing
|
|
17
|
+
* spaces or shell metacharacters cannot break or inject into the shell string.
|
|
18
|
+
*
|
|
19
|
+
* The `merge --ff-only` is guarded to only run when the main checkout is currently on
|
|
20
|
+
* `main`, so a non-`main` checkout (detached HEAD, or another branch checked out)
|
|
21
|
+
* warns-and-continues instead of fast-forwarding the wrong branch. No `git switch` is
|
|
22
|
+
* performed (a state change) — only the guard test runs.
|
|
23
|
+
*
|
|
24
|
+
* No imports so this file vendors into the `.claude/hooks/` bundle unchanged
|
|
25
|
+
* (vendored modules may only import `node:` builtins or relative paths).
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Timeout (ms) for the `git worktree list` resolution step (the fetch-half budget;
|
|
30
|
+
* a separate fetch timeout isn't applied — the fetch runs inline within the merge
|
|
31
|
+
* command under `MAIN_CHECKOUT_FF_MERGE_TIMEOUT_MS`).
|
|
32
|
+
*/
|
|
33
|
+
export const MAIN_CHECKOUT_FF_FETCH_TIMEOUT_MS = 60_000;
|
|
34
|
+
|
|
35
|
+
/** Timeout (ms) for the `git merge --ff-only origin/main` half. */
|
|
36
|
+
export const MAIN_CHECKOUT_FF_MERGE_TIMEOUT_MS = 60_000;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* POSIX single-quote a path so spaces/shell metacharacters in a consumer's checkout
|
|
40
|
+
* path cannot break or inject into the shell string.
|
|
41
|
+
*/
|
|
42
|
+
function shellQuotePath(value) {
|
|
43
|
+
return `'${String(value).replace(/'/g, "'\\''")}'`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Build the best-effort main-checkout fast-forward command string.
|
|
48
|
+
*
|
|
49
|
+
* @param {string} mainCheckout - Absolute path to the main (primary) git checkout.
|
|
50
|
+
* @returns {string} `git -C '<main>' fetch origin main && [ "$(git -C '<main>' rev-parse --abbrev-ref HEAD)" = main ] && git -C '<main>' merge --ff-only origin/main` (path POSIX single-quoted; merge only runs when the main checkout is on `main`)
|
|
51
|
+
*/
|
|
52
|
+
export function buildMainCheckoutFastForwardCommand(mainCheckout) {
|
|
53
|
+
const quoted = shellQuotePath(mainCheckout);
|
|
54
|
+
// ponytail: guard with a `[ ... = main ]` test instead of switching branches — a
|
|
55
|
+
// non-main checkout fails the && chain (warn-and-continue) rather than ff-ing the
|
|
56
|
+
// wrong branch. No state change, no git switch.
|
|
57
|
+
return `git -C ${quoted} fetch origin main && [ "$(git -C ${quoted} rev-parse --abbrev-ref HEAD)" = main ] && git -C ${quoted} merge --ff-only origin/main`;
|
|
58
|
+
}
|
|
@@ -252,8 +252,26 @@ function formatRefinementBlockedReason(linkedIssue, status, refinementArtifact)
|
|
|
252
252
|
return `The draft gate cannot complete: the linked issue has no detectable refinement artifact (Acceptance criteria / DoD / linked refinement doc). finding=${REFINEMENT_ARTIFACT_FINDING}`;
|
|
253
253
|
}
|
|
254
254
|
|
|
255
|
-
|
|
256
|
-
|
|
255
|
+
// #1472: describes the CI state a round-cap-reached fallback branch actually
|
|
256
|
+
// granted on, for human-read reason text. preApprovalRequireCi:false plus a
|
|
257
|
+
// non-success/non-crediblyGreen ciStatus is not "green CI" — claiming it is
|
|
258
|
+
// would be the same false-CI-claim buildRoundExhaustionGateEvidenceNote below
|
|
259
|
+
// exists to avoid. The `ciStatus === "success"` fallback covers the
|
|
260
|
+
// requireCi:true path, where these callers are only reachable with ciStatus
|
|
261
|
+
// "success" (failure/crediblyGreen/pending/none all return earlier).
|
|
262
|
+
function describeAcceptedCiState(ciStatus, preApprovalRequireCi) {
|
|
263
|
+
if (ciStatus === "crediblyGreen") return "credibly green CI";
|
|
264
|
+
if (ciStatus === "success" || preApprovalRequireCi !== false) return "green CI";
|
|
265
|
+
return "CI not required by config";
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function buildRoundExhaustionGateEvidenceNote({ copilotReviewRoundCount, maxCopilotRounds, ciStatus, preApprovalRequireCi, ciDescriptor }) {
|
|
269
|
+
// One rule serves both the reason string and this note. A branch whose CI
|
|
270
|
+
// acceptance differs from describeAcceptedCiState (the strict-green grant
|
|
271
|
+
// rejects crediblyGreen as a basis) passes its own descriptor explicitly so
|
|
272
|
+
// reason and note can never disagree.
|
|
273
|
+
const descriptor = ciDescriptor ?? describeAcceptedCiState(ciStatus, preApprovalRequireCi);
|
|
274
|
+
return `Copilot review rounds exhausted (${copilotReviewRoundCount}/${maxCopilotRounds}); current head has zero unresolved threads and ${descriptor}, so pre_approval_gate fallback is allowed without another Copilot re-request.`;
|
|
257
275
|
}
|
|
258
276
|
|
|
259
277
|
/**
|
|
@@ -514,6 +532,21 @@ const PRE_APPROVAL_ENTRY_BOUNDARIES = Object.freeze([
|
|
|
514
532
|
PR_CHECKPOINT.FINAL_APPROVAL_READY,
|
|
515
533
|
]);
|
|
516
534
|
|
|
535
|
+
/**
|
|
536
|
+
* Identifies the ROUND_CAP_REACHED branch's own defensive grant shape (see
|
|
537
|
+
* that branch below): lifecycleState stays round_cap_reached (never
|
|
538
|
+
* round_cap_clean_fallback) while gateBoundary settles on
|
|
539
|
+
* pre_approval_gate_window. Every caller that exempts the round-cap clean
|
|
540
|
+
* fallback from the formal-request / unsettled-review guards
|
|
541
|
+
* (applyUnsettledCopilotReviewEntryGuard below, and
|
|
542
|
+
* detect-pr-gate-coordination-state.mjs's shouldGuardCopilotReviewRequest
|
|
543
|
+
* wiring) must exempt this shape too, or the grant this evaluator just
|
|
544
|
+
* returned gets rewritten straight back to a Copilot-review wait.
|
|
545
|
+
*/
|
|
546
|
+
export function isRoundCapReachedCleanGrant({ lifecycleState, gateBoundary } = {}) {
|
|
547
|
+
return lifecycleState === STATE.ROUND_CAP_REACHED && gateBoundary === PR_CHECKPOINT.PRE_APPROVAL_GATE_WINDOW;
|
|
548
|
+
}
|
|
549
|
+
|
|
517
550
|
function applyUnsettledCopilotReviewEntryGuard(input, result) {
|
|
518
551
|
if (!result || typeof result !== "object" || !PRE_APPROVAL_ENTRY_BOUNDARIES.includes(result.gateBoundary)) {
|
|
519
552
|
return null;
|
|
@@ -544,7 +577,12 @@ function applyUnsettledCopilotReviewEntryGuard(input, result) {
|
|
|
544
577
|
maxCopilotRounds: input.maxCopilotRounds,
|
|
545
578
|
});
|
|
546
579
|
const lifecycleState = typeof input.lifecycleState === "string" ? input.lifecycleState.trim().toLowerCase() : "";
|
|
547
|
-
|
|
580
|
+
// Also exempt the evaluator's own ROUND_CAP_REACHED grant shape (#1472):
|
|
581
|
+
// without this, this guard would rewrite that grant back to
|
|
582
|
+
// waiting_for_copilot_review the instant it is produced, re-introducing the
|
|
583
|
+
// never-arriving-review dead-end the round-cap exemption exists to prevent.
|
|
584
|
+
const roundCapCleanFallback = lifecycleState === STATE.ROUND_CAP_CLEAN_FALLBACK
|
|
585
|
+
|| isRoundCapReachedCleanGrant(result);
|
|
548
586
|
if (
|
|
549
587
|
roundCapReached
|
|
550
588
|
&& (input.sameHeadCleanConverged === true || roundCapCleanFallback)
|
|
@@ -647,6 +685,14 @@ function evaluatePrGateCoordinationCore(input = {}) {
|
|
|
647
685
|
const prClosed = input.prClosed === true;
|
|
648
686
|
const prMerged = input.prMerged === true;
|
|
649
687
|
const sameHeadCleanConverged = input.sameHeadCleanConverged === true;
|
|
688
|
+
// Operator-authorized post-convergence suppression (#1441): set only when the
|
|
689
|
+
// caller has verified an explicit prior withdrawal (withdraw-copilot-review-
|
|
690
|
+
// request.mjs) recorded a suppression marker for this EXACT head, proving the
|
|
691
|
+
// delta since Copilot's last submitted review is a pure doc/prose bump. Never
|
|
692
|
+
// derived here from other snapshot facts — this evaluator trusts the caller's
|
|
693
|
+
// verification rather than re-deriving it, so it cannot become an automatic
|
|
694
|
+
// loosening of the round-below-cap precondition.
|
|
695
|
+
const postConvergenceReviewSuppressed = input.postConvergenceReviewSuppressed === true;
|
|
650
696
|
// maxCopilotRounds: 0 disables the external Copilot review gate entirely
|
|
651
697
|
// (for repos without Copilot / local-harness-only review). It reuses the
|
|
652
698
|
// existing internal_only routing — skip the Copilot cycle, go straight to
|
|
@@ -668,6 +714,15 @@ function evaluatePrGateCoordinationCore(input = {}) {
|
|
|
668
714
|
const copilotReviewRoundCount = normalizeNonNegativeInteger(input.copilotReviewRoundCount);
|
|
669
715
|
const maxCopilotRounds = normalizePositiveInteger(input.maxCopilotRounds);
|
|
670
716
|
const roundCapReached = isCopilotRoundCapReached({ copilotReviewRoundCount, maxCopilotRounds });
|
|
717
|
+
// #1472: explicit current-head unresolved-thread signal for the round-cap
|
|
718
|
+
// fallback check below (STATE.ROUND_CAP_REACHED). Unlike copilotReviewRoundCount
|
|
719
|
+
// (which safely defaults to 0 when absent), an absent/invalid count here must
|
|
720
|
+
// NOT be coerced to 0 — that would silently treat an unknown thread count as
|
|
721
|
+
// clean and wrongly unblock the fallback. `null` means "unknown" (fail closed).
|
|
722
|
+
const unresolvedThreadCount = Number.isInteger(input.unresolvedThreadCount)
|
|
723
|
+
&& input.unresolvedThreadCount >= 0
|
|
724
|
+
? input.unresolvedThreadCount
|
|
725
|
+
: null;
|
|
671
726
|
const postConvergenceSignificantChange = input.postConvergenceSignificantChange === true;
|
|
672
727
|
const roundCapNewCycleRequired = roundCapReached && copilotReviewRoundCount > 0 && postConvergenceSignificantChange;
|
|
673
728
|
const prTitle = typeof input.prTitle === "string" ? input.prTitle : "";
|
|
@@ -1255,10 +1310,10 @@ function evaluatePrGateCoordinationCore(input = {}) {
|
|
|
1255
1310
|
}
|
|
1256
1311
|
|
|
1257
1312
|
const roundExhaustionGateEvidenceNote = (roundCapReached && !roundCapNewCycleRequired)
|
|
1258
|
-
? buildRoundExhaustionGateEvidenceNote({ copilotReviewRoundCount, maxCopilotRounds })
|
|
1313
|
+
? buildRoundExhaustionGateEvidenceNote({ copilotReviewRoundCount, maxCopilotRounds, ciStatus, preApprovalRequireCi })
|
|
1259
1314
|
: null;
|
|
1260
1315
|
|
|
1261
|
-
if (!sameHeadCleanConverged && (!roundCapReached || roundCapNewCycleRequired)) {
|
|
1316
|
+
if (!sameHeadCleanConverged && !postConvergenceReviewSuppressed && (!roundCapReached || roundCapNewCycleRequired)) {
|
|
1262
1317
|
pushUnique(allowedNextActions, [PR_CHECKPOINT_ACTION.REREQUEST_COPILOT_REVIEW]);
|
|
1263
1318
|
pushUnique(forbiddenActions, postDraftForbidden);
|
|
1264
1319
|
return buildResult({
|
|
@@ -1334,7 +1389,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
|
|
|
1334
1389
|
forbiddenActions,
|
|
1335
1390
|
nextAction: PR_CHECKPOINT_ACTION.AWAIT_FINAL_HUMAN_APPROVAL,
|
|
1336
1391
|
reason: roundCapReached
|
|
1337
|
-
? `Round-cap clean fallback accepted as draft gate equivalent (${copilotReviewRoundCount}/${maxCopilotRounds} rounds, zero unresolved threads, ${ciStatus
|
|
1392
|
+
? `Round-cap clean fallback accepted as draft gate equivalent (${copilotReviewRoundCount}/${maxCopilotRounds} rounds, zero unresolved threads, ${describeAcceptedCiState(ciStatus, preApprovalRequireCi)}). The current head has clean \`pre_approval_gate\` evidence, so the PR is at the final approval boundary.`
|
|
1338
1393
|
: (ciStatus === "crediblyGreen"
|
|
1339
1394
|
? "The current head has both a clean settled review cycle and clean `pre_approval_gate` evidence, and its zero-suite CI state is accepted as credibly green, so the PR is at the final approval boundary."
|
|
1340
1395
|
: "The current head has both a clean settled review cycle and clean `pre_approval_gate` evidence, so the PR is at the final approval boundary."),
|
|
@@ -1365,10 +1420,12 @@ function evaluatePrGateCoordinationCore(input = {}) {
|
|
|
1365
1420
|
forbiddenActions,
|
|
1366
1421
|
nextAction: PR_CHECKPOINT_ACTION.RUN_PRE_APPROVAL_GATE,
|
|
1367
1422
|
reason: roundCapReached
|
|
1368
|
-
? `The Copilot round limit is exhausted (${copilotReviewRoundCount}/${maxCopilotRounds}), and the current head has zero unresolved threads with ${ciStatus
|
|
1369
|
-
: (
|
|
1370
|
-
? "
|
|
1371
|
-
:
|
|
1423
|
+
? `The Copilot round limit is exhausted (${copilotReviewRoundCount}/${maxCopilotRounds}), and the current head has zero unresolved threads with ${describeAcceptedCiState(ciStatus, preApprovalRequireCi)}, so \`pre_approval_gate\` fallback is now the next legal boundary.`
|
|
1424
|
+
: (postConvergenceReviewSuppressed && !sameHeadCleanConverged
|
|
1425
|
+
? "An operator explicitly withdrew a stranded Copilot review request for this exact head, whose delta since Copilot's last submitted review is a provable pure doc/prose bump; the prior converged Copilot review still stands, so `pre_approval_gate` is now the next legal boundary."
|
|
1426
|
+
: (ciStatus === "crediblyGreen"
|
|
1427
|
+
? "The current head has a clean settled post-draft review cycle, and its zero-suite CI state is accepted as credibly green, so `pre_approval_gate` is now the next legal boundary."
|
|
1428
|
+
: "The current head has a clean settled post-draft review cycle, so `pre_approval_gate` is now the next legal boundary.")),
|
|
1372
1429
|
mergeStateStatus,
|
|
1373
1430
|
conflictFiles,
|
|
1374
1431
|
gateEvidenceNote: roundCapReached ? roundExhaustionGateEvidenceNote : null,
|
|
@@ -1380,41 +1437,17 @@ function evaluatePrGateCoordinationCore(input = {}) {
|
|
|
1380
1437
|
// exhausted and the current head is clean (zero unresolved threads + green CI)
|
|
1381
1438
|
// — including a POST-CAP head Copilot has not (and will not) re-review, since
|
|
1382
1439
|
// no further Copilot round is permitted. Re-requesting review is illegal here,
|
|
1383
|
-
// so this MUST NOT dead-end at READY_TO_REREQUEST_REVIEW
|
|
1384
|
-
//
|
|
1385
|
-
//
|
|
1386
|
-
//
|
|
1387
|
-
//
|
|
1440
|
+
// so this MUST NOT dead-end at READY_TO_REREQUEST_REVIEW — nor at a forced
|
|
1441
|
+
// rerequest for a post-convergence significant change (#1387): the cap makes
|
|
1442
|
+
// that rerequest impossible (request-copilot-review suppresses it), so a
|
|
1443
|
+
// significant change discovered here is reviewed by the pre_approval_gate
|
|
1444
|
+
// fan-out itself, on the post-cap head, same as any other clean fallback. It
|
|
1445
|
+
// routes to the pre_approval_gate, which reviews the post-cap head itself
|
|
1446
|
+
// (per #848). The CI guards below still hold (failing / credibly-green CI
|
|
1447
|
+
// blocks), and conflicts / blocked states are handled earlier, so
|
|
1448
|
+
// genuinely-blocked states still forbid pre_approval. Mirrors
|
|
1449
|
+
// LOW_SIGNAL_CONVERGED routing with round-cap reasoning.
|
|
1388
1450
|
if (effectiveLifecycleState === STATE.ROUND_CAP_CLEAN_FALLBACK) {
|
|
1389
|
-
if (roundCapNewCycleRequired) {
|
|
1390
|
-
pushUnique(allowedNextActions, [PR_CHECKPOINT_ACTION.REREQUEST_COPILOT_REVIEW]);
|
|
1391
|
-
pushUnique(forbiddenActions, [
|
|
1392
|
-
PR_CHECKPOINT_ACTION.RUN_DRAFT_GATE,
|
|
1393
|
-
PR_CHECKPOINT_ACTION.MARK_READY_FOR_REVIEW,
|
|
1394
|
-
PR_CHECKPOINT_ACTION.REQUEST_COPILOT_REVIEW,
|
|
1395
|
-
PR_CHECKPOINT_ACTION.RUN_PRE_APPROVAL_GATE,
|
|
1396
|
-
PR_CHECKPOINT_ACTION.DECLARE_MERGE_READY,
|
|
1397
|
-
]);
|
|
1398
|
-
return buildResult({
|
|
1399
|
-
repo: input.repo ?? null,
|
|
1400
|
-
pr: Number.isInteger(input.pr) ? input.pr : null,
|
|
1401
|
-
currentHeadSha,
|
|
1402
|
-
lifecycleState: STATE.READY_TO_REREQUEST_REVIEW,
|
|
1403
|
-
loopDisposition: DISPOSITION.ACTION_REQUIRED,
|
|
1404
|
-
gateBoundary: PR_CHECKPOINT.POST_DRAFT_EXTERNAL_REVIEW,
|
|
1405
|
-
draftGateAlreadySatisfied: roundCapReached ? true : draftGateAlreadySatisfied,
|
|
1406
|
-
draftGate,
|
|
1407
|
-
preApprovalGate,
|
|
1408
|
-
allowedNextActions,
|
|
1409
|
-
forbiddenActions,
|
|
1410
|
-
nextAction: PR_CHECKPOINT_ACTION.REREQUEST_COPILOT_REVIEW,
|
|
1411
|
-
reason: `The previous Copilot cycle converged at the round cap (${copilotReviewRoundCount}/${maxCopilotRounds}), but significant post-convergence changes landed on the current head. Open a new cycle and re-request Copilot review before entering \`pre_approval_gate\`.`,
|
|
1412
|
-
mergeStateStatus,
|
|
1413
|
-
conflictFiles,
|
|
1414
|
-
refinementArtifact,
|
|
1415
|
-
copilotReviewRoundCount,
|
|
1416
|
-
});
|
|
1417
|
-
}
|
|
1418
1451
|
if (preApprovalRequireCi && (ciStatus === "failure" || ciStatus === "crediblyGreen")) {
|
|
1419
1452
|
pushUnique(allowedNextActions, [PR_CHECKPOINT_ACTION.REPORT_BLOCKED]);
|
|
1420
1453
|
pushUnique(forbiddenActions, postDraftForbidden);
|
|
@@ -1521,7 +1554,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
|
|
|
1521
1554
|
allowedNextActions,
|
|
1522
1555
|
forbiddenActions,
|
|
1523
1556
|
nextAction: PR_CHECKPOINT_ACTION.AWAIT_FINAL_HUMAN_APPROVAL,
|
|
1524
|
-
reason: `Round-cap clean fallback accepted as draft gate equivalent (${copilotReviewRoundCount}/${maxCopilotRounds} rounds, zero unresolved threads, ${ciStatus
|
|
1557
|
+
reason: `Round-cap clean fallback accepted as draft gate equivalent (${copilotReviewRoundCount}/${maxCopilotRounds} rounds, zero unresolved threads, ${describeAcceptedCiState(ciStatus, preApprovalRequireCi)}). The current head has clean \`pre_approval_gate\` evidence, so the PR is at the final approval boundary.`,
|
|
1525
1558
|
mergeStateStatus,
|
|
1526
1559
|
conflictFiles,
|
|
1527
1560
|
refinementArtifact,
|
|
@@ -1548,15 +1581,139 @@ function evaluatePrGateCoordinationCore(input = {}) {
|
|
|
1548
1581
|
allowedNextActions,
|
|
1549
1582
|
forbiddenActions,
|
|
1550
1583
|
nextAction: PR_CHECKPOINT_ACTION.RUN_PRE_APPROVAL_GATE,
|
|
1551
|
-
reason: `The Copilot round limit is exhausted (${copilotReviewRoundCount}/${maxCopilotRounds}), and the current head has zero unresolved threads with ${ciStatus
|
|
1584
|
+
reason: `The Copilot round limit is exhausted (${copilotReviewRoundCount}/${maxCopilotRounds}), and the current head has zero unresolved threads with ${describeAcceptedCiState(ciStatus, preApprovalRequireCi)}, so \`pre_approval_gate\` fallback is now the next legal boundary (it reviews the current post-cap head; no further Copilot re-request is permitted).`,
|
|
1552
1585
|
mergeStateStatus,
|
|
1553
1586
|
conflictFiles,
|
|
1554
1587
|
refinementArtifact,
|
|
1555
|
-
gateEvidenceNote: buildRoundExhaustionGateEvidenceNote({ copilotReviewRoundCount, maxCopilotRounds }),
|
|
1588
|
+
gateEvidenceNote: buildRoundExhaustionGateEvidenceNote({ copilotReviewRoundCount, maxCopilotRounds, ciStatus, preApprovalRequireCi }),
|
|
1556
1589
|
copilotReviewRoundCount,
|
|
1557
1590
|
});
|
|
1558
1591
|
}
|
|
1559
1592
|
|
|
1593
|
+
// Defensive gate-entry re-check for ROUND_CAP_REACHED (#1472): the compound
|
|
1594
|
+
// "unresolved threads OR non-clean CI" hard stop that copilot-loop-state.mjs
|
|
1595
|
+
// emits has no dedicated boundary of its own, so without this branch the
|
|
1596
|
+
// generic fallback below always names `report_blocked`. Both shipped
|
|
1597
|
+
// callers read lifecycleState and the CI/thread facts from ONE snapshot, and
|
|
1598
|
+
// the interpreter's own CI predicate is strictly wider than this branch's
|
|
1599
|
+
// (it also accepts `crediblyGreen`, #1371): whenever this branch's narrower
|
|
1600
|
+
// predicate and zero unresolved threads hold, the interpreter has already
|
|
1601
|
+
// classified the snapshot ROUND_CAP_CLEAN_FALLBACK, never ROUND_CAP_REACHED
|
|
1602
|
+
// (see the equivalence test in pr-gate-coordination.test.mjs). This branch
|
|
1603
|
+
// is therefore unreachable through the shipped callers — it is pure
|
|
1604
|
+
// defense-in-depth, demanded by issue 1472's corrected AC so the three
|
|
1605
|
+
// fields can never disagree if a future caller hands the evaluator a
|
|
1606
|
+
// round_cap_reached label alongside facts that satisfy the grant. The
|
|
1607
|
+
// predicate intentionally mirrors every other pre-approval CI boundary in
|
|
1608
|
+
// this file (success, or CI not required) — `crediblyGreen` is unconfirmed
|
|
1609
|
+
// CI and stays blocked here exactly as it does everywhere else (#1371). Any
|
|
1610
|
+
// other combination (threads still unresolved, an unknown thread count, or
|
|
1611
|
+
// CI not confirmed green) falls through unchanged to the generic default
|
|
1612
|
+
// below, preserving today's blocked behavior exactly.
|
|
1613
|
+
if (effectiveLifecycleState === STATE.ROUND_CAP_REACHED && roundCapReached) {
|
|
1614
|
+
const ciConfirmedGreen = ciStatus === "success" || !preApprovalRequireCi;
|
|
1615
|
+
// ciConfirmedGreen above is only true when ciStatus is literally "success"
|
|
1616
|
+
// OR CI is not required by config — never for an unconfirmed/failing/absent
|
|
1617
|
+
// status gated in only because requireCi is false. Naming it "green" in
|
|
1618
|
+
// human-read reason/evidence text for the latter case would be a false CI
|
|
1619
|
+
// claim (#1472 defer), so describe the actual grant basis instead.
|
|
1620
|
+
const ciClause = ciStatus === "success" ? "green CI" : "CI not required by config";
|
|
1621
|
+
if (unresolvedThreadCount === 0 && ciConfirmedGreen) {
|
|
1622
|
+
if (preApprovalGate.currentHeadClean) {
|
|
1623
|
+
// Inline title-marker check, mirroring ROUND_CAP_CLEAN_FALLBACK: the
|
|
1624
|
+
// outer post-pass guards FINAL_APPROVAL_READY and
|
|
1625
|
+
// PRE_APPROVAL_GATE_WINDOW, but NOT the DRAFT_GATE_NEEDED boundary the
|
|
1626
|
+
// sub-branch below can return — without this check a marker-titled
|
|
1627
|
+
// head would route to reconcile_draft_gate instead of blocking.
|
|
1628
|
+
const grantTitleMarkers = findBlockingTitleMarkers(prTitle);
|
|
1629
|
+
if (grantTitleMarkers.length > 0) {
|
|
1630
|
+
return buildTitleMarkerBlockedResult({
|
|
1631
|
+
input,
|
|
1632
|
+
currentHeadSha,
|
|
1633
|
+
draftGateAlreadySatisfied: true,
|
|
1634
|
+
draftGate,
|
|
1635
|
+
preApprovalGate,
|
|
1636
|
+
mergeStateStatus,
|
|
1637
|
+
conflictFiles,
|
|
1638
|
+
markers: grantTitleMarkers,
|
|
1639
|
+
refinementArtifact,
|
|
1640
|
+
});
|
|
1641
|
+
}
|
|
1642
|
+
// Mirror ROUND_CAP_CLEAN_FALLBACK/#579: a clean current head with no clean
|
|
1643
|
+
// draft_gate evidence must reconcile the draft gate rather than jump to
|
|
1644
|
+
// final approval.
|
|
1645
|
+
if (!draftGate.cleanEvidenceExists) {
|
|
1646
|
+
return buildDraftGateNeededForMergeResult({
|
|
1647
|
+
input,
|
|
1648
|
+
currentHeadSha,
|
|
1649
|
+
draftGate,
|
|
1650
|
+
preApprovalGate,
|
|
1651
|
+
mergeStateStatus,
|
|
1652
|
+
conflictFiles,
|
|
1653
|
+
underlyingReason: "Round-cap exhaustion fallback has clean pre_approval_gate but no clean draft_gate evidence.",
|
|
1654
|
+
refinementArtifact,
|
|
1655
|
+
effectiveLifecycleState,
|
|
1656
|
+
});
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
pushUnique(allowedNextActions, [PR_CHECKPOINT_ACTION.AWAIT_FINAL_HUMAN_APPROVAL]);
|
|
1660
|
+
pushUnique(forbiddenActions, [
|
|
1661
|
+
PR_CHECKPOINT_ACTION.RUN_DRAFT_GATE,
|
|
1662
|
+
PR_CHECKPOINT_ACTION.MARK_READY_FOR_REVIEW,
|
|
1663
|
+
PR_CHECKPOINT_ACTION.REQUEST_COPILOT_REVIEW,
|
|
1664
|
+
PR_CHECKPOINT_ACTION.DECLARE_MERGE_READY,
|
|
1665
|
+
]);
|
|
1666
|
+
return buildResult({
|
|
1667
|
+
repo: input.repo ?? null,
|
|
1668
|
+
pr: Number.isInteger(input.pr) ? input.pr : null,
|
|
1669
|
+
currentHeadSha,
|
|
1670
|
+
lifecycleState: effectiveLifecycleState,
|
|
1671
|
+
loopDisposition: loopDisposition ?? DISPOSITION.CLEAN_CONVERGED,
|
|
1672
|
+
gateBoundary: PR_CHECKPOINT.FINAL_APPROVAL_READY,
|
|
1673
|
+
draftGateAlreadySatisfied: true,
|
|
1674
|
+
draftGate,
|
|
1675
|
+
preApprovalGate,
|
|
1676
|
+
allowedNextActions,
|
|
1677
|
+
forbiddenActions,
|
|
1678
|
+
nextAction: PR_CHECKPOINT_ACTION.AWAIT_FINAL_HUMAN_APPROVAL,
|
|
1679
|
+
reason: `Round-cap exhaustion fallback accepted as draft gate equivalent (${copilotReviewRoundCount}/${maxCopilotRounds} rounds, zero unresolved threads, ${ciClause}). The current head has clean \`pre_approval_gate\` evidence, so the PR is at the final approval boundary.`,
|
|
1680
|
+
mergeStateStatus,
|
|
1681
|
+
conflictFiles,
|
|
1682
|
+
refinementArtifact,
|
|
1683
|
+
});
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
pushUnique(allowedNextActions, [PR_CHECKPOINT_ACTION.RUN_PRE_APPROVAL_GATE]);
|
|
1687
|
+
pushUnique(forbiddenActions, [
|
|
1688
|
+
PR_CHECKPOINT_ACTION.RUN_DRAFT_GATE,
|
|
1689
|
+
PR_CHECKPOINT_ACTION.MARK_READY_FOR_REVIEW,
|
|
1690
|
+
PR_CHECKPOINT_ACTION.REQUEST_COPILOT_REVIEW,
|
|
1691
|
+
PR_CHECKPOINT_ACTION.REREQUEST_COPILOT_REVIEW,
|
|
1692
|
+
PR_CHECKPOINT_ACTION.DECLARE_MERGE_READY,
|
|
1693
|
+
]);
|
|
1694
|
+
return buildResult({
|
|
1695
|
+
repo: input.repo ?? null,
|
|
1696
|
+
pr: Number.isInteger(input.pr) ? input.pr : null,
|
|
1697
|
+
currentHeadSha,
|
|
1698
|
+
lifecycleState: effectiveLifecycleState,
|
|
1699
|
+
loopDisposition: loopDisposition ?? DISPOSITION.CLEAN_CONVERGED,
|
|
1700
|
+
gateBoundary: PR_CHECKPOINT.PRE_APPROVAL_GATE_WINDOW,
|
|
1701
|
+
draftGateAlreadySatisfied: true,
|
|
1702
|
+
draftGate,
|
|
1703
|
+
preApprovalGate,
|
|
1704
|
+
allowedNextActions,
|
|
1705
|
+
forbiddenActions,
|
|
1706
|
+
nextAction: PR_CHECKPOINT_ACTION.RUN_PRE_APPROVAL_GATE,
|
|
1707
|
+
reason: `The Copilot round limit is exhausted (${copilotReviewRoundCount}/${maxCopilotRounds}), and the current head has zero unresolved threads and ${ciClause}, so \`pre_approval_gate\` fallback is now the next legal boundary (it reviews the current post-cap head; no further Copilot re-request is permitted).`,
|
|
1708
|
+
mergeStateStatus,
|
|
1709
|
+
conflictFiles,
|
|
1710
|
+
refinementArtifact,
|
|
1711
|
+
gateEvidenceNote: buildRoundExhaustionGateEvidenceNote({ copilotReviewRoundCount, maxCopilotRounds, ciDescriptor: ciClause }),
|
|
1712
|
+
copilotReviewRoundCount,
|
|
1713
|
+
});
|
|
1714
|
+
}
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1560
1717
|
if (effectiveLifecycleState === STATE.LOW_SIGNAL_CONVERGED) {
|
|
1561
1718
|
if (preApprovalRequireCi && (ciStatus === "failure" || ciStatus === "crediblyGreen")) {
|
|
1562
1719
|
pushUnique(allowedNextActions, [PR_CHECKPOINT_ACTION.REPORT_BLOCKED]);
|
|
@@ -12,30 +12,91 @@
|
|
|
12
12
|
* It is intentionally pure and side-effect free.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Builds a status-marker tester for a bare word like "WIP" or "DRAFT".
|
|
17
|
+
*
|
|
18
|
+
* A status marker asserts, on its own, that the PR is unfinished: bracketed
|
|
19
|
+
* (`[WIP]`), parenthesized (`(draft)`), colon-suffixed (`WIP:`), or the
|
|
20
|
+
* entire title with nothing else attached (a bare standalone `DRAFT`). A
|
|
21
|
+
* plain `\bWORD\b` match also hits the same word inside a compound noun
|
|
22
|
+
* phrase that names a component instead — `draft-gate`, `draft gate`,
|
|
23
|
+
* `wip-branch` — because a hyphen or a space is itself a word boundary. An
|
|
24
|
+
* underscore is a word character, so `draft_gate` never matched `\bDRAFT\b`
|
|
25
|
+
* to begin with; it is listed among the exempt forms for consistency, not
|
|
26
|
+
* because it was ever flagged. None of those forms satisfy any construction
|
|
27
|
+
* below, so a component name is left unflagged while a real status claim
|
|
28
|
+
* still is. "swipe"/"wiped"/"drafting" already fail every construction
|
|
29
|
+
* because there is no boundary between the marker word and the letters that
|
|
30
|
+
* follow it; a hyphen-prefixed compound like "re-draft" DOES create such a
|
|
31
|
+
* boundary (`\b` sees the hyphen).
|
|
32
|
+
*
|
|
33
|
+
* A trailing tag set off by a dash (`Fix login flow — WIP`) is deliberately
|
|
34
|
+
* NOT its own construction, even though it reads as a real status claim.
|
|
35
|
+
* Any dash-based construction narrow enough to close a title's tag also
|
|
36
|
+
* reopens the compound-noun false positive whenever the joiner is a
|
|
37
|
+
* different dash character (`Handle en dash–draft–gate naming`), and any fix
|
|
38
|
+
* for that narrows the construction until it drops real status claims that
|
|
39
|
+
* were previously caught (`Fix login flow — WIP.`, `— WIP (rebasing)`). The
|
|
40
|
+
* bracket/paren/colon/standalone set stays free of both failure modes, so a
|
|
41
|
+
* dash-set-off marker is left unflagged; `WIP:`/`DRAFT:` remains one
|
|
42
|
+
* keystroke away and stays flagged, the same trade already accepted for
|
|
43
|
+
* `WIP foo bar`.
|
|
44
|
+
*
|
|
45
|
+
* The bracket/paren/colon constructions all require the opening delimiter
|
|
46
|
+
* (`[`, `(`, or the marker word itself for colon) to sit at the start of the
|
|
47
|
+
* title or after whitespace — never directly after a letter, `/`, or `-` —
|
|
48
|
+
* so a conventional-commit scope (`fix(draft): support x`), a path segment
|
|
49
|
+
* (`app/[draft]/page.tsx`), a scoped label (`feat/draft: x`, `docs/wip:
|
|
50
|
+
* notes`), and a hyphen-prefixed compound (`re-draft: cleanup`) are all read
|
|
51
|
+
* as a component name, not a status claim — the same anchoring rule as the
|
|
52
|
+
* hyphen/underscore/space compound-noun exemption above. This does introduce
|
|
53
|
+
* one accepted false-negative class: a marker preceded by punctuation other
|
|
54
|
+
* than whitespace with no space of its own, e.g. `Fix bug,(draft)` or `Fix
|
|
55
|
+
* login(wip)`. Widening the anchor to "start, whitespace, or punctuation"
|
|
56
|
+
* would also re-admit the very forms (`/`, `-`) the anchor exists to
|
|
57
|
+
* exclude, so the narrower, whitespace-only anchor is kept and this
|
|
58
|
+
* false-negative class is accepted as its cost.
|
|
59
|
+
*
|
|
60
|
+
* The colon construction additionally requires the colon itself to CLOSE the
|
|
61
|
+
* tag — followed by whitespace or the end of the title, never directly by
|
|
62
|
+
* another character — so a scheme/tag/ref that merely starts with the
|
|
63
|
+
* marker word (`draft://`, `draft:latest`, `wip:branch`) is read as an
|
|
64
|
+
* unrelated identifier, not a status claim.
|
|
65
|
+
*/
|
|
66
|
+
function statusMarkerTester(word) {
|
|
67
|
+
const bracket = new RegExp(`(?:^|\\s)\\[\\s*${word}\\s*\\]`, "i");
|
|
68
|
+
const paren = new RegExp(`(?:^|\\s)\\(\\s*${word}\\s*\\)`, "i");
|
|
69
|
+
const colon = new RegExp(`(?:^|\\s)${word}\\s*:(?:\\s|$)`, "i");
|
|
70
|
+
const standalone = new RegExp(`^\\s*${word}\\s*$`, "i");
|
|
71
|
+
return (title) => bracket.test(title) || paren.test(title) || colon.test(title)
|
|
72
|
+
|| standalone.test(title);
|
|
73
|
+
}
|
|
74
|
+
|
|
15
75
|
/**
|
|
16
76
|
* Canonical merge-blocking markers and how to detect them.
|
|
17
77
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* match those variants. The construction emoji has no word boundary, so it is
|
|
23
|
-
* matched literally anywhere in the title.
|
|
78
|
+
* "DO NOT MERGE" is a three-word phrase with no plausible compound-noun
|
|
79
|
+
* reading, so it keeps simple word-boundary matching. The construction emoji
|
|
80
|
+
* has no word boundary at all, so it is matched literally anywhere in the
|
|
81
|
+
* title.
|
|
24
82
|
*/
|
|
25
83
|
const MARKER_MATCHERS = [
|
|
26
|
-
{ label: "WIP",
|
|
27
|
-
{ label: "DRAFT",
|
|
84
|
+
{ label: "WIP", test: statusMarkerTester("WIP") },
|
|
85
|
+
{ label: "DRAFT", test: statusMarkerTester("DRAFT") },
|
|
28
86
|
// Flexible (any) whitespace between the phrase words, case-insensitive.
|
|
29
|
-
{ label: "DO NOT MERGE",
|
|
30
|
-
{ label: "🚧",
|
|
87
|
+
{ label: "DO NOT MERGE", test: (title) => /\bDO\s+NOT\s+MERGE\b/i.test(title) },
|
|
88
|
+
{ label: "🚧", test: (title) => /🚧/u.test(title) },
|
|
31
89
|
];
|
|
32
90
|
|
|
33
91
|
/**
|
|
34
92
|
* Finds merge-blocking markers in a PR title.
|
|
35
93
|
*
|
|
36
|
-
* Returns the canonical labels of every matched marker,
|
|
37
|
-
*
|
|
38
|
-
*
|
|
94
|
+
* Returns the canonical labels of every matched marker, in a stable order
|
|
95
|
+
* (the declaration order of {@link MARKER_MATCHERS}). Each label can appear
|
|
96
|
+
* at most once: MARKER_MATCHERS visits each entry exactly once and every
|
|
97
|
+
* entry's label is distinct, so the result is de-duped by construction —
|
|
98
|
+
* there is no separate dedupe step to fail. Returns an empty array when the
|
|
99
|
+
* title is clean, empty, or not a string.
|
|
39
100
|
*
|
|
40
101
|
* @param {unknown} title - The PR title to inspect.
|
|
41
102
|
* @returns {string[]} Canonical labels of matched markers, e.g. ["WIP"] or
|
|
@@ -47,8 +108,8 @@ export function findBlockingTitleMarkers(title) {
|
|
|
47
108
|
}
|
|
48
109
|
|
|
49
110
|
const matched = [];
|
|
50
|
-
for (const { label,
|
|
51
|
-
if (
|
|
111
|
+
for (const { label, test } of MARKER_MATCHERS) {
|
|
112
|
+
if (test(title)) {
|
|
52
113
|
matched.push(label);
|
|
53
114
|
}
|
|
54
115
|
}
|