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
16 changes: 16 additions & 0 deletions .gittensory.yml.example
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,22 @@ gate:
# or dashboard toggle; this can only be set here.
lockfileIntegrity: off

# CLA / license-compatibility gate (#2564). Confirms contributor license-agreement consent before a PR
# can auto-merge — the gittensory analog of a "CLA assistant" bot. off | advisory | block. Default: off.
# advisory — surfaces a cla_consent_missing finding but never blocks.
# block — also hard-blocks (one-shot close for a contributor) when neither detection method
# below confirms consent.
# Config-as-code only — no DB column or dashboard toggle; this can only be set here.
claMode: off
cla:
# A phrase gittensory looks for in the PR description (case-insensitive substring match), mirroring
# review.pre_merge_checks' descriptionContains. String or null. Default: null (not configured).
consentPhrase: "I have read and agree to the CLA"
# Name of a separate CLA-bot check-run this repo also runs (e.g. a CLA Assistant GitHub Action). A
# success/neutral conclusion for a check-run with this exact name also satisfies consent. Either
# method configured is enough; both may be set. String or null. Default: null (not configured).
checkRunName: null

# Composite merge-readiness gate (no min score).
# off | advisory | block. Default: off.
mergeReadiness: off
Expand Down
16 changes: 16 additions & 0 deletions apps/gittensory-ui/public/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -8931,6 +8931,22 @@
"contributorCapCancelCi": {
"type": "boolean",
"nullable": true
},
"claGateMode": {
"type": "string",
"enum": [
"off",
"advisory",
"block"
]
},
"claConsentPhrase": {
"type": "string",
"nullable": true
},
"claCheckRunName": {
"type": "string",
"nullable": true
}
},
"required": [
Expand Down
41 changes: 41 additions & 0 deletions src/github/backfill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2350,6 +2350,47 @@ export async function fetchRequiredStatusContexts(
return names;
}

/**
* Best-effort fetch of ONE named check-run's conclusion on a head SHA (#2564, the CLA-bot check-run detection
* mode of `gate.claMode`). Returns the conclusion string (lowercased; `"neutral"`/`"success"`/… or `""` when
* concluded with no conclusion field, which should not normally happen) when a check-run with that exact name
* (case-insensitive) is found; `null` when the head SHA has no such check-run (a resolved "not found," distinct
* from "could not resolve"); `undefined` when the check-runs themselves could not be read at all (network/auth
* error, or no headSha) — the caller must treat `undefined` as "not evaluated," never as "missing," so a
* transient fetch failure can never manufacture a false CLA-missing blocker. Scans only the FIRST page (100
* check-runs) — a CLA bot posts exactly one check-run, so a repo with >100 check-runs on a single commit (very
* unusual) risks missing it only in that pathological case, and still degrades to `undefined` (not evaluated)
* rather than a false negative.
*/
export async function fetchNamedCheckRunConclusion(
env: Env,
repoFullName: string,
headSha: string | null | undefined,
checkRunName: string,
token: string | undefined,
admissionKey?: GitHubRateLimitAdmissionKey,
): Promise<string | null | undefined> {
if (!headSha) return undefined;
const result = await githubJsonWithHeaders<{ check_runs?: GitHubCheckRunPayload[] }>(
env,
repoFullName,
`/commits/${headSha}/check-runs?per_page=100&page=1`,
token,
githubRateLimitOptions(admissionKey),
).catch(() => undefined);
if (!result) return undefined; // fetch failed → not evaluated, never a false "missing".
const nameLc = checkRunName.trim().toLowerCase();
const run = (result.data.check_runs ?? []).find((candidate) => candidate.name.trim().toLowerCase() === nameLc);
if (!run) return null; // resolved: no check-run with this name exists on this commit.
// A matching check-run that has NOT finished yet (status !== "completed") has conclusion: null by GitHub's
// own contract — that is "not yet resolved," not "resolved with an empty conclusion." Returning `undefined`
// here (rather than coercing to "") keeps this indistinguishable from a fetch failure to the caller, so
// `claMode: block` HOLDS instead of hard-failing a PR before the named check has actually finished running
// (#2564 gate-review finding).
if (run.status !== "completed") return undefined;
return (run.conclusion ?? "").toLowerCase();
}

// Minimal structural shape the CI reducer needs from a check-run — a superset of the REST GitHubCheckRunPayload
// (so REST payloads assign directly) AND buildable from the GraphQL CheckRun node (which has no `id`).
type LiveCiCheckRun = {
Expand Down
3 changes: 3 additions & 0 deletions src/openapi/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -598,6 +598,9 @@ export const RepositorySettingsSchema = z
slopGateMode: z.enum(["off", "advisory", "block"]),
sizeGateMode: z.enum(["off", "advisory", "block"]).optional(),
lockfileIntegrityGateMode: z.enum(["off", "advisory", "block"]).optional(),
claGateMode: z.enum(["off", "advisory", "block"]).optional(),
claConsentPhrase: z.string().nullable().optional(),
claCheckRunName: z.string().nullable().optional(),
gateDryRun: z.boolean().optional(),
premergeContentRecheck: z.boolean().optional(),
requireFreshRebaseWindowMinutes: z.number().int().positive().nullable().optional(),
Expand Down
31 changes: 31 additions & 0 deletions src/queue/processors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ import {
fetchLivePullRequestReviewDecision,
fetchLiveReviewThreadBlockers,
fetchLivePullRequestState,
fetchNamedCheckRunConclusion,
fetchOpenPullRequestNumbersForCommit,
fetchRequiredStatusContexts,
invalidatePrStateCache,
Expand Down Expand Up @@ -352,6 +353,7 @@ import {
maybePostInlineComments,
shouldRequestInlineFindings,
} from "../review/inline-comments";
import { evaluateClaCheck } from "../review/cla-check";
import { evaluatePreMergeChecks } from "../review/pre-merge-checks";
import { secretLeakFinding } from "../review/safety";
import { lockfileTamperRiskFinding } from "../review/lockfile-tamper";
Expand Down Expand Up @@ -4980,6 +4982,10 @@ export function gateCheckPolicy(
changedFileCount: sizeContext?.changedFileCount ?? null,
changedLineCount: sizeContext?.changedLineCount ?? null,
guardrailHit: sizeContext?.guardrailHit ?? false,
// CLA / license-compatibility gate (#2564): the MODE comes from config; the `cla_consent_missing` finding
// itself (or its absence) is pushed into the advisory upstream by evaluateClaCheck, so this only decides
// whether isConfiguredGateBlocker escalates it to a hard blocker.
claGateMode: settings.claGateMode,
// #gate-dryrun: render the would-be merge/close/manual verdict (advisory promoted to block) without enforcing.
dryRun: settings.gateDryRun ?? false,
};
Expand Down Expand Up @@ -6558,6 +6564,31 @@ async function maybePublishPrPublicSurface(
}),
);
}
// CLA / license-compatibility gate (#2564, opt-in via .gittensory.yml gate.claMode). DETERMINISTIC — a PR-body
// consent-phrase match (mirrors pre_merge_checks' descriptionContains exactly) and/or a named CLA-bot
// check-run's conclusion; consent is satisfied when EITHER configured method holds. No AI judgment, so this
// can never cause an AI false-close. Off by default (claGateMode undefined/"off"), so a repo that has not
// opted in makes no extra GitHub call and pushes no finding — byte-identical to today.
if (settings.claGateMode && settings.claGateMode !== "off") {
const claCheckRunName = settings.claCheckRunName ?? null;
// Only resolve a live check-run when the maintainer actually configured that detection method — a
// phrase-only config must never spend an extra GitHub call.
const claCheckRunConclusion = claCheckRunName
? await fetchNamedCheckRunConclusion(
env,
repoFullName,
advisory.headSha,
claCheckRunName,
await resolveReviewEnrichmentGithubToken(env, repoFullName),
)
: undefined;
advisory.findings.push(
...evaluateClaCheck(
{ consentPhrase: settings.claConsentPhrase ?? null, checkRunName: claCheckRunName },
{ body: pr.body, checkRunConclusion: claCheckRunConclusion },
),
);
}

// AI maintainer review (opt-in via aiReviewMode). Mutates `advisory` with a consensus defect (if any)
// BEFORE the gate evaluates, and returns advisory notes for the panel. Inside the try so any AI
Expand Down
83 changes: 83 additions & 0 deletions src/review/cla-check.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import type { AdvisoryFinding } from "../types";

/** Finding code raised when `gate.claMode` is opted in (advisory/block) and neither configured detection method
* (the PR body consent phrase, or the named CLA-bot check-run) confirms consent. ALWAYS severity "warning" at
* generation time — mirrors `manifest_missing_tests`/`manifest_linked_issue_required` (focus-manifest.ts): a
* single finding code whose escalation to a hard blocker is decided entirely by the configured gate MODE
* (isConfiguredGateBlocker, src/rules/advisory.ts), not by this evaluator. */
export const CLA_CONSENT_MISSING_CODE = "cla_consent_missing";
/** Finding code emitted when check-run detection is the ONLY configured method and its conclusion could not be
* resolved (a transient fetch failure, not a resolved "no such check-run"). Mirrors `pre_merge_check_unresolved`
* (review/pre-merge-checks.ts): isEvaluationBlocker (advisory.ts) treats this as a NEUTRAL gate (HELD,
* re-evaluates automatically) — never silently skipping a hard requirement and never hard-closing the
* contributor on a transient resolution miss. */
export const CLA_CHECK_UNRESOLVED_CODE = "cla_check_unresolved";

export type ClaCheckConfig = {
/** Public-safe-filtered consent phrase a maintainer requires somewhere in the PR body (case-insensitive
* substring match), e.g. "I have read and agree to the CLA". `null` ⇒ phrase-match detection is not configured. */
consentPhrase: string | null;
/** Name of a separate CLA-bot check-run this repo also runs (e.g. "CLA Assistant Lite"). When set, a
* `success`/`neutral` conclusion for a check-run with this exact name (case-insensitive) also satisfies
* consent. `null` ⇒ check-run detection is not configured. */
checkRunName: string | null;
};

/**
* Evaluate `.gittensory.yml gate.claMode` + `gate.cla` (consentPhrase / checkRunName) against a PR —
* DETERMINISTICALLY, mirroring the pre-merge-checks title/description phrase-match pattern (review/pre-merge-checks.ts)
* exactly: a case-insensitive substring match against already-resolved PR data, no AI judgment. Consent is
* satisfied when EITHER configured method holds (an "either" contract, not "all", because a repo may only be able
* to detect ONE method for a given PR — e.g. no check-run data was resolved): the PR body contains
* `consentPhrase`, OR a check-run named `checkRunName` concluded `success`/`neutral`. When NEITHER method is
* configured (both null), there is nothing to evaluate — no finding (byte-identical, matches `pre_merge_checks`'
* empty-checks behavior).
*
* `checkRunConclusion` is `undefined` when the caller could not resolve check-run data at all (a transient
* fetch failure, or the predicted-gate metadata-only path, which never sees live check-runs) — that is NOT the
* same as a resolved-but-absent check-run (`null`, "no check-run with this name exists"). When check-run
* detection is configured and its conclusion is unresolved (a transient fetch failure, or "not yet run"), this
* HOLDS (`cla_check_unresolved`) instead of failing closed — exactly like an unresolved changed-file set HOLDS
* a path-gated pre-merge check rather than silently skipping (auto-merge bypass) or hard-closing on a
* transient miss. This applies EVEN WHEN `consentPhrase` is ALSO configured but not (yet) satisfied: per the
* "either method holds ⇒ satisfied" contract above, an unresolved check-run might still satisfy consent, so
* deciding purely from a not-yet-satisfied phrase would hard-fail a PR the check-run could have saved (#2564
* gate-review finding). A hold only degrades to a hard `cla_consent_missing` once EVERY configured method has
* been definitively resolved and none of them is satisfied. Pure + side-effect-free; the caller pushes the
* finding into the advisory before the gate evaluates.
*/
export function evaluateClaCheck(
config: ClaCheckConfig,
ctx: { body?: string | null | undefined; checkRunConclusion?: string | null | undefined },
): AdvisoryFinding[] {
if (config.consentPhrase === null && config.checkRunName === null) return []; // nothing configured ⇒ no finding
const phraseSatisfied = config.consentPhrase !== null && (ctx.body ?? "").toLowerCase().includes(config.consentPhrase.toLowerCase());
const checkRunSatisfied = config.checkRunName !== null && (ctx.checkRunConclusion === "success" || ctx.checkRunConclusion === "neutral");
if (phraseSatisfied || checkRunSatisfied) return [];
// A configured check-run whose conclusion is unresolved: cannot confirm OR deny consent via that method, so
// HOLD rather than fail closed — regardless of whether consentPhrase is ALSO configured (a not-yet-satisfied
// phrase does not mean consent is definitively absent while the check-run could still satisfy it).
if (config.checkRunName !== null && ctx.checkRunConclusion === undefined) {
return [
{
code: CLA_CHECK_UNRESOLVED_CODE,
severity: "warning",
title: `CLA check held — "${config.checkRunName}" not resolved`,
detail: `Gittensory could not resolve the "${config.checkRunName}" check-run's conclusion for this PR; the gate is held and re-evaluates automatically.`,
action: "No action needed — the gate re-evaluates once the check-run's conclusion is available.",
},
];
}
const missing: string[] = [];
if (config.consentPhrase !== null) missing.push(`the PR description must contain "${config.consentPhrase}"`);
if (config.checkRunName !== null) missing.push(`the "${config.checkRunName}" check must pass`);
return [
{
code: CLA_CONSENT_MISSING_CODE,
severity: "warning",
title: "CLA consent not confirmed",
detail: `This PR does not confirm contributor license agreement consent: ${missing.join(" or ")}.`,
action: "Add the required CLA consent phrase to the PR description, or complete the CLA check, then re-run the gate.",
},
];
}
23 changes: 20 additions & 3 deletions src/rules/advisory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { isDuplicateClusterWinnerByClaim } from "../signals/duplicate-winner";
import { isTestPath } from "../signals/test-evidence";
import { nowIso } from "../utils/json";
import { GITTENSORY_GATE_CHECK_NAME } from "../review/check-names";
import { CLA_CHECK_UNRESOLVED_CODE, CLA_CONSENT_MISSING_CODE } from "../review/cla-check";
import { REVIEW_THREAD_BLOCKER_CODE } from "../review/review-thread-findings";
import { labelMatchesPattern } from "../scoring/preview";

Expand Down Expand Up @@ -52,6 +53,11 @@ export type GateCheckPolicy = {
* the PR author also filed the linked issue — becomes a hard blocker. Defaults to `advisory` — the
* finding is surfaced but never blocks unless the maintainer opts in. */
selfAuthoredLinkedIssueGateMode?: GateRuleMode | undefined;
/** CLA / license-compatibility gate (#2564). When `block`, a `cla_consent_missing` finding — raised when
* neither configured detection method (a consent phrase in the PR body, or a named CLA-bot check-run
* conclusion) confirms consent — becomes a hard blocker. `off` (default) = no finding at all; `advisory` =
* the finding surfaces but never blocks. Independent of every other gate dimension, like manifestPolicy. */
claGateMode?: GateRuleMode | undefined;
/** First-time-contributor grace (#552). RESERVED / currently INERT (#2266): threaded through from config,
* but evaluateGateCheckCore never reads it (see the removal note below) — a would-be blocker gates a
* genuine newcomer exactly like a repeat contributor. Kept for potential future use. */
Expand Down Expand Up @@ -505,7 +511,7 @@ function evaluateGateCheckCore(advisoryResult: Advisory, policy: GateCheckPolicy
// App/infra state (repo not synced yet, PR not cached): gittensory cannot evaluate this PR yet, so the
// gate is NEUTRAL (non-blocking) and re-evaluates automatically on the next sync/webhook. Never block a
// contributor on the app's OWN state.
if (advisoryResult.findings.some((finding) => isEvaluationBlocker(finding.code))) {
if (advisoryResult.findings.some((finding) => isEvaluationBlocker(finding.code, policy))) {
return {
enabled: true,
conclusion: "neutral",
Expand Down Expand Up @@ -832,11 +838,19 @@ function conclusionForSeverity(severity: AdvisorySeverity, findings: AdvisoryFin
return "success";
}

function isEvaluationBlocker(code: string): boolean {
function isEvaluationBlocker(code: string, policy: GateCheckPolicy): boolean {
// pre_merge_check_unresolved: an enforced path-gated pre-merge check whose changed-file set could not be
// resolved — gittensory cannot evaluate it yet, so the gate is NEUTRAL (held) and re-evaluates on the next
// sync, rather than auto-merging past the unverified requirement or hard-closing on a transient miss. (#review-audit)
return code === "repo_not_registered" || code === "repo_not_seen" || code === "pr_not_cached" || code === "pre_merge_check_unresolved";
if (code === "repo_not_registered" || code === "repo_not_seen" || code === "pr_not_cached" || code === "pre_merge_check_unresolved") return true;
// cla_check_unresolved (#2564): the CLA-bot check-run's conclusion could not be resolved. Unlike the codes
// above (which are never mode-gated), evaluateClaCheck runs for BOTH claGateMode "advisory" and "block" (so
// the finding surfaces either way) — only "block" should ever HOLD the gate on an unresolved check-run.
// "advisory" mode's whole contract is "surface findings, never affect the verdict"; unconditionally holding
// here would violate that for any advisory-mode repo using check-run-only detection (#2564 gate-review
// finding). advisory mode still gets the finding in the panel via the normal warnings path below.
if (code === CLA_CHECK_UNRESOLVED_CODE) return policy.claGateMode === "block";
return false;
}

// Default configured close-confidence floor (#7) retained for settings compatibility and public calibration text.
Expand Down Expand Up @@ -883,6 +897,9 @@ function isConfiguredGateBlocker(finding: AdvisoryFinding, policy: GateCheckPoli
// (the finding is never even produced — see maybeAddLockfileTamperFinding's mode gate in queue/processors.ts),
// so this branch only matters once a repo has explicitly turned the scan on.
if (code === "lockfile_tamper_risk") return gateMode(policy.lockfileIntegrityGateMode ?? "off") === "block";
// CLA / license-compatibility gate (#2564): blocks only when the maintainer opts into claMode: block.
// Defaults to off (evaluateClaCheck never even runs for an off repo, so the finding does not exist).
if (code === CLA_CONSENT_MISSING_CODE) return gateMode(policy.claGateMode ?? "off") === "block";
return false;
}

Expand Down
Loading
Loading