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
8 changes: 8 additions & 0 deletions apps/gittensory-ui/public/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -8014,6 +8014,9 @@
"advisory",
"block"
]
},
"firstTimeContributorGrace": {
"type": "boolean"
}
},
"required": [
Expand All @@ -8030,6 +8033,7 @@
"qualityGateMode",
"slopGateMode",
"mergeReadinessGateMode",
"firstTimeContributorGrace",
"slopAiAdvisory",
"autoLabelEnabled",
"gittensorLabel",
Expand Down Expand Up @@ -8617,6 +8621,9 @@
"advisory",
"block"
]
},
"firstTimeContributorGrace": {
"type": "boolean"
}
},
"required": [
Expand All @@ -8633,6 +8640,7 @@
"qualityGateMode",
"slopGateMode",
"mergeReadinessGateMode",
"firstTimeContributorGrace",
"autoLabelEnabled",
"gittensorLabel",
"createMissingLabel",
Expand Down
5 changes: 5 additions & 0 deletions migrations/0039_first_time_contributor_grace.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-- First-time-contributor-aware gating (#552). Opt-in boolean: when on, a would-be hard 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 0
-- (off) preserves existing behavior for every current repo.
ALTER TABLE repository_settings ADD COLUMN first_time_contributor_grace INTEGER NOT NULL DEFAULT 0;
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",
firstTimeContributorGrace: false,
slopGateMinScore: null,
slopAiAdvisory: false,
aiReviewMode: "off",
Expand Down Expand Up @@ -435,6 +436,7 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise
qualityGateMinScore: normalizeQualityGateMinScore(row.qualityGateMinScore),
slopGateMode: parseGateRuleMode(row.slopGateMode),
mergeReadinessGateMode: parseGateRuleMode(row.mergeReadinessGateMode),
firstTimeContributorGrace: row.firstTimeContributorGrace,
slopGateMinScore: normalizeQualityGateMinScore(row.slopGateMinScore),
slopAiAdvisory: row.slopAiAdvisory,
aiReviewMode: parseGateRuleMode(row.aiReviewMode),
Expand Down Expand Up @@ -472,6 +474,7 @@ export async function upsertRepositorySettings(env: Env, settings: Partial<Repos
qualityGateMinScore: normalizeQualityGateMinScore(settings.qualityGateMinScore),
slopGateMode: settings.slopGateMode ?? "off",
mergeReadinessGateMode: settings.mergeReadinessGateMode ?? "off",
firstTimeContributorGrace: settings.firstTimeContributorGrace ?? false,
slopGateMinScore: normalizeQualityGateMinScore(settings.slopGateMinScore),
slopAiAdvisory: settings.slopAiAdvisory ?? false,
aiReviewMode: settings.aiReviewMode ?? "off",
Expand Down Expand Up @@ -507,6 +510,7 @@ export async function upsertRepositorySettings(env: Env, settings: Partial<Repos
qualityGateMinScore: resolved.qualityGateMinScore,
slopGateMode: resolved.slopGateMode,
mergeReadinessGateMode: resolved.mergeReadinessGateMode,
firstTimeContributorGrace: resolved.firstTimeContributorGrace,
slopGateMinScore: resolved.slopGateMinScore,
slopAiAdvisory: resolved.slopAiAdvisory,
aiReviewMode: resolved.aiReviewMode,
Expand Down Expand Up @@ -543,6 +547,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,
firstTimeContributorGrace: resolved.firstTimeContributorGrace,
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 @@ -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"),
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),
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 @@ -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"]),
firstTimeContributorGrace: z.boolean(),
slopGateMinScore: z.number().nullable().optional(),
slopAiAdvisory: z.boolean(),
autoLabelEnabled: z.boolean(),
Expand Down Expand Up @@ -603,6 +604,7 @@ export const RepoSettingsPreviewSchema = z
qualityGateMinScore: z.number().nullable().optional(),
slopGateMode: z.enum(["off", "advisory", "block"]),
mergeReadinessGateMode: z.enum(["off", "advisory", "block"]),
firstTimeContributorGrace: z.boolean(),
slopGateMinScore: z.number().nullable().optional(),
autoLabelEnabled: z.boolean(),
gittensorLabel: z.string(),
Expand Down
22 changes: 20 additions & 2 deletions src/queue/processors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -857,7 +857,13 @@ function shouldProcessPullRequestPublicSurface(action: string | undefined): bool
return PR_PUBLIC_SURFACE_ACTIONS.has(action ?? "") || PR_GATE_CLOSED_ACTIONS.has(action ?? "");
}

export function gateCheckPolicy(settings: RepositorySettings, readinessScore?: number | null, confirmedContributor?: boolean, slopRisk?: number | null) {
export function gateCheckPolicy(
settings: RepositorySettings,
readinessScore?: number | null,
confirmedContributor?: boolean,
slopRisk?: number | null,
authorHistory?: { mergedPrCount: number; closedUnmergedPrCount: number },
) {
// `settings` is already the EFFECTIVE config (`.gittensory.yml` > DB > defaults), resolved upstream by
// resolveRepositorySettings, so the blocker modes here reflect the repo's config file directly.
// The `oss-anti-slop` pack (#692) is repo-agnostic: it blocks ANY author whose PR trips an opted-in
Expand All @@ -873,6 +879,9 @@ export function gateCheckPolicy(settings: RepositorySettings, readinessScore?: n
readinessScore: readinessScore ?? null,
slopGateMode: settings.slopGateMode,
mergeReadinessGateMode: settings.mergeReadinessGateMode,
firstTimeContributorGrace: settings.firstTimeContributorGrace,
authorMergedPrCount: authorHistory?.mergedPrCount,
authorClosedUnmergedPrCount: authorHistory?.closedUnmergedPrCount,
slopGateMinScore: settings.slopGateMinScore ?? null,
slopRisk: slopRisk ?? null,
confirmedContributor: confirmedContributorForPack,
Expand Down Expand Up @@ -1237,7 +1246,16 @@ async function maybePublishPrPublicSurface(
// failure is caught and the gate is still finalized (never left in_progress).
aiReview = await runAiReviewForAdvisory(env, { settings, advisory, repoFullName, pr, author, confirmedContributor });

const gatePolicy = gateCheckPolicy(settings, readiness.total, confirmedContributor, slopRisk);
// First-time-contributor grace (#552): the author's per-repo PR history (excluding this PR). Newcomer =
// 0 merged here; repeat offender = >= 3 closed-unmerged here. Cheap (in-memory over the already-loaded
// repo PRs) and only consulted by evaluateGateCheck when firstTimeContributorGrace is on.
const authorPrs = author ? repoPullRequests.filter((candidate) => candidate.authorLogin === author && candidate.number !== pr.number) : [];
const authorHistory = {
mergedPrCount: authorPrs.filter((candidate) => candidate.mergedAt || candidate.state === "merged").length,
closedUnmergedPrCount: authorPrs.filter((candidate) => candidate.state === "closed" && !candidate.mergedAt).length,
};

const gatePolicy = gateCheckPolicy(settings, readiness.total, confirmedContributor, slopRisk, authorHistory);
gateEvaluation = gateEnabled ? evaluateGateCheck(advisory, gatePolicy) : undefined;
if (gateEnabled) {
const gateCheckResult = await createOrUpdateGateCheckRun(
Expand Down
26 changes: 26 additions & 0 deletions src/rules/advisory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,14 @@ 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;
/** 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. */
firstTimeContributorGrace?: boolean | undefined;
/** The PR author's merged PR count in THIS repo (newcomer = 0). Used only by the grace rule. */
authorMergedPrCount?: number | undefined;
/** The PR author's closed-unmerged PR count in THIS repo (repeat offender = >= 3). Used only by grace. */
authorClosedUnmergedPrCount?: number | 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 @@ -327,6 +335,24 @@ export function evaluateGateCheck(advisoryResult: Advisory, policy: GateCheckPol
warnings,
};
}
// First-time-contributor grace (#552): when the maintainer opted in, a genuine newcomer (0 merged PRs in
// this repo) who is NOT a repeat offender (< 3 closed-unmerged PRs) gets a neutral, non-blocking gate even
// when blockers fired — they keep the advisory findings without the hard block. Repeat offenders, authors
// with merge history, and repos with the setting off are gated normally below. Public-safe: this only
// expresses advisory-vs-block, never any reward/trust internals.
const isNewcomer = (effective.authorMergedPrCount ?? 0) === 0;
const isRepeatOffender = (effective.authorClosedUnmergedPrCount ?? 0) >= 3;
const graceApplies = effective.firstTimeContributorGrace === true && isNewcomer && !isRepeatOffender;
if (graceApplies && blockers.length > 0) {
return {
enabled: true,
conclusion: "neutral",
title: "Gittensory Gate — first-contribution grace",
summary: "This is a first-time contribution to this repo, so the gate stays advisory rather than blocking. The findings remain visible, and the gate will apply normally once this author has merge history here.",
blockers: [],
warnings,
};
}
if (blockers.length === 0) {
return {
enabled: true,
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"];
firstTimeContributorGrace: boolean;
slopGateMinScore?: number | null | undefined;
autoLabelEnabled: boolean;
gittensorLabel: string;
Expand Down Expand Up @@ -305,6 +306,7 @@ export function buildRepoSettingsPreview(args: {
qualityGateMinScore: settings.qualityGateMinScore ?? null,
slopGateMode: settings.slopGateMode,
mergeReadinessGateMode: settings.mergeReadinessGateMode,
firstTimeContributorGrace: settings.firstTimeContributorGrace,
slopGateMinScore: settings.slopGateMinScore ?? null,
autoLabelEnabled: settings.autoLabelEnabled,
gittensorLabel: settings.gittensorLabel,
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;
/** 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. */
firstTimeContributorGrace: boolean;
/** 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
58 changes: 58 additions & 0 deletions test/unit/gate-check-policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,3 +206,61 @@ describe("merge-readiness composite gate (#551)", () => {
expect(result.summary).toContain("Possible duplicate PR");
});
});

describe("first-time-contributor grace (#552)", () => {
// A would-be hard blocker for a confirmed contributor (linked-issue: block trips on the missing-issue PR).
const blockingPolicy = { linkedIssueGateMode: "block" as const, confirmedContributor: true };

it("(a) softens the block to a neutral/advisory gate for a genuine newcomer (0 merged, 0 closed-unmerged)", () => {
const result = evaluateGateCheck(missingIssueAdvisory(), {
...blockingPolicy,
firstTimeContributorGrace: true,
authorMergedPrCount: 0,
authorClosedUnmergedPrCount: 0,
});
expect(result.conclusion).toBe("neutral");
expect(result.blockers).toEqual([]);
expect(result.title).toContain("first-contribution grace");
});

it("(b) still blocks a repeat offender (0 merged, >= 3 closed-unmerged) — grace does not apply", () => {
const result = evaluateGateCheck(missingIssueAdvisory(), {
...blockingPolicy,
firstTimeContributorGrace: true,
authorMergedPrCount: 0,
authorClosedUnmergedPrCount: 3,
});
expect(result.conclusion).toBe("failure");
expect(result.blockers.map((finding) => finding.code)).toEqual(["missing_linked_issue"]);
});

it("(c) blocks normally when the grace setting is off, even for a newcomer", () => {
const result = evaluateGateCheck(missingIssueAdvisory(), {
...blockingPolicy,
firstTimeContributorGrace: false,
authorMergedPrCount: 0,
authorClosedUnmergedPrCount: 0,
});
expect(result.conclusion).toBe("failure");
expect(result.blockers.map((finding) => finding.code)).toEqual(["missing_linked_issue"]);
});

it("(d) blocks an author with merge history (not a newcomer) even with grace on", () => {
const result = evaluateGateCheck(missingIssueAdvisory(), {
...blockingPolicy,
firstTimeContributorGrace: true,
authorMergedPrCount: 2,
authorClosedUnmergedPrCount: 0,
});
expect(result.conclusion).toBe("failure");
expect(result.blockers.map((finding) => finding.code)).toEqual(["missing_linked_issue"]);
});

it("gateCheckPolicy threads firstTimeContributorGrace + the author's per-repo history into the policy", () => {
const policy = gateCheckPolicy(settings({ firstTimeContributorGrace: true }), null, true, null, { mergedPrCount: 0, closedUnmergedPrCount: 1 });
expect(policy.firstTimeContributorGrace).toBe(true);
expect(policy.authorMergedPrCount).toBe(0);
expect(policy.authorClosedUnmergedPrCount).toBe(1);
expect(evaluateGateCheck(missingIssueAdvisory(), { ...policy, linkedIssueGateMode: "block" }).conclusion).toBe("neutral");
});
});
1 change: 1 addition & 0 deletions test/unit/maintainer-activation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ function settings(overrides: Partial<RepositorySettings> = {}): RepositorySettin
qualityGateMode: "advisory",
slopGateMode: "off",
mergeReadinessGateMode: "off",
firstTimeContributorGrace: false,
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 @@ -68,6 +68,7 @@ function settingsFor(repoFullName: string, overrides: Partial<RepositorySettings
qualityGateMode: "advisory",
slopGateMode: "off",
mergeReadinessGateMode: "off",
firstTimeContributorGrace: false,
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 @@ -48,6 +48,7 @@ function settingsFor(repoFullName: string, overrides: Partial<RepositorySettings
qualityGateMode: "advisory",
slopGateMode: "off",
mergeReadinessGateMode: "off",
firstTimeContributorGrace: false,
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 @@ -26,6 +26,7 @@ function settings(overrides: Partial<RepositorySettings> = {}): RepositorySettin
qualityGateMode: "advisory",
slopGateMode: "off",
mergeReadinessGateMode: "off",
firstTimeContributorGrace: false,
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 @@ -61,6 +61,7 @@ function settingsFor(repoFullName: string, overrides: Partial<RepositorySettings
qualityGateMode: "advisory",
slopGateMode: "off",
mergeReadinessGateMode: "off",
firstTimeContributorGrace: false,
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 @@ -45,6 +45,7 @@ function settings(overrides: Partial<RepositorySettings> = {}): RepositorySettin
qualityGateMode: "advisory",
slopGateMode: "off",
mergeReadinessGateMode: "off",
firstTimeContributorGrace: false,
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 @@ -1531,6 +1531,7 @@ function repoSettings(repoFullName: string): RepositorySettings {
qualityGateMode: "advisory",
slopGateMode: "off",
mergeReadinessGateMode: "off",
firstTimeContributorGrace: false,
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 @@ -1625,6 +1625,7 @@ describe("v2 signal builders", () => {
qualityGateMode: "advisory",
slopGateMode: "off",
mergeReadinessGateMode: "off",
firstTimeContributorGrace: false,
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 @@ -391,6 +391,7 @@ describe("world-class backend signals", () => {
qualityGateMode: "advisory" as const,
slopGateMode: "off" as const,
mergeReadinessGateMode: "off" as const,
firstTimeContributorGrace: false,
slopAiAdvisory: false,
qualityGateMinScore: null,
autoLabelEnabled: true,
Expand Down Expand Up @@ -440,6 +441,7 @@ describe("world-class backend signals", () => {
qualityGateMode: "advisory" as const,
slopGateMode: "off" as const,
mergeReadinessGateMode: "off" as const,
firstTimeContributorGrace: false,
slopAiAdvisory: false,
qualityGateMinScore: null,
autoLabelEnabled: true,
Expand Down Expand Up @@ -509,6 +511,7 @@ describe("world-class backend signals", () => {
qualityGateMode: "advisory",
slopGateMode: "off",
mergeReadinessGateMode: "off",
firstTimeContributorGrace: false,
slopAiAdvisory: false,
qualityGateMinScore: null,
autoLabelEnabled: true,
Expand Down Expand Up @@ -599,6 +602,7 @@ describe("world-class backend signals", () => {
qualityGateMode: "advisory",
slopGateMode: "off",
mergeReadinessGateMode: "off",
firstTimeContributorGrace: false,
slopAiAdvisory: false,
qualityGateMinScore: null,
autoLabelEnabled: true,
Expand Down Expand Up @@ -664,6 +668,7 @@ describe("world-class backend signals", () => {
qualityGateMode: "advisory",
slopGateMode: "off",
mergeReadinessGateMode: "off",
firstTimeContributorGrace: false,
slopAiAdvisory: false,
qualityGateMinScore: null,
autoLabelEnabled: true,
Expand Down
Loading