Skip to content

Commit 35e574d

Browse files
authored
gate(eligibility): hold PRs against a priority issue until its window opens (#9738) (#9847)
* gate(eligibility): hold PRs against a priority issue until its window opens (#9738) Priority issues carry the highest payout, so assignment fairness matters most there -- and first-come pickup is only fair if everyone can SEE the issue before anyone can act on it. A PR opened moments after the label lands means the window between "issue becomes valuable" and "issue is claimed" was effectively zero for everyone else watching the repo. A PR closing a `gittensor:priority` issue is now gate-eligible only once the label has been publicly present for `gate.priorityEligibilityWindow` minutes (default 30, per repo, `0` disables it). A PR arriving inside the window is NOT rejected: it is HELD with a neutral comment naming the moment it becomes eligible, and proceeds normally once the window elapses. No penalty beyond waiting. Two decisions worth stating: - The clock is anchored to the EARLIEST labeling event, not the most recent. The spec requires that re-applying the label not reset the window for already-open PRs; an earliest anchor gives that to everyone and makes "when does this issue open for work" a single knowable instant nothing can move. - Every unknown FAILS OPEN -- an unreadable timestamp, an absent label, a GraphQL error, a missing token. Holding a contributor's PR on a fact we could not read is a penalty for our own gap. The hold reuses the existing merge-hold rail (`heldForManualReview`), so it can never close a PR. Merge holds become DATA rather than a widening list of booleans. `MERGE_HOLD_INPUTS` is the one table; the input type (`Record<MergeHoldInput, boolean>`), the `heldForManualReview` fold, and both test fixtures all derive from it. Adding a hold was three independent edits that could each be forgotten -- declaring one and not folding it into the decision compiled fine. It is now one entry, and omitting the wiring is a compile error at the single call site. `normalizeOptionalNonNegativeInteger` exists because `0` is meaningful here (it turns the rule off) and the positive-integer normalizer would have discarded it as invalid and silently applied the default -- an operator's explicit "off" becoming "on". 100% statements and branches on both changed source files. * test(eligibility): cover the priority-window wiring, and fix the config round-trip it exposed The GraphQL label-timestamp read had no tests at all: every fail-open path, the earliest-labeling anchor, and the query shape (first:, not last: -- using last: would return the most RECENT labeling, which is exactly the value re-applying the label could use to push a contributor's window back) are now pinned. Writing the manifest test surfaced a real gap: gateConfigToJson never emitted priorityEligibilityWindow, so the setting did not round-trip. resolvePriorityEligibilityHold now DEFAULTS fetchLabeledAt to the real GraphQL read rather than returning undefined when none is injected -- its doc comment already claimed that default existed. The production caller passes facts only, and a new test proves the default really reaches GitHub instead of every case passing on an injected fake. resolvePriorityTypeLabel replaces the ad-hoc `typeLabels?.priority ?? DEFAULT` each caller spelled for itself. The label-author rule (#9737) and this window act on the same label; two spellings could disagree about a repo that renamed it. * test(eligibility): close the last coverage gaps without suppressions Codecov counts a /* v8 ignore */ line as unhit, so both suppressions are gone rather than carried: DEFAULT_PRIORITY_LABEL names the built-in value, so resolvePriorityTypeLabel returns it directly. PrTypeLabelSet is an open Record, which made DEFAULT_TYPE_LABELS.priority read as possibly-undefined and forced a fallback branch nothing could ever take. The .catch on resolvePriorityEligibilityHold is removed: that function catches its own GitHub read and cannot reject, so the guard was unreachable code that read as tested-and-fine while never running. The remaining branch is closed by an end-to-end test instead: a clean, approved, green PR that would MERGE is held because the priority issue it closes only became public a minute ago -- held, never closed. * test(engine): behaviour-test the priority label and window in the ENGINE's own suite packages/loopover-engine/src/** is credited by TWO Codecov uploads: the unflagged backend one from the root vitest run, and an "engine" flag fed by the package's own node:test suite (codecov.yml's own note: a PR touching engine source should get credit from the suite that actually behaviour-tests it). These two surfaces were only covered by the root suite, so the engine flag reported one uncovered statement per file -- which is where codecov/patch's missing 2 lines came from. A full unsharded local run (25,512 tests) confirms the backend side is already at 100%. resolvePriorityTypeLabel is exported from the engine index so its own suite can reach it, and MERGE_HOLD_INPUTS drops an export nothing outside its file used.
1 parent e3247ed commit 35e574d

23 files changed

Lines changed: 926 additions & 33 deletions

.loopover.yml.example

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -407,6 +407,22 @@ gate:
407407
# DB-backed (dashboard-settable too); this overrides the stored value.
408408
requireFreshRebaseWindow: 10
409409

410+
# Priority-issue eligibility window (#9738) — minutes a `gittensor:priority` label must have been publicly
411+
# present before a PR that closes that issue is gate-eligible.
412+
#
413+
# Priority carries the highest payout, so assignment fairness matters most there, and first-come pickup is
414+
# only fair if everyone can SEE the issue before anyone can act on it: a PR opened moments after the label
415+
# lands means the window between "issue becomes valuable" and "issue is claimed" was effectively zero for
416+
# everyone else watching the repo.
417+
#
418+
# A PR arriving inside the window is NOT rejected. It is HELD, with a neutral comment naming the moment it
419+
# becomes eligible, and proceeds normally once the window elapses — no penalty beyond waiting, and the
420+
# contributor keeps their work. The clock is anchored to the EARLIEST time the label was applied, so
421+
# re-applying it never resets the window for anyone.
422+
#
423+
# Whole number of minutes, 0 to 1440. `0` turns the rule off. Default: 30. Config-as-code only.
424+
priorityEligibilityWindow: 30
425+
410426
# Stale-base auto-rebase threshold. When the repository's current default branch is at least this many
411427
# commits ahead of a PR's own base commit, the pre-review readiness gate forces an update_branch before
412428
# review runs — independent of GitHub's own mergeable_state "behind" signal, which only fires when the

apps/loopover-ui/public/openapi.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10191,6 +10191,12 @@
1019110191
"guardrailEscalationSelfConsistencyRuns": {
1019210192
"type": "number",
1019310193
"nullable": true
10194+
},
10195+
"priorityEligibilityWindowMinutes": {
10196+
"type": "integer",
10197+
"nullable": true,
10198+
"minimum": 0,
10199+
"maximum": 1440
1019410200
}
1019510201
},
1019610202
"required": [

config/examples/loopover.full.yml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,6 +421,22 @@ gate:
421421
# DB-backed (dashboard-settable too); this overrides the stored value.
422422
requireFreshRebaseWindow: 10
423423

424+
# Priority-issue eligibility window (#9738) — minutes a `gittensor:priority` label must have been publicly
425+
# present before a PR that closes that issue is gate-eligible.
426+
#
427+
# Priority carries the highest payout, so assignment fairness matters most there, and first-come pickup is
428+
# only fair if everyone can SEE the issue before anyone can act on it: a PR opened moments after the label
429+
# lands means the window between "issue becomes valuable" and "issue is claimed" was effectively zero for
430+
# everyone else watching the repo.
431+
#
432+
# A PR arriving inside the window is NOT rejected. It is HELD, with a neutral comment naming the moment it
433+
# becomes eligible, and proceeds normally once the window elapses — no penalty beyond waiting, and the
434+
# contributor keeps their work. The clock is anchored to the EARLIEST time the label was applied, so
435+
# re-applying it never resets the window for anyone.
436+
#
437+
# Whole number of minutes, 0 to 1440. `0` turns the rule off. Default: 30. Config-as-code only.
438+
priorityEligibilityWindow: 30
439+
424440
# Stale-base auto-rebase threshold. When the repository's current default branch is at least this many
425441
# commits ahead of a PR's own base commit, the pre-review readiness gate forces an update_branch before
426442
# review runs — independent of GitHub's own mergeable_state "behind" signal, which only fires when the

packages/loopover-engine/src/focus-manifest.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,12 @@ export type FocusManifestGateConfig = {
209209
* (byte-identical to today) — a discrete positive-minutes count, not a score, so it is neither clamped
210210
* nor rounded; an invalid value (fractional, non-positive, non-finite) is dropped with a warning. */
211211
requireFreshRebaseWindowMinutes: number | null;
212+
/** `gate.priorityEligibilityWindow` (#9738): minutes a `gittensor:priority` label must have been publicly
213+
* present before a PR closing that issue is gate-eligible. Priority issues carry the highest payout, so
214+
* first-come pickup is only fair if everyone can see the issue before anyone can act on it. A PR inside
215+
* the window is HELD with a neutral comment, never rejected, and proceeds once the window elapses.
216+
* `0` disables the rule; null means the shipped default (30). */
217+
priorityEligibilityWindowMinutes: number | null;
212218
/** `gate.staleBaseAheadByThreshold` (#review-grounding stale-base fact): a commit count. When the repo's
213219
* current default branch is at least this many commits ahead of a PR's own base commit, the pre-review
214220
* readiness gate forces an `update_branch` (same action class as the existing `mergeableState: "behind"`
@@ -1386,6 +1392,7 @@ const EMPTY_GATE_CONFIG: FocusManifestGateConfig = {
13861392
dryRun: null,
13871393
premergeContentRecheck: null,
13881394
requireFreshRebaseWindowMinutes: null,
1395+
priorityEligibilityWindowMinutes: null,
13891396
staleBaseAheadByThreshold: null,
13901397
claMode: null,
13911398
claConsentPhrase: null,
@@ -1837,6 +1844,7 @@ const GATE_TOP_LEVEL_KEYS = new Set<string>([
18371844
"dryRun",
18381845
"premergeContentRecheck",
18391846
"requireFreshRebaseWindow",
1847+
"priorityEligibilityWindow",
18401848
"staleBaseAheadByThreshold",
18411849
"claMode",
18421850
"cla",
@@ -1941,6 +1949,8 @@ function parseGateConfig(value: JsonValue | undefined, warnings: string[]): Focu
19411949
dryRun: normalizeOptionalBoolean(record.dryRun, "gate.dryRun", warnings),
19421950
premergeContentRecheck: normalizeOptionalBoolean(record.premergeContentRecheck, "gate.premergeContentRecheck", warnings),
19431951
requireFreshRebaseWindowMinutes: normalizeOptionalPositiveInteger(record.requireFreshRebaseWindow, "gate.requireFreshRebaseWindow", warnings),
1952+
// Zero is MEANINGFUL here (it turns the rule off), so this cannot use normalizeOptionalPositiveInteger.
1953+
priorityEligibilityWindowMinutes: normalizeOptionalNonNegativeInteger(record.priorityEligibilityWindow, "gate.priorityEligibilityWindow", warnings, MAX_PRIORITY_ELIGIBILITY_WINDOW_MINUTES),
19441954
staleBaseAheadByThreshold: normalizeOptionalPositiveInteger(record.staleBaseAheadByThreshold, "gate.staleBaseAheadByThreshold", warnings),
19451955
claMode: normalizeOptionalGateMode(record.claMode, "gate.claMode", warnings),
19461956
claConsentPhrase: parsePublicSafeText(claRecord?.consentPhrase, "gate.cla.consentPhrase", warnings),
@@ -2004,6 +2014,7 @@ function parseGateConfig(value: JsonValue | undefined, warnings: string[]): Focu
20042014
gate.dryRun !== null ||
20052015
gate.premergeContentRecheck !== null ||
20062016
gate.requireFreshRebaseWindowMinutes !== null ||
2017+
gate.priorityEligibilityWindowMinutes !== null ||
20072018
gate.staleBaseAheadByThreshold !== null ||
20082019
gate.claMode !== null ||
20092020
gate.claConsentPhrase !== null ||
@@ -2122,6 +2133,7 @@ export function gateConfigToJson(gate: FocusManifestGateConfig): JsonValue {
21222133
if (gate.dryRun !== null) out.dryRun = gate.dryRun;
21232134
if (gate.premergeContentRecheck !== null) out.premergeContentRecheck = gate.premergeContentRecheck;
21242135
if (gate.requireFreshRebaseWindowMinutes !== null) out.requireFreshRebaseWindow = gate.requireFreshRebaseWindowMinutes;
2136+
if (gate.priorityEligibilityWindowMinutes !== null) out.priorityEligibilityWindow = gate.priorityEligibilityWindowMinutes;
21252137
if (gate.staleBaseAheadByThreshold !== null) out.staleBaseAheadByThreshold = gate.staleBaseAheadByThreshold;
21262138
if (gate.claMode !== null) out.claMode = gate.claMode;
21272139
if (gate.claConsentPhrase !== null || gate.claCheckRunName !== null || gate.claCheckRunAppSlug !== null) {
@@ -2221,6 +2233,17 @@ export function experimentalConfigToJson(experimental: FocusManifestExperimental
22212233
return out;
22222234
}
22232235

2236+
/** A NON-NEGATIVE integer, for a field where zero is a real setting rather than an absence -- e.g.
2237+
* `gate.priorityEligibilityWindow: 0` deliberately turns the window off, which `normalizeOptionalPositiveInteger`
2238+
* below would reject as invalid and silently replace with the shipped default (#9738). Bounded above so a typo
2239+
* cannot set a window that never opens. */
2240+
function normalizeOptionalNonNegativeInteger(value: JsonValue | undefined, field: string, warnings: string[], max: number): number | null {
2241+
if (value === undefined || value === null) return null;
2242+
if (typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= max) return value;
2243+
warnings.push(`Manifest field "${field}" must be a whole number between 0 and ${max}; ignoring it.`);
2244+
return null;
2245+
}
2246+
22242247
/** A positive INTEGER count (not a score/confidence) — e.g. `contentLane.maxAppendedEntries` counts discrete
22252248
* surfaces[] entries, so a fractional value (a likely typo) would render a nonsensical contributor-facing close
22262249
* message ("append between 1 and 2.5 entries"). Rejects fractional and non-positive values alike. */
@@ -2233,6 +2256,9 @@ function normalizeOptionalPositiveInteger(value: JsonValue | undefined, field: s
22332256

22342257
const MAX_CONTRIBUTOR_OPEN_ITEM_CAP = 100;
22352258

2259+
/** A day. Long enough for any deliberate cool-off, short enough that a typo cannot park work indefinitely. */
2260+
const MAX_PRIORITY_ELIGIBILITY_WINDOW_MINUTES = 24 * 60;
2261+
22362262
function normalizeOptionalContributorOpenItemCap(value: JsonValue | undefined, field: string, warnings: string[]): number | null {
22372263
const parsed = normalizeOptionalPositiveInteger(value, field, warnings);
22382264
if (parsed === null) return null;

packages/loopover-engine/src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -952,3 +952,7 @@ export {
952952
type PlanProgress,
953953
} from "./plan-dag.js";
954954
export { isDone as isPlanStepDone, nextReadySteps } from "./plan-step-readiness.js";
955+
956+
// #9743: the priority label's single resolution, exported so the engine's OWN behaviour suite can test
957+
// it -- the `engine` Codecov flag credits engine source from that suite, not from the root vitest run.
958+
export { DEFAULT_PRIORITY_LABEL, resolvePriorityTypeLabel } from "./settings/pr-type-label.js";

packages/loopover-engine/src/settings/pr-type-label.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,16 @@ export type { PrTypeLabelSet } from "../types/manifest-deps-types.js";
2222
* assumption (#label-modularity): a self-hoster's `typeLabels` fully replaces the category set these
2323
* keys are drawn from. The built-in categories are mutually exclusive by default (see
2424
* `resolvePrTypeLabel`'s `removeLabels`) unless a propagation mapping is explicitly additive. */
25+
/** The built-in PRIORITY label, named on its own so it reads as `string`. `PrTypeLabelSet` is an open
26+
* `Record`, which makes `DEFAULT_TYPE_LABELS.priority` optional to the type system even though the
27+
* built-in set always defines it -- naming the value is what lets `resolvePriorityTypeLabel` return it
28+
* without a fallback branch nothing can ever take. */
29+
export const DEFAULT_PRIORITY_LABEL = "gittensor:priority";
30+
2531
export const DEFAULT_TYPE_LABELS: PrTypeLabelSet = {
2632
bug: "gittensor:bug",
2733
feature: "gittensor:feature",
28-
priority: "gittensor:priority",
34+
priority: DEFAULT_PRIORITY_LABEL,
2935
};
3036

3137
export const MAX_TYPE_LABEL_CATEGORIES = 32;
@@ -142,6 +148,20 @@ export type PrTypeLabelDecision = {
142148
* misconfigured additive mapping's `prLabel` happens to collide with a type-label-set name (it is
143149
* excluded from removal since it is also being applied). Pure + total.
144150
*/
151+
/**
152+
* The PRIORITY label as this repo names it, falling back to the built-in default.
153+
*
154+
* One resolution rather than the ad-hoc `settings.typeLabels?.priority ?? DEFAULT_TYPE_LABELS.priority`
155+
* each caller used to spell for itself: the label-author rule (#9737) and the eligibility window (#9738)
156+
* both act on this exact label, and a repo that renamed it would be enforced inconsistently if the two
157+
* ever disagreed about which label they mean.
158+
*/
159+
export function resolvePriorityTypeLabel(labels: PrTypeLabelSet | null | undefined): string {
160+
const configured = labels?.priority;
161+
if (typeof configured === "string" && configured.trim().length > 0) return configured;
162+
return DEFAULT_PRIORITY_LABEL;
163+
}
164+
145165
export function resolvePrTypeLabel(input: {
146166
title: string | undefined;
147167
linkedIssueLabels?: string[] | undefined;
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { test } from "node:test";
2+
import assert from "node:assert/strict";
3+
import { DEFAULT_PRIORITY_LABEL, gateConfigToJson, parseFocusManifest, resolvePriorityTypeLabel } from "../dist/index.js";
4+
5+
// The engine's own behaviour suite for the two surfaces #9738/#9743 added here. The root vitest suite
6+
// covers them too, but the `engine` Codecov flag is fed by THIS suite -- and, more to the point, these are
7+
// engine semantics, so they belong beside the rest of the engine's behaviour tests.
8+
9+
test("resolvePriorityTypeLabel: a repo's configured name wins (#9743)", () => {
10+
assert.equal(resolvePriorityTypeLabel({ bug: "b", feature: "f", priority: "team:top" }), "team:top");
11+
});
12+
13+
test("resolvePriorityTypeLabel: falls back to the built-in default when unconfigured", () => {
14+
assert.equal(resolvePriorityTypeLabel(undefined), DEFAULT_PRIORITY_LABEL);
15+
assert.equal(resolvePriorityTypeLabel(null), DEFAULT_PRIORITY_LABEL);
16+
assert.equal(resolvePriorityTypeLabel({}), DEFAULT_PRIORITY_LABEL);
17+
});
18+
19+
test("resolvePriorityTypeLabel: a blank configured name is unconfigured, not an empty label", () => {
20+
// An empty label would match nothing and silently disable both rules that key on it.
21+
for (const priority of ["", " "]) {
22+
assert.equal(resolvePriorityTypeLabel({ bug: "b", feature: "f", priority }), DEFAULT_PRIORITY_LABEL);
23+
}
24+
});
25+
26+
test("gateConfigToJson round-trips priorityEligibilityWindow (#9738)", () => {
27+
// The setting used to parse but never serialize, so it was silently dropped on the way back out.
28+
const parsed = parseFocusManifest({ gate: { priorityEligibilityWindow: 45 } });
29+
assert.equal(parsed.gate.priorityEligibilityWindowMinutes, 45);
30+
assert.equal((gateConfigToJson(parsed.gate) as { priorityEligibilityWindow?: number }).priorityEligibilityWindow, 45);
31+
});
32+
33+
test("gateConfigToJson: 0 is an explicit OFF and must survive the round trip", () => {
34+
const parsed = parseFocusManifest({ gate: { priorityEligibilityWindow: 0 } });
35+
assert.equal(parsed.gate.priorityEligibilityWindowMinutes, 0);
36+
assert.equal((gateConfigToJson(parsed.gate) as { priorityEligibilityWindow?: number }).priorityEligibilityWindow, 0);
37+
});

scripts/check-docs-drift.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,7 @@ export const SETTINGS_ALIAS_MANIFEST: AliasManifestRow[] = [
190190
{ field: "aiReviewOnMerge", aliases: ["onMerge"] },
191191
{ field: "aiReviewReviewers", aliases: ["reviewers:"] },
192192
{ field: "requireFreshRebaseWindowMinutes", aliases: ["requireFreshRebaseWindow"] },
193+
{ field: "priorityEligibilityWindowMinutes", aliases: ["priorityEligibilityWindow"] },
193194
{ field: "sizeGateMaxFiles", aliases: ["maxFiles"] },
194195
{ field: "sizeGateMaxLines", aliases: ["maxLines"] },
195196
];

src/github/backfill.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3559,6 +3559,46 @@ export async function fetchLivePullRequestMergedAt(
35593559
return result === undefined ? undefined : (result.data.merged_at ?? null);
35603560
}
35613561

3562+
/**
3563+
* When a label FIRST landed on an issue, ISO-8601, or null when it never did / cannot be read (#9738).
3564+
*
3565+
* `first: N` on purpose: the eligibility window is anchored to the EARLIEST labeling so re-applying the label
3566+
* cannot reset the clock for anyone. GitHub returns timeline items chronologically, so the first LABELED_EVENT
3567+
* naming this label is the moment the issue became publicly valuable.
3568+
*
3569+
* Reads at most one page. A maintainer who has labeled and unlabeled an issue more times than that has an
3570+
* issue whose history is not what this rule is for, and a null here FAILS OPEN (no hold) rather than guessing.
3571+
*/
3572+
export async function fetchIssueLabelFirstAppliedAt(
3573+
env: Env,
3574+
repoFullName: string,
3575+
issueNumber: number,
3576+
labelName: string,
3577+
token: string | undefined,
3578+
admissionKey?: GitHubRateLimitAdmissionKey,
3579+
): Promise<string | null> {
3580+
if (!token || !labelName) return null;
3581+
const { owner, name } = repoParts(repoFullName);
3582+
if (!owner || !name) return null;
3583+
const query = `query LoopOverIssueLabeledAt { repository(owner: ${JSON.stringify(owner)}, name: ${JSON.stringify(name)}) { issue(number: ${issueNumber}) { timelineItems(first: 100, itemTypes: [LABELED_EVENT]) { nodes { __typename ... on LabeledEvent { createdAt label { name } } } } } } }`;
3584+
const result = await githubGraphQl<{
3585+
data?: {
3586+
repository?: {
3587+
issue?: { timelineItems?: { nodes?: Array<{ createdAt?: string | null; label?: { name?: string | null } | null } | null> | null } | null } | null;
3588+
} | null;
3589+
};
3590+
errors?: unknown[];
3591+
}>(env, query, token, admissionKey).catch(() => undefined);
3592+
if (result === undefined) return null;
3593+
if (Array.isArray(result.errors) && result.errors.length > 0) return null;
3594+
const wanted = labelName.toLowerCase();
3595+
for (const node of result.data?.repository?.issue?.timelineItems?.nodes ?? []) {
3596+
const label = node?.label?.name;
3597+
if (typeof label === "string" && label.toLowerCase() === wanted && typeof node?.createdAt === "string") return node.createdAt;
3598+
}
3599+
return null;
3600+
}
3601+
35623602
export type LinkedIssueClosureByPullRequestResult = "closed_by_pull_request" | "not_closed_by_pull_request" | "fetch_error";
35633603

35643604
/** Verifies whether GitHub attributes this issue's closure to the specific PR, via GraphQL's `ClosedEvent.closer`

src/openapi/schemas.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { AGENT_ACTION_CLASSES, AUTONOMY_LEVELS, FEASIBILITY_VERDICTS, PUBLIC_SUR
44
// #9773: the request bodies these routes really accept, from the one place they are defined.
55
import { checkBeforeStartSchema, slopRiskSchema, validateFocusManifestSchema, validateLinkedIssueSchema } from "@loopover/contract/api-requests";
66
import { MAX_REVIEW_NAG_COOLDOWN_DAYS } from "../settings/agent-actions";
7+
import { MAX_PRIORITY_ELIGIBILITY_WINDOW_MINUTES } from "../review/priority-eligibility-window";
78
import { MAX_CONTRIBUTOR_OPEN_ITEM_CAP } from "../types";
89
import {
910
MAX_FIND_OPPORTUNITIES_TARGETS,
@@ -759,6 +760,8 @@ export const RepositorySettingsSchema = z
759760
gateDryRun: z.boolean().optional(),
760761
premergeContentRecheck: z.boolean().optional(),
761762
requireFreshRebaseWindowMinutes: z.number().int().positive().nullable().optional(),
763+
// #9738: non-negative, not positive -- 0 is the documented way to turn the window off.
764+
priorityEligibilityWindowMinutes: z.number().int().min(0).max(MAX_PRIORITY_ELIGIBILITY_WINDOW_MINUTES).nullable().optional(),
762765
staleBaseAheadByThreshold: z.number().int().positive().nullable().optional(),
763766
mergeReadinessGateMode: z.enum(["off", "advisory", "block"]),
764767
manifestPolicyGateMode: z.enum(["off", "advisory", "block"]),

0 commit comments

Comments
 (0)