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 @@ -8006,6 +8006,14 @@
},
"slopAiAdvisory": {
"type": "boolean"
},
"mergeReadinessGateMode": {
"type": "string",
"enum": [
"off",
"advisory",
"block"
]
}
},
"required": [
Expand All @@ -8021,6 +8029,7 @@
"duplicatePrGateMode",
"qualityGateMode",
"slopGateMode",
"mergeReadinessGateMode",
"slopAiAdvisory",
"autoLabelEnabled",
"gittensorLabel",
Expand Down Expand Up @@ -8600,6 +8609,14 @@
"slopGateMinScore": {
"type": "number",
"nullable": true
},
"mergeReadinessGateMode": {
"type": "string",
"enum": [
"off",
"advisory",
"block"
]
}
},
"required": [
Expand All @@ -8615,6 +8632,7 @@
"duplicatePrGateMode",
"qualityGateMode",
"slopGateMode",
"mergeReadinessGateMode",
"autoLabelEnabled",
"gittensorLabel",
"createMissingLabel",
Expand Down
5 changes: 5 additions & 0 deletions migrations/0038_merge_readiness_gate.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-- Merge-readiness composite gate (#551). One tunable `merge_readiness_gate_mode`: off (default) | advisory |
-- block. When set, it rolls the four sub-gates (linked-issue, duplicate, quality/readiness, slop) into a
-- single `Gittensory Gate` pass/fail so a maintainer keeps ONE required check instead of four. Default 'off'
-- preserves existing behavior for every current repo.
ALTER TABLE repository_settings ADD COLUMN merge_readiness_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 @@ -401,6 +401,7 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise
qualityGateMode: "advisory",
qualityGateMinScore: null,
slopGateMode: "off",
mergeReadinessGateMode: "off",
slopGateMinScore: null,
slopAiAdvisory: false,
aiReviewMode: "off",
Expand Down Expand Up @@ -433,6 +434,7 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise
qualityGateMode: parseGateRuleMode(row.qualityGateMode),
qualityGateMinScore: normalizeQualityGateMinScore(row.qualityGateMinScore),
slopGateMode: parseGateRuleMode(row.slopGateMode),
mergeReadinessGateMode: parseGateRuleMode(row.mergeReadinessGateMode),
slopGateMinScore: normalizeQualityGateMinScore(row.slopGateMinScore),
slopAiAdvisory: row.slopAiAdvisory,
aiReviewMode: parseGateRuleMode(row.aiReviewMode),
Expand Down Expand Up @@ -469,6 +471,7 @@ export async function upsertRepositorySettings(env: Env, settings: Partial<Repos
qualityGateMode: settings.qualityGateMode ?? "advisory",
qualityGateMinScore: normalizeQualityGateMinScore(settings.qualityGateMinScore),
slopGateMode: settings.slopGateMode ?? "off",
mergeReadinessGateMode: settings.mergeReadinessGateMode ?? "off",
slopGateMinScore: normalizeQualityGateMinScore(settings.slopGateMinScore),
slopAiAdvisory: settings.slopAiAdvisory ?? false,
aiReviewMode: settings.aiReviewMode ?? "off",
Expand Down Expand Up @@ -503,6 +506,7 @@ export async function upsertRepositorySettings(env: Env, settings: Partial<Repos
qualityGateMode: resolved.qualityGateMode,
qualityGateMinScore: resolved.qualityGateMinScore,
slopGateMode: resolved.slopGateMode,
mergeReadinessGateMode: resolved.mergeReadinessGateMode,
slopGateMinScore: resolved.slopGateMinScore,
slopAiAdvisory: resolved.slopAiAdvisory,
aiReviewMode: resolved.aiReviewMode,
Expand Down Expand Up @@ -538,6 +542,7 @@ export async function upsertRepositorySettings(env: Env, settings: Partial<Repos
// slop_* were previously absent from the UPDATE branch (only INSERT), so slop settings did not
// persist on update of an existing row. Restored here alongside the new slopAiAdvisory field.
slopGateMode: resolved.slopGateMode,
mergeReadinessGateMode: resolved.mergeReadinessGateMode,
slopGateMinScore: resolved.slopGateMinScore,
slopAiAdvisory: resolved.slopAiAdvisory,
aiReviewMode: resolved.aiReviewMode,
Expand Down
1 change: 1 addition & 0 deletions src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export const repositorySettings = sqliteTable("repository_settings", {
qualityGateMode: text("quality_gate_mode").notNull().default("advisory"),
qualityGateMinScore: integer("quality_gate_min_score"),
slopGateMode: text("slop_gate_mode").notNull().default("off"),
mergeReadinessGateMode: text("merge_readiness_gate_mode").notNull().default("off"),
slopGateMinScore: integer("slop_gate_min_score"),
slopAiAdvisory: integer("slop_ai_advisory", { mode: "boolean" }).notNull().default(false),
aiReviewMode: text("ai_review_mode").notNull().default("off"),
Expand Down
2 changes: 2 additions & 0 deletions src/openapi/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -564,6 +564,7 @@ export const RepositorySettingsSchema = z
qualityGateMode: z.enum(["off", "advisory", "block"]),
qualityGateMinScore: z.number().nullable().optional(),
slopGateMode: z.enum(["off", "advisory", "block"]),
mergeReadinessGateMode: z.enum(["off", "advisory", "block"]),
slopGateMinScore: z.number().nullable().optional(),
slopAiAdvisory: z.boolean(),
autoLabelEnabled: z.boolean(),
Expand Down Expand Up @@ -601,6 +602,7 @@ export const RepoSettingsPreviewSchema = z
qualityGateMode: z.enum(["off", "advisory", "block"]),
qualityGateMinScore: z.number().nullable().optional(),
slopGateMode: z.enum(["off", "advisory", "block"]),
mergeReadinessGateMode: z.enum(["off", "advisory", "block"]),
slopGateMinScore: z.number().nullable().optional(),
autoLabelEnabled: z.boolean(),
gittensorLabel: z.string(),
Expand Down
1 change: 1 addition & 0 deletions src/queue/processors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -858,6 +858,7 @@ export function gateCheckPolicy(settings: RepositorySettings, readinessScore?: n
aiReviewGateMode: settings.aiReviewMode,
readinessScore: readinessScore ?? null,
slopGateMode: settings.slopGateMode,
mergeReadinessGateMode: settings.mergeReadinessGateMode,
slopGateMinScore: settings.slopGateMinScore ?? null,
slopRisk: slopRisk ?? null,
confirmedContributor: confirmedContributorForPack,
Expand Down
30 changes: 26 additions & 4 deletions src/rules/advisory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ export type GateCheckPolicy = {
slopGateMode?: GateRuleMode | undefined;
slopGateMinScore?: number | null | undefined;
slopRisk?: number | null | undefined;
/** Master "merge-readiness" composite (#551). When set (advisory/block) it OVERRIDES all four sub-gates —
* 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;
/** ONLY confirmed gittensor contributors can be hard-blocked. When explicitly `false`, the gate is
* forced to a neutral (non-blocking) conclusion regardless of blockers — gittensory must never block
* a non-confirmed contributor. `undefined` = the caller did not gate on contributor status. */
Expand Down Expand Up @@ -303,14 +307,17 @@ export function evaluateGateCheck(advisoryResult: Advisory, policy: GateCheckPol
warnings,
};
}
const configuredBlockers = advisoryResult.findings.filter((finding) => isConfiguredGateBlocker(finding.code, policy));
const qualityBlocker = buildQualityGateBlocker(policy);
const slopBlocker = buildSlopGateBlocker(policy);
// Merge-readiness composite (#551): when set, escalate every sub-gate to its mode so they roll into one
// pass/fail. When off, this is a no-op and each sub-gate keeps its own mode.
const effective = applyMergeReadinessGate(policy);
const configuredBlockers = advisoryResult.findings.filter((finding) => isConfiguredGateBlocker(finding.code, effective));
const qualityBlocker = buildQualityGateBlocker(effective);
const slopBlocker = buildSlopGateBlocker(effective);
const blockers = [...configuredBlockers, ...(qualityBlocker ? [qualityBlocker] : []), ...(slopBlocker ? [slopBlocker] : [])];
// Contributor-gated: ONLY confirmed Gittensor contributors can be hard-blocked. For everyone else the
// gate is neutral (non-blocking) + the minimal advisory comment — gittensory must never block a
// non-confirmed contributor, regardless of what blockers fired.
if (policy.confirmedContributor === false && blockers.length > 0) {
if (effective.confirmedContributor === false && blockers.length > 0) {
return {
enabled: true,
conclusion: "neutral",
Expand Down Expand Up @@ -610,6 +617,21 @@ function gateMode(value: GateRuleMode | null | undefined): GateRuleMode {
return value === "off" || value === "block" ? value : "advisory";
}

// #551: the master merge-readiness composite. When mergeReadinessGateMode is set (advisory/block) it
// OVERRIDES the four sub-gates to its mode so they roll into one pass/fail; when off, the policy is returned
// unchanged and each sub-gate keeps its own mode.
function applyMergeReadinessGate(policy: GateCheckPolicy): GateCheckPolicy {
const composite = gateMode(policy.mergeReadinessGateMode ?? "off");
if (composite === "off") return policy;
return {
...policy,
linkedIssueGateMode: composite,
duplicatePrGateMode: composite,
qualityGateMode: composite,
slopGateMode: composite,
};
}

function normalizeScore(value: number | null | undefined): number | null {
if (typeof value !== "number" || !Number.isFinite(value)) return null;
return Math.max(0, Math.min(100, Math.round(value)));
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 @@ -188,6 +188,7 @@ export type RepoSettingsPreview = {
qualityGateMode: RepositorySettings["qualityGateMode"];
qualityGateMinScore?: number | null | undefined;
slopGateMode: RepositorySettings["slopGateMode"];
mergeReadinessGateMode: RepositorySettings["mergeReadinessGateMode"];
slopGateMinScore?: number | null | undefined;
autoLabelEnabled: boolean;
gittensorLabel: string;
Expand Down Expand Up @@ -302,6 +303,7 @@ export function buildRepoSettingsPreview(args: {
qualityGateMode: settings.qualityGateMode,
qualityGateMinScore: settings.qualityGateMinScore ?? null,
slopGateMode: settings.slopGateMode,
mergeReadinessGateMode: settings.mergeReadinessGateMode,
slopGateMinScore: settings.slopGateMinScore ?? null,
autoLabelEnabled: settings.autoLabelEnabled,
gittensorLabel: settings.gittensorLabel,
Expand Down
2 changes: 2 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,8 @@ export type RepositorySettings = {
* score + warnings in context; `block` = ALSO hard-block when slopRisk >= slopGateMinScore (deterministic
* only, confirmed-contributor-gated like every blocker). Default `off` — opt-in via .gittensory.yml. */
slopGateMode: GateRuleMode;
/** Merge-readiness gate (#merge-readiness). `off`/`advisory`/`block`. No min-score. Default `off`. */
mergeReadinessGateMode: GateRuleMode;
/** Slop-risk threshold (0-100) at/above which `slopGateMode: block` blocks. Default 60 (the `high` band). */
slopGateMinScore?: number | null | undefined;
/** AI-assisted slop advisory (the `slopAiAdvisory` capability). When true AND `slopGateMode != off`, a
Expand Down
29 changes: 29 additions & 0 deletions test/unit/gate-check-policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,3 +177,32 @@ describe("slop gate (#530/#532)", () => {
expect(blocked.blockers.map((finding) => finding.code)).toContain("slop_risk_above_threshold");
});
});

describe("merge-readiness composite gate (#551)", () => {
it("block escalates an otherwise-advisory sub-gate (linked-issue) into a hard blocker", () => {
const eff = resolveEffectiveSettings(settings({ linkedIssueGateMode: "advisory", mergeReadinessGateMode: "block" }), parseFocusManifest(null));
const result = evaluateGateCheck(missingIssueAdvisory(), gateCheckPolicy(eff, null, true));
expect(result.conclusion).toBe("failure");
expect(result.summary).toContain("No linked issue detected");
});

it("advisory keeps the composite non-blocking even when a sub-gate is individually set to block", () => {
const eff = resolveEffectiveSettings(settings({ linkedIssueGateMode: "block", mergeReadinessGateMode: "advisory" }), parseFocusManifest(null));
expect(evaluateGateCheck(missingIssueAdvisory(), gateCheckPolicy(eff, null, true)).conclusion).toBe("success");
});

it("off is a no-op: sub-gates keep their own modes (linked-issue stays advisory -> non-blocking)", () => {
const eff = resolveEffectiveSettings(settings({ linkedIssueGateMode: "advisory", mergeReadinessGateMode: "off" }), parseFocusManifest(null));
expect(evaluateGateCheck(missingIssueAdvisory(), gateCheckPolicy(eff, null, true)).conclusion).toBe("success");
});

it("the blocking summary lists each unmet sub-gate condition", () => {
const advisory = missingIssueAdvisory();
advisory.findings.push({ code: "duplicate_pr_risk", title: "Possible duplicate PR", severity: "warning", detail: "Overlaps #9.", action: "Close the duplicate." });
const eff = resolveEffectiveSettings(settings({ mergeReadinessGateMode: "block" }), parseFocusManifest(null));
const result = evaluateGateCheck(advisory, gateCheckPolicy(eff, null, true));
expect(result.conclusion).toBe("failure");
expect(result.summary).toContain("No linked issue detected");
expect(result.summary).toContain("Possible duplicate PR");
});
});
1 change: 1 addition & 0 deletions test/unit/maintainer-activation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ function settings(overrides: Partial<RepositorySettings> = {}): RepositorySettin
duplicatePrGateMode: "advisory",
qualityGateMode: "advisory",
slopGateMode: "off",
mergeReadinessGateMode: "off",
slopAiAdvisory: false,
qualityGateMinScore: null,
autoLabelEnabled: true,
Expand Down
1 change: 1 addition & 0 deletions test/unit/policy-sanitizer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ function settingsFor(repoFullName: string, overrides: Partial<RepositorySettings
duplicatePrGateMode: "advisory",
qualityGateMode: "advisory",
slopGateMode: "off",
mergeReadinessGateMode: "off",
slopAiAdvisory: false,
qualityGateMinScore: null,
autoLabelEnabled: true,
Expand Down
1 change: 1 addition & 0 deletions test/unit/registration-readiness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ function settingsFor(repoFullName: string, overrides: Partial<RepositorySettings
duplicatePrGateMode: "advisory",
qualityGateMode: "advisory",
slopGateMode: "off",
mergeReadinessGateMode: "off",
slopAiAdvisory: false,
qualityGateMinScore: null,
autoLabelEnabled: true,
Expand Down
1 change: 1 addition & 0 deletions test/unit/repo-policy-readiness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ function settings(overrides: Partial<RepositorySettings> = {}): RepositorySettin
duplicatePrGateMode: "advisory",
qualityGateMode: "advisory",
slopGateMode: "off",
mergeReadinessGateMode: "off",
slopAiAdvisory: false,
qualityGateMinScore: null,
autoLabelEnabled: true,
Expand Down
1 change: 1 addition & 0 deletions test/unit/self-dogfood-registration-pack.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ function settingsFor(repoFullName: string, overrides: Partial<RepositorySettings
duplicatePrGateMode: "advisory",
qualityGateMode: "advisory",
slopGateMode: "off",
mergeReadinessGateMode: "off",
slopAiAdvisory: false,
qualityGateMinScore: null,
autoLabelEnabled: true,
Expand Down
1 change: 1 addition & 0 deletions test/unit/settings-preview.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ function settings(overrides: Partial<RepositorySettings> = {}): RepositorySettin
duplicatePrGateMode: "advisory",
qualityGateMode: "advisory",
slopGateMode: "off",
mergeReadinessGateMode: "off",
slopAiAdvisory: false,
qualityGateMinScore: null,
autoLabelEnabled: true,
Expand Down
1 change: 1 addition & 0 deletions test/unit/signals-coverage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1530,6 +1530,7 @@ function repoSettings(repoFullName: string): RepositorySettings {
duplicatePrGateMode: "advisory",
qualityGateMode: "advisory",
slopGateMode: "off",
mergeReadinessGateMode: "off",
slopAiAdvisory: false,
qualityGateMinScore: null,
autoLabelEnabled: true,
Expand Down
1 change: 1 addition & 0 deletions test/unit/signals-v2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1624,6 +1624,7 @@ describe("v2 signal builders", () => {
duplicatePrGateMode: "advisory",
qualityGateMode: "advisory",
slopGateMode: "off",
mergeReadinessGateMode: "off",
slopAiAdvisory: false,
qualityGateMinScore: null,
autoLabelEnabled: true,
Expand Down
5 changes: 5 additions & 0 deletions test/unit/signals.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,7 @@ describe("world-class backend signals", () => {
duplicatePrGateMode: "advisory" as const,
qualityGateMode: "advisory" as const,
slopGateMode: "off" as const,
mergeReadinessGateMode: "off" as const,
slopAiAdvisory: false,
qualityGateMinScore: null,
autoLabelEnabled: true,
Expand Down Expand Up @@ -438,6 +439,7 @@ describe("world-class backend signals", () => {
duplicatePrGateMode: "advisory" as const,
qualityGateMode: "advisory" as const,
slopGateMode: "off" as const,
mergeReadinessGateMode: "off" as const,
slopAiAdvisory: false,
qualityGateMinScore: null,
autoLabelEnabled: true,
Expand Down Expand Up @@ -506,6 +508,7 @@ describe("world-class backend signals", () => {
duplicatePrGateMode: "advisory",
qualityGateMode: "advisory",
slopGateMode: "off",
mergeReadinessGateMode: "off",
slopAiAdvisory: false,
qualityGateMinScore: null,
autoLabelEnabled: true,
Expand Down Expand Up @@ -595,6 +598,7 @@ describe("world-class backend signals", () => {
duplicatePrGateMode: "advisory",
qualityGateMode: "advisory",
slopGateMode: "off",
mergeReadinessGateMode: "off",
slopAiAdvisory: false,
qualityGateMinScore: null,
autoLabelEnabled: true,
Expand Down Expand Up @@ -659,6 +663,7 @@ describe("world-class backend signals", () => {
duplicatePrGateMode: "advisory",
qualityGateMode: "advisory",
slopGateMode: "off",
mergeReadinessGateMode: "off",
slopAiAdvisory: false,
qualityGateMinScore: null,
autoLabelEnabled: true,
Expand Down
Loading