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
18 changes: 18 additions & 0 deletions apps/gittensory-ui/public/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -8017,6 +8017,14 @@
},
"firstTimeContributorGrace": {
"type": "boolean"
},
"manifestPolicyGateMode": {
"type": "string",
"enum": [
"off",
"advisory",
"block"
]
}
},
"required": [
Expand All @@ -8033,6 +8041,7 @@
"qualityGateMode",
"slopGateMode",
"mergeReadinessGateMode",
"manifestPolicyGateMode",
"firstTimeContributorGrace",
"slopAiAdvisory",
"autoLabelEnabled",
Expand Down Expand Up @@ -8624,6 +8633,14 @@
},
"firstTimeContributorGrace": {
"type": "boolean"
},
"manifestPolicyGateMode": {
"type": "string",
"enum": [
"off",
"advisory",
"block"
]
}
},
"required": [
Expand All @@ -8640,6 +8657,7 @@
"qualityGateMode",
"slopGateMode",
"mergeReadinessGateMode",
"manifestPolicyGateMode",
"firstTimeContributorGrace",
"autoLabelEnabled",
"gittensorLabel",
Expand Down
6 changes: 6 additions & 0 deletions migrations/0040_manifest_policy_gate.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
-- Focus-manifest policy gate (#555). One tunable `manifest_policy_gate_mode`: off (default) | advisory |
-- block. When set to block, the focus manifest's declared policy (blocked paths, required-linked-issue, test
-- expectations) becomes an enforceable `Gittensory Gate` blocker — surfaced through the single required check.
-- An INDEPENDENT dimension, deliberately not folded into the merge-readiness composite. Default 'off'
-- preserves existing behavior for every current repo.
ALTER TABLE repository_settings ADD COLUMN manifest_policy_gate_mode TEXT NOT NULL DEFAULT 'off';
5 changes: 5 additions & 0 deletions src/db/repositories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,7 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise
qualityGateMinScore: null,
slopGateMode: "off",
mergeReadinessGateMode: "off",
manifestPolicyGateMode: "off",
firstTimeContributorGrace: false,
slopGateMinScore: null,
slopAiAdvisory: false,
Expand Down Expand Up @@ -436,6 +437,7 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise
qualityGateMinScore: normalizeQualityGateMinScore(row.qualityGateMinScore),
slopGateMode: parseGateRuleMode(row.slopGateMode),
mergeReadinessGateMode: parseGateRuleMode(row.mergeReadinessGateMode),
manifestPolicyGateMode: parseGateRuleMode(row.manifestPolicyGateMode),
firstTimeContributorGrace: row.firstTimeContributorGrace,
slopGateMinScore: normalizeQualityGateMinScore(row.slopGateMinScore),
slopAiAdvisory: row.slopAiAdvisory,
Expand Down Expand Up @@ -474,6 +476,7 @@ export async function upsertRepositorySettings(env: Env, settings: Partial<Repos
qualityGateMinScore: normalizeQualityGateMinScore(settings.qualityGateMinScore),
slopGateMode: settings.slopGateMode ?? "off",
mergeReadinessGateMode: settings.mergeReadinessGateMode ?? "off",
manifestPolicyGateMode: settings.manifestPolicyGateMode ?? "off",
firstTimeContributorGrace: settings.firstTimeContributorGrace ?? false,
slopGateMinScore: normalizeQualityGateMinScore(settings.slopGateMinScore),
slopAiAdvisory: settings.slopAiAdvisory ?? false,
Expand Down Expand Up @@ -510,6 +513,7 @@ export async function upsertRepositorySettings(env: Env, settings: Partial<Repos
qualityGateMinScore: resolved.qualityGateMinScore,
slopGateMode: resolved.slopGateMode,
mergeReadinessGateMode: resolved.mergeReadinessGateMode,
manifestPolicyGateMode: resolved.manifestPolicyGateMode,
firstTimeContributorGrace: resolved.firstTimeContributorGrace,
slopGateMinScore: resolved.slopGateMinScore,
slopAiAdvisory: resolved.slopAiAdvisory,
Expand Down Expand Up @@ -547,6 +551,7 @@ export async function upsertRepositorySettings(env: Env, settings: Partial<Repos
// persist on update of an existing row. Restored here alongside the new slopAiAdvisory field.
slopGateMode: resolved.slopGateMode,
mergeReadinessGateMode: resolved.mergeReadinessGateMode,
manifestPolicyGateMode: resolved.manifestPolicyGateMode,
firstTimeContributorGrace: resolved.firstTimeContributorGrace,
slopGateMinScore: resolved.slopGateMinScore,
slopAiAdvisory: resolved.slopAiAdvisory,
Expand Down
1 change: 1 addition & 0 deletions src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export const repositorySettings = sqliteTable("repository_settings", {
qualityGateMinScore: integer("quality_gate_min_score"),
slopGateMode: text("slop_gate_mode").notNull().default("off"),
mergeReadinessGateMode: text("merge_readiness_gate_mode").notNull().default("off"),
manifestPolicyGateMode: text("manifest_policy_gate_mode").notNull().default("off"),
firstTimeContributorGrace: integer("first_time_contributor_grace", { mode: "boolean" }).notNull().default(false),
slopGateMinScore: integer("slop_gate_min_score"),
slopAiAdvisory: integer("slop_ai_advisory", { mode: "boolean" }).notNull().default(false),
Expand Down
2 changes: 2 additions & 0 deletions src/openapi/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -565,6 +565,7 @@ export const RepositorySettingsSchema = z
qualityGateMinScore: z.number().nullable().optional(),
slopGateMode: z.enum(["off", "advisory", "block"]),
mergeReadinessGateMode: z.enum(["off", "advisory", "block"]),
manifestPolicyGateMode: z.enum(["off", "advisory", "block"]),
firstTimeContributorGrace: z.boolean(),
slopGateMinScore: z.number().nullable().optional(),
slopAiAdvisory: z.boolean(),
Expand Down Expand Up @@ -604,6 +605,7 @@ export const RepoSettingsPreviewSchema = z
qualityGateMinScore: z.number().nullable().optional(),
slopGateMode: z.enum(["off", "advisory", "block"]),
mergeReadinessGateMode: z.enum(["off", "advisory", "block"]),
manifestPolicyGateMode: z.enum(["off", "advisory", "block"]),
firstTimeContributorGrace: z.boolean(),
slopGateMinScore: z.number().nullable().optional(),
autoLabelEnabled: z.boolean(),
Expand Down
40 changes: 37 additions & 3 deletions src/queue/processors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ import {
import { ensurePullRequestLabel } from "../github/labels";
import { fetchPublicContributorProfile } from "../github/public";
import { refreshRegistry } from "../registry/sync";
import { buildIssueAdvisory, buildPullRequestAdvisory, evaluateGateCheck } from "../rules/advisory";
import { buildIssueAdvisory, buildPullRequestAdvisory, evaluateGateCheck, isTestPath } from "../rules/advisory";
import { detectNotificationEvents } from "../notifications/events";
import { deliverNotification, detectIssueWatchEvents, evaluateNotificationEvent } from "../notifications/service";
import { getOrCreateScoringModelSnapshot, refreshScoringModelSnapshot } from "../scoring/model";
Expand Down Expand Up @@ -137,7 +137,7 @@ import { buildIssueSlopAssessment, buildSlopAssessment, type SlopBand } from "..
import { runGittensoryAiSlopAdvisory } from "../services/ai-slop";
import { decidePublicSurface } from "../signals/settings-preview";
import { loadRepoFocusManifest } from "../signals/focus-manifest-loader";
import { resolveEffectiveSettings } from "../signals/focus-manifest";
import { buildFocusManifestGuidance, resolveEffectiveSettings } from "../signals/focus-manifest";
import type { LocalBranchAnalysisInput } from "../signals/local-branch";
import { runGittensoryAiReview } from "../services/ai-review";
import type { AdvisoryFinding, ContributorEvidenceRecord, DetectedNotificationEvent, GitHubWebhookPayload, JobMessage, JsonValue, PullRequestRecord, RepositorySettings } from "../types";
Expand Down Expand Up @@ -879,6 +879,7 @@ export function gateCheckPolicy(
readinessScore: readinessScore ?? null,
slopGateMode: settings.slopGateMode,
mergeReadinessGateMode: settings.mergeReadinessGateMode,
manifestPolicyGateMode: settings.manifestPolicyGateMode,
firstTimeContributorGrace: settings.firstTimeContributorGrace,
authorMergedPrCount: authorHistory?.mergedPrCount,
authorClosedUnmergedPrCount: authorHistory?.closedUnmergedPrCount,
Expand Down Expand Up @@ -1223,8 +1224,14 @@ async function maybePublishPrPublicSurface(
// findings as advisory context, and feed the score to the gate (it only blocks under slop: block + the
// threshold). Loads files lazily so disabled repos pay nothing.
let slopRisk: number | null = null;
// Slop (#530) and focus-manifest-policy (#555) gates both need the PR's changed files. Load ONCE and
// share so two opted-in gates don't double-fetch; the load is lazy so a repo with both off pays nothing.
let gateFiles: Awaited<ReturnType<typeof listPullRequestFiles>> | null = null;
if (settings.slopGateMode !== "off" || settings.manifestPolicyGateMode !== "off") {
gateFiles = await listPullRequestFiles(env, repoFullName, pr.number);
}
if (settings.slopGateMode !== "off") {
const slopFiles = await listPullRequestFiles(env, repoFullName, pr.number);
const slopFiles = gateFiles ?? [];
const slop = buildSlopAssessment({
changedFiles: slopFiles.map((file) => ({ path: file.path, additions: file.additions, deletions: file.deletions })),
description: pr.body,
Expand All @@ -1240,6 +1247,33 @@ async function maybePublishPrPublicSurface(
await runAiSlopForAdvisory(env, { settings, advisory, repoFullName, pr, author, files: slopFiles, deterministicBand: slop.band, confirmedContributor });
}
}
// Focus-manifest policy (#555, opt-in via manifestPolicyGateMode). Reload the CACHED manifest (the
// settings resolver discards the raw manifest, but loadRepoFocusManifest is cached so this is cheap),
// recompute the guidance over the PR's changed files, and push ONLY the three enforceable policy
// findings into the advisory so isConfiguredGateBlocker can block under manifestPolicy: block.
if (settings.manifestPolicyGateMode !== "off") {
const manifestFiles = gateFiles ?? [];
const manifest = await loadRepoFocusManifest(env, repoFullName);
const guidance = buildFocusManifestGuidance({
manifest,
changedPaths: manifestFiles.map((file) => file.path),
labels: pr.labels,
linkedIssueCount: pr.linkedIssues.length,
testFileCount: manifestFiles.filter((file) => isTestPath(file.path)).length,
passedValidationCount: 0,
});
const policyCodes = new Set(["manifest_blocked_path", "manifest_linked_issue_required", "manifest_missing_tests"]);
for (const finding of guidance.findings) {
if (!policyCodes.has(finding.code)) continue;
advisory.findings.push({
code: finding.code,
severity: finding.severity,
title: finding.title,
detail: finding.detail,
...(finding.action !== undefined ? { action: finding.action } : {}),
});
}
}

// 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
12 changes: 11 additions & 1 deletion src/rules/advisory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ export type GateCheckPolicy = {
* linked-issue, duplicate, quality/readiness, slop — to its mode, so a maintainer flips ONE switch instead
* of four and `Gittensory Gate` stays the single required check. `off` = sub-gates use their own modes. */
mergeReadinessGateMode?: GateRuleMode | undefined;
/** Focus-manifest policy gate (#555). When `block`, the focus manifest's declared policy findings —
* `manifest_blocked_path`, `manifest_linked_issue_required`, `manifest_missing_tests` — become hard
* blockers. An INDEPENDENT dimension, deliberately NOT folded into the merge-readiness composite so #555
* stays focused. `off`/`advisory` = the findings stay advisory (never block). Default off. */
manifestPolicyGateMode?: GateRuleMode | undefined;
/** First-time-contributor grace (#552). When true AND the author is a genuine newcomer (0 merged PRs in
* this repo) who is NOT a repeat offender (< 3 closed-unmerged PRs), a would-be BLOCK is softened to a
* neutral/advisory gate. `undefined`/false = the grace rule does not apply and blockers gate normally. */
Expand Down Expand Up @@ -177,7 +182,7 @@ function isCodePath(path: string): boolean {
return /\.(ts|tsx|js|jsx|py|go|rs|java|rb|php|cs|cpp|c|h|swift|kt|m|sql|yaml|yml|json|toml|md)$/i.test(path);
}

function isTestPath(path: string): boolean {
export function isTestPath(path: string): boolean {
return (
/(^|\/)(test|tests|spec|__tests__)\//i.test(path) ||
/\.(test|spec)\.(ts|tsx|js|jsx|py|go|rs)$/i.test(path) ||
Expand Down Expand Up @@ -604,6 +609,11 @@ function isConfiguredGateBlocker(code: string, policy: GateCheckPolicy): boolean
// most conservative AI signal (two independent models, high confidence) but still confirmed-contributor
// gated by evaluateGateCheck, and advisory by default.
if (code === "ai_consensus_defect") return gateMode(policy.aiReviewGateMode ?? "advisory") === "block";
// Focus-manifest policy (#555): the three enforceable manifest findings block ONLY when the maintainer
// opts into manifestPolicy: block. Default off/advisory keeps them advisory-only.
if (code === "manifest_blocked_path" || code === "manifest_linked_issue_required" || code === "manifest_missing_tests") {
return gateMode(policy.manifestPolicyGateMode ?? "off") === "block";
}
return false;
}

Expand Down
6 changes: 6 additions & 0 deletions src/signals/focus-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export type FocusManifestGateConfig = {
aiReviewProvider: "anthropic" | "openai" | null;
aiReviewModel: string | null;
mergeReadiness: GateRuleMode | null;
manifestPolicy: GateRuleMode | null;
firstTimeContributorGrace: boolean | null;
};

Expand Down Expand Up @@ -160,6 +161,7 @@ const EMPTY_GATE_CONFIG: FocusManifestGateConfig = {
aiReviewProvider: null,
aiReviewModel: null,
mergeReadiness: null,
manifestPolicy: null,
firstTimeContributorGrace: null,
};

Expand Down Expand Up @@ -302,6 +304,7 @@ function parseGateConfig(value: JsonValue | undefined, warnings: string[]): Focu
aiReviewProvider: normalizeOptionalEnum(aiReviewRecord?.provider, "gate.aiReview.provider", ["anthropic", "openai"] as const, warnings),
aiReviewModel: normalizeOptionalString(aiReviewRecord?.model, "gate.aiReview.model", warnings),
mergeReadiness: normalizeOptionalGateMode(record.mergeReadiness, "gate.mergeReadiness", warnings),
manifestPolicy: normalizeOptionalGateMode(record.manifestPolicy, "gate.manifestPolicy", warnings),
firstTimeContributorGrace: normalizeOptionalBoolean(record.firstTimeContributorGrace, "gate.firstTimeContributorGrace", warnings),
};
gate.present =
Expand All @@ -319,6 +322,7 @@ function parseGateConfig(value: JsonValue | undefined, warnings: string[]): Focu
gate.aiReviewProvider !== null ||
gate.aiReviewModel !== null ||
gate.mergeReadiness !== null ||
gate.manifestPolicy !== null ||
gate.firstTimeContributorGrace !== null;
return gate;
}
Expand Down Expand Up @@ -356,6 +360,7 @@ export function gateConfigToJson(gate: FocusManifestGateConfig): JsonValue {
out.aiReview = aiReview;
}
if (gate.mergeReadiness !== null) out.mergeReadiness = gate.mergeReadiness;
if (gate.manifestPolicy !== null) out.manifestPolicy = gate.manifestPolicy;
if (gate.firstTimeContributorGrace !== null) out.firstTimeContributorGrace = gate.firstTimeContributorGrace;
return out;
}
Expand Down Expand Up @@ -503,6 +508,7 @@ export function resolveEffectiveSettings(dbSettings: RepositorySettings, manifes
if (gate.aiReviewProvider !== null) effective.aiReviewProvider = gate.aiReviewProvider;
if (gate.aiReviewModel !== null) effective.aiReviewModel = gate.aiReviewModel;
if (gate.mergeReadiness !== null) effective.mergeReadinessGateMode = gate.mergeReadiness;
if (gate.manifestPolicy !== null) effective.manifestPolicyGateMode = gate.manifestPolicy;
if (gate.firstTimeContributorGrace !== null) effective.firstTimeContributorGrace = gate.firstTimeContributorGrace;
return effective;
}
Expand Down
2 changes: 2 additions & 0 deletions src/signals/settings-preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ export type RepoSettingsPreview = {
qualityGateMinScore?: number | null | undefined;
slopGateMode: RepositorySettings["slopGateMode"];
mergeReadinessGateMode: RepositorySettings["mergeReadinessGateMode"];
manifestPolicyGateMode: RepositorySettings["manifestPolicyGateMode"];
firstTimeContributorGrace: boolean;
slopGateMinScore?: number | null | undefined;
autoLabelEnabled: boolean;
Expand Down Expand Up @@ -306,6 +307,7 @@ export function buildRepoSettingsPreview(args: {
qualityGateMinScore: settings.qualityGateMinScore ?? null,
slopGateMode: settings.slopGateMode,
mergeReadinessGateMode: settings.mergeReadinessGateMode,
manifestPolicyGateMode: settings.manifestPolicyGateMode,
firstTimeContributorGrace: settings.firstTimeContributorGrace,
slopGateMinScore: settings.slopGateMinScore ?? null,
autoLabelEnabled: settings.autoLabelEnabled,
Expand Down
4 changes: 4 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,10 @@ export type RepositorySettings = {
slopGateMode: GateRuleMode;
/** Merge-readiness gate (#merge-readiness). `off`/`advisory`/`block`. No min-score. Default `off`. */
mergeReadinessGateMode: GateRuleMode;
/** Focus-manifest policy gate (#555). When `block`, the focus manifest's declared policy (blocked paths,
* required-linked-issue, test expectations) becomes an enforceable `Gittensory Gate` blocker. An
* INDEPENDENT dimension, deliberately not folded into the merge-readiness composite. Default `off` — opt-in. */
manifestPolicyGateMode: GateRuleMode;
/** First-time-contributor grace (#552). When true, a would-be BLOCK is softened to a neutral/advisory gate
* for a genuine newcomer (0 merged PRs in this repo) who is NOT a repeat offender (< 3 closed-unmerged PRs).
* Repeat offenders and authors with merge history are gated normally. Default false — opt-in. */
Expand Down
Loading
Loading