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
10 changes: 7 additions & 3 deletions .gittensory.yml.example
Original file line number Diff line number Diff line change
Expand Up @@ -101,9 +101,13 @@ gate:
# Default: gittensor.
pack: gittensor

# Linked-issue gate. off | advisory | block. Default: advisory.
# (If the dashboard "Require linked issue" toggle is on but this is `off`,
# it is auto-promoted to `block`.)
# Linked-issue gate — what happens when a PR has NO linked issue at all. off | advisory | block.
# Default: advisory (a missing issue is surfaced in the review panel but never blocks the gate; issues
# aren't always available, e.g. small/self-evident fixes). Set `block` here, or turn on the dashboard
# "Require linked issue" toggle, to make a missing issue an explicit opt-in blocker.
# (If the dashboard toggle is on but this is still `off`, it is auto-promoted to `block`.)
# This is UNRELATED to closing a PR that links an INELIGIBLE issue (owner-assigned, wrong label, etc.) —
# that is a separate, deterministic rule, not this gate.
linkedIssue: advisory

# Duplicate-PR gate — detects duplicate/superseding PRs.
Expand Down
10 changes: 7 additions & 3 deletions apps/gittensory-ui/src/routes/docs.tuning.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -220,9 +220,13 @@ function Tuning() {
<code>block</code>.
</li>
<li>
<code>gate.linkedIssue</code> — linked-issue gate. Default <code>advisory</code>. If the
dashboard "Require linked issue" toggle is on but this is <code>off</code>, it is
auto-promoted to <code>block</code>.
<code>gate.linkedIssue</code> — what happens when a PR has <em>no linked issue at all</em>
{". "}Default <code>advisory</code> (surfaced in the review panel, never blocks — issues
aren&apos;t always available). Set <code>block</code>, or turn on the dashboard "Require
linked issue" toggle, to make a missing issue an explicit opt-in blocker (if the toggle is
on but this is still <code>off</code>, it is auto-promoted to <code>block</code>). This is
unrelated to closing a PR that links an <em>ineligible</em> issue (owner-assigned, wrong
label, etc.) — that is a separate, deterministic rule, not this gate.
</li>
<li>
<code>gate.readiness.mode</code> — the PR-quality / merge-readiness score gate. Default{" "}
Expand Down
57 changes: 57 additions & 0 deletions migrations/0102_fix_linked_issue_gate_mode_default.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
-- #selfhost-linked-issue-gate-drift: repository_settings.linked_issue_gate_mode was persisted as 'block'
-- by migration 0023 (its initial ADD COLUMN default) and migration 0025 (a since-superseded "restore"
-- backfill that flipped any 'advisory' row to 'block' for repos with gate_check_mode='enabled'), even
-- though missing a linked issue is only ever supposed to be advisory unless a maintainer explicitly opts
-- into blocking. The application-level fallback (src/db/repositories.ts) and every documented default
-- (.gittensory.yml.example, docs.tuning.tsx, the settings API schema) already say 'advisory' -- only the
-- persisted column value drifted.
--
-- #gate-review-2727 round 1: require_linked_issue = 0 alone does NOT prove drift. linkedIssueGateMode and
-- requireLinkedIssue are independently settable (the maintainer settings UI exposes them as a separate
-- dropdown and toggle; both PUT .../settings and the internal settings route accept linkedIssueGateMode on
-- its own), so a maintainer can genuinely choose 'block' while leaving requireLinkedIssue off. No column on
-- this row records which field a maintainer last touched, so per-field intent can't be recovered by itself.
--
-- #gate-review-2727 round 2: an earlier draft used `updated_at = created_at` ("never written to since
-- creation") as the drift signal, anchored implicitly on the row's OWN creation time. That missed rows last
-- written before migration 0023 even added the column (provably drift too, just not "never written")
-- AND, the opposite failure mode, could clobber a repo whose very FIRST-EVER settings save explicitly chose
-- 'block' (upsertRepositorySettings sets createdAt and updatedAt to the same instant on that single INSERT,
-- so a genuine first-save opt-in is indistinguishable from untouched drift under that condition alone).
--
-- What CAN be proven: updated_at is bumped on every write through upsertRepositorySettings
-- (src/db/repositories.ts) -- the ONLY code path that ever inserts or updates this row, always through
-- Drizzle, always with an explicit resolved value for this column (never omitted, so the raw column-level
-- default below never fires through it) -- so updated_at is exactly "the last instant any application code
-- touched this row, insert or update alike". linked_issue_gate_mode did not exist as a column, and therefore
-- could not have been part of ANY resolved settings value, before migration 0023 was deployed at commit
-- 66e4dd6b (2026-06-05T14:01:39-06:00 = 2026-06-05T20:01:39Z; this repo auto-deploys `wrangler d1 migrations
-- apply --remote` on every merge to main, so the merge commit time is a same-day, conservatively-early proxy
-- for the real remote apply time -- true apply is always slightly AFTER this timestamp, never before, so an
-- inclusive upper bound here can only under-select rows, never wrongly include one written after the column
-- existed). A row whose updated_at is at or before that instant has therefore NEVER been written to by any
-- application code since the column started existing, under ANY value -- its current 'block' can only be
-- the byproduct of migration 0023's column-add default or 0025's later blanket flip, never a maintainer's
-- resolved choice. That single condition subsumes the "never touched since creation" case too: a row with no
-- write at all still has its ORIGINAL creation timestamp as its updated_at, which for any row old enough to
-- predate 0023 is at-or-before the cutoff by construction.
--
-- A row with updated_at after that cutoff has been through at least one real settings write (its own
-- creation or a later update) since the column existed and is left alone, even though some of those may also
-- be untouched drift this migration can no longer safely reach -- a maintainer stuck with a leftover 'block'
-- default can flip it from the settings UI. Leaving that row alone is the safe failure mode; silently
-- downgrading a real 'block' opt-in -- first-save or later -- to advisory is not.
--
-- The raw SQLite column-level DEFAULT on linked_issue_gate_mode itself (set to 'block' by migration 0023's
-- `ADD COLUMN ... DEFAULT 'block'`) is intentionally left as-is here rather than rebuilt to 'advisory': SQLite
-- has no ALTER COLUMN SET DEFAULT, so changing it requires a full create-copy-drop-rename table rebuild --
-- an operation with no precedent anywhere in migrations/ and real operational risk for a table this central,
-- for a column-level default that no live code path ever reaches (upsertRepositorySettings is the only
-- writer, and it always resolves and supplies an explicit value; see
-- test/unit/schema-timestamp-defaults.test.ts, which pins that even an omitted-field Drizzle insert resolves
-- via the schema.ts default, never this raw column default).
UPDATE repository_settings
SET linked_issue_gate_mode = 'advisory'
WHERE linked_issue_gate_mode = 'block'
AND require_linked_issue = 0
AND updated_at <= '2026-06-05T20:01:39.000Z';
11 changes: 10 additions & 1 deletion src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,16 @@ export const repositorySettings = sqliteTable("repository_settings", {
checkRunDetailLevel: text("check_run_detail_level").notNull().default("minimal"),
gateCheckMode: text("gate_check_mode").notNull().default("off"),
gatePack: text("gate_pack").notNull().default("gittensor"),
linkedIssueGateMode: text("linked_issue_gate_mode").notNull().default("block"),
// Missing a linked issue is advisory-only by default -- issues aren't always available, so it only
// blocks when a repo explicitly opts in (linkedIssueGateMode: "block" or the requireLinkedIssue toggle;
// see resolveEffectiveSettings in signals/focus-manifest.ts). This default was "block" until #selfhost-
// linked-issue-gate-drift, which also backfills any row an earlier migration (0023/0025) persisted as
// "block" without an explicit opt-in (migrations/0102_fix_linked_issue_gate_mode_default.sql). The raw
// SQLite column still has a DEFAULT 'block' from migration 0023 -- SQLite has no ALTER COLUMN SET DEFAULT,
// so fixing that requires a full table rebuild, not done here since no write path ever omits this field
// (see test/unit/schema-timestamp-defaults.test.ts). This .default("advisory") is what actually applies,
// client-side, on any insert that omits the field.
linkedIssueGateMode: text("linked_issue_gate_mode").notNull().default("advisory"),
duplicatePrGateMode: text("duplicate_pr_gate_mode").notNull().default("block"),
qualityGateMode: text("quality_gate_mode").notNull().default("advisory"),
qualityGateMinScore: integer("quality_gate_min_score"),
Expand Down
16 changes: 16 additions & 0 deletions test/unit/focus-manifest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1660,6 +1660,22 @@ describe("parseFocusManifest settings override + resolveEffectiveSettings", () =
const dbAdvisory = { qualityGateMode: "advisory" } as unknown as RepositorySettings;
expect(resolveEffectiveSettings(dbAdvisory, parseFocusManifest(null)).qualityGateMode).toBe("advisory");
});

it("REGRESSION: keeps missing-linked-issue advisory when the DB row already says advisory and nothing overrides it (#selfhost-linked-issue-gate-drift)", () => {
const db = { linkedIssueGateMode: "advisory", requireLinkedIssue: false } as unknown as RepositorySettings;
expect(resolveEffectiveSettings(db, parseFocusManifest(null)).linkedIssueGateMode).toBe("advisory");
});

it("does NOT blanket-downgrade a DB linkedIssueGateMode: block to advisory -- unlike qualityGateMode, block is a legitimate opt-in here (#selfhost-linked-issue-gate-drift)", () => {
// Deliberately the OPPOSITE assertion from the qualityGateMode regression above: qualityGateMode can
// NEVER legitimately be "block" (isConfiguredGateBlocker has no branch for it), so resolveEffectiveSettings
// unconditionally downgrades it. linkedIssueGateMode CAN legitimately be "block" -- a maintainer may
// explicitly opt into it -- so migration 0102's data fix (conservative: only provably-drifted rows) is
// the correct place to correct historically-drifted rows, not a resolver-level downgrade that would also
// silently defeat a real, current opt-in.
const db = { linkedIssueGateMode: "block", requireLinkedIssue: false } as unknown as RepositorySettings;
expect(resolveEffectiveSettings(db, parseFocusManifest(null)).linkedIssueGateMode).toBe("block");
});
});

describe("parseFocusManifest review config", () => {
Expand Down
22 changes: 22 additions & 0 deletions test/unit/linked-issue-hard-rules.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {
type LinkedIssueFacts,
type LinkedIssueHardRulesConfig,
} from "../../src/review/linked-issue-hard-rules";
import { parseFocusManifest, resolveEffectiveSettings } from "../../src/signals/focus-manifest";
import type { RepositorySettings } from "../../src/types";

function config(overrides: Partial<LinkedIssueHardRulesConfig> = {}): LinkedIssueHardRulesConfig {
return {
Expand Down Expand Up @@ -341,4 +343,24 @@ describe("resolveLinkedIssueHardRule (#1144 — overflow + orchestration)", () =
expect(spy).toHaveBeenCalledWith(expect.anything(), "owner/repo", 7, "installation-token", "installation:143010787");
spy.mockRestore();
});

it("REGRESSION: an ineligible (owner-assigned) linked issue still violates the hard rule regardless of linkedIssueGateMode -- the two are fully independent (#selfhost-linked-issue-gate-drift)", () => {
// evaluateLinkedIssueHardRules's own input type (`{ issues, config, repoOwner }`) has no linkedIssueGateMode
// field at all -- it structurally cannot read it. This test pins the END-TO-END behavior: fixing
// linkedIssueGateMode's default to "advisory" (missing-issue is non-blocking by default) must never soften
// or bypass the hard rule for a linked issue that DOES exist but is ineligible (owner-assigned here).
const result = evaluateLinkedIssueHardRules({
issues: [issue({ number: 9, assignees: ["jsonbored"] })],
config: config({ ownerAssignedClose: "block" }),
repoOwner: OWNER,
});
expect(result.violated).toBe(true);
expect(result.reason).toContain("#9");

// The gate-mode side, evaluated completely separately: a repo with no explicit override now resolves
// linkedIssueGateMode to "advisory" (the fixed default) -- confirming the fix under test is live -- while
// the hard-rule violation above is computed independently and is unaffected by it either way.
const db = { linkedIssueGateMode: "advisory", requireLinkedIssue: false } as unknown as RepositorySettings;
expect(resolveEffectiveSettings(db, parseFocusManifest(null)).linkedIssueGateMode).toBe("advisory");
});
});
Loading
Loading