diff --git a/apps/gittensory-ui/public/openapi.json b/apps/gittensory-ui/public/openapi.json index f65ae7d242..fe05bb57f8 100644 --- a/apps/gittensory-ui/public/openapi.json +++ b/apps/gittensory-ui/public/openapi.json @@ -8014,6 +8014,9 @@ "advisory", "block" ] + }, + "firstTimeContributorGrace": { + "type": "boolean" } }, "required": [ @@ -8030,6 +8033,7 @@ "qualityGateMode", "slopGateMode", "mergeReadinessGateMode", + "firstTimeContributorGrace", "slopAiAdvisory", "autoLabelEnabled", "gittensorLabel", @@ -8617,6 +8621,9 @@ "advisory", "block" ] + }, + "firstTimeContributorGrace": { + "type": "boolean" } }, "required": [ @@ -8633,6 +8640,7 @@ "qualityGateMode", "slopGateMode", "mergeReadinessGateMode", + "firstTimeContributorGrace", "autoLabelEnabled", "gittensorLabel", "createMissingLabel", diff --git a/migrations/0039_first_time_contributor_grace.sql b/migrations/0039_first_time_contributor_grace.sql new file mode 100644 index 0000000000..aaf174dd1f --- /dev/null +++ b/migrations/0039_first_time_contributor_grace.sql @@ -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; diff --git a/src/db/repositories.ts b/src/db/repositories.ts index b8dd029da9..aef947574e 100644 --- a/src/db/repositories.ts +++ b/src/db/repositories.ts @@ -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", @@ -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), @@ -472,6 +474,7 @@ export async function upsertRepositorySettings(env: Env, settings: Partial 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 @@ -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, @@ -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( diff --git a/src/rules/advisory.ts b/src/rules/advisory.ts index 01ebd9b0bf..88ec3c3794 100644 --- a/src/rules/advisory.ts +++ b/src/rules/advisory.ts @@ -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. */ @@ -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, diff --git a/src/signals/settings-preview.ts b/src/signals/settings-preview.ts index 5dbd745ffa..f0fb9a7d90 100644 --- a/src/signals/settings-preview.ts +++ b/src/signals/settings-preview.ts @@ -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; @@ -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, diff --git a/src/types.ts b/src/types.ts index cf8f4a2fb2..de60f9b4f2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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 diff --git a/test/unit/gate-check-policy.test.ts b/test/unit/gate-check-policy.test.ts index c13843f4c4..5ab975dcf9 100644 --- a/test/unit/gate-check-policy.test.ts +++ b/test/unit/gate-check-policy.test.ts @@ -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"); + }); +}); diff --git a/test/unit/maintainer-activation.test.ts b/test/unit/maintainer-activation.test.ts index 344fe946aa..ffcead4a83 100644 --- a/test/unit/maintainer-activation.test.ts +++ b/test/unit/maintainer-activation.test.ts @@ -34,6 +34,7 @@ function settings(overrides: Partial = {}): RepositorySettin qualityGateMode: "advisory", slopGateMode: "off", mergeReadinessGateMode: "off", + firstTimeContributorGrace: false, slopAiAdvisory: false, qualityGateMinScore: null, autoLabelEnabled: true, diff --git a/test/unit/policy-sanitizer.test.ts b/test/unit/policy-sanitizer.test.ts index 2b00cdd617..88a3912600 100644 --- a/test/unit/policy-sanitizer.test.ts +++ b/test/unit/policy-sanitizer.test.ts @@ -68,6 +68,7 @@ function settingsFor(repoFullName: string, overrides: Partial = {}): RepositorySettin qualityGateMode: "advisory", slopGateMode: "off", mergeReadinessGateMode: "off", + firstTimeContributorGrace: false, slopAiAdvisory: false, qualityGateMinScore: null, autoLabelEnabled: true, diff --git a/test/unit/self-dogfood-registration-pack.test.ts b/test/unit/self-dogfood-registration-pack.test.ts index 885c283c61..b8f13151a9 100644 --- a/test/unit/self-dogfood-registration-pack.test.ts +++ b/test/unit/self-dogfood-registration-pack.test.ts @@ -61,6 +61,7 @@ function settingsFor(repoFullName: string, overrides: Partial = {}): RepositorySettin qualityGateMode: "advisory", slopGateMode: "off", mergeReadinessGateMode: "off", + firstTimeContributorGrace: false, slopAiAdvisory: false, qualityGateMinScore: null, autoLabelEnabled: true, diff --git a/test/unit/signals-coverage.test.ts b/test/unit/signals-coverage.test.ts index e8676208d4..7bbd6e3cf4 100644 --- a/test/unit/signals-coverage.test.ts +++ b/test/unit/signals-coverage.test.ts @@ -1531,6 +1531,7 @@ function repoSettings(repoFullName: string): RepositorySettings { qualityGateMode: "advisory", slopGateMode: "off", mergeReadinessGateMode: "off", + firstTimeContributorGrace: false, slopAiAdvisory: false, qualityGateMinScore: null, autoLabelEnabled: true, diff --git a/test/unit/signals-v2.test.ts b/test/unit/signals-v2.test.ts index 14b5d909c0..7637faceb9 100644 --- a/test/unit/signals-v2.test.ts +++ b/test/unit/signals-v2.test.ts @@ -1625,6 +1625,7 @@ describe("v2 signal builders", () => { qualityGateMode: "advisory", slopGateMode: "off", mergeReadinessGateMode: "off", + firstTimeContributorGrace: false, slopAiAdvisory: false, qualityGateMinScore: null, autoLabelEnabled: true, diff --git a/test/unit/signals.test.ts b/test/unit/signals.test.ts index e4757cbca7..d482ce4126 100644 --- a/test/unit/signals.test.ts +++ b/test/unit/signals.test.ts @@ -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, @@ -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, @@ -509,6 +511,7 @@ describe("world-class backend signals", () => { qualityGateMode: "advisory", slopGateMode: "off", mergeReadinessGateMode: "off", + firstTimeContributorGrace: false, slopAiAdvisory: false, qualityGateMinScore: null, autoLabelEnabled: true, @@ -599,6 +602,7 @@ describe("world-class backend signals", () => { qualityGateMode: "advisory", slopGateMode: "off", mergeReadinessGateMode: "off", + firstTimeContributorGrace: false, slopAiAdvisory: false, qualityGateMinScore: null, autoLabelEnabled: true, @@ -664,6 +668,7 @@ describe("world-class backend signals", () => { qualityGateMode: "advisory", slopGateMode: "off", mergeReadinessGateMode: "off", + firstTimeContributorGrace: false, slopAiAdvisory: false, qualityGateMinScore: null, autoLabelEnabled: true,