Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .loopover.yml.example
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,25 @@ gate:
- name: Contributor trust
appSlug: example-security-app

# Check-runs to IGNORE ENTIRELY (#9810) — the stronger sibling of advisoryCheckRuns above. Same
# spoof-resistant { name, appSlug } matching, but a matched run is treated as if it did not exist: it never
# gates CI, never counts as "still running", and — unlike advisory — never routes the PR to a manual-review
# hold either. Its conclusion is surfaced informationally only.
#
# Use this when a check's verdict carries no signal for YOUR repo while OTHER checks from the same app stay
# meaningful. The motivating case: a vendor app publishes both a real security scan AND a heuristic
# contributor-trust score. The scan is worth gating on; the trust score fails for perfectly good
# contributors, and listing it under advisoryCheckRuns still converts every one of their otherwise-clean PRs
# into a manual review — automation replaced by a queue of human decisions, and contributors left wondering
# whether they are being judged fairly. Ignoring the trust check keeps the scan's protection and drops the
# noise. If BOTH lists name the same check, ignore wins (it is the stronger, more explicit intent).
#
# List of { name, appSlug }, or omit. Default: not configured (byte-identical behavior for every repo that
# doesn't opt in). Config-as-code only — no DB column or dashboard toggle.
ignoredCheckRuns:
- name: Contributor trust
appSlug: example-security-app

# Promote a confident AI-judgment-only finding (one the reviewer itself placed under "Blockers", never
# a "Nit") into a real, deterministic gate blocker instead of leaving it advisory (#3907). Only matters
# for repos already running the registry content lane (see contentLane below) — content/registry repos
Expand Down
19 changes: 19 additions & 0 deletions apps/loopover-ui/public/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -10127,6 +10127,25 @@
"updatedAt": {
"type": "string",
"nullable": true
},
"ignoredCheckRuns": {
"type": "array",
"nullable": true,
"items": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"appSlug": {
"type": "string"
}
},
"required": [
"name",
"appSlug"
]
}
}
},
"required": [
Expand Down
8 changes: 8 additions & 0 deletions codecov.yml
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,14 @@ comment:
# matches the local report.
ignore:
- "src/env.d.ts"
# Type-ONLY declaration modules (#9810): zero runtime statements, so v8 instruments nothing and the whole
# file reports 0% — the same artifact src/env.d.ts is ignored for above. Adding a single field to one of
# these otherwise fails codecov/patch on lines that can never execute. Guarded, not trusted: the
# "codecov ignore list stays honest" test in test/unit/codecov-ignore-type-only.test.ts fails if any path
# listed here ever gains a runtime declaration, so an ignore can never quietly start hiding real code.
- "packages/loopover-engine/src/types/manifest-deps-types.ts"
- "packages/loopover-engine/src/types/predicted-gate-types.ts"
- "packages/loopover-engine/src/types/reward-risk-types.ts"
- "apps/**"
- "test/**"
- "scripts/**"
Expand Down
14 changes: 14 additions & 0 deletions packages/loopover-engine/src/focus-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,13 @@ export type FocusManifestGateConfig = {
* config-only — no vendor name is ever hardcoded in behavior. null/empty (unset) ⇒ byte-identical to today.
* See {@link RepositorySettings.advisoryCheckRuns}. */
advisoryCheckRuns: ReadonlyArray<{ name: string; appSlug: string }> | null;
/** `gate.ignoredCheckRuns` (#9810): third-party check-runs to EXCLUDE from CI resolution entirely -- never
* gate, never pend, never hold. Same spoof-resistant `{ name, appSlug }` matching as advisoryCheckRuns.
* For a check whose verdict a maintainer has decided carries no signal for this repo (e.g. a vendor's
* contributor-trust score) while OTHER checks from the same app stay meaningful. Advisory still routes a
* failure to a manual-review hold; ignored does not -- the run is treated as if it did not exist, and is
* surfaced informationally in the aggregate for panel transparency. */
ignoredCheckRuns: ReadonlyArray<{ name: string; appSlug: string }> | null;
/** `gate.aiJudgmentBlockers` (#3907): "gate" | "advisory", null (unset) ⇒ "advisory" (byte-identical to
* today everywhere that doesn't opt in). Config-as-code only, YML-only (no DB column, no dashboard
* toggle) — mirrors `contentLane`'s own YML-only shape, since this only has an effect for repos already
Expand Down Expand Up @@ -1358,6 +1365,7 @@ const EMPTY_GATE_CONFIG: FocusManifestGateConfig = {
claCheckRunAppSlug: null,
expectedCiContexts: null,
advisoryCheckRuns: null,
ignoredCheckRuns: null,
aiJudgmentBlockersMode: null,
copycatMode: null,
copycatMinScore: null,
Expand Down Expand Up @@ -1806,6 +1814,7 @@ const GATE_TOP_LEVEL_KEYS = new Set<string>([
"cla",
"expectedCiContexts",
"advisoryCheckRuns",
"ignoredCheckRuns",
"aiJudgmentBlockers",
"copycat",
]);
Expand Down Expand Up @@ -1900,6 +1909,7 @@ function parseGateConfig(value: JsonValue | undefined, warnings: string[]): Focu
claCheckRunAppSlug: parsePublicSafeText(claRecord?.checkRunAppSlug, "gate.cla.checkRunAppSlug", warnings),
expectedCiContexts: normalizeOptionalStringList(record.expectedCiContexts, "gate.expectedCiContexts", warnings),
advisoryCheckRuns: normalizeOptionalAdvisoryCheckRuns(record.advisoryCheckRuns, "gate.advisoryCheckRuns", warnings),
ignoredCheckRuns: normalizeOptionalAdvisoryCheckRuns(record.ignoredCheckRuns, "gate.ignoredCheckRuns", warnings),
aiJudgmentBlockersMode: normalizeOptionalEnum(record.aiJudgmentBlockers, "gate.aiJudgmentBlockers", ["gate", "advisory"] as const, warnings),
copycatMode: normalizeOptionalEnum(copycatRecord?.mode, "gate.copycat.mode", ["off", "warn", "label", "block"] as const, warnings),
copycatMinScore: normalizeOptionalScore(copycatRecord?.minScore, "gate.copycat.minScore", warnings),
Expand Down Expand Up @@ -1960,6 +1970,7 @@ function parseGateConfig(value: JsonValue | undefined, warnings: string[]): Focu
gate.claCheckRunAppSlug !== null ||
gate.expectedCiContexts !== null ||
gate.advisoryCheckRuns !== null ||
gate.ignoredCheckRuns !== null ||
gate.aiJudgmentBlockersMode !== null ||
gate.copycatMode !== null ||
gate.copycatMinScore !== null;
Expand Down Expand Up @@ -2059,6 +2070,9 @@ export function gateConfigToJson(gate: FocusManifestGateConfig): JsonValue {
if (gate.advisoryCheckRuns !== null) {
out.advisoryCheckRuns = gate.advisoryCheckRuns.map((c) => ({ name: c.name, appSlug: c.appSlug })) as JsonValue;
}
if (gate.ignoredCheckRuns !== null) {
out.ignoredCheckRuns = gate.ignoredCheckRuns.map((c) => ({ name: c.name, appSlug: c.appSlug })) as JsonValue;
}
if (gate.aiJudgmentBlockersMode !== null) out.aiJudgmentBlockers = gate.aiJudgmentBlockersMode;
if (gate.copycatMode !== null || gate.copycatMinScore !== null) {
const copycat: Record<string, JsonValue> = {};
Expand Down
3 changes: 3 additions & 0 deletions packages/loopover-engine/src/types/manifest-deps-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,9 @@ export type RepositorySettings = {
* counts as "still running"); a non-passing conclusion routes the PR to the manual-review hold instead of
* being swallowed. Config-as-code only — no DB column; set via `.loopover.yml gate.advisoryCheckRuns`. */
advisoryCheckRuns?: ReadonlyArray<{ name: string; appSlug: string }> | null | undefined;
/** `gate.ignoredCheckRuns` (#9810): check-runs excluded from CI resolution entirely -- never gate, never
* pend, never hold. Config-as-code only; same `{ name, appSlug }` anti-spoof shape as advisoryCheckRuns. */
ignoredCheckRuns?: ReadonlyArray<{ name: string; appSlug: string }> | null | undefined;
/** Dry-run disposition (#gate-dryrun). When true, the gate renders the would-be merge/close/manual verdict (every
* advisory sub-gate promoted to block) WITHOUT enforcing — the posted check stays non-blocking. Lets advisory mode
* preview exactly what it would do before the maintainer flips to real enforcement. Default off. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,7 @@ export type FocusManifestGateConfig = {
claCheckRunAppSlug: string | null;
expectedCiContexts: ReadonlyArray<string> | null;
advisoryCheckRuns: ReadonlyArray<{ name: string; appSlug: string }> | null;
ignoredCheckRuns: ReadonlyArray<{ name: string; appSlug: string }> | null;
};

export type PreMergeCheck = {
Expand Down
36 changes: 31 additions & 5 deletions src/github/backfill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2719,6 +2719,11 @@ export type LiveCiAggregate = {
// surfaced here so the disposition planner can route the PR to a manual-review hold instead of silently
// swallowing a signal a maintainer installed a whole app to raise. Empty for every repo that doesn't opt in.
advisoryHoldDetails: Array<{ name: string; appSlug: string; conclusion: string }>;
// #9810: a maintainer-declared `gate.ignoredCheckRuns` check-run that was seen and excluded ENTIRELY -- never
// gates, never pends, never holds, whatever its conclusion. Informational only, surfaced so the panel/audit
// can say "this check was ignored by repo policy" instead of the check silently vanishing. Empty for every
// repo that doesn't opt in.
ignoredCheckDetails: Array<{ name: string; appSlug: string; conclusion: string }>;
// Informational-only (#2137): set when the aggregate resolved to "passed" with no branch-protection required
// contexts configured (`enforceRequiredOnly` false) — meaning a workflow that never triggers on this commit at
// all (e.g. path-filtered out, or a broken YAML trigger) is indistinguishable from one that doesn't exist, and
Expand Down Expand Up @@ -2992,12 +2997,13 @@ async function reduceLiveCiAggregate(
statuses: ReadonlyArray<LiveCiStatus>;
requiredContexts: ReadonlySet<string> | null | undefined;
advisoryCheckRuns?: ReadonlyArray<{ name: string; appSlug: string }> | null | undefined;
ignoredCheckRuns?: ReadonlyArray<{ name: string; appSlug: string }> | null | undefined;
checkRunsIncomplete: boolean;
statusIncomplete: boolean;
fetchSuites: () => Promise<ReadonlyArray<LiveCiSuite> | null>;
},
): Promise<LiveCiAggregate> {
const { checkRuns, statuses, requiredContexts, advisoryCheckRuns, checkRunsIncomplete, statusIncomplete, fetchSuites } = inputs;
const { checkRuns, statuses, requiredContexts, advisoryCheckRuns, ignoredCheckRuns, checkRunsIncomplete, statusIncomplete, fetchSuites } = inputs;
const enforceRequiredOnly = requiredContexts != null && requiredContexts.size > 0;
const isRequired = (name: string): boolean => !enforceRequiredOnly || requiredContexts!.has(name);
// Deliberately the OPPOSITE unknown-case default from isRequired() above, and used ONLY for a third-party
Expand All @@ -3016,6 +3022,7 @@ async function reduceLiveCiAggregate(
const failingDetails: LiveCiAggregate["failingDetails"] = [];
const nonRequiredFailingDetails: LiveCiAggregate["nonRequiredFailingDetails"] = [];
const advisoryHoldDetails: LiveCiAggregate["advisoryHoldDetails"] = [];
const ignoredCheckDetails: LiveCiAggregate["ignoredCheckDetails"] = [];
let total = 0;
let anyPending = false;
let anyVisiblePending = false;
Expand All @@ -3040,6 +3047,14 @@ async function reduceLiveCiAggregate(
// with a non-passing conclusion, it is recorded in advisoryHoldDetails so the disposition planner can route
// the PR to a manual-review hold. An advisory check still in progress is simply ignored (it may yet pass);
// nothing about it holds the gate either way.
// #9810: an ignored check-run is excluded from EVERYTHING -- ciState, pending, holds. Recorded
// informationally so the exclusion is visible rather than the check silently vanishing. Checked BEFORE
// the advisory list: if a check appears in both, ignore is the stronger, later-declared maintainer intent.
const ignoredMatch = matchAdvisoryCheckRun(run, ignoredCheckRuns);
if (ignoredMatch) {
ignoredCheckDetails.push({ name: ignoredMatch.name, appSlug: ignoredMatch.appSlug, conclusion: (run.conclusion ?? run.status ?? "").toLowerCase() });
continue;
}
const advisoryMatch = matchAdvisoryCheckRun(run, advisoryCheckRuns);
if (advisoryMatch) {
const advisoryConclusion = (run.conclusion ?? "").toLowerCase();
Expand Down Expand Up @@ -3159,7 +3174,7 @@ async function reduceLiveCiAggregate(
// A partial/paginated read can't tell "never appears" from "appears on a page we didn't fetch" -- only a
// COMPLETE read's absence is a confident signal worth a short surfacing cap (#selfhost-ci-deferral-staleness).
const hasMissingRequiredContext = anyMissingRequiredContext && !checkRunsIncomplete && !statusIncomplete;
return { ciState, hasPending, hasVisiblePending: anyRequiredVisiblePending, hasMissingRequiredContext, failingDetails, nonRequiredFailingDetails, advisoryHoldDetails, ciCompletenessWarning };
return { ciState, hasPending, hasVisiblePending: anyRequiredVisiblePending, hasMissingRequiredContext, failingDetails, nonRequiredFailingDetails, advisoryHoldDetails, ignoredCheckDetails, ciCompletenessWarning };
}

/**
Expand All @@ -3183,8 +3198,11 @@ export async function fetchLiveCiAggregate(
// #4372: maintainer-declared advisory check-runs (trailing/optional — every existing positional caller passing
// requiredContexts+admissionKey stays byte-identical). Excluded from the aggregate; non-passing ⇒ advisoryHoldDetails.
advisoryCheckRuns?: ReadonlyArray<{ name: string; appSlug: string }> | null,
// #9810: maintainer-declared IGNORED check-runs — excluded from the aggregate entirely, and (unlike advisory)
// never routed to a hold. Same trailing-optional discipline: existing positional callers are unaffected.
ignoredCheckRuns?: ReadonlyArray<{ name: string; appSlug: string }> | null,
): Promise<LiveCiAggregate> {
if (!headSha) return { ciState: "unverified", hasPending: false, hasVisiblePending: false, hasMissingRequiredContext: false, failingDetails: [], nonRequiredFailingDetails: [], advisoryHoldDetails: [], ciCompletenessWarning: null };
if (!headSha) return { ciState: "unverified", hasPending: false, hasVisiblePending: false, hasMissingRequiredContext: false, failingDetails: [], nonRequiredFailingDetails: [], advisoryHoldDetails: [], ignoredCheckDetails: [], ciCompletenessWarning: null };
// Check-runs + classic statuses are accumulated across pages here; the single classification lives in
// reduceLiveCiAggregate so the REST and GraphQL paths reach byte-identical verdicts (#1941).
const checkRuns: LiveCiCheckRun[] = [];
Expand Down Expand Up @@ -3239,6 +3257,7 @@ export async function fetchLiveCiAggregate(
statuses,
requiredContexts,
advisoryCheckRuns,
ignoredCheckRuns,
checkRunsIncomplete,
statusIncomplete,
// Lazily read the check-SUITES backstop only when the reducer finds the cheaper sources fully settled; a fetch
Expand Down Expand Up @@ -3305,6 +3324,7 @@ export async function fetchLiveCiAggregateViaGraphQl(
requiredContexts?: ReadonlySet<string> | null,
admissionKey?: GitHubRateLimitAdmissionKey,
advisoryCheckRuns?: ReadonlyArray<{ name: string; appSlug: string }> | null,
ignoredCheckRuns?: ReadonlyArray<{ name: string; appSlug: string }> | null,
): Promise<LiveCiAggregate | null> {
if (!headSha || !token) return null;
const parsed = parseBackfillRepoFullName(repoFullName);
Expand Down Expand Up @@ -3390,6 +3410,7 @@ export async function fetchLiveCiAggregateViaGraphQl(
statuses,
requiredContexts,
advisoryCheckRuns,
ignoredCheckRuns,
checkRunsIncomplete: false,
statusIncomplete: false,
fetchSuites: async () => suites, // already fetched in the same query — never a second round-trip
Expand All @@ -3410,14 +3431,15 @@ export async function fetchLiveCiAggregatePreferGraphQl(
requiredContexts?: ReadonlySet<string> | null,
admissionKey?: GitHubRateLimitAdmissionKey,
advisoryCheckRuns?: ReadonlyArray<{ name: string; appSlug: string }> | null,
ignoredCheckRuns?: ReadonlyArray<{ name: string; appSlug: string }> | null,
): Promise<LiveCiAggregate> {
if (isStatusRollupGraphQlEnabled(env)) {
// fetchLiveCiAggregateViaGraphQl handles all its own errors and returns null on any uncertainty (it never
// rejects), so a null result — not a throw — is the fall-back-to-REST signal.
const rollup = await fetchLiveCiAggregateViaGraphQl(env, repoFullName, headSha, token, requiredContexts, admissionKey, advisoryCheckRuns);
const rollup = await fetchLiveCiAggregateViaGraphQl(env, repoFullName, headSha, token, requiredContexts, admissionKey, advisoryCheckRuns, ignoredCheckRuns);
if (rollup) return rollup;
}
return fetchLiveCiAggregate(env, repoFullName, headSha, token, requiredContexts, admissionKey, advisoryCheckRuns);
return fetchLiveCiAggregate(env, repoFullName, headSha, token, requiredContexts, admissionKey, advisoryCheckRuns, ignoredCheckRuns);
}

/**
Expand Down Expand Up @@ -3958,6 +3980,10 @@ export function deserializeCachedCiAggregate(
// it is already baked into the cached `ciState`. Pinned end-to-end by pr-detail-durable-cache.test.ts (the
// deserialize-[] + invalidation halves) and backfill-2.test.ts (the fresh read re-deriving the hold).
advisoryHoldDetails: [],
// #9810: same treatment, and safer still -- ignoredCheckDetails drives NOTHING (informational surfacing
// only), so an empty reconstruction on a cache hit cannot change any disposition. The exclusion's real
// effect is already baked into the cached `ciState`.
ignoredCheckDetails: [],
ciCompletenessWarning: cached.ciCompletenessWarning ?? null,
};
} catch {
Expand Down
1 change: 1 addition & 0 deletions src/openapi/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -745,6 +745,7 @@ export const RepositorySettingsSchema = z
// the compile-time parity assertion in test/unit/openapi-settings-schema-parity.test.ts.
expectedCiContexts: z.array(z.string()).readonly().nullable().optional(),
advisoryCheckRuns: z.array(z.object({ name: z.string(), appSlug: z.string() })).readonly().nullable().optional(),
ignoredCheckRuns: z.array(z.object({ name: z.string(), appSlug: z.string() })).readonly().nullable().optional(),
copycatGateMode: z.enum(["off", "warn", "label", "block"]).optional(),
copycatGateMinScore: z.number().nullable().optional(),
gateDryRun: z.boolean().optional(),
Expand Down
Loading
Loading