diff --git a/apps/gittensory-ui/public/openapi.json b/apps/gittensory-ui/public/openapi.json index 45a810efec..cb340760cf 100644 --- a/apps/gittensory-ui/public/openapi.json +++ b/apps/gittensory-ui/public/openapi.json @@ -8144,6 +8144,14 @@ }, "agentDryRun": { "type": "boolean" + }, + "selfAuthoredLinkedIssueGateMode": { + "type": "string", + "enum": [ + "off", + "advisory", + "block" + ] } }, "required": [ @@ -8161,6 +8169,7 @@ "slopGateMode", "mergeReadinessGateMode", "manifestPolicyGateMode", + "selfAuthoredLinkedIssueGateMode", "firstTimeContributorGrace", "slopAiAdvisory", "autoLabelEnabled", @@ -8760,6 +8769,14 @@ "advisory", "block" ] + }, + "selfAuthoredLinkedIssueGateMode": { + "type": "string", + "enum": [ + "off", + "advisory", + "block" + ] } }, "required": [ @@ -8777,6 +8794,7 @@ "slopGateMode", "mergeReadinessGateMode", "manifestPolicyGateMode", + "selfAuthoredLinkedIssueGateMode", "firstTimeContributorGrace", "autoLabelEnabled", "gittensorLabel", diff --git a/migrations/0055_self_authored_linked_issue_gate_mode.sql b/migrations/0055_self_authored_linked_issue_gate_mode.sql new file mode 100644 index 0000000000..e8686823c5 --- /dev/null +++ b/migrations/0055_self_authored_linked_issue_gate_mode.sql @@ -0,0 +1,5 @@ +-- Per-repo gate mode for self-authored linked issues (#self-authored-linked-issue-gate). +-- When `advisory` (default), the `self_authored_linked_issue` finding is surfaced in the review +-- panel but never blocks the gate — no behavior change for existing repos. When `block`, the gate +-- closes the PR when the contributor opens a PR that links an issue they themselves filed. +ALTER TABLE repository_settings ADD COLUMN self_authored_linked_issue_gate_mode TEXT NOT NULL DEFAULT 'advisory'; diff --git a/src/db/repositories.ts b/src/db/repositories.ts index 6e18a30339..aae1b83b8f 100644 --- a/src/db/repositories.ts +++ b/src/db/repositories.ts @@ -415,6 +415,7 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise slopGateMode: "off", mergeReadinessGateMode: "off", manifestPolicyGateMode: "off", + selfAuthoredLinkedIssueGateMode: "advisory", firstTimeContributorGrace: false, slopGateMinScore: null, slopAiAdvisory: false, @@ -454,6 +455,7 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise slopGateMode: parseGateRuleMode(row.slopGateMode), mergeReadinessGateMode: parseGateRuleMode(row.mergeReadinessGateMode), manifestPolicyGateMode: parseGateRuleMode(row.manifestPolicyGateMode), + selfAuthoredLinkedIssueGateMode: parseGateRuleMode(row.selfAuthoredLinkedIssueGateMode), firstTimeContributorGrace: row.firstTimeContributorGrace, slopGateMinScore: normalizeQualityGateMinScore(row.slopGateMinScore), slopAiAdvisory: row.slopAiAdvisory, @@ -497,6 +499,7 @@ export async function upsertRepositorySettings(env: Env, settings: Partial false))) { return; } - const [repo, settings, otherOpenPullRequests] = await Promise.all([ + const [repo, settings, otherOpenPullRequests, linkedIssueAuthorLogins] = await Promise.all([ getRepository(env, repoFullName), resolveRepositorySettings(env, repoFullName), listOtherOpenPullRequests(env, repoFullName, pr.number), + resolveLinkedIssueAuthorLogins(env, repoFullName, pr.linkedIssues), ]); const advisory = buildPullRequestAdvisory(repo, pr, { otherOpenPullRequests, requireLinkedIssue: shouldCollectLinkedIssueEvidence(settings), duplicateWinnerEnabled: env.GITTENSORY_DUPLICATE_WINNER === "true", + linkedIssueAuthorLogins, }); await persistAdvisory(env, advisory); if (installationId && shouldProcessPullRequestPublicSurface(payload.action)) { @@ -1707,6 +1714,16 @@ export function shouldCollectLinkedIssueEvidence(settings: Pick { + if (linkedIssues.length === 0) return []; + const results = await Promise.all(linkedIssues.map((n) => getIssue(env, repoFullName, n).then((i) => i?.authorLogin ?? null).catch(() => null))); + return results; +} + export function shouldCollectSlopEvidence(settings: Pick): boolean { return settings.slopGateMode !== "off" || mergeReadinessGateEnabled(settings); } @@ -1742,6 +1759,7 @@ export function gateCheckPolicy( slopGateMode: settings.slopGateMode, mergeReadinessGateMode: settings.mergeReadinessGateMode, manifestPolicyGateMode: settings.manifestPolicyGateMode, + selfAuthoredLinkedIssueGateMode: settings.selfAuthoredLinkedIssueGateMode, firstTimeContributorGrace: settings.firstTimeContributorGrace, authorMergedPrCount: authorHistory?.mergedPrCount, authorClosedUnmergedPrCount: authorHistory?.closedUnmergedPrCount, diff --git a/src/rules/advisory.ts b/src/rules/advisory.ts index f83b7c6006..49be0fe099 100644 --- a/src/rules/advisory.ts +++ b/src/rules/advisory.ts @@ -38,6 +38,10 @@ export type GateCheckPolicy = { * 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; + /** Self-authored linked-issue gate. When `block`, a `self_authored_linked_issue` finding — raised when + * 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; /** 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. */ @@ -89,6 +93,10 @@ export function buildPullRequestAdvisory( * closed as a duplicate. Default/false ⇒ every duplicate sibling keeps the finding (byte-identical). The * caller sets this to `env.GITTENSORY_DUPLICATE_WINNER === "true"`. */ duplicateWinnerEnabled?: boolean; + /** Author logins of the linked issues (one entry per resolved issue, may be null when unknown). Used to + * surface a `self_authored_linked_issue` finding when the PR author also opened the linked issue. Absent + * or empty ⇒ the finding is never raised (fail-open: unknown issue authorship stays advisory-only). */ + linkedIssueAuthorLogins?: (string | null | undefined)[]; } = {}, ): Advisory { const repoFullName = pr?.repoFullName ?? repo?.fullName ?? "unknown/unknown"; @@ -114,7 +122,7 @@ export function buildPullRequestAdvisory( action: "Re-deliver the webhook or wait for the next sync.", }); } else { - addPullRequestFindings(repo, pr, findings, context.otherOpenPullRequests ?? [], Boolean(context.requireLinkedIssue), Boolean(context.duplicateWinnerEnabled)); + addPullRequestFindings(repo, pr, findings, context.otherOpenPullRequests ?? [], Boolean(context.requireLinkedIssue), Boolean(context.duplicateWinnerEnabled), context.linkedIssueAuthorLogins ?? []); } return advisory("pull_request", targetKey, repoFullName, findings, "Pull request advisory generated.", pr?.number, undefined, pr?.headSha ?? undefined); } @@ -504,6 +512,7 @@ function addPullRequestFindings( otherOpenPullRequests: PullRequestRecord[], requireLinkedIssue: boolean, duplicateWinnerEnabled: boolean, + linkedIssueAuthorLogins: (string | null | undefined)[], ): void { if (pr.state !== "open") { findings.push({ @@ -540,6 +549,23 @@ function addPullRequestFindings( }); } } + // Self-authored linked-issue detection: the PR author also filed the linked issue. Raised when at least + // one linked issue's author login is a case-insensitive match for the PR author. Gated by + // selfAuthoredLinkedIssueGateMode — advisory by default so this never blocks without maintainer opt-in. + // Absent/null issue author logins are treated as unknown and never trigger the finding (fail-open). + if (pr.linkedIssues.length > 0 && pr.authorLogin) { + const prAuthor = pr.authorLogin.toLowerCase(); + const selfAuthored = linkedIssueAuthorLogins.some((login) => login != null && login.toLowerCase() === prAuthor); + if (selfAuthored) { + findings.push({ + code: "self_authored_linked_issue", + severity: "warning", + title: "PR author also opened the linked issue", + detail: "The contributor who opened this PR also filed the linked issue. This pattern can indicate artificial issue-discovery work rather than solving an independently discovered problem.", + action: "Link an issue that was opened by a different contributor, or provide a rationale for why this self-authored issue represents genuine discovery work.", + }); + } + } if (otherOpenPullRequests.length >= 10) { findings.push({ code: "busy_pr_queue", @@ -667,6 +693,9 @@ function isConfiguredGateBlocker(code: string, policy: GateCheckPolicy): boolean if (code === "manifest_blocked_path" || code === "manifest_linked_issue_required" || code === "manifest_missing_tests") { return gateMode(policy.manifestPolicyGateMode ?? "off") === "block"; } + // Self-authored linked-issue gate: blocks only when the maintainer opts in with `block`. Defaults to + // advisory — the finding surfaces in the panel without ever closing the PR unless explicitly configured. + if (code === "self_authored_linked_issue") return gateMode(policy.selfAuthoredLinkedIssueGateMode ?? "advisory") === "block"; return false; } diff --git a/src/rules/predicted-gate.ts b/src/rules/predicted-gate.ts index d6ffebe17e..5e0b3d4bb0 100644 --- a/src/rules/predicted-gate.ts +++ b/src/rules/predicted-gate.ts @@ -157,6 +157,7 @@ export function buildPredictedGateVerdict(args: { qualityGateMinScore: gate.readinessMinScore ?? null, aiReviewGateMode: gate.aiReviewMode ?? undefined, mergeReadinessGateMode: gate.mergeReadiness ?? undefined, + selfAuthoredLinkedIssueGateMode: gate.selfAuthoredLinkedIssue ?? 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 33232d1f6b..ee33682178 100644 --- a/src/signals/focus-manifest.ts +++ b/src/signals/focus-manifest.ts @@ -32,6 +32,7 @@ export type FocusManifestGateConfig = { aiReviewModel: string | null; mergeReadiness: GateRuleMode | null; manifestPolicy: GateRuleMode | null; + selfAuthoredLinkedIssue: GateRuleMode | null; firstTimeContributorGrace: boolean | null; }; @@ -53,6 +54,7 @@ export type FocusManifestSettings = Partial< | "gateCheckMode" | "linkedIssueGateMode" | "duplicatePrGateMode" + | "selfAuthoredLinkedIssueGateMode" | "qualityGateMode" | "qualityGateMinScore" | "aiReviewMode" @@ -167,6 +169,7 @@ const EMPTY_GATE_CONFIG: FocusManifestGateConfig = { aiReviewModel: null, mergeReadiness: null, manifestPolicy: null, + selfAuthoredLinkedIssue: null, firstTimeContributorGrace: null, }; @@ -310,6 +313,7 @@ function parseGateConfig(value: JsonValue | undefined, warnings: string[]): Focu aiReviewModel: normalizeOptionalString(aiReviewRecord?.model, "gate.aiReview.model", warnings), mergeReadiness: normalizeOptionalGateMode(record.mergeReadiness, "gate.mergeReadiness", warnings), manifestPolicy: normalizeOptionalGateMode(record.manifestPolicy, "gate.manifestPolicy", warnings), + selfAuthoredLinkedIssue: normalizeOptionalGateMode(record.selfAuthoredLinkedIssue, "gate.selfAuthoredLinkedIssue", warnings), firstTimeContributorGrace: normalizeOptionalBoolean(record.firstTimeContributorGrace, "gate.firstTimeContributorGrace", warnings), }; gate.present = @@ -328,6 +332,7 @@ function parseGateConfig(value: JsonValue | undefined, warnings: string[]): Focu gate.aiReviewModel !== null || gate.mergeReadiness !== null || gate.manifestPolicy !== null || + gate.selfAuthoredLinkedIssue !== null || gate.firstTimeContributorGrace !== null; return gate; } @@ -366,6 +371,7 @@ export function gateConfigToJson(gate: FocusManifestGateConfig): JsonValue { } if (gate.mergeReadiness !== null) out.mergeReadiness = gate.mergeReadiness; if (gate.manifestPolicy !== null) out.manifestPolicy = gate.manifestPolicy; + if (gate.selfAuthoredLinkedIssue !== null) out.selfAuthoredLinkedIssue = gate.selfAuthoredLinkedIssue; if (gate.firstTimeContributorGrace !== null) out.firstTimeContributorGrace = gate.firstTimeContributorGrace; return out; } @@ -412,6 +418,8 @@ function parseSettingsOverride(value: JsonValue | undefined, warnings: string[]) if (linkedIssueGateMode !== null) out.linkedIssueGateMode = linkedIssueGateMode; const duplicatePrGateMode = normalizeOptionalGateMode(r.duplicatePrGateMode, "settings.duplicatePrGateMode", warnings); if (duplicatePrGateMode !== null) out.duplicatePrGateMode = duplicatePrGateMode; + const selfAuthoredLinkedIssueGateMode = normalizeOptionalGateMode(r.selfAuthoredLinkedIssueGateMode, "settings.selfAuthoredLinkedIssueGateMode", warnings); + if (selfAuthoredLinkedIssueGateMode !== null) out.selfAuthoredLinkedIssueGateMode = selfAuthoredLinkedIssueGateMode; const qualityGateMode = normalizeOptionalGateMode(r.qualityGateMode, "settings.qualityGateMode", warnings); if (qualityGateMode !== null) out.qualityGateMode = qualityGateMode; const qualityGateMinScore = normalizeOptionalScore(r.qualityGateMinScore, "settings.qualityGateMinScore", warnings); @@ -526,6 +534,7 @@ export function resolveEffectiveSettings(dbSettings: RepositorySettings, manifes 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.selfAuthoredLinkedIssue !== null) effective.selfAuthoredLinkedIssueGateMode = gate.selfAuthoredLinkedIssue; if (gate.firstTimeContributorGrace !== null) effective.firstTimeContributorGrace = gate.firstTimeContributorGrace; // 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). diff --git a/src/signals/settings-preview.ts b/src/signals/settings-preview.ts index ab24ae5ab3..32347b5ce0 100644 --- a/src/signals/settings-preview.ts +++ b/src/signals/settings-preview.ts @@ -190,6 +190,7 @@ export type RepoSettingsPreview = { slopGateMode: RepositorySettings["slopGateMode"]; mergeReadinessGateMode: RepositorySettings["mergeReadinessGateMode"]; manifestPolicyGateMode: RepositorySettings["manifestPolicyGateMode"]; + selfAuthoredLinkedIssueGateMode: RepositorySettings["selfAuthoredLinkedIssueGateMode"]; firstTimeContributorGrace: boolean; slopGateMinScore?: number | null | undefined; autoLabelEnabled: boolean; @@ -312,6 +313,7 @@ export function buildRepoSettingsPreview(args: { slopGateMode: settings.slopGateMode, mergeReadinessGateMode: settings.mergeReadinessGateMode, manifestPolicyGateMode: settings.manifestPolicyGateMode, + selfAuthoredLinkedIssueGateMode: settings.selfAuthoredLinkedIssueGateMode, firstTimeContributorGrace: settings.firstTimeContributorGrace, slopGateMinScore: settings.slopGateMinScore ?? null, autoLabelEnabled: settings.autoLabelEnabled, diff --git a/src/types.ts b/src/types.ts index 9f2cd0bbc8..9f0c29a391 100644 --- a/src/types.ts +++ b/src/types.ts @@ -486,6 +486,10 @@ export type RepositorySettings = { * 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; + /** Self-authored linked-issue gate. When `block`, the gate closes a PR where the contributor also + * opened the linked issue (`pr.authorLogin === issue.authorLogin`). Defaults to `advisory` — the finding + * is surfaced in the review panel but never blocks unless the maintainer opts in. */ + selfAuthoredLinkedIssueGateMode: 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. */ diff --git a/test/unit/focus-manifest.test.ts b/test/unit/focus-manifest.test.ts index ecea97f7c5..f0473de165 100644 --- a/test/unit/focus-manifest.test.ts +++ b/test/unit/focus-manifest.test.ts @@ -426,7 +426,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, aiReviewMode: null, aiReviewByok: null, aiReviewProvider: null, aiReviewModel: null, mergeReadiness: null, manifestPolicy: null, firstTimeContributorGrace: null }, + gate: { present: false, enabled: null, pack: null, linkedIssue: null, duplicates: null, readinessMode: null, readinessMinScore: null, slopMode: null, slopMinScore: null, slopAiAdvisory: null, aiReviewMode: null, aiReviewByok: null, aiReviewProvider: null, aiReviewModel: null, mergeReadiness: null, selfAuthoredLinkedIssue: null, manifestPolicy: null, firstTimeContributorGrace: null }, settings: {}, review: { present: false, footerText: null, note: null, fields: {} }, warnings: [], @@ -714,7 +714,7 @@ describe("parseFocusManifest gate config", () => { it("parses a full gate section including the readiness block", () => { const m = parseFocusManifest({ gate: { linkedIssue: "block", duplicates: "advisory", readiness: { mode: "block", minScore: 70 } } }); expect(m.present).toBe(true); - expect(m.gate).toEqual({ present: true, enabled: null, pack: null, linkedIssue: "block", duplicates: "advisory", readinessMode: "block", readinessMinScore: 70, slopMode: null, slopMinScore: null, slopAiAdvisory: null, aiReviewMode: null, aiReviewByok: null, aiReviewProvider: null, aiReviewModel: null, mergeReadiness: null, manifestPolicy: null, firstTimeContributorGrace: null }); + expect(m.gate).toEqual({ present: true, enabled: null, pack: null, linkedIssue: "block", duplicates: "advisory", readinessMode: "block", readinessMinScore: 70, slopMode: null, slopMinScore: null, slopAiAdvisory: null, aiReviewMode: null, aiReviewByok: null, aiReviewProvider: null, aiReviewModel: null, mergeReadiness: null, selfAuthoredLinkedIssue: null, manifestPolicy: null, firstTimeContributorGrace: null }); }); it("parses gate.mergeReadiness + gate.firstTimeContributorGrace, round-trips them, and warns on bad values (#822)", () => { diff --git a/test/unit/gate-check-policy.test.ts b/test/unit/gate-check-policy.test.ts index 7b1cbb7f16..6f36fd6acc 100644 --- a/test/unit/gate-check-policy.test.ts +++ b/test/unit/gate-check-policy.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from "vitest"; -import { gateCheckPolicy, shouldCollectLinkedIssueEvidence, shouldCollectSlopEvidence, shouldRunSlopAiAdvisory } from "../../src/queue/processors"; +import { gateCheckPolicy, resolveLinkedIssueAuthorLogins, shouldCollectLinkedIssueEvidence, shouldCollectSlopEvidence, shouldRunSlopAiAdvisory } from "../../src/queue/processors"; +import { createTestEnv } from "../helpers/d1"; +import { upsertIssueFromGitHub, upsertRepositoryFromGitHub } from "../../src/db/repositories"; import { evaluateGateCheck } from "../../src/rules/advisory"; import { parseFocusManifest, resolveEffectiveSettings } from "../../src/signals/focus-manifest"; import type { Advisory, RepositorySettings } from "../../src/types"; @@ -367,3 +369,35 @@ describe("focus-manifest policy gate (#555)", () => { expect(result.blockers.map((finding) => finding.code)).toContain("manifest_blocked_path"); }); }); + +describe("resolveLinkedIssueAuthorLogins", () => { + it("returns [] immediately for an empty linkedIssues array (no DB work)", async () => { + const env = createTestEnv(); + const result = await resolveLinkedIssueAuthorLogins(env, "owner/repo", []); + expect(result).toEqual([]); + }); + + it("returns the authorLogin for each linked issue found in the DB", async () => { + const env = createTestEnv(); + await upsertRepositoryFromGitHub(env, { name: "repo", full_name: "owner/repo", private: false, owner: { login: "owner" } }, 1); + await upsertIssueFromGitHub(env, "owner/repo", { number: 10, title: "Bug report", body: "", state: "open", user: { login: "alice" }, labels: [], html_url: "https://github.com/owner/repo/issues/10", created_at: "2026-01-01T00:00:00Z", updated_at: "2026-01-01T00:00:00Z" }); + await upsertIssueFromGitHub(env, "owner/repo", { number: 11, title: "Feature", body: "", state: "open", user: { login: "bob" }, labels: [], html_url: "https://github.com/owner/repo/issues/11", created_at: "2026-01-01T00:00:00Z", updated_at: "2026-01-01T00:00:00Z" }); + + const result = await resolveLinkedIssueAuthorLogins(env, "owner/repo", [10, 11]); + expect(result).toEqual(["alice", "bob"]); + }); + + it("returns null for an issue not in the DB (fail-open: unknown author does not trigger the finding)", async () => { + const env = createTestEnv(); + const result = await resolveLinkedIssueAuthorLogins(env, "owner/repo", [99]); + expect(result).toEqual([null]); + }); + + it("swallows per-issue DB errors and returns null for the erroring issue", async () => { + const env = createTestEnv(); + // Pass a broken DB binding to force a DB error. + const brokenEnv = { ...env, DB: null } as unknown as typeof env; + const result = await resolveLinkedIssueAuthorLogins(brokenEnv, "owner/repo", [1]); + expect(result).toEqual([null]); + }); +}); diff --git a/test/unit/maintainer-activation.test.ts b/test/unit/maintainer-activation.test.ts index 4ff6911367..7b9c2c40e3 100644 --- a/test/unit/maintainer-activation.test.ts +++ b/test/unit/maintainer-activation.test.ts @@ -35,6 +35,7 @@ function settings(overrides: Partial = {}): RepositorySettin slopGateMode: "off", mergeReadinessGateMode: "off", manifestPolicyGateMode: "off", + selfAuthoredLinkedIssueGateMode: "advisory", firstTimeContributorGrace: false, slopAiAdvisory: false, qualityGateMinScore: null, diff --git a/test/unit/policy-sanitizer.test.ts b/test/unit/policy-sanitizer.test.ts index e7e8298d35..d22d700649 100644 --- a/test/unit/policy-sanitizer.test.ts +++ b/test/unit/policy-sanitizer.test.ts @@ -69,6 +69,7 @@ function settingsFor(repoFullName: string, overrides: Partial = {}): RepositorySettin slopGateMode: "off", mergeReadinessGateMode: "off", manifestPolicyGateMode: "off", + selfAuthoredLinkedIssueGateMode: "advisory", firstTimeContributorGrace: false, slopAiAdvisory: false, qualityGateMinScore: null, diff --git a/test/unit/repository-settings-enforcement.test.ts b/test/unit/repository-settings-enforcement.test.ts index 5c2044b2c9..804a3f5ba2 100644 --- a/test/unit/repository-settings-enforcement.test.ts +++ b/test/unit/repository-settings-enforcement.test.ts @@ -23,6 +23,7 @@ function settings(over: Partial = {}): RepositorySettings { slopGateMode: "off", mergeReadinessGateMode: "off", manifestPolicyGateMode: "off", + selfAuthoredLinkedIssueGateMode: "advisory", firstTimeContributorGrace: false, slopAiAdvisory: false, qualityGateMinScore: null, diff --git a/test/unit/rules.test.ts b/test/unit/rules.test.ts index de94aa5ab1..9b9ec7801e 100644 --- a/test/unit/rules.test.ts +++ b/test/unit/rules.test.ts @@ -984,6 +984,98 @@ describe("firstAddedLineFromPatch", () => { }); }); + describe("self_authored_linked_issue finding", () => { + const prBase: PullRequestRecord = { + repoFullName: repo.fullName, + number: 55, + title: "Fix login bug", + state: "open", + authorLogin: "contributor1", + authorAssociation: "NONE", + headSha: "sha55", + labels: [], + linkedIssues: [10], + }; + + it("raises self_authored_linked_issue when the PR author also opened the linked issue", () => { + const advisory = buildPullRequestAdvisory(repo, prBase, { linkedIssueAuthorLogins: ["contributor1"] }); + expect(advisory.findings.map((f) => f.code)).toContain("self_authored_linked_issue"); + }); + + it("is case-insensitive when comparing author logins", () => { + const advisory = buildPullRequestAdvisory(repo, prBase, { linkedIssueAuthorLogins: ["Contributor1"] }); + expect(advisory.findings.map((f) => f.code)).toContain("self_authored_linked_issue"); + }); + + it("does not raise the finding when the PR author differs from the issue author", () => { + const advisory = buildPullRequestAdvisory(repo, prBase, { linkedIssueAuthorLogins: ["someone_else"] }); + expect(advisory.findings.map((f) => f.code)).not.toContain("self_authored_linked_issue"); + }); + + it("does not raise the finding when linkedIssueAuthorLogins is absent (fail-open: unknown authorship stays advisory-only)", () => { + const advisory = buildPullRequestAdvisory(repo, prBase); + expect(advisory.findings.map((f) => f.code)).not.toContain("self_authored_linked_issue"); + }); + + it("does not raise the finding when linkedIssueAuthorLogins contains only null values (author unknown)", () => { + const advisory = buildPullRequestAdvisory(repo, prBase, { linkedIssueAuthorLogins: [null, undefined] }); + expect(advisory.findings.map((f) => f.code)).not.toContain("self_authored_linked_issue"); + }); + + it("does not raise the finding when the PR has no linked issues", () => { + const noIssuePr = { ...prBase, linkedIssues: [] }; + const advisory = buildPullRequestAdvisory(repo, noIssuePr, { linkedIssueAuthorLogins: ["contributor1"] }); + expect(advisory.findings.map((f) => f.code)).not.toContain("self_authored_linked_issue"); + }); + + it("does not raise the finding when the PR author is unknown (null authorLogin)", () => { + const noAuthorPr = { ...prBase, authorLogin: null }; + const advisory = buildPullRequestAdvisory(repo, noAuthorPr, { linkedIssueAuthorLogins: ["contributor1"] }); + expect(advisory.findings.map((f) => f.code)).not.toContain("self_authored_linked_issue"); + }); + + it("raises the finding when at least one linked issue author matches the PR author (mixed authors)", () => { + const advisory = buildPullRequestAdvisory(repo, prBase, { linkedIssueAuthorLogins: ["other_user", "contributor1"] }); + expect(advisory.findings.map((f) => f.code)).toContain("self_authored_linked_issue"); + }); + + it("is advisory by default: self_authored_linked_issue is NOT a gate blocker without policy override", () => { + const advisory = buildPullRequestAdvisory(repo, prBase, { linkedIssueAuthorLogins: ["contributor1"] }); + const gate = evaluateGateCheck(advisory); + expect(gate.conclusion).toBe("success"); + expect(gate.blockers.map((f) => f.code)).not.toContain("self_authored_linked_issue"); + expect(gate.warnings.map((f) => f.code)).toContain("self_authored_linked_issue"); + }); + + it("is advisory when selfAuthoredLinkedIssueGateMode is advisory", () => { + const advisory = buildPullRequestAdvisory(repo, prBase, { linkedIssueAuthorLogins: ["contributor1"] }); + const gate = evaluateGateCheck(advisory, { selfAuthoredLinkedIssueGateMode: "advisory" }); + expect(gate.conclusion).toBe("success"); + expect(gate.blockers.map((f) => f.code)).not.toContain("self_authored_linked_issue"); + }); + + it("is advisory when selfAuthoredLinkedIssueGateMode is off", () => { + const advisory = buildPullRequestAdvisory(repo, prBase, { linkedIssueAuthorLogins: ["contributor1"] }); + const gate = evaluateGateCheck(advisory, { selfAuthoredLinkedIssueGateMode: "off" }); + expect(gate.conclusion).toBe("success"); + expect(gate.blockers.map((f) => f.code)).not.toContain("self_authored_linked_issue"); + }); + + it("blocks when selfAuthoredLinkedIssueGateMode is block", () => { + const advisory = buildPullRequestAdvisory(repo, prBase, { linkedIssueAuthorLogins: ["contributor1"] }); + const gate = evaluateGateCheck(advisory, { selfAuthoredLinkedIssueGateMode: "block" }); + expect(gate.conclusion).toBe("failure"); + expect(gate.blockers.map((f) => f.code)).toContain("self_authored_linked_issue"); + }); + + it("does not block when selfAuthoredLinkedIssueGateMode is block but no self-authored issue finding exists", () => { + const advisory = buildPullRequestAdvisory(repo, prBase, { linkedIssueAuthorLogins: ["someone_else"] }); + const gate = evaluateGateCheck(advisory, { selfAuthoredLinkedIssueGateMode: "block" }); + expect(gate.conclusion).toBe("success"); + expect(gate.blockers.map((f) => f.code)).not.toContain("self_authored_linked_issue"); + }); + }); + function emptyCollisions(): CollisionReport { return { repoFullName: "JSONbored/gittensory", diff --git a/test/unit/self-dogfood-registration-pack.test.ts b/test/unit/self-dogfood-registration-pack.test.ts index 12bb61612a..ae11357cf7 100644 --- a/test/unit/self-dogfood-registration-pack.test.ts +++ b/test/unit/self-dogfood-registration-pack.test.ts @@ -62,6 +62,7 @@ function settingsFor(repoFullName: string, overrides: Partial = {}): RepositorySettin slopGateMode: "off", mergeReadinessGateMode: "off", manifestPolicyGateMode: "off", + selfAuthoredLinkedIssueGateMode: "advisory", firstTimeContributorGrace: false, slopAiAdvisory: false, qualityGateMinScore: null, diff --git a/test/unit/signals-coverage.test.ts b/test/unit/signals-coverage.test.ts index 1b3246b785..b168c692d6 100644 --- a/test/unit/signals-coverage.test.ts +++ b/test/unit/signals-coverage.test.ts @@ -1662,6 +1662,7 @@ function repoSettings(repoFullName: string): RepositorySettings { slopGateMode: "off", mergeReadinessGateMode: "off", manifestPolicyGateMode: "off", + selfAuthoredLinkedIssueGateMode: "advisory", firstTimeContributorGrace: false, slopAiAdvisory: false, qualityGateMinScore: null, diff --git a/test/unit/signals-v2.test.ts b/test/unit/signals-v2.test.ts index 9ec0511fa8..38ba458e2b 100644 --- a/test/unit/signals-v2.test.ts +++ b/test/unit/signals-v2.test.ts @@ -1655,6 +1655,7 @@ describe("v2 signal builders", () => { slopGateMode: "off", mergeReadinessGateMode: "off", manifestPolicyGateMode: "off", + selfAuthoredLinkedIssueGateMode: "advisory", firstTimeContributorGrace: false, slopAiAdvisory: false, qualityGateMinScore: null, diff --git a/test/unit/signals.test.ts b/test/unit/signals.test.ts index 26f6ec6755..1bfcfd46d0 100644 --- a/test/unit/signals.test.ts +++ b/test/unit/signals.test.ts @@ -394,6 +394,7 @@ describe("world-class backend signals", () => { slopGateMode: "off" as const, mergeReadinessGateMode: "off" as const, manifestPolicyGateMode: "off" as const, + selfAuthoredLinkedIssueGateMode: "advisory" as const, firstTimeContributorGrace: false, slopAiAdvisory: false, qualityGateMinScore: null, @@ -445,6 +446,7 @@ describe("world-class backend signals", () => { slopGateMode: "off" as const, mergeReadinessGateMode: "off" as const, manifestPolicyGateMode: "off" as const, + selfAuthoredLinkedIssueGateMode: "advisory" as const, firstTimeContributorGrace: false, slopAiAdvisory: false, qualityGateMinScore: null, @@ -516,6 +518,7 @@ describe("world-class backend signals", () => { slopGateMode: "off", mergeReadinessGateMode: "off", manifestPolicyGateMode: "off", + selfAuthoredLinkedIssueGateMode: "advisory", firstTimeContributorGrace: false, slopAiAdvisory: false, qualityGateMinScore: null, @@ -608,6 +611,7 @@ describe("world-class backend signals", () => { slopGateMode: "off", mergeReadinessGateMode: "off", manifestPolicyGateMode: "off", + selfAuthoredLinkedIssueGateMode: "advisory", firstTimeContributorGrace: false, slopAiAdvisory: false, qualityGateMinScore: null, @@ -675,6 +679,7 @@ describe("world-class backend signals", () => { slopGateMode: "off", mergeReadinessGateMode: "off", manifestPolicyGateMode: "off", + selfAuthoredLinkedIssueGateMode: "advisory", firstTimeContributorGrace: false, slopAiAdvisory: false, qualityGateMinScore: null, @@ -786,6 +791,7 @@ describe("world-class backend signals", () => { slopGateMode: "off", mergeReadinessGateMode: "off", manifestPolicyGateMode: "off", + selfAuthoredLinkedIssueGateMode: "advisory", firstTimeContributorGrace: false, slopAiAdvisory: false, qualityGateMinScore: null, diff --git a/test/unit/unified-comment-parity.test.ts b/test/unit/unified-comment-parity.test.ts index 6e6ec64a1f..db62c14591 100644 --- a/test/unit/unified-comment-parity.test.ts +++ b/test/unit/unified-comment-parity.test.ts @@ -59,6 +59,7 @@ const settings: RepositorySettings = { slopGateMode: "off", mergeReadinessGateMode: "off", manifestPolicyGateMode: "off", + selfAuthoredLinkedIssueGateMode: "advisory", firstTimeContributorGrace: false, slopAiAdvisory: false, qualityGateMinScore: null,