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 @@ -161,6 +161,25 @@ gate:
# that is a separate, deterministic rule, not this gate.
linkedIssue: advisory

# Exempt MAINTAINER-authored PRs from the missing-linked-issue penalty above, without weakening it for
# contributors and without giving up any linked-issue analysis. Off (unset) by default.
#
# With `linkedIssue: block` a maintainer's own quick fix is failed/held for the same reason a drive-by
# contributor PR is, which is rarely what the block is for — it exists to stop unlinked contributor work,
# not to make the maintainer file an issue against themselves before touching their own repo.
#
# Set this true and, for MAINTAINER-authored PRs only (repo owner, per-repo admins, and your automation
# bots — the same protected-author set that is already exempt from auto-close), the gate is clamped from
# `block` to `advisory`:
# • the "No linked issue detected" finding is STILL raised and still shown in the review comment
# • it no longer fails the gate, holds the PR, or closes it
# • contributors are completely unaffected — `linkedIssue: block` keeps its full force for them
#
# It changes nothing about a PR that DOES link an issue. Every linked-issue analysis — satisfaction
# (`linkedIssueSatisfaction`), the hard rules (`settings.linkedIssueHardRules`), and label propagation —
# only runs when an issue is cited, so all of it applies to maintainers exactly as before.
linkedIssueMaintainerExempt: false

# Duplicate-PR gate — detects duplicate/superseding PRs.
# off | advisory | block. Default: block.
duplicates: block
Expand Down
4 changes: 4 additions & 0 deletions apps/loopover-ui/public/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -10453,6 +10453,10 @@
"proceed",
null
]
},
"linkedIssueMaintainerExempt": {
"type": "boolean",
"nullable": true
}
},
"required": [
Expand Down
19 changes: 19 additions & 0 deletions config/examples/loopover.full.yml
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,25 @@ gate:
# that is a separate, deterministic rule, not this gate.
linkedIssue: advisory

# Exempt MAINTAINER-authored PRs from the missing-linked-issue penalty above, without weakening it for
# contributors and without giving up any linked-issue analysis. Off (unset) by default.
#
# With `linkedIssue: block` a maintainer's own quick fix is failed/held for the same reason a drive-by
# contributor PR is, which is rarely what the block is for — it exists to stop unlinked contributor work,
# not to make the maintainer file an issue against themselves before touching their own repo.
#
# Set this true and, for MAINTAINER-authored PRs only (repo owner, per-repo admins, and your automation
# bots — the same protected-author set that is already exempt from auto-close), the gate is clamped from
# `block` to `advisory`:
# • the "No linked issue detected" finding is STILL raised and still shown in the review comment
# • it no longer fails the gate, holds the PR, or closes it
# • contributors are completely unaffected — `linkedIssue: block` keeps its full force for them
#
# It changes nothing about a PR that DOES link an issue. Every linked-issue analysis — satisfaction
# (`linkedIssueSatisfaction`), the hard rules (`settings.linkedIssueHardRules`), and label propagation —
# only runs when an issue is cited, so all of it applies to maintainers exactly as before.
linkedIssueMaintainerExempt: false

# Duplicate-PR gate — detects duplicate/superseding PRs.
# off | advisory | block. Default: block.
duplicates: block
Expand Down
20 changes: 20 additions & 0 deletions packages/loopover-engine/src/focus-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,22 @@ export type FocusManifestGateConfig = {
checkMode: ReviewCheckMode | null;
pack: GatePolicyPack | null;
linkedIssue: GateRuleMode | null;
/** `gate.linkedIssueMaintainerExempt` (#10158): exempt MAINTAINER-authored PRs from the missing-linked-issue
* penalty, without weakening it for contributors and without giving up any linked-issue analysis.
*
* It clamps `linkedIssue` from `block` to `advisory` for those authors and nothing else. That one clamp is
* the whole feature, because the two halves are already separate concerns: the `missing_linked_issue`
* finding is PRODUCED on `requireLinkedIssue` (true whenever the mode is not `off`), and only BLOCKS when
* the resolved mode is `block`. Clamping to `advisory` therefore keeps the finding visible in the review
* comment while removing its power to fail the gate, hold the PR, or close it.
*
* Everything that inspects an issue that IS linked -- `linkedIssueSatisfaction`, `linkedIssueHardRules`,
* `linkedIssueLabelPropagation` -- is untouched by construction: each only fires when the PR cites at
* least one issue, which is precisely the case this knob says nothing about.
*
* null (unset) ⇒ byte-identical to before this existed. Deliberately scoped to the MISSING case: a
* maintainer who does link an issue gets the same scrutiny anyone else would. */
linkedIssueMaintainerExempt: boolean | null;
duplicates: GateRuleMode | null;
/** `gate.readiness.mode`/`gate.readiness.minScore` -- this engine-layer pair folds into
* `RepositorySettings.qualityGateMode`/`qualityGateMinScore` (src/signals/focus-manifest.ts), a third
Expand Down Expand Up @@ -1364,6 +1380,7 @@ const EMPTY_GATE_CONFIG: FocusManifestGateConfig = {
pack: null,
closeAuditHoldoutPct: null,
linkedIssue: null,
linkedIssueMaintainerExempt: null,
duplicates: null,
readinessMode: null,
readinessMinScore: null,
Expand Down Expand Up @@ -1837,6 +1854,7 @@ const GATE_TOP_LEVEL_KEYS = new Set<string>([
"checkMode",
"pack",
"linkedIssue",
"linkedIssueMaintainerExempt",
"duplicates",
"readiness",
"aiReview",
Expand Down Expand Up @@ -1916,6 +1934,7 @@ function parseGateConfig(value: JsonValue | undefined, warnings: string[]): Focu
checkMode: normalizeOptionalEnum(record.checkMode, "gate.checkMode", ["required", "visible", "disabled"] as const, warnings),
pack: normalizeOptionalEnum(record.pack, "gate.pack", ["gittensor", "oss-anti-slop"] as const, warnings),
linkedIssue: normalizeOptionalGateMode(record.linkedIssue, "gate.linkedIssue", warnings),
linkedIssueMaintainerExempt: normalizeOptionalBoolean(record.linkedIssueMaintainerExempt, "gate.linkedIssueMaintainerExempt", warnings),
duplicates: normalizeOptionalGateMode(record.duplicates, "gate.duplicates", warnings),
readinessMode: normalizeReadinessGateMode(readinessRecord?.mode, "gate.readiness.mode", warnings),
readinessMinScore: normalizeOptionalScore(readinessRecord?.minScore, "gate.readiness.minScore", warnings),
Expand Down Expand Up @@ -2064,6 +2083,7 @@ export function gateConfigToJson(gate: FocusManifestGateConfig): JsonValue {
if (gate.checkMode !== null) out.checkMode = gate.checkMode;
if (gate.pack !== null) out.pack = gate.pack;
if (gate.linkedIssue !== null) out.linkedIssue = gate.linkedIssue;
if (gate.linkedIssueMaintainerExempt !== null) out.linkedIssueMaintainerExempt = gate.linkedIssueMaintainerExempt;
if (gate.duplicates !== null) out.duplicates = gate.duplicates;
if (gate.readinessMode !== null || gate.readinessMinScore !== null) {
const readiness: Record<string, JsonValue> = {};
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import assert from "node:assert/strict";
import { test } from "node:test";

import { gateConfigToJson, parseFocusManifest } from "../dist/focus-manifest.js";

// #10158 adds `gate.linkedIssueMaintainerExempt`. It lives in the ENGINE, which has its own c8 coverage run
// over dist/ with `--all`, so the root vitest suite's coverage of the same source does NOT satisfy the engine
// flag -- these lines need a test HERE or codecov/patch fails on lines that are in fact exercised.
//
// The clamp itself is root-side (src/settings/linked-issue-exemption.ts, tested there). What the engine owns
// is the three things that make the knob reachable at all: it parses, it survives a round-trip, and it is a
// RECOGNISED gate key. That last one is not a formality -- the key is validated against an allowlist
// (GATE_TOP_LEVEL_KEYS), and a knob added to the type and the parser but not the allowlist parses to its
// value and then warns "unknown key ...; ignoring it", i.e. is silently inert in every real config.

test("parses gate.linkedIssueMaintainerExempt as a tri-state boolean", () => {
assert.equal(parseFocusManifest({ gate: { linkedIssueMaintainerExempt: true } }).gate.linkedIssueMaintainerExempt, true);
assert.equal(parseFocusManifest({ gate: { linkedIssueMaintainerExempt: false } }).gate.linkedIssueMaintainerExempt, false);
});

test("is null when absent, so an unset knob leaves the DB/global value alone", () => {
// null is "unset", distinct from false ("explicitly off"). resolveEffectiveSettings only overrides the
// effective setting when this is non-null, so conflating the two would make every repo that never mentions
// the key start overriding an inherited global with `false`.
assert.equal(parseFocusManifest({ gate: { linkedIssue: "block" } }).gate.linkedIssueMaintainerExempt, null);
assert.equal(parseFocusManifest({}).gate.linkedIssueMaintainerExempt, null);
});

test("REGRESSION: it is a RECOGNISED gate key — no 'unknown key' warning", () => {
// The failure this pins is silent: without the GATE_TOP_LEVEL_KEYS entry the field still parses, so every
// unit test on the parser passes, and the only symptom is a warning in the manifest guidance while the
// knob does nothing in production.
const warnings = parseFocusManifest({ gate: { linkedIssueMaintainerExempt: true } }).warnings;
assert.deepEqual(warnings.filter((w) => w.includes("linkedIssueMaintainerExempt")), []);
});

test("warns and yields null on a non-boolean, rather than coercing a truthy string", () => {
// A yml typo like `linkedIssueMaintainerExempt: yes-please` must not silently disable the linked-issue
// gate for maintainers.
const m = parseFocusManifest({ gate: { linkedIssueMaintainerExempt: "yes-please" } });
assert.equal(m.gate.linkedIssueMaintainerExempt, null);
assert.ok(m.warnings.some((w) => w.includes("gate.linkedIssueMaintainerExempt")));
});

test("round-trips through gateConfigToJson", () => {
const parsed = parseFocusManifest({ gate: { linkedIssue: "block", linkedIssueMaintainerExempt: true } });
const reparsed = parseFocusManifest({ gate: gateConfigToJson(parsed.gate) });
assert.equal(reparsed.gate.linkedIssueMaintainerExempt, true);
assert.equal(reparsed.gate.linkedIssue, "block");
});
1 change: 1 addition & 0 deletions src/openapi/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -729,6 +729,7 @@ export const RepositorySettingsSchema = z
autoProjectMilestoneMatchBackend: z.enum(["github", "linear"]).optional(),
gatePack: z.enum(["gittensor", "oss-anti-slop"]),
linkedIssueGateMode: z.enum(["off", "advisory", "block"]),
linkedIssueMaintainerExempt: z.boolean().nullable().optional(),
duplicatePrGateMode: z.enum(["off", "advisory", "block"]),
qualityGateMode: z.enum(["off", "advisory", "block"]),
qualityGateMinScore: z.number().nullable().optional(),
Expand Down
54 changes: 50 additions & 4 deletions src/queue/processors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ import {
upsertRepositoryFromGitHub,
getLatestAdvisoryForPullRequest,
} from "../db/repositories";
import { withLinkedIssueMaintainerExemption, type LinkedIssueExemptionAuthor } from "../settings/linked-issue-exemption";
import { renameRepositoryIdentity } from "../db/repo-identity-rename";
import {
effectiveIssueCapForAccountAge,
Expand Down Expand Up @@ -1649,7 +1650,17 @@ export async function sweepRepoRegate(
});
const gate = evaluateGateCheck(
advisory,
gateCheckPolicy(settings, null, undefined, pr.slopRisk ?? null, undefined, undefined, sweepCloseConfidenceOverride),
// #10158: the sweep must apply the SAME maintainer exemption the webhook path does, or the same PR
// gets one verdict live and a different one when the sweep re-gates it.
gateCheckPolicy(
withLinkedIssueMaintainerExemption(settings, await resolveLinkedIssueExemptionAuthor(env, sweepInstallationId, repoFullName, pr.authorLogin)),
null,
undefined,
pr.slopRisk ?? null,
undefined,
undefined,
sweepCloseConfidenceOverride,
),
);
verdicts[String(pr.number)] = gate.conclusion;
if (gate.conclusion === "failure" || gate.conclusion === "action_required")
Expand Down Expand Up @@ -5275,6 +5286,36 @@ async function maybeForceFreshRebase(
// re-review a given PR at most once per this window. The re-review always re-fetches the LIVE CI, so the window
// only bounds FREQUENCY, never correctness — a later out-of-window completion + the hourly sweep + the merge-time
// re-check still catch the settled state.
/**
* The author facts the linked-issue maintainer exemption reads (#10158), resolved identically at every gate
* evaluation so the same PR cannot get different verdicts from the webhook path and the re-gate sweep.
*
* Exactly the PROTECTED AUTHOR set used elsewhere in this file (`protectedAuthor`) -- owner, per-repo admin,
* or a protected automation author -- rather than a second definition of "maintainer". `isPerTenantAdmin`
* short-circuits to an env-allowlist lookup unless per-repo admin mode is on, so this is cheap on the common
* path; it fails CLOSED (not a maintainer) on any error, which degrades to today's behaviour rather than
* silently widening the exemption.
*/
async function resolveLinkedIssueExemptionAuthor(
env: Env,
installationId: number | null,
repoFullName: string,
// Nullable because the callers' PR records differ on this: some carry a guaranteed login, others a
// possibly-absent one. Normalised HERE rather than coerced at four call sites, so an unknown author can
// only ever resolve to "not a maintainer" -- coercing `null` to `""` at a call site would work today and
// silently become an owner match the day a repo is owned by the empty string's uppercase twin.
authorLogin: string | null | undefined,
): Promise<LinkedIssueExemptionAuthor> {
const login = (authorLogin ?? "").trim();
if (login.length === 0) return { authorIsOwner: false, authorIsAdmin: false, authorIsAutomationBot: false };
const repoOwner = repoOwnerLoginFromFullName(repoFullName);
return {
authorIsOwner: repoOwner.length > 0 && login.toLowerCase() === repoOwner.toLowerCase(),
authorIsAdmin: await isPerTenantAdmin(env, installationId, repoFullName, login).catch(() => false),
authorIsAutomationBot: isProtectedAutomationAuthor(login, env),
};
}

const CI_COALESCE_WINDOW_SECONDS = 60;

/**
Expand Down Expand Up @@ -12214,7 +12255,12 @@ async function maybePublishPrPublicSurface(
guardrailMatches: guardrailPathMatches(guardrailChangedPaths, hardGuardrailGlobs),
};
const gatePolicy = gateCheckPolicy(
settings,
// #10158: the maintainer linked-issue exemption is applied to `settings` here rather than passed as an
// argument, because BOTH halves that must agree read this object -- `requireLinkedIssue` (does the
// finding get produced) and `linkedIssueGateMode` (does it block). Clamping once upstream makes them
// unable to disagree; gateCheckPolicy already takes seven positional arguments and does not need an
// eighth. Same call in the sweep and re-gate paths, so a PR cannot be judged differently by each.
withLinkedIssueMaintainerExemption(settings, await resolveLinkedIssueExemptionAuthor(env, installationId, repoFullName, pr.authorLogin)),
readiness.total,
confirmedContributor,
slopRisk,
Expand Down Expand Up @@ -13853,7 +13899,7 @@ async function maybeProcessResolveCommand(env: Env, deliveryId: string, payload:
if (!findingRef.ok) { await recordAuditEvent(env, { eventType: "github_app.finding_resolved_skipped", actor: req.actor, targetKey, outcome: "completed", detail: findingRef.reason, metadata: { deliveryId, repoFullName: req.repoFullName, reason: findingRef.reason } }); await recordGithubProductUsage(env, "finding_resolved_skipped", { actor: req.actor, repoFullName: req.repoFullName, targetKey, outcome: "skipped", metadata: { reason: findingRef.reason } }); return true; }
const { advisory } = await buildAuthorizedPrActionAdvisory(env, req.repoFullName, pr, settings);
await appendPublishedAiReviewFindingsForResolve(env, req.repoFullName, pr, settings.aiReviewMode, advisory);
const gate = evaluateGateCheck(advisory, gateCheckPolicy(settings, null, undefined, pr.slopRisk ?? null, undefined, undefined, await resolveAutomaticCloseConfidence(env, req.repoFullName, await getAiReviewCloseConfidenceOverride(env, req.repoFullName))));
const gate = evaluateGateCheck(advisory, gateCheckPolicy(withLinkedIssueMaintainerExemption(settings, await resolveLinkedIssueExemptionAuthor(env, req.installationId ?? null, req.repoFullName, pr.authorLogin)), null, undefined, pr.slopRisk ?? null, undefined, undefined, await resolveAutomaticCloseConfidence(env, req.repoFullName, await getAiReviewCloseConfidenceOverride(env, req.repoFullName))));
const selection = selectWarningsForResolve(gate.warnings, findingRef);
if (selection.reason === "finding_not_found") { await recordAuditEvent(env, { eventType: "github_app.finding_resolved_skipped", actor: req.actor, targetKey, outcome: "completed", detail: selection.reason, metadata: { deliveryId, repoFullName: req.repoFullName, reason: selection.reason } }); await recordGithubProductUsage(env, "finding_resolved_skipped", { actor: req.actor, repoFullName: req.repoFullName, targetKey, outcome: "skipped", metadata: { reason: selection.reason } }); return true; }
const mode = resolveAgentActionMode({ globalPaused: isGlobalAgentPause(env) || (await isGlobalAgentFrozen(env)), instanceMode: forcedSelfhostMode(env), agentPaused: settings.agentPaused, agentDryRun: settings.agentDryRun });
Expand Down Expand Up @@ -14100,7 +14146,7 @@ async function maybeProcessExplainCommand(env: Env, deliveryId: string, payload:
}
const { advisory } = await buildAuthorizedPrActionAdvisory(env, req.repoFullName, pr, settings);
await appendPublishedAiReviewFindingsForResolve(env, req.repoFullName, pr, settings.aiReviewMode, advisory);
const gate = evaluateGateCheck(advisory, gateCheckPolicy(settings, null, undefined, pr.slopRisk ?? null, undefined, undefined, await resolveAutomaticCloseConfidence(env, req.repoFullName, await getAiReviewCloseConfidenceOverride(env, req.repoFullName))));
const gate = evaluateGateCheck(advisory, gateCheckPolicy(withLinkedIssueMaintainerExemption(settings, await resolveLinkedIssueExemptionAuthor(env, req.installationId ?? null, req.repoFullName, pr.authorLogin)), null, undefined, pr.slopRisk ?? null, undefined, undefined, await resolveAutomaticCloseConfidence(env, req.repoFullName, await getAiReviewCloseConfidenceOverride(env, req.repoFullName))));
const selection = selectWarningsForResolve(gate.warnings, findingRef);
if (selection.reason === "finding_not_found") {
const notFound = sanitizePublicComment([AGENT_COMMAND_COMMENT_MARKER, "", "> [!NOTE]", `> **No review finding \`${findingRef.findingCode}\` on this PR**`, "> That id is not among this PR's current review findings — re-run `@loopover explain <finding-id>` with an id from the review summary.", "", "---", loopoverFooter(env)].join("\n"));
Expand Down
Loading
Loading