diff --git a/.gittensory.yml.example b/.gittensory.yml.example index 3d141479af..9305b656e4 100644 --- a/.gittensory.yml.example +++ b/.gittensory.yml.example @@ -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 diff --git a/apps/gittensory-ui/public/openapi.json b/apps/gittensory-ui/public/openapi.json index 12356f2976..4098054615 100644 --- a/apps/gittensory-ui/public/openapi.json +++ b/apps/gittensory-ui/public/openapi.json @@ -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": [ diff --git a/src/github/backfill.ts b/src/github/backfill.ts index 0d277c2350..4d040a5409 100644 --- a/src/github/backfill.ts +++ b/src/github/backfill.ts @@ -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 { + 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 = { diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index b275a75833..a31ce0bfc6 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -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(), diff --git a/src/queue/processors.ts b/src/queue/processors.ts index c0c4d96971..87c8b52e86 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -93,6 +93,7 @@ import { fetchLivePullRequestReviewDecision, fetchLiveReviewThreadBlockers, fetchLivePullRequestState, + fetchNamedCheckRunConclusion, fetchOpenPullRequestNumbersForCommit, fetchRequiredStatusContexts, invalidatePrStateCache, @@ -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"; @@ -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, }; @@ -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 diff --git a/src/review/cla-check.ts b/src/review/cla-check.ts new file mode 100644 index 0000000000..452f370f8f --- /dev/null +++ b/src/review/cla-check.ts @@ -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.", + }, + ]; +} diff --git a/src/rules/advisory.ts b/src/rules/advisory.ts index 5e758bd9f2..68bed9ae3d 100644 --- a/src/rules/advisory.ts +++ b/src/rules/advisory.ts @@ -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"; @@ -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. */ @@ -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", @@ -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. @@ -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; } diff --git a/src/rules/predicted-gate.ts b/src/rules/predicted-gate.ts index 9e5fc130ca..9fe5fbb4f8 100644 --- a/src/rules/predicted-gate.ts +++ b/src/rules/predicted-gate.ts @@ -28,6 +28,7 @@ const OSS_ANTI_SLOP_FUNNEL = { } as const; import { buildPullRequestAdvisory, evaluateGateCheck, type GateCheckConclusion } from "./advisory"; import { isTestPath } from "../signals/test-evidence"; +import { evaluateClaCheck } from "../review/cla-check"; import { evaluatePreMergeChecks } from "../review/pre-merge-checks"; /** @@ -222,6 +223,15 @@ export function buildPredictedGateVerdict(args: { ...evaluatePreMergeChecks(predictablePreMergeChecks, { title: syntheticPr.title, body: syntheticPr.body, labels: syntheticPr.labels, changedPaths, filesResolved: hasChangedPaths }), ); + // CLA / license-compatibility gate parity (#2564): this metadata-only predictor never resolves a LIVE + // check-run (it runs before the PR exists), so only the phrase-match detection method is predictable — + // checkRunConclusion stays undefined, mirroring evaluateClaCheck's "not evaluated" contract for an + // unresolved check-run. A repo relying solely on checkRunName (no consentPhrase configured) therefore + // predicts no finding either way; the note below discloses this limitation. + if (gate.claMode !== null && gate.claMode !== "off") { + advisory.findings.push(...evaluateClaCheck({ consentPhrase: gate.claConsentPhrase, checkRunName: gate.claCheckRunName }, { body: syntheticPr.body, checkRunConclusion: undefined })); + } + // Focus-manifest path policy parity (#12): the LIVE gate (manifestPolicyGateMode) pushes the three enforceable // policy findings over the PR's changed paths. Mirror it when the caller supplied paths and the PUBLIC config // opts in — recompute the guidance and append ONLY the policy codes, then thread manifestPolicyGateMode into @@ -271,6 +281,8 @@ export function buildPredictedGateVerdict(args: { // absent paths ⇒ no manifest finding exists, so this mode has nothing to act on (byte-identical). manifestPolicyGateMode: gate.manifestPolicy ?? undefined, selfAuthoredLinkedIssueGateMode: gate.selfAuthoredLinkedIssue ?? undefined, + // #2564: only meaningful when the finding was pushed above (gate.claMode opted in); byte-identical otherwise. + claGateMode: gate.claMode ?? undefined, readinessScore: readiness.total, confirmedContributor: effectiveConfirmedContributor, firstTimeContributorGrace: gate.firstTimeContributorGrace ?? undefined, diff --git a/src/signals/focus-manifest.ts b/src/signals/focus-manifest.ts index 89c07f164e..98da3f8e59 100644 --- a/src/signals/focus-manifest.ts +++ b/src/signals/focus-manifest.ts @@ -82,6 +82,15 @@ export type FocusManifestGateConfig = { * (byte-identical to today) — a discrete positive-minutes count, not a score, so it is neither clamped * nor rounded; an invalid value (fractional, non-positive, non-finite) is dropped with a warning. */ requireFreshRebaseWindowMinutes: number | null; + /** `gate.claMode` (#2564): off/advisory/block. null (unset) ⇒ off (byte-identical to today) — a repo must + * explicitly opt in before any CLA consent check runs. */ + claMode: GateRuleMode | null; + /** `gate.cla.consentPhrase` (#2564): the required PR-body consent phrase. null (unset) ⇒ phrase-match + * detection is not configured. */ + claConsentPhrase: string | null; + /** `gate.cla.checkRunName` (#2564): the CLA-bot check-run name to trust. null (unset) ⇒ check-run + * detection is not configured. */ + claCheckRunName: string | null; }; // The converged per-PR review features a self-host operator toggles PER-REPO under `features:` in the private @@ -348,6 +357,9 @@ const EMPTY_GATE_CONFIG: FocusManifestGateConfig = { firstTimeContributorGrace: null, premergeContentRecheck: null, requireFreshRebaseWindowMinutes: null, + claMode: null, + claConsentPhrase: null, + claCheckRunName: null, }; const EMPTY_FEATURES_CONFIG: FocusManifestFeaturesConfig = { @@ -589,6 +601,11 @@ function parseGateConfig(value: JsonValue | undefined, warnings: string[]): Focu if (size !== undefined && size !== null && sizeRecord === undefined) { warnings.push(`Manifest gate field "gate.size" must be a mapping; ignoring it.`); } + const cla = record.cla; + const claRecord = cla !== null && typeof cla === "object" && !Array.isArray(cla) ? (cla as Record) : undefined; + if (cla !== undefined && cla !== null && claRecord === undefined) { + warnings.push(`Manifest gate field "gate.cla" must be a mapping; ignoring it.`); + } const gate: FocusManifestGateConfig = { present: false, enabled: normalizeOptionalBoolean(record.enabled, "gate.enabled", warnings), @@ -618,6 +635,9 @@ function parseGateConfig(value: JsonValue | undefined, warnings: string[]): Focu firstTimeContributorGrace: normalizeOptionalBoolean(record.firstTimeContributorGrace, "gate.firstTimeContributorGrace", warnings), premergeContentRecheck: normalizeOptionalBoolean(record.premergeContentRecheck, "gate.premergeContentRecheck", warnings), requireFreshRebaseWindowMinutes: normalizeOptionalPositiveInteger(record.requireFreshRebaseWindow, "gate.requireFreshRebaseWindow", warnings), + claMode: normalizeOptionalGateMode(record.claMode, "gate.claMode", warnings), + claConsentPhrase: parsePublicSafeText(claRecord?.consentPhrase, "gate.cla.consentPhrase", warnings), + claCheckRunName: parsePublicSafeText(claRecord?.checkRunName, "gate.cla.checkRunName", warnings), }; // #2266: the flag is parsed, clamped, and threaded end-to-end, but the gate evaluator never reads it — a // maintainer who sets it to true believing it softens a blocker for newcomers gets no such effect. Surface @@ -653,7 +673,10 @@ function parseGateConfig(value: JsonValue | undefined, warnings: string[]): Focu gate.dryRun !== null || gate.firstTimeContributorGrace !== null || gate.premergeContentRecheck !== null || - gate.requireFreshRebaseWindowMinutes !== null; + gate.requireFreshRebaseWindowMinutes !== null || + gate.claMode !== null || + gate.claConsentPhrase !== null || + gate.claCheckRunName !== null; return gate; } @@ -717,6 +740,13 @@ export function gateConfigToJson(gate: FocusManifestGateConfig): JsonValue { if (gate.firstTimeContributorGrace !== null) out.firstTimeContributorGrace = gate.firstTimeContributorGrace; if (gate.premergeContentRecheck !== null) out.premergeContentRecheck = gate.premergeContentRecheck; if (gate.requireFreshRebaseWindowMinutes !== null) out.requireFreshRebaseWindow = gate.requireFreshRebaseWindowMinutes; + if (gate.claMode !== null) out.claMode = gate.claMode; + if (gate.claConsentPhrase !== null || gate.claCheckRunName !== null) { + const cla: Record = {}; + if (gate.claConsentPhrase !== null) cla.consentPhrase = gate.claConsentPhrase; + if (gate.claCheckRunName !== null) cla.checkRunName = gate.claCheckRunName; + out.cla = cla; + } return out; } @@ -1383,6 +1413,9 @@ export function resolveEffectiveSettings( if (gate.firstTimeContributorGrace !== null) effective.firstTimeContributorGrace = gate.firstTimeContributorGrace; if (gate.premergeContentRecheck !== null) effective.premergeContentRecheck = gate.premergeContentRecheck; if (gate.requireFreshRebaseWindowMinutes !== null) effective.requireFreshRebaseWindowMinutes = gate.requireFreshRebaseWindowMinutes; + if (gate.claMode !== null) effective.claGateMode = gate.claMode; + if (gate.claConsentPhrase !== null) effective.claConsentPhrase = gate.claConsentPhrase; + if (gate.claCheckRunName !== null) effective.claCheckRunName = gate.claCheckRunName; // The dashboard "Require linked issue" toggle must not silently diverge from gate blocking: when the // boolean is on but linkedIssueGateMode is still off, treat it as a block requirement (#797). if (effective.requireLinkedIssue && effective.linkedIssueGateMode === "off") { diff --git a/src/types.ts b/src/types.ts index ec8841c096..b2db7d69dd 100644 --- a/src/types.ts +++ b/src/types.ts @@ -567,6 +567,20 @@ export type RepositorySettings = { * in review-enrichment — this is a tamper/integrity-substitution check, not a known-CVE check. Config-as-code * only — no DB column or dashboard toggle; set via `.gittensory.yml gate.lockfileIntegrity`. */ lockfileIntegrityGateMode?: GateRuleMode | undefined; + /** CLA / license-compatibility gate (#2564). `off` (default/absent) = no CLA check at all; `advisory`/`block` = + * evaluate the configured detection method(s) (`claConsentPhrase` and/or `claCheckRunName`) and raise a + * `cla_consent_missing` finding when neither confirms consent — `block` also hard-blocks the gate. Config-as-code + * only (no DB column, mirrors sizeGateMode) — set via `.gittensory.yml gate.claMode`. */ + claGateMode?: GateRuleMode | undefined; + /** `gate.cla.consentPhrase`: a public-safe-filtered phrase a maintainer requires somewhere in the PR body (e.g. + * "I have read and agree to the CLA"), matched case-insensitively. `null`/absent ⇒ phrase-match detection is not + * configured. Config-as-code only, alongside {@link claGateMode}. */ + claConsentPhrase?: string | null | undefined; + /** `gate.cla.checkRunName`: the name of a separate CLA-bot check-run this repo also runs (e.g. "CLA Assistant + * Lite"). A `success`/`neutral` conclusion for a check-run with this exact name (case-insensitive) also + * satisfies consent. `null`/absent ⇒ check-run detection is not configured. Config-as-code only, alongside + * {@link claGateMode}. */ + claCheckRunName?: 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. */ diff --git a/test/unit/agent-actions.test.ts b/test/unit/agent-actions.test.ts index dbfeaaf3b1..7eb6b5efbb 100644 --- a/test/unit/agent-actions.test.ts +++ b/test/unit/agent-actions.test.ts @@ -495,6 +495,19 @@ describe("planAgentMaintenanceActions (#778)", () => { const plan = classes(planAgentMaintenanceActions(input({ conclusion: "failure", autonomy: { close: "auto" }, blockerTitles: ["x"], authorIsOwner: false, authorIsAutomationBot: true, closeOwnerAuthors: true, ciState: "passed", pr: { labels: [], slopRisk: 95 } }))); expect(plan).not.toContain("close"); }); + + // #2564: a block-mode CLA finding (cla_consent_missing) reaches the disposition planner exactly like any + // other configured gate blocker (a conclusion: "failure" + the blocker's title) — it carries no special + // owner/admin handling of its own, so it inherits the SAME generic isContributor exemption every other + // blocker gets here. This is the concrete case the CLA gate's "owner/admin exemption" acceptance criterion + // exercises; it is not a new mechanism. + it("does NOT auto-close the repo owner's own PR over a CLA-consent-missing blocker; DOES close the same blocker for a contributor", () => { + const claBlockerTitles = ["CLA consent not confirmed"]; + const ownerPlan = classes(planAgentMaintenanceActions(input({ conclusion: "failure", autonomy: { close: "auto" }, blockerTitles: claBlockerTitles, authorIsOwner: true, ciState: "passed", pr: { labels: [] } }))); + expect(ownerPlan).not.toContain("close"); + const contributorPlan = classes(planAgentMaintenanceActions(input({ conclusion: "failure", autonomy: { close: "auto" }, blockerTitles: claBlockerTitles, authorIsOwner: false, authorIsAutomationBot: false, ciState: "passed", pr: { labels: [] } }))); + expect(contributorPlan).toContain("close"); + }); }); describe("admin-login guard: ADMIN_GITHUB_LOGINS gets the same never-auto-close exemption as the owner (#2133)", () => { diff --git a/test/unit/backfill.test.ts b/test/unit/backfill.test.ts index ddb0e7d9ae..5bc8cd8d5a 100644 --- a/test/unit/backfill.test.ts +++ b/test/unit/backfill.test.ts @@ -37,6 +37,7 @@ import { fetchLinkedIssueFacts, fetchLiveCiAggregate, fetchLiveReviewThreadBlockers, + fetchNamedCheckRunConclusion, fetchRequiredStatusContexts, isOwnReviewThreadAuthor, isRateLimitedGitHubFailure, @@ -5051,6 +5052,59 @@ describe("GitHub backfill", () => { }); }); + describe("fetchNamedCheckRunConclusion (#2564)", () => { + it("returns undefined without fetching when headSha is missing", async () => { + const env = createTestEnv({ GITHUB_PUBLIC_TOKEN: "public-token" }); + const fetchSpy = vi.fn(); + vi.stubGlobal("fetch", fetchSpy); + expect(await fetchNamedCheckRunConclusion(env, "JSONbored/gittensory", null, "CLA Assistant Lite", "public-token")).toBeUndefined(); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("returns the lowercased conclusion for a matching check-run (case-insensitive name match)", async () => { + const env = createTestEnv({ GITHUB_PUBLIC_TOKEN: "public-token" }); + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + expect(input.toString()).toContain("/commits/sha1/check-runs"); + return Response.json({ total_count: 1, check_runs: [{ id: 1, name: "cla assistant lite", status: "completed", conclusion: "SUCCESS" }] }); + }); + expect(await fetchNamedCheckRunConclusion(env, "JSONbored/gittensory", "sha1", "CLA Assistant Lite", "public-token")).toBe("success"); + }); + + it("returns null (resolved: not found) when the head SHA has no check-run with that name", async () => { + const env = createTestEnv({ GITHUB_PUBLIC_TOKEN: "public-token" }); + vi.stubGlobal("fetch", async () => Response.json({ total_count: 1, check_runs: [{ id: 1, name: "Some Other Check", status: "completed", conclusion: "success" }] })); + expect(await fetchNamedCheckRunConclusion(env, "JSONbored/gittensory", "sha1", "CLA Assistant Lite", "public-token")).toBeNull(); + }); + + it("returns null (resolved: not found) when the response omits check_runs entirely (nullish fallback)", async () => { + const env = createTestEnv({ GITHUB_PUBLIC_TOKEN: "public-token" }); + vi.stubGlobal("fetch", async () => Response.json({ total_count: 0 })); + expect(await fetchNamedCheckRunConclusion(env, "JSONbored/gittensory", "sha1", "CLA Assistant Lite", "public-token")).toBeNull(); + }); + + // #2564 gate-review finding: a matching check-run that has NOT finished yet must resolve to undefined + // (unresolved), not "" — an in-progress run's conclusion:null means "not decided yet," not "resolved with + // an empty conclusion." Coercing it to "" made claMode: block hard-fail a PR before the named check had + // actually finished running. + it("returns undefined (unresolved) for a matching but still-in-progress check-run (status !== completed, conclusion: null)", async () => { + const env = createTestEnv({ GITHUB_PUBLIC_TOKEN: "public-token" }); + vi.stubGlobal("fetch", async () => Response.json({ total_count: 1, check_runs: [{ id: 1, name: "CLA Assistant Lite", status: "in_progress", conclusion: null }] })); + expect(await fetchNamedCheckRunConclusion(env, "JSONbored/gittensory", "sha1", "CLA Assistant Lite", "public-token")).toBeUndefined(); + }); + + it("returns an empty string for a matching, COMPLETED check-run with an unexpected empty conclusion (genuine edge case, not the in-progress case)", async () => { + const env = createTestEnv({ GITHUB_PUBLIC_TOKEN: "public-token" }); + vi.stubGlobal("fetch", async () => Response.json({ total_count: 1, check_runs: [{ id: 1, name: "CLA Assistant Lite", status: "completed", conclusion: null }] })); + expect(await fetchNamedCheckRunConclusion(env, "JSONbored/gittensory", "sha1", "CLA Assistant Lite", "public-token")).toBe(""); + }); + + it("returns undefined (not evaluated) when the fetch fails, never a false 'missing'", async () => { + const env = createTestEnv({ GITHUB_PUBLIC_TOKEN: "public-token" }); + vi.stubGlobal("fetch", async () => new Response("forbidden", { status: 403 })); + expect(await fetchNamedCheckRunConclusion(env, "JSONbored/gittensory", "sha1", "CLA Assistant Lite", "public-token")).toBeUndefined(); + }); + }); + describe("fetchLinkedIssueFacts (#2136)", () => { it("returns a found result with the extracted facts, falling back to the requested number and open state when the payload omits them", async () => { const env = createTestEnv({}); diff --git a/test/unit/cla-check.test.ts b/test/unit/cla-check.test.ts new file mode 100644 index 0000000000..92fa7fae07 --- /dev/null +++ b/test/unit/cla-check.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from "vitest"; + +import { CLA_CHECK_UNRESOLVED_CODE, CLA_CONSENT_MISSING_CODE, evaluateClaCheck, type ClaCheckConfig } from "../../src/review/cla-check"; + +const config = (over: Partial = {}): ClaCheckConfig => ({ + consentPhrase: null, + checkRunName: null, + ...over, +}); + +describe("evaluateClaCheck (#2564)", () => { + it("no findings when neither detection method is configured (byte-identical)", () => { + expect(evaluateClaCheck(config(), { body: "no consent here" })).toEqual([]); + }); + + describe("phrase-match detection", () => { + it("satisfied (case-insensitive substring) yields no finding", () => { + const out = evaluateClaCheck(config({ consentPhrase: "I have read and agree to the CLA" }), { + body: "Some intro.\n\ni HAVE READ AND AGREE TO THE cla\n\nMore text.", + }); + expect(out).toEqual([]); + }); + + it("missing phrase → cla_consent_missing (warning)", () => { + const out = evaluateClaCheck(config({ consentPhrase: "I have read and agree to the CLA" }), { body: "no consent statement here" }); + expect(out).toHaveLength(1); + expect(out[0]?.code).toBe(CLA_CONSENT_MISSING_CODE); + expect(out[0]?.severity).toBe("warning"); + expect(out[0]?.detail).toContain('the PR description must contain "I have read and agree to the CLA"'); + }); + + it("null/absent body defaults to empty (no crash; the assertion simply fails)", () => { + const out = evaluateClaCheck(config({ consentPhrase: "agree to the CLA" }), {}); + expect(out).toHaveLength(1); + expect(out[0]?.code).toBe(CLA_CONSENT_MISSING_CODE); + }); + }); + + describe("check-run-conclusion detection", () => { + it("a success conclusion satisfies consent", () => { + expect(evaluateClaCheck(config({ checkRunName: "CLA Assistant Lite" }), { checkRunConclusion: "success" })).toEqual([]); + }); + + it("a neutral conclusion also satisfies consent", () => { + expect(evaluateClaCheck(config({ checkRunName: "CLA Assistant Lite" }), { checkRunConclusion: "neutral" })).toEqual([]); + }); + + it("a resolved-but-failing conclusion → cla_consent_missing", () => { + const out = evaluateClaCheck(config({ checkRunName: "CLA Assistant Lite" }), { checkRunConclusion: "failure" }); + expect(out).toHaveLength(1); + expect(out[0]?.code).toBe(CLA_CONSENT_MISSING_CODE); + expect(out[0]?.detail).toContain('the "CLA Assistant Lite" check must pass'); + }); + + it("a resolved-absent check-run (null, 'no such check-run') → cla_consent_missing, not held", () => { + const out = evaluateClaCheck(config({ checkRunName: "CLA Assistant Lite" }), { checkRunConclusion: null }); + expect(out).toHaveLength(1); + expect(out[0]?.code).toBe(CLA_CONSENT_MISSING_CODE); + }); + + it("an UNRESOLVED conclusion (undefined) with check-run as the ONLY configured method → cla_check_unresolved (HOLD)", () => { + const out = evaluateClaCheck(config({ checkRunName: "CLA Assistant Lite" }), { checkRunConclusion: undefined }); + expect(out).toHaveLength(1); + expect(out[0]?.code).toBe(CLA_CHECK_UNRESOLVED_CODE); + expect(out[0]?.severity).toBe("warning"); + expect(out[0]?.title).toContain("CLA Assistant Lite"); + }); + + it("omitting checkRunConclusion entirely behaves like undefined (unresolved → HOLD)", () => { + const out = evaluateClaCheck(config({ checkRunName: "CLA Assistant Lite" }), {}); + expect(out).toHaveLength(1); + expect(out[0]?.code).toBe(CLA_CHECK_UNRESOLVED_CODE); + }); + }); + + describe("either-method contract (both configured)", () => { + it("phrase satisfied, check-run unresolved → satisfied (phrase alone decides; no hold)", () => { + const out = evaluateClaCheck(config({ consentPhrase: "agree to the CLA", checkRunName: "CLA Assistant Lite" }), { + body: "I agree to the CLA.", + checkRunConclusion: undefined, + }); + expect(out).toEqual([]); + }); + + it("check-run satisfied, phrase missing → satisfied (either method is enough)", () => { + const out = evaluateClaCheck(config({ consentPhrase: "agree to the CLA", checkRunName: "CLA Assistant Lite" }), { + body: "no phrase here", + checkRunConclusion: "success", + }); + expect(out).toEqual([]); + }); + + it("both fail with the check-run resolved-but-failing → cla_consent_missing lists both, never held", () => { + const out = evaluateClaCheck(config({ consentPhrase: "agree to the CLA", checkRunName: "CLA Assistant Lite" }), { + body: "no phrase here", + checkRunConclusion: "failure", + }); + expect(out).toHaveLength(1); + expect(out[0]?.code).toBe(CLA_CONSENT_MISSING_CODE); + expect(out[0]?.detail).toContain('the PR description must contain "agree to the CLA"'); + expect(out[0]?.detail).toContain('the "CLA Assistant Lite" check must pass'); + }); + + // #2564 gate-review finding: an unresolved check-run must HOLD even when consentPhrase is ALSO configured + // but not (yet) satisfied — the check-run might still satisfy consent, so a transient GitHub read failure + // must never hard-fail a PR the check-run method could have saved. + it("phrase missing, check-run UNRESOLVED → held (cla_check_unresolved), NOT hard-failed — the check-run might still satisfy consent", () => { + const out = evaluateClaCheck(config({ consentPhrase: "agree to the CLA", checkRunName: "CLA Assistant Lite" }), { + body: "no phrase here", + checkRunConclusion: undefined, + }); + expect(out).toHaveLength(1); + expect(out[0]?.code).toBe(CLA_CHECK_UNRESOLVED_CODE); + }); + }); +}); diff --git a/test/unit/focus-manifest.test.ts b/test/unit/focus-manifest.test.ts index 15d6e36ab0..cbdfe979f1 100644 --- a/test/unit/focus-manifest.test.ts +++ b/test/unit/focus-manifest.test.ts @@ -496,7 +496,7 @@ describe("compileFocusManifestPolicy", () => { issueDiscoveryPolicy: "neutral", maintainerNotes: [], publicNotes: ["Keep PRs focused.", "Maximize your reward payout"], - gate: { present: false, enabled: null, pack: null, linkedIssue: null, duplicates: null, readinessMode: null, readinessMinScore: null, slopMode: null, slopMinScore: null, slopAiAdvisory: null, sizeMode: null, lockfileIntegrityMode: null, aiReviewMode: null, aiReviewByok: null, aiReviewProvider: null, aiReviewModel: null, aiReviewAllAuthors: null, aiReviewCloseConfidence: null, aiReviewCombine: null, aiReviewOnMerge: null, aiReviewReviewers: null, mergeReadiness: null, selfAuthoredLinkedIssue: null, manifestPolicy: null, dryRun: null, firstTimeContributorGrace: null, premergeContentRecheck: null, requireFreshRebaseWindowMinutes: null }, + gate: { present: false, enabled: null, pack: null, linkedIssue: null, duplicates: null, readinessMode: null, readinessMinScore: null, slopMode: null, slopMinScore: null, slopAiAdvisory: null, sizeMode: null, lockfileIntegrityMode: null, aiReviewMode: null, aiReviewByok: null, aiReviewProvider: null, aiReviewModel: null, aiReviewAllAuthors: null, aiReviewCloseConfidence: null, aiReviewCombine: null, aiReviewOnMerge: null, aiReviewReviewers: null, mergeReadiness: null, selfAuthoredLinkedIssue: null, manifestPolicy: null, dryRun: null, firstTimeContributorGrace: null, premergeContentRecheck: null, requireFreshRebaseWindowMinutes: null, claMode: null, claConsentPhrase: null, claCheckRunName: null }, settings: {}, review: { present: false, footerText: null, note: null, fields: {}, profile: null, securityFocus: null, inlineComments: null, pathInstructions: [], instructions: null, excludePaths: [], preMergeChecks: [] }, features: { present: false, rag: null, reputation: null, unifiedComment: null, safety: null }, @@ -804,7 +804,7 @@ describe("parseFocusManifest gate config", () => { // the block→advisory deprecation-downgrade behavior itself is covered separately below. const m = parseFocusManifest({ gate: { linkedIssue: "block", duplicates: "advisory", readiness: { mode: "advisory", minScore: 70 } } }); expect(m.present).toBe(true); - expect(m.gate).toEqual({ present: true, enabled: null, pack: null, linkedIssue: "block", duplicates: "advisory", readinessMode: "advisory", readinessMinScore: 70, slopMode: null, slopMinScore: null, slopAiAdvisory: null, sizeMode: null, lockfileIntegrityMode: null, aiReviewMode: null, aiReviewByok: null, aiReviewProvider: null, aiReviewModel: null, aiReviewAllAuthors: null, aiReviewCloseConfidence: null, aiReviewCombine: null, aiReviewOnMerge: null, aiReviewReviewers: null, mergeReadiness: null, selfAuthoredLinkedIssue: null, manifestPolicy: null, dryRun: null, firstTimeContributorGrace: null, premergeContentRecheck: null, requireFreshRebaseWindowMinutes: null }); + expect(m.gate).toEqual({ present: true, enabled: null, pack: null, linkedIssue: "block", duplicates: "advisory", readinessMode: "advisory", readinessMinScore: 70, slopMode: null, slopMinScore: null, slopAiAdvisory: null, sizeMode: null, lockfileIntegrityMode: null, aiReviewMode: null, aiReviewByok: null, aiReviewProvider: null, aiReviewModel: null, aiReviewAllAuthors: null, aiReviewCloseConfidence: null, aiReviewCombine: null, aiReviewOnMerge: null, aiReviewReviewers: null, mergeReadiness: null, selfAuthoredLinkedIssue: null, manifestPolicy: null, dryRun: null, firstTimeContributorGrace: null, premergeContentRecheck: null, requireFreshRebaseWindowMinutes: null, claMode: null, claConsentPhrase: null, claCheckRunName: null }); }); it("parses gate.mergeReadiness + gate.firstTimeContributorGrace, round-trips them, and warns on bad values (#822)", () => { @@ -2066,3 +2066,64 @@ describe("gate.requireFreshRebaseWindow force-rebase-before-merge config (#2552) expect(eff.requireFreshRebaseWindowMinutes).toBe(15); }); }); + +describe("gate.claMode / gate.cla CLA / license-compatibility gate config (#2564)", () => { + it("parses gate.claMode, sets present, round-trips, and resolves into effective settings", () => { + const m = parseFocusManifest({ gate: { claMode: "block" } }); + expect(m.gate.claMode).toBe("block"); + expect(m.gate.present).toBe(true); + expect(gateConfigToJson(m.gate)).toMatchObject({ claMode: "block" }); + const eff = resolveEffectiveSettings({} as unknown as RepositorySettings, m); + expect(eff.claGateMode).toBe("block"); + }); + + it("defaults to unset/undefined when omitted — byte-identical to today (off by default)", () => { + const m = parseFocusManifest({}); + expect(m.gate.claMode).toBeNull(); + expect(m.gate.claConsentPhrase).toBeNull(); + expect(m.gate.claCheckRunName).toBeNull(); + const eff = resolveEffectiveSettings({} as unknown as RepositorySettings, m); + expect(eff.claGateMode).toBeUndefined(); + expect(eff.claConsentPhrase).toBeUndefined(); + expect(eff.claCheckRunName).toBeUndefined(); + }); + + it("warns and drops an invalid claMode value rather than silently coercing it", () => { + const m = parseFocusManifest({ gate: { claMode: "sometimes" as never } }); + expect(m.gate.claMode).toBeNull(); + expect(m.warnings.some((w) => /gate\.claMode/i.test(w))).toBe(true); + }); + + it("parses the gate.cla block (consentPhrase + checkRunName), round-trips it, and warns on a non-mapping", () => { + const m = parseFocusManifest({ gate: { claMode: "block", cla: { consentPhrase: "I have read and agree to the CLA", checkRunName: "CLA Assistant Lite" } } }); + expect(m.gate.claConsentPhrase).toBe("I have read and agree to the CLA"); + expect(m.gate.claCheckRunName).toBe("CLA Assistant Lite"); + expect(gateConfigToJson(m.gate)).toMatchObject({ cla: { consentPhrase: "I have read and agree to the CLA", checkRunName: "CLA Assistant Lite" } }); + + const bad = parseFocusManifest({ gate: { cla: "block" as never } }); + expect(bad.gate.claConsentPhrase).toBeNull(); + expect(bad.gate.claCheckRunName).toBeNull(); + expect(bad.warnings.some((w) => /gate\.cla/.test(w))).toBe(true); + }); + + it("drops a consentPhrase/checkRunName that is not public-safe, with a warning (mirrors pre_merge_checks.titleContains)", () => { + const m = parseFocusManifest({ gate: { cla: { consentPhrase: "please share your wallet hotkey to agree", checkRunName: "leak reward payout check" } } }); + expect(m.gate.claConsentPhrase).toBeNull(); + expect(m.gate.claCheckRunName).toBeNull(); + expect(m.warnings.some((w) => /gate\.cla\.consentPhrase/i.test(w))).toBe(true); + expect(m.warnings.some((w) => /gate\.cla\.checkRunName/i.test(w))).toBe(true); + }); + + it("round-trips a full gate.claMode + gate.cla config through gateConfigToJson + parse (the cache path)", () => { + const original = parseFocusManifest({ gate: { claMode: "advisory", cla: { consentPhrase: "agree to the CLA" } } }); + const reparsed = parseFocusManifest({ gate: gateConfigToJson(original.gate) }); + expect(reparsed.gate).toEqual(original.gate); + }); + + it("lets the DB value pass through when the manifest doesn't override it", () => { + const db = { claGateMode: "advisory", claConsentPhrase: "agree to the CLA" } as unknown as RepositorySettings; + const eff = resolveEffectiveSettings(db, parseFocusManifest(null)); + expect(eff.claGateMode).toBe("advisory"); + expect(eff.claConsentPhrase).toBe("agree to the CLA"); + }); +}); diff --git a/test/unit/gate-check-policy.test.ts b/test/unit/gate-check-policy.test.ts index 3a989b2e2d..17c3cf993a 100644 --- a/test/unit/gate-check-policy.test.ts +++ b/test/unit/gate-check-policy.test.ts @@ -201,6 +201,87 @@ describe("AI fail-closed hold (#ai-fail-closed)", () => { }); }); +describe("CLA / license-compatibility gate (#2564)", () => { + function claAdvisory(): Advisory { + return { + ...missingIssueAdvisory(), + findings: [{ code: "cla_consent_missing", title: "CLA consent not confirmed", severity: "warning", detail: 'the PR description must contain "I agree to the CLA"', action: "add it" }], + }; + } + + it("blocks a confirmed contributor when claMode: block", () => { + const result = evaluateGateCheck(claAdvisory(), { claGateMode: "block", confirmedContributor: true }); + expect(result.conclusion).toBe("failure"); + expect(result.blockers.map((finding) => finding.code)).toContain("cla_consent_missing"); + }); + + it("blocks a non-confirmed contributor under claMode: block, the same as a confirmed one (#gate-nonconfirmed)", () => { + const result = evaluateGateCheck(claAdvisory(), { claGateMode: "block", confirmedContributor: false }); + expect(result.conclusion).toBe("failure"); + expect(result.blockers.map((finding) => finding.code)).toContain("cla_consent_missing"); + }); + + it("does not block when claMode: advisory (surfaces as a warning, never blocks)", () => { + const result = evaluateGateCheck(claAdvisory(), { claGateMode: "advisory", confirmedContributor: true }); + expect(result.conclusion).toBe("success"); + expect(result.blockers).toEqual([]); + expect(result.warnings.map((finding) => finding.code)).toContain("cla_consent_missing"); + }); + + it("does not block when claMode is unset/off (default) — zero behavior change for a repo that has not opted in", () => { + expect(evaluateGateCheck(claAdvisory(), { confirmedContributor: true }).conclusion).toBe("success"); + expect(evaluateGateCheck(claAdvisory(), { claGateMode: "off", confirmedContributor: true }).conclusion).toBe("success"); + }); + + it("an UNRESOLVED CLA check-run (cla_check_unresolved) HOLDS the gate (neutral) under claMode: block, never close or pass", () => { + const held: Advisory = { + ...missingIssueAdvisory(), + findings: [{ code: "cla_check_unresolved", title: 'CLA check held — "CLA Assistant Lite" not resolved', severity: "warning", detail: "could not resolve the check-run", action: "re-evaluates automatically" }], + }; + const result = evaluateGateCheck(held, { claGateMode: "block", confirmedContributor: true }); + expect(result.conclusion).toBe("neutral"); + expect(result.blockers).toEqual([]); + }); + + // #2564 gate-review finding: advisory mode's whole contract is "surface findings, never affect the verdict" + // — an unresolved check-run must NOT hold the gate under claMode: advisory, unlike claMode: block above. + it("an UNRESOLVED CLA check-run does NOT hold the gate under claMode: advisory — surfaces as a warning only", () => { + const held: Advisory = { + ...missingIssueAdvisory(), + findings: [{ code: "cla_check_unresolved", title: 'CLA check held — "CLA Assistant Lite" not resolved', severity: "warning", detail: "could not resolve the check-run", action: "re-evaluates automatically" }], + }; + const result = evaluateGateCheck(held, { claGateMode: "advisory", confirmedContributor: true }); + expect(result.conclusion).toBe("success"); + expect(result.blockers).toEqual([]); + expect(result.warnings.map((finding) => finding.code)).toContain("cla_check_unresolved"); + }); + + it("gateCheckPolicy threads claGateMode into the policy", () => { + expect(gateCheckPolicy(settings({ claGateMode: "block" }), null, true).claGateMode).toBe("block"); + }); + + it("resolveEffectiveSettings maps gate.claMode / gate.cla.{consentPhrase,checkRunName} onto the effective settings", () => { + const eff = resolveEffectiveSettings(settings({}), parseFocusManifest({ gate: { claMode: "block", cla: { consentPhrase: "I agree to the CLA", checkRunName: "CLA Assistant Lite" } } })); + expect(eff.claGateMode).toBe("block"); + expect(eff.claConsentPhrase).toBe("I agree to the CLA"); + expect(eff.claCheckRunName).toBe("CLA Assistant Lite"); + }); + + it("resolveEffectiveSettings leaves claGateMode unset when the manifest has no gate.claMode (byte-identical default)", () => { + const eff = resolveEffectiveSettings(settings({}), parseFocusManifest(null)); + expect(eff.claGateMode).toBeUndefined(); + expect(eff.claConsentPhrase).toBeUndefined(); + expect(eff.claCheckRunName).toBeUndefined(); + }); + + it("end-to-end: a manifest gate.claMode: block + consentPhrase blocks a PR missing CLA consent (acceptance criterion)", () => { + const eff = resolveEffectiveSettings(settings({}), parseFocusManifest({ gate: { claMode: "block", cla: { consentPhrase: "I agree to the CLA" } } })); + const result = evaluateGateCheck(claAdvisory(), gateCheckPolicy(eff, null, true)); + expect(result.conclusion).toBe("failure"); + expect(result.blockers.map((finding) => finding.code)).toContain("cla_consent_missing"); + }); +}); + describe("policy pack (#692)", () => { it("gittensor pack hard-blocks every author the same — confirmed status no longer changes the verdict (#gate-nonconfirmed)", () => { const gittensor = settings({ gatePack: "gittensor", linkedIssueGateMode: "block" }); diff --git a/test/unit/predicted-gate.test.ts b/test/unit/predicted-gate.test.ts index b1981ed204..906f1402f4 100644 --- a/test/unit/predicted-gate.test.ts +++ b/test/unit/predicted-gate.test.ts @@ -300,6 +300,42 @@ describe("buildPredictedGateVerdict", () => { expect(result.blockers.some((b) => b.code === "pre_merge_check_required")).toBe(false); }); + it("predicts a BLOCK when gate.claMode: block + consentPhrase is configured and the PR body lacks it (#2564)", () => { + const result = verdict({ gate: { claMode: "block", cla: { consentPhrase: "I agree to the CLA" } } }); + expect(result.conclusion).toBe("failure"); + expect(result.blockers.some((b) => b.code === "cla_consent_missing")).toBe(true); + }); + + it("predicts a PASS once the CLA consent phrase is present in the PR body", () => { + const result = verdict({ + gate: { claMode: "block", cla: { consentPhrase: "I agree to the CLA" } }, + input: { body: "Closes #7\n\nI agree to the CLA." }, + }); + expect(result.conclusion).not.toBe("failure"); + expect(result.blockers.some((b) => b.code === "cla_consent_missing")).toBe(false); + }); + + it("surfaces a missing CLA consent as a WARNING under claMode: advisory, never a blocker", () => { + const result = verdict({ gate: { claMode: "advisory", cla: { consentPhrase: "I agree to the CLA" } } }); + expect(result.conclusion).not.toBe("failure"); + expect(result.warnings.some((w) => w.code === "cla_consent_missing")).toBe(true); + }); + + it("does NOT predict a cla_consent_missing finding when claMode is off (default, no opt-in)", () => { + const result = verdict({ gate: {} }); + expect(result.blockers.some((b) => b.code === "cla_consent_missing")).toBe(false); + expect(result.warnings.some((w) => w.code === "cla_consent_missing")).toBe(false); + }); + + it("a check-run-only CLA config (no consentPhrase) predicts a HOLD, never a false block, pre-submission (no live check-run data)", () => { + // The metadata-only predictor never resolves a live check-run, so checkRunConclusion stays undefined — + // and with no consentPhrase configured, evaluateClaCheck cannot confirm or deny consent, so it HOLDS + // (cla_check_unresolved) rather than manufacturing a false cla_consent_missing block. + const result = verdict({ gate: { claMode: "block", cla: { checkRunName: "CLA Assistant Lite" } } }); + expect(result.blockers.some((b) => b.code === "cla_consent_missing")).toBe(false); + expect(result.conclusion).toBe("neutral"); + }); + it("predicts a manifest path-policy HOLD when a changed path hits a blocked glob and manifestPolicy:block (#12)", () => { const result = verdict({ gate: { manifestPolicy: "block" }, diff --git a/test/unit/queue.test.ts b/test/unit/queue.test.ts index fbff035f7d..cf849294e3 100644 --- a/test/unit/queue.test.ts +++ b/test/unit/queue.test.ts @@ -6168,6 +6168,389 @@ describe("queue processors", () => { expect(gateText).toContain("Pre-merge check not satisfied: Approved label required"); }); + it("CLA gate (#2564): claMode: block + a missing consent phrase blocks the auto-merge (acceptance criterion)", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + await persistRegistrySnapshot( + env, + normalizeRegistryPayload({ "JSONbored/gittensory": { emission_share: 0.01, issue_discovery_share: 0 } }, { kind: "raw-github", url: "https://example.test" }, "2026-05-23T00:00:00.000Z"), + ); + await upsertRepositoryFromGitHub(env, { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, 123); + await upsertInstallation(env, { + installation: { + id: 123, + account: { login: "JSONbored", id: 1, type: "User" }, + repository_selection: "selected", + permissions: { metadata: "read", pull_requests: "write", issues: "write" }, + events: ["pull_request"], + }, + repositories: [{ name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }], + }); + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + commentMode: "off", + publicSurface: "off", + autoLabelEnabled: false, + checkRunMode: "enabled", + gateCheckMode: "enabled", + autonomy: { merge: "observe", request_changes: "observe" }, + agentDryRun: false, + }); + await upsertOfficialMinerDetection(env, "contributor", { status: "confirmed", snapshot: queueMinerSnapshot("contributor") }, 60_000); + await upsertRepoFocusManifest(env, "JSONbored/gittensory", { gate: { claMode: "block", cla: { consentPhrase: "I have read and agree to the CLA" } } }); + await upsertPullRequestFile(env, { repoFullName: "JSONbored/gittensory", pullNumber: 49, path: "src/feature.ts", status: "modified", additions: 5, deletions: 0, changes: 5, payload: {} }); + + let gateConclusion: string | undefined; + let gateText = ""; + const captureGate = (body: { name?: string; conclusion?: string; output?: { title?: string; summary?: string } }) => { + if ((body.name ?? "").includes("Gittensory Orb Review Agent") && body.conclusion) { + gateConclusion = body.conclusion; + gateText = `${body.output?.title ?? ""} ${body.output?.summary ?? ""}`; + } + }; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + if (url === "https://api.gittensor.io/miners") return Response.json([]); + if (url.includes("/access_tokens")) return Response.json({ token: "fake-installation-token" }); + if (url.includes("/commits/") && url.includes("/check-runs")) return Response.json({ total_count: 0, check_runs: [] }); + if (url.includes("/commits/") && url.includes("/status")) return Response.json({ statuses: [] }); + if (url.includes("/check-runs")) { + if (init?.body) captureGate(JSON.parse(init.body.toString())); + return Response.json({ id: 901 }, { status: 201 }); + } + return new Response("not found", { status: 404 }); + }); + + await processJob(env, { + type: "github-webhook", + deliveryId: "cla-gate-block", + eventName: "pull_request", + payload: { + action: "opened", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + pull_request: { + number: 49, + title: "feat: add a feature", + state: "open", + user: { login: "contributor" }, + head: { sha: "gate126" }, + labels: [], + body: "Closes #1", // missing the required CLA consent phrase → the gate FAILS + mergeable_state: "clean", + reviewDecision: "APPROVED", + }, + }, + }); + // The CLA consent phrase is missing → the gate check-run is a FAILURE naming the CLA finding. + expect(gateConclusion).toBe("failure"); + expect(gateText).toContain("CLA consent not confirmed"); + }); + + it("CLA gate (#2564): claMode: block + the consent phrase present in the PR body passes the gate", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + await persistRegistrySnapshot( + env, + normalizeRegistryPayload({ "JSONbored/gittensory": { emission_share: 0.01, issue_discovery_share: 0 } }, { kind: "raw-github", url: "https://example.test" }, "2026-05-23T00:00:00.000Z"), + ); + await upsertRepositoryFromGitHub(env, { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, 123); + await upsertInstallation(env, { + installation: { + id: 123, + account: { login: "JSONbored", id: 1, type: "User" }, + repository_selection: "selected", + permissions: { metadata: "read", pull_requests: "write", issues: "write" }, + events: ["pull_request"], + }, + repositories: [{ name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }], + }); + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + commentMode: "off", + publicSurface: "off", + autoLabelEnabled: false, + checkRunMode: "enabled", + gateCheckMode: "enabled", + autonomy: { merge: "observe", request_changes: "observe" }, + agentDryRun: false, + }); + await upsertOfficialMinerDetection(env, "contributor", { status: "confirmed", snapshot: queueMinerSnapshot("contributor") }, 60_000); + await upsertRepoFocusManifest(env, "JSONbored/gittensory", { gate: { claMode: "block", cla: { consentPhrase: "I have read and agree to the CLA" } } }); + await upsertPullRequestFile(env, { repoFullName: "JSONbored/gittensory", pullNumber: 50, path: "src/feature.ts", status: "modified", additions: 5, deletions: 0, changes: 5, payload: {} }); + + let gateConclusion: string | undefined; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + if (url === "https://api.gittensor.io/miners") return Response.json([]); + if (url.includes("/access_tokens")) return Response.json({ token: "fake-installation-token" }); + if (url.includes("/commits/") && url.includes("/check-runs")) return Response.json({ total_count: 0, check_runs: [] }); + if (url.includes("/commits/") && url.includes("/status")) return Response.json({ statuses: [] }); + if (url.includes("/check-runs")) { + if (init?.body) { + const body = JSON.parse(init.body.toString()) as { name?: string; conclusion?: string }; + if ((body.name ?? "").includes("Gittensory Orb Review Agent") && body.conclusion) gateConclusion = body.conclusion; + } + return Response.json({ id: 902 }, { status: 201 }); + } + return new Response("not found", { status: 404 }); + }); + + await processJob(env, { + type: "github-webhook", + deliveryId: "cla-gate-pass", + eventName: "pull_request", + payload: { + action: "opened", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + pull_request: { + number: 50, + title: "feat: add a feature", + state: "open", + user: { login: "contributor" }, + head: { sha: "gate127" }, + labels: [], + body: "Closes #1\n\nI have read and agree to the CLA.", + mergeable_state: "clean", + reviewDecision: "APPROVED", + }, + }, + }); + expect(gateConclusion).not.toBe("failure"); + }); + + it("CLA gate (#2564) is OFF by default: no manifest opt-in ⇒ a PR with no CLA consent still passes (zero behavior change)", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + await persistRegistrySnapshot( + env, + normalizeRegistryPayload({ "JSONbored/gittensory": { emission_share: 0.01, issue_discovery_share: 0 } }, { kind: "raw-github", url: "https://example.test" }, "2026-05-23T00:00:00.000Z"), + ); + await upsertRepositoryFromGitHub(env, { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, 123); + await upsertInstallation(env, { + installation: { + id: 123, + account: { login: "JSONbored", id: 1, type: "User" }, + repository_selection: "selected", + permissions: { metadata: "read", pull_requests: "write", issues: "write" }, + events: ["pull_request"], + }, + repositories: [{ name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }], + }); + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + commentMode: "off", + publicSurface: "off", + autoLabelEnabled: false, + checkRunMode: "enabled", + gateCheckMode: "enabled", + autonomy: { merge: "observe", request_changes: "observe" }, + agentDryRun: false, + // No gate.claMode manifest override — claGateMode stays undefined (the safe default). + }); + await upsertOfficialMinerDetection(env, "contributor", { status: "confirmed", snapshot: queueMinerSnapshot("contributor") }, 60_000); + await upsertPullRequestFile(env, { repoFullName: "JSONbored/gittensory", pullNumber: 51, path: "src/feature.ts", status: "modified", additions: 5, deletions: 0, changes: 5, payload: {} }); + + let gateConclusion: string | undefined; + let gateText = ""; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + if (url === "https://api.gittensor.io/miners") return Response.json([]); + if (url.includes("/access_tokens")) return Response.json({ token: "fake-installation-token" }); + if (url.includes("/commits/") && url.includes("/check-runs")) return Response.json({ total_count: 0, check_runs: [] }); + if (url.includes("/commits/") && url.includes("/status")) return Response.json({ statuses: [] }); + if (url.includes("/check-runs")) { + if (init?.body) { + const body = JSON.parse(init.body.toString()) as { name?: string; conclusion?: string; output?: { title?: string; summary?: string } }; + if ((body.name ?? "").includes("Gittensory Orb Review Agent") && body.conclusion) { + gateConclusion = body.conclusion; + gateText = `${body.output?.title ?? ""} ${body.output?.summary ?? ""}`; + } + } + return Response.json({ id: 903 }, { status: 201 }); + } + return new Response("not found", { status: 404 }); + }); + + await processJob(env, { + type: "github-webhook", + deliveryId: "cla-gate-off-default", + eventName: "pull_request", + payload: { + action: "opened", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + pull_request: { + number: 51, + title: "feat: add a feature", + state: "open", + user: { login: "contributor" }, + head: { sha: "gate128" }, + labels: [], + body: "Closes #1", // no CLA consent anywhere — must not matter when claMode is off + mergeable_state: "clean", + reviewDecision: "APPROVED", + }, + }, + }); + expect(gateConclusion).not.toBe("failure"); + expect(gateText).not.toContain("CLA consent not confirmed"); + }); + + it("CLA gate (#2564): check-run-conclusion detection — a passing named CLA-bot check-run satisfies consent", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + await persistRegistrySnapshot( + env, + normalizeRegistryPayload({ "JSONbored/gittensory": { emission_share: 0.01, issue_discovery_share: 0 } }, { kind: "raw-github", url: "https://example.test" }, "2026-05-23T00:00:00.000Z"), + ); + await upsertRepositoryFromGitHub(env, { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, 123); + await upsertInstallation(env, { + installation: { + id: 123, + account: { login: "JSONbored", id: 1, type: "User" }, + repository_selection: "selected", + permissions: { metadata: "read", pull_requests: "write", issues: "write" }, + events: ["pull_request"], + }, + repositories: [{ name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }], + }); + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + commentMode: "off", + publicSurface: "off", + autoLabelEnabled: false, + checkRunMode: "enabled", + gateCheckMode: "enabled", + autonomy: { merge: "observe", request_changes: "observe" }, + agentDryRun: false, + }); + await upsertOfficialMinerDetection(env, "contributor", { status: "confirmed", snapshot: queueMinerSnapshot("contributor") }, 60_000); + // Check-run-only config: no consentPhrase, so ONLY the named check-run's conclusion is consulted. + await upsertRepoFocusManifest(env, "JSONbored/gittensory", { gate: { claMode: "block", cla: { checkRunName: "CLA Assistant Lite" } } }); + await upsertPullRequestFile(env, { repoFullName: "JSONbored/gittensory", pullNumber: 52, path: "src/feature.ts", status: "modified", additions: 5, deletions: 0, changes: 5, payload: {} }); + + let gateConclusion: string | undefined; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + if (url === "https://api.gittensor.io/miners") return Response.json([]); + if (url.includes("/access_tokens")) return Response.json({ token: "fake-installation-token" }); + if (url.includes("/commits/gate129/check-runs")) { + return Response.json({ total_count: 1, check_runs: [{ id: 1, name: "CLA Assistant Lite", status: "completed", conclusion: "success" }] }); + } + if (url.includes("/commits/") && url.includes("/check-runs")) return Response.json({ total_count: 0, check_runs: [] }); + if (url.includes("/commits/") && url.includes("/status")) return Response.json({ statuses: [] }); + if (url.includes("/check-runs")) { + if (init?.body) { + const body = JSON.parse(init.body.toString()) as { name?: string; conclusion?: string }; + if ((body.name ?? "").includes("Gittensory Orb Review Agent") && body.conclusion) gateConclusion = body.conclusion; + } + return Response.json({ id: 904 }, { status: 201 }); + } + return new Response("not found", { status: 404 }); + }); + + await processJob(env, { + type: "github-webhook", + deliveryId: "cla-gate-checkrun-pass", + eventName: "pull_request", + payload: { + action: "opened", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + pull_request: { + number: 52, + title: "feat: add a feature", + state: "open", + user: { login: "contributor" }, + head: { sha: "gate129" }, + labels: [], + body: "Closes #1", // no phrase — consent comes entirely from the check-run + mergeable_state: "clean", + reviewDecision: "APPROVED", + }, + }, + }); + expect(gateConclusion).not.toBe("failure"); + }); + + it("CLA gate (#2564): check-run-conclusion detection — a failing named CLA-bot check-run blocks the auto-merge", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + await persistRegistrySnapshot( + env, + normalizeRegistryPayload({ "JSONbored/gittensory": { emission_share: 0.01, issue_discovery_share: 0 } }, { kind: "raw-github", url: "https://example.test" }, "2026-05-23T00:00:00.000Z"), + ); + await upsertRepositoryFromGitHub(env, { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, 123); + await upsertInstallation(env, { + installation: { + id: 123, + account: { login: "JSONbored", id: 1, type: "User" }, + repository_selection: "selected", + permissions: { metadata: "read", pull_requests: "write", issues: "write" }, + events: ["pull_request"], + }, + repositories: [{ name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }], + }); + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + commentMode: "off", + publicSurface: "off", + autoLabelEnabled: false, + checkRunMode: "enabled", + gateCheckMode: "enabled", + autonomy: { merge: "observe", request_changes: "observe" }, + agentDryRun: false, + }); + await upsertOfficialMinerDetection(env, "contributor", { status: "confirmed", snapshot: queueMinerSnapshot("contributor") }, 60_000); + await upsertRepoFocusManifest(env, "JSONbored/gittensory", { gate: { claMode: "block", cla: { checkRunName: "CLA Assistant Lite" } } }); + await upsertPullRequestFile(env, { repoFullName: "JSONbored/gittensory", pullNumber: 53, path: "src/feature.ts", status: "modified", additions: 5, deletions: 0, changes: 5, payload: {} }); + + let gateConclusion: string | undefined; + let gateText = ""; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + if (url === "https://api.gittensor.io/miners") return Response.json([]); + if (url.includes("/access_tokens")) return Response.json({ token: "fake-installation-token" }); + if (url.includes("/commits/gate130/check-runs")) { + return Response.json({ total_count: 1, check_runs: [{ id: 2, name: "CLA Assistant Lite", status: "completed", conclusion: "failure" }] }); + } + if (url.includes("/commits/") && url.includes("/check-runs")) return Response.json({ total_count: 0, check_runs: [] }); + if (url.includes("/commits/") && url.includes("/status")) return Response.json({ statuses: [] }); + if (url.includes("/check-runs")) { + if (init?.body) { + const body = JSON.parse(init.body.toString()) as { name?: string; conclusion?: string; output?: { title?: string; summary?: string } }; + if ((body.name ?? "").includes("Gittensory Orb Review Agent") && body.conclusion) { + gateConclusion = body.conclusion; + gateText = `${body.output?.title ?? ""} ${body.output?.summary ?? ""}`; + } + } + return Response.json({ id: 905 }, { status: 201 }); + } + return new Response("not found", { status: 404 }); + }); + + await processJob(env, { + type: "github-webhook", + deliveryId: "cla-gate-checkrun-fail", + eventName: "pull_request", + payload: { + action: "opened", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + pull_request: { + number: 53, + title: "feat: add a feature", + state: "open", + user: { login: "contributor" }, + head: { sha: "gate130" }, + labels: [], + body: "Closes #1", + mergeable_state: "clean", + reviewDecision: "APPROVED", + }, + }, + }); + expect(gateConclusion).toBe("failure"); + expect(gateText).toContain("CLA consent not confirmed"); + }); + async function setupPlannerRepo(env: Env): Promise { await upsertRepositoryFromGitHub(env, { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, 123); await upsertInstallation(env, {