diff --git a/src/api/routes.ts b/src/api/routes.ts index 6d0c9225d7..2ef368e7a6 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -222,6 +222,7 @@ import { loadGatePrecisionReport } from "../services/gate-precision"; import { buildMaintainerQualityDashboard, isMaintainerQualityDataStale } from "../services/maintainer-quality-dashboard"; import { MAX_LOCAL_SCORER_WARNING_CHARS, MAX_LOCAL_SCORER_WARNING_COUNT } from "../signals/local-scorer-diagnostics"; import { compileFocusManifestPolicy, MAX_FOCUS_MANIFEST_BYTES } from "../signals/focus-manifest"; +import { resolveRepositorySettings } from "../settings/repository-settings"; import { loadPublicRepoFocusManifest, loadRepoFocusManifest, upsertRepoFocusManifest } from "../signals/focus-manifest-loader"; import { buildRepoOnboardingPackPreviewForRepo } from "../services/repo-onboarding-pack"; import { generateContributorIssueDrafts } from "../services/contributor-issue-draft"; @@ -2195,7 +2196,7 @@ export function createApp() { if (unauthorized) return unauthorized; } const [settings, issues, pullRequests] = await Promise.all([ - getRepositorySettings(c.env, fullName), + resolveRepositorySettings(c.env, fullName), listIssues(c.env, fullName), listPullRequests(c.env, fullName), ]); diff --git a/src/queue/processors.ts b/src/queue/processors.ts index 0d897dda2f..092e287217 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -146,8 +146,9 @@ import { import { buildIssueSlopAssessment, buildSlopAssessment, type SlopBand } from "../signals/slop"; import { runGittensoryAiSlopAdvisory } from "../services/ai-slop"; import { decidePublicSurface } from "../signals/settings-preview"; +import { buildFocusManifestGuidance } from "../signals/focus-manifest"; import { loadRepoFocusManifest } from "../signals/focus-manifest-loader"; -import { buildFocusManifestGuidance, resolveEffectiveSettings } from "../signals/focus-manifest"; +import { resolveRepositorySettings } from "../settings/repository-settings"; import type { LocalBranchAnalysisInput } from "../signals/local-branch"; import { runGittensoryAiReview } from "../services/ai-review"; import type { AdvisoryFinding, ContributorEvidenceRecord, ContributorRepoStatRecord, DetectedNotificationEvent, GitHubWebhookPayload, IssueRecord, JobMessage, JsonValue, PullRequestFilePathRecord, PullRequestRecord, RepositoryRecord, RepositorySettings } from "../types"; @@ -1114,16 +1115,6 @@ async function loadGateAuthorHistory(env: Env, repoFullName: string, author: str } } -/** - * Effective repository settings for webhook handling: the DB-backed settings overlaid with the repo's - * `.gittensory.yml` (config-as-code). This single resolver is why EVERYTHING — gate on/off, all blocker - * modes, comments, labels, surface, audience — is controllable from the repo's config file. - */ -async function resolveRepositorySettings(env: Env, repoFullName: string): Promise { - const [dbSettings, manifest] = await Promise.all([getRepositorySettings(env, repoFullName), loadRepoFocusManifest(env, repoFullName)]); - return resolveEffectiveSettings(dbSettings, manifest); -} - /** Build a bounded unified-diff string from cached PR files for the AI reviewer. Caps total size so a * huge PR cannot blow the model context or the neuron budget; each file's patch is taken from the raw * GitHub file payload when present. */ diff --git a/src/services/maintainer-activation.ts b/src/services/maintainer-activation.ts index d239475fdb..0b2c723070 100644 --- a/src/services/maintainer-activation.ts +++ b/src/services/maintainer-activation.ts @@ -58,7 +58,7 @@ export function buildMaintainerActivationPreview(args: { const samples: MaintainerActivationSample[] = recent.map((pr) => { const advisory = buildPullRequestAdvisory(args.repo, pr, { otherOpenPullRequests: args.pullRequests.filter((other) => other.number !== pr.number), - requireLinkedIssue: true, + requireLinkedIssue: args.settings.requireLinkedIssue || args.settings.linkedIssueGateMode !== "off", }); for (const finding of advisory.findings) codeCounts.set(finding.code, (codeCounts.get(finding.code) ?? 0) + 1); return { diff --git a/src/settings/repository-settings.ts b/src/settings/repository-settings.ts new file mode 100644 index 0000000000..ddba01e790 --- /dev/null +++ b/src/settings/repository-settings.ts @@ -0,0 +1,13 @@ +import { getRepositorySettings } from "../db/repositories"; +import { resolveEffectiveSettings } from "../signals/focus-manifest"; +import { loadRepoFocusManifest } from "../signals/focus-manifest-loader"; +import type { RepositorySettings } from "../types"; + +/** Effective repository settings: DB values overlaid with `.gittensory.yml` (config-as-code). */ +export async function resolveRepositorySettings(env: Env, repoFullName: string): Promise { + const [dbSettings, manifest] = await Promise.all([ + getRepositorySettings(env, repoFullName), + loadRepoFocusManifest(env, repoFullName), + ]); + return resolveEffectiveSettings(dbSettings, manifest); +} diff --git a/src/signals/focus-manifest.ts b/src/signals/focus-manifest.ts index aa9709d2a2..ae70886b2f 100644 --- a/src/signals/focus-manifest.ts +++ b/src/signals/focus-manifest.ts @@ -527,6 +527,11 @@ export function resolveEffectiveSettings(dbSettings: RepositorySettings, manifes if (gate.mergeReadiness !== null) effective.mergeReadinessGateMode = gate.mergeReadiness; if (gate.manifestPolicy !== null) effective.manifestPolicyGateMode = gate.manifestPolicy; 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). + if (effective.requireLinkedIssue && effective.linkedIssueGateMode === "off") { + effective.linkedIssueGateMode = "block"; + } return effective; } diff --git a/src/signals/settings-preview.ts b/src/signals/settings-preview.ts index 91ab43a1b3..ab24ae5ab3 100644 --- a/src/signals/settings-preview.ts +++ b/src/signals/settings-preview.ts @@ -198,6 +198,10 @@ export type RepoSettingsPreview = { includeMaintainerAuthors: boolean; requireLinkedIssue: boolean; badgeEnabled: boolean; + aiReviewMode: RepositorySettings["aiReviewMode"]; + aiReviewByok: boolean; + aiReviewProvider: string | null; + aiReviewModel: string | null; commandAuthorization: { defaultAllowed: CommandAuthorizationRole[]; commandOverrides: Array<{ command: string; allowedRoles: CommandAuthorizationRole[] }>; @@ -316,6 +320,10 @@ export function buildRepoSettingsPreview(args: { includeMaintainerAuthors: settings.includeMaintainerAuthors, requireLinkedIssue: settings.requireLinkedIssue, badgeEnabled: settings.badgeEnabled ?? false, + aiReviewMode: settings.aiReviewMode, + aiReviewByok: settings.aiReviewByok, + aiReviewProvider: settings.aiReviewProvider ?? null, + aiReviewModel: settings.aiReviewModel ?? null, commandAuthorization: summarizeCommandAuthorizationPolicy(settings.commandAuthorization), }, commandAuthorizationPreview, diff --git a/test/unit/focus-manifest.test.ts b/test/unit/focus-manifest.test.ts index 0af4c1db32..5241c676d6 100644 --- a/test/unit/focus-manifest.test.ts +++ b/test/unit/focus-manifest.test.ts @@ -951,6 +951,14 @@ describe("parseFocusManifest settings override + resolveEffectiveSettings", () = expect(eff.aiReviewMode).toBe("block"); expect(eff.aiReviewByok).toBe(true); }); + + it("promotes requireLinkedIssue to linkedIssueGateMode block when the gate mode is still off (#797)", () => { + const eff = resolveEffectiveSettings( + { requireLinkedIssue: true, linkedIssueGateMode: "off" } as RepositorySettings, + parseFocusManifest(null), + ); + expect(eff.linkedIssueGateMode).toBe("block"); + }); }); describe("parseFocusManifest review config", () => { diff --git a/test/unit/maintainer-activation.test.ts b/test/unit/maintainer-activation.test.ts index 6ccbab7e03..311f3fd22a 100644 --- a/test/unit/maintainer-activation.test.ts +++ b/test/unit/maintainer-activation.test.ts @@ -72,7 +72,7 @@ describe("buildMaintainerActivationPreview", () => { const preview = buildMaintainerActivationPreview({ repoFullName: repo.fullName, repo, - settings: settings(), + settings: settings({ requireLinkedIssue: true }), pullRequests: [pr(1, { linkedIssues: [] }), pr(2, { linkedIssues: [5] })], generatedAt: "2026-06-14T00:00:00.000Z", }); diff --git a/test/unit/repository-settings-enforcement.test.ts b/test/unit/repository-settings-enforcement.test.ts new file mode 100644 index 0000000000..5c2044b2c9 --- /dev/null +++ b/test/unit/repository-settings-enforcement.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from "vitest"; +import { gateCheckPolicy } from "../../src/queue/processors"; +import { evaluateGateCheck } from "../../src/rules/advisory"; +import { runAiReviewForAdvisory } from "../../src/queue/processors"; +import { buildMaintainerActivationPreview } from "../../src/services/maintainer-activation"; +import { decidePublicSurface } from "../../src/signals/settings-preview"; +import { parseFocusManifest, resolveEffectiveSettings } from "../../src/signals/focus-manifest"; +import type { Advisory, PullRequestRecord, RepositoryRecord, RepositorySettings } from "../../src/types"; + +function settings(over: Partial = {}): RepositorySettings { + return { + repoFullName: "owner/repo", + commentMode: "detected_contributors_only", + publicAudienceMode: "oss_maintainer", + publicSignalLevel: "standard", + checkRunMode: "off", + checkRunDetailLevel: "standard", + gateCheckMode: "enabled", + gatePack: "gittensor", + linkedIssueGateMode: "off", + duplicatePrGateMode: "advisory", + qualityGateMode: "advisory", + slopGateMode: "off", + mergeReadinessGateMode: "off", + manifestPolicyGateMode: "off", + firstTimeContributorGrace: false, + slopAiAdvisory: false, + qualityGateMinScore: null, + autoLabelEnabled: true, + gittensorLabel: "gittensor", + createMissingLabel: true, + publicSurface: "comment_and_label", + includeMaintainerAuthors: false, + requireLinkedIssue: false, + backfillEnabled: true, + privateTrustEnabled: true, + aiReviewMode: "off", + aiReviewByok: false, + aiReviewProvider: null, + aiReviewModel: null, + ...over, + }; +} + +function missingIssueAdvisory(): Advisory { + return { + id: "advisory-policy", + targetType: "pull_request", + targetKey: "owner/repo#7", + repoFullName: "owner/repo", + pullNumber: 7, + headSha: "sha7", + conclusion: "neutral", + severity: "warning", + title: "Gittensory advisory available", + summary: "1 advisory finding generated.", + findings: [{ code: "missing_linked_issue", title: "No linked issue detected", severity: "warning", detail: "No closing reference.", action: "Link the issue." }], + generatedAt: "2026-06-13T00:00:00.000Z", + }; +} + +describe("repository settings enforcement audit (#797)", () => { + it("maps requireLinkedIssue to linkedIssueGateMode block when the gate mode is off", () => { + const effective = resolveEffectiveSettings(settings({ requireLinkedIssue: true, linkedIssueGateMode: "off" }), parseFocusManifest(null)); + expect(effective.linkedIssueGateMode).toBe("block"); + }); + + it("blocks confirmed contributors when requireLinkedIssue is enabled via the boolean alone", () => { + const effective = resolveEffectiveSettings(settings({ requireLinkedIssue: true, linkedIssueGateMode: "off" }), parseFocusManifest(null)); + const result = evaluateGateCheck(missingIssueAdvisory(), gateCheckPolicy(effective, null, true)); + expect(result.conclusion).toBe("failure"); + }); + + it("skips maintainer-authored public surfaces when includeMaintainerAuthors is false", () => { + const decision = decidePublicSurface({ + settings: settings({ includeMaintainerAuthors: false }), + authorLogin: "owner", + authorType: "User", + authorAssociation: "OWNER", + minerStatus: "confirmed", + }); + expect(decision).toMatchObject({ skipped: true, skipReason: "maintainer_author" }); + }); + + it("no-ops AI review when aiReviewMode is off", async () => { + const advisory = missingIssueAdvisory(); + const notes = await runAiReviewForAdvisory({} as Env, { + settings: settings({ aiReviewMode: "off" }), + advisory, + repoFullName: "owner/repo", + pr: { number: 7, title: "Test" }, + author: "miner", + confirmedContributor: true, + }); + expect(notes).toBeUndefined(); + expect(advisory.findings.some((finding) => finding.code === "ai_consensus_defect")).toBe(false); + }); + + it("uses the repo requireLinkedIssue setting in maintainer activation previews", () => { + const repo: RepositoryRecord = { + fullName: "owner/repo", + owner: "owner", + name: "repo", + isInstalled: true, + isRegistered: true, + isPrivate: false, + registryConfig: { repo: "owner/repo", emissionShare: 0.01, issueDiscoveryShare: 0, maintainerCut: 0, labelMultipliers: {}, raw: {} }, + }; + const pull: PullRequestRecord = { + repoFullName: repo.fullName, + number: 1, + title: "No issue link", + state: "open", + authorLogin: "miner", + body: "No closing reference.", + labels: [], + linkedIssues: [], + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", + }; + const withRequirement = buildMaintainerActivationPreview({ + repoFullName: repo.fullName, + repo, + settings: settings({ requireLinkedIssue: true }), + pullRequests: [pull], + generatedAt: "2026-06-01T00:00:00.000Z", + }); + const withoutRequirement = buildMaintainerActivationPreview({ + repoFullName: repo.fullName, + repo, + settings: settings({ requireLinkedIssue: false, linkedIssueGateMode: "off" }), + pullRequests: [pull], + generatedAt: "2026-06-01T00:00:00.000Z", + }); + expect(withRequirement.samples[0]?.findings.some((finding) => finding.code === "missing_linked_issue")).toBe(true); + expect(withoutRequirement.samples[0]?.findings.some((finding) => finding.code === "missing_linked_issue")).toBe(false); + }); +});