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
975 changes: 498 additions & 477 deletions apps/gittensory-ui/public/openapi.json

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions src/openapi/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -653,6 +653,12 @@ export const RepositorySettingsSchema = z
closeDelaySeconds: z.number().int().min(0).max(300),
})
.optional(),
unlinkedIssueGuardrail: z
.object({
mode: z.enum(["hold", "off"]),
minConfidence: z.number().min(0).max(1),
})
.optional(),
gittensorLabel: z.string(),
blacklistLabel: z.string().nullable(),
createMissingLabel: z.boolean(),
Expand Down
97 changes: 72 additions & 25 deletions src/queue/processors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -464,7 +464,10 @@ import {
import {
loadLinkedIssueHardRules,
resolveLinkedIssueHardRule,
resolveLinkedIssueHasOpenReference,
} from "../review/linked-issue-hard-rules";
import { DEFAULT_UNLINKED_ISSUE_GUARDRAIL } from "../review/unlinked-issue-guardrail-config";
import { resolveUnlinkedIssueMatchDisposition } from "../review/unlinked-issue-guardrail";
import { isOpsEnabled, runOpsAlerts } from "../review/ops-wire";
import { isSelfTuneEnabled, runSelfTune } from "../review/selftune-wire";
import {
Expand Down Expand Up @@ -1646,20 +1649,22 @@ async function sweepRepoRegate(
const others = openPullRequests.filter(
(other) => other.number !== pr.number,
);
// Thread linked-issue authors so the re-gate sweep applies the self-authored-linked-issue block too — without
// this a self-authored PR re-gated by the sweep escapes a block the main webhook path applies. (#self-authored-parity)
const linkedIssueAuthorLogins = await resolveLinkedIssueAuthorLogins(
// Thread linked-issue authors + the open-reference check so the re-gate sweep applies the same
// self-authored-linked-issue block AND stale-issue-link countermeasure the main webhook path applies —
// without this a self-authored or stale-link-gaming PR re-gated by the sweep escapes both. (#self-authored-parity, #unlinked-issue-guardrail-followup)
const { linkedIssueAuthorLogins, confirmedNoOpenLinkedIssue } = await resolveLinkedIssueAdvisoryContext(
env,
sweepInstallationId,
repoFullName,
pr.linkedIssues,
settings.selfAuthoredLinkedIssueGateMode === "block",
settings,
);
const advisory = buildPullRequestAdvisory(repo, pr, {
otherOpenPullRequests: others,
requireLinkedIssue,
duplicateWinnerEnabled,
linkedIssueAuthorLogins,
confirmedNoOpenLinkedIssue,
});
const gate = evaluateGateCheck(
advisory,
Expand Down Expand Up @@ -2483,6 +2488,32 @@ async function runAgentMaintenancePlanAndExecute(
installationId,
});

// Unlinked-issue guardrail (#unlinked-issue-guardrail, credibility-gate-farming defense): when this PR
// links NO issue and the repo opted in (settings.unlinkedIssueGuardrail.mode === "hold"), check whether the
// diff appears to directly, unambiguously solve an EXISTING open issue that was never linked -- a possible
// sign of a contributor slicing an issue into unlinked PRs to dodge scope scrutiny while still farming
// merge-ratio credibility. Config-gated AND linked-issue-count-gated at the CALL SITE (not just inside the
// resolver) so the diff-building work below is skipped entirely for the default-off / already-linked cases
// -- byte-identical extra cost, mirroring migrationCollisionHold's own gating above. A FIRST confirmed match
// only ever HOLDS the PR for manual review (folded into heldForManualReview); a CONFIRMED REPEAT by the
// same contributor (#unlinked-issue-guardrail-followup, tracked via audit_events) escalates to a CLOSE.
const unlinkedIssueGuardrailConfig = settings.unlinkedIssueGuardrail ?? DEFAULT_UNLINKED_ISSUE_GUARDRAIL;
const unlinkedIssueMatchDisposition =
unlinkedIssueGuardrailConfig.mode === "hold" && pr.linkedIssues.length === 0
? await resolveUnlinkedIssueMatchDisposition(env, {
repoFullName,
config: unlinkedIssueGuardrailConfig,
linkedIssueCount: pr.linkedIssues.length,
prTitle: pr.title,
prBody: pr.body,
changedPaths,
diff: buildAiReviewDiff(changedFiles),
prAuthorLogin: pr.authorLogin,
})
: undefined;
const unlinkedIssueMatchHold = unlinkedIssueMatchDisposition?.kind === "hold" ? unlinkedIssueMatchDisposition : undefined;
const unlinkedIssueMatchClose = unlinkedIssueMatchDisposition?.kind === "close" ? unlinkedIssueMatchDisposition : undefined;

// Contributor blacklist (#1425): resolve whether the PR author is on the repo's blacklist (the shared/global
// list unions in once its table lands). A match short-circuits the planner to a deterministic label + close
// ahead of merit/CI/AI; only the configured label (default "slop") reaches public actions.
Expand Down Expand Up @@ -2655,6 +2686,8 @@ async function runAgentMaintenancePlanAndExecute(
closeDelaySeconds: linkedIssueRulesConfig.closeDelaySeconds,
},
...(migrationCollisionHold !== undefined ? { migrationCollisionHold } : {}),
...(unlinkedIssueMatchHold !== undefined ? { unlinkedIssueMatchHold } : {}),
...(unlinkedIssueMatchClose !== undefined ? { unlinkedIssueMatchClose } : {}),
pr: {
mergeableState: liveMergeState ?? pr.mergeableState,
reviewDecision: liveReviewDecision ?? pr.reviewDecision,
Expand Down Expand Up @@ -2988,16 +3021,10 @@ async function reReviewStoredPullRequest(
))
)
return;
const [cachedOtherOpenPullRequests, linkedIssueAuthorLogins] =
const [cachedOtherOpenPullRequests, { linkedIssueAuthorLogins, confirmedNoOpenLinkedIssue }] =
await Promise.all([
listOtherOpenPullRequests(env, repoFullName, prNumber),
resolveLinkedIssueAuthorLogins(
env,
installationId,
repoFullName,
pr.linkedIssues,
settings.selfAuthoredLinkedIssueGateMode === "block",
),
resolveLinkedIssueAdvisoryContext(env, installationId, repoFullName, pr.linkedIssues, settings),
]);
// #dup-winner / audit #15: drop any cached-open duplicate sibling already closed on GitHub before the advisory
// (and the disposition below) elect the cluster winner, so the real lowest-OPEN PR is never demoted+auto-closed.
Expand All @@ -3012,6 +3039,7 @@ async function reReviewStoredPullRequest(
otherOpenPullRequests,
requireLinkedIssue: shouldCollectLinkedIssueEvidence(settings),
duplicateWinnerEnabled: env.GITTENSORY_DUPLICATE_WINNER === "true",
confirmedNoOpenLinkedIssue,
linkedIssueAuthorLogins,
});
await persistAdvisory(env, advisory);
Expand Down Expand Up @@ -5384,19 +5412,14 @@ async function processGitHubWebhook(
});
return;
}
// Resolve settings first so the self-authored live-fetch fallback only fires when its gate is in block mode.
// Resolve settings first so the self-authored + open-reference live-fetch fallbacks only fire when their
// respective gates are in block mode.
const settings = await resolveRepositorySettings(env, repoFullName);
const [repo, cachedOtherOpenPullRequests, linkedIssueAuthorLogins] =
const [repo, cachedOtherOpenPullRequests, { linkedIssueAuthorLogins, confirmedNoOpenLinkedIssue }] =
await Promise.all([
getRepository(env, repoFullName),
listOtherOpenPullRequests(env, repoFullName, pr.number),
resolveLinkedIssueAuthorLogins(
env,
installationId,
repoFullName,
pr.linkedIssues,
settings.selfAuthoredLinkedIssueGateMode === "block",
),
resolveLinkedIssueAdvisoryContext(env, installationId, repoFullName, pr.linkedIssues, settings),
]);
// #dup-winner / audit #15: drop any cached-open duplicate sibling already closed on GitHub before the
// advisory (and the disposition) elect the cluster winner, so the real lowest-OPEN PR is never auto-closed.
Expand All @@ -5411,6 +5434,7 @@ async function processGitHubWebhook(
otherOpenPullRequests,
requireLinkedIssue: shouldCollectLinkedIssueEvidence(settings),
duplicateWinnerEnabled: env.GITTENSORY_DUPLICATE_WINNER === "true",
confirmedNoOpenLinkedIssue,
linkedIssueAuthorLogins,
});
await persistAdvisory(env, advisory);
Expand Down Expand Up @@ -5961,6 +5985,27 @@ export async function resolveLinkedIssueAuthorLogins(
);
}

// Shared per-call-site resolver for buildPullRequestAdvisory's linked-issue-derived context
// (#unlinked-issue-guardrail-followup). Every gate-evaluating call site (the main webhook path, the cron
// sweep, the heavy re-review pass, and authorized PR actions) already threads `linkedIssueAuthorLogins` the
// same way; bundling the new open-reference check into the SAME resolver keeps all of them in parity rather
// than risking only some remembering to add it. The live open-reference fetch is skipped entirely (resolves
// `true` with no network call) unless `linkedIssueGateMode` is actually "block" -- the only mode where
// whether a citation is open can change the gate's outcome.
export async function resolveLinkedIssueAdvisoryContext(
env: Env,
installationId: number | null | undefined,
repoFullName: string,
linkedIssues: number[],
settings: Pick<RepositorySettings, "selfAuthoredLinkedIssueGateMode" | "linkedIssueGateMode">,
): Promise<{ linkedIssueAuthorLogins: (string | null)[]; confirmedNoOpenLinkedIssue: boolean }> {
const [linkedIssueAuthorLogins, hasOpenReference] = await Promise.all([
resolveLinkedIssueAuthorLogins(env, installationId, repoFullName, linkedIssues, settings.selfAuthoredLinkedIssueGateMode === "block"),
settings.linkedIssueGateMode === "block" ? resolveLinkedIssueHasOpenReference({ env, repoFullName, linkedIssues, installationId }) : Promise.resolve(true),
]);
return { linkedIssueAuthorLogins, confirmedNoOpenLinkedIssue: !hasOpenReference };
}

export function shouldCollectSlopEvidence(
settings: Pick<RepositorySettings, "slopGateMode" | "mergeReadinessGateMode">,
): boolean {
Expand Down Expand Up @@ -10168,19 +10213,21 @@ export async function buildAuthorizedPrActionAdvisory(
getRepository(env, repoFullName),
listOtherOpenPullRequests(env, repoFullName, pr.number),
]);
// Mirror the main webhook path: thread linked-issue authors so an authorized PR action (gate-override / panel
// retrigger) honors the self-authored-linked-issue block too. installationId comes from the repo record. (#self-authored-parity)
const linkedIssueAuthorLogins = await resolveLinkedIssueAuthorLogins(
// Mirror the main webhook path: thread linked-issue authors + the open-reference check so an authorized PR
// action (gate-override / panel retrigger) honors the same self-authored-linked-issue block AND stale-
// issue-link countermeasure. installationId comes from the repo record. (#self-authored-parity, #unlinked-issue-guardrail-followup)
const { linkedIssueAuthorLogins, confirmedNoOpenLinkedIssue } = await resolveLinkedIssueAdvisoryContext(
env,
repo?.installationId ?? null,
repoFullName,
pr.linkedIssues,
settings.selfAuthoredLinkedIssueGateMode === "block",
settings,
);
const advisory = buildPullRequestAdvisory(repo, pr, {
otherOpenPullRequests,
requireLinkedIssue: shouldCollectLinkedIssueEvidence(settings),
duplicateWinnerEnabled: env.GITTENSORY_DUPLICATE_WINNER === "true",
confirmedNoOpenLinkedIssue,
linkedIssueAuthorLogins,
});
return { repo, advisory };
Expand Down
49 changes: 48 additions & 1 deletion src/review/linked-issue-hard-rules.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { fetchLinkedIssueFacts } from "../github/backfill";
import { fetchLinkedIssueFacts, type LinkedIssueFactsFetch } from "../github/backfill";
import { githubRateLimitAdmissionKeyForToken } from "../github/client";
import { createInstallationToken } from "../github/app";
import { extractLinkedIssueNumbersWithOverflow } from "../db/repositories";
import { resolveRepositorySettings } from "../settings/repository-settings";
import { DEFAULT_LINKED_ISSUE_HARD_RULES } from "./linked-issue-hard-rules-config";
Expand Down Expand Up @@ -184,3 +185,49 @@ export async function resolveLinkedIssueHardRule(args: {
}
return evaluateLinkedIssueHardRules({ issues: issueFacts, config: args.config, repoOwner: args.repoOwner, prAuthorLogin: args.prAuthorLogin });
}

// ── Stale/fabricated-link countermeasure for the "must link an issue" HARD gate (#unlinked-issue-guardrail-
// followup) ──────────────────────────────────────────────────────────────────────────────────────────────
//
// `pr.linkedIssues` (extractLinkedIssueNumbersWithOverflow) is a pure body-text regex match — it never checks
// whether the cited issue is actually OPEN. So a repo running `linkedIssueGateMode: "block"` (requires a
// linked issue to merge) can be satisfied by a contributor citing an already-CLOSED or fabricated issue
// number, which defeats the whole point of requiring a link. This pair of functions gives the gate a
// verified, fail-open "is at least one citation a real, currently open issue" signal to use INSTEAD of bare
// presence, without changing what `pr.linkedIssues` itself means anywhere else it's used (duplicate-winner
// overlap, label propagation, scoring, etc. all keep reading raw presence).

/**
* PURE evaluator. `true` means "treat the presence check as satisfied" — either a linked issue is CONFIRMED
* open, or at least one fetch was ambiguous (`fetch_error`) and we can't rule out a real open issue behind
* it. `false` — the only case this whole mechanism exists to catch — means EVERY fetched result conclusively
* resolved to NOT an open issue (found-but-closed, or a confirmed 404), with zero ambiguity. An empty input
* (nothing was fetched, e.g. the caller didn't need to check) fails open to `true` — the caller is
* responsible for handling "no linked issues at all" separately (that's the existing bare-presence check).
*/
export function hasVerifiableOpenLinkedIssueReference(fetchResults: LinkedIssueFactsFetch[]): boolean {
if (fetchResults.length === 0) return true;
if (fetchResults.some((result) => result.status === "found" && result.facts.state === "open")) return true;
return fetchResults.some((result) => result.status === "fetch_error");
}

/**
* Orchestrate the live per-issue fetch for {@link hasVerifiableOpenLinkedIssueReference}. Mints its own
* installation token (falling back to the public token, exactly like fetchLinkedIssueFacts's own
* hasProvenAccess discipline degrades a public-token 404 to `fetch_error` rather than a confirmed miss) so
* callers only need an `installationId`, mirroring `resolveLinkedIssueAuthorLogins`'s lazy-token pattern.
* Fail-safe: a token-mint failure still proceeds on the public token rather than skipping the check.
*/
export async function resolveLinkedIssueHasOpenReference(args: {
env: Env;
repoFullName: string;
linkedIssues: number[];
installationId?: number | null | undefined;
}): Promise<boolean> {
if (args.linkedIssues.length === 0) return true;
const ciToken = args.installationId ? await createInstallationToken(args.env, args.installationId).catch(() => undefined) : undefined;
const token = ciToken ?? args.env.GITHUB_PUBLIC_TOKEN;
const admissionKey = githubRateLimitAdmissionKeyForToken(args.env, token, args.installationId);
const fetchResults = await Promise.all(args.linkedIssues.map((issueNumber) => fetchLinkedIssueFacts(args.env, args.repoFullName, issueNumber, token, admissionKey)));
return hasVerifiableOpenLinkedIssueReference(fetchResults);
}
47 changes: 47 additions & 0 deletions src/review/unlinked-issue-guardrail-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import type { UnlinkedIssueGuardrailConfig, UnlinkedIssueGuardrailMode } from "../types";

const VALID_UNLINKED_ISSUE_GUARDRAIL_MODES: readonly UnlinkedIssueGuardrailMode[] = ["hold", "off"];
const DEFAULT_MIN_CONFIDENCE = 0.85;

export const DEFAULT_UNLINKED_ISSUE_GUARDRAIL: UnlinkedIssueGuardrailConfig = {
mode: "off",
minConfidence: DEFAULT_MIN_CONFIDENCE,
};

export function isUnlinkedIssueGuardrailMode(value: unknown): value is UnlinkedIssueGuardrailMode {
return typeof value === "string" && (VALID_UNLINKED_ISSUE_GUARDRAIL_MODES as readonly string[]).includes(value);
}

function normalizeMode(value: unknown, warnings: string[]): UnlinkedIssueGuardrailMode {
if (value === undefined) return DEFAULT_UNLINKED_ISSUE_GUARDRAIL.mode;
if (isUnlinkedIssueGuardrailMode(value)) return value;
warnings.push(`settings.unlinkedIssueGuardrail.mode must be one of hold, off; using the default "${DEFAULT_UNLINKED_ISSUE_GUARDRAIL.mode}".`);
return DEFAULT_UNLINKED_ISSUE_GUARDRAIL.mode;
}

function normalizeMinConfidence(value: unknown, warnings: string[]): number {
if (value === undefined) return DEFAULT_UNLINKED_ISSUE_GUARDRAIL.minConfidence;
if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 1) {
warnings.push(`settings.unlinkedIssueGuardrail.minConfidence must be a number between 0 and 1; using the default "${DEFAULT_MIN_CONFIDENCE}".`);
return DEFAULT_MIN_CONFIDENCE;
}
return value;
}

/**
* Normalize a raw `.gittensory.yml settings.unlinkedIssueGuardrail` value into a typed config,
* fail-safe: any malformed field falls back to its own default and pushes a warning rather than
* rejecting the whole block. Mirrors `normalizeLinkedIssueHardRulesConfig`'s per-field discipline.
*/
export function normalizeUnlinkedIssueGuardrailConfig(input: unknown, warnings: string[]): UnlinkedIssueGuardrailConfig {
if (input === undefined) return { ...DEFAULT_UNLINKED_ISSUE_GUARDRAIL };
if (typeof input !== "object" || input === null || Array.isArray(input)) {
warnings.push("settings.unlinkedIssueGuardrail must be an object; using the default off policy.");
return { ...DEFAULT_UNLINKED_ISSUE_GUARDRAIL };
}
const record = input as Record<string, unknown>;
return {
mode: normalizeMode(record.mode, warnings),
minConfidence: normalizeMinConfidence(record.minConfidence, warnings),
};
}
Loading
Loading