diff --git a/.gittensory.yml.example b/.gittensory.yml.example
index bc269a0bb0..7ef6ae3797 100644
--- a/.gittensory.yml.example
+++ b/.gittensory.yml.example
@@ -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.
diff --git a/apps/gittensory-ui/src/routes/docs.tuning.tsx b/apps/gittensory-ui/src/routes/docs.tuning.tsx
index 07f4711540..27de1f1853 100644
--- a/apps/gittensory-ui/src/routes/docs.tuning.tsx
+++ b/apps/gittensory-ui/src/routes/docs.tuning.tsx
@@ -220,9 +220,13 @@ function Tuning() {
block.
- gate.linkedIssue — linked-issue gate. Default advisory. If the
- dashboard "Require linked issue" toggle is on but this is off, it is
- auto-promoted to block.
+ gate.linkedIssue — what happens when a PR has no linked issue at all
+ {". "}Default advisory (surfaced in the review panel, never blocks — issues
+ aren't always available). Set block, 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 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.
gate.readiness.mode — the PR-quality / merge-readiness score gate. Default{" "}
diff --git a/migrations/0102_fix_linked_issue_gate_mode_default.sql b/migrations/0102_fix_linked_issue_gate_mode_default.sql
new file mode 100644
index 0000000000..238a485557
--- /dev/null
+++ b/migrations/0102_fix_linked_issue_gate_mode_default.sql
@@ -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';
diff --git a/src/db/schema.ts b/src/db/schema.ts
index 275e436e15..a7e1ac01ed 100644
--- a/src/db/schema.ts
+++ b/src/db/schema.ts
@@ -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"),
diff --git a/test/unit/focus-manifest.test.ts b/test/unit/focus-manifest.test.ts
index 4c93854a80..4b1b2e4e60 100644
--- a/test/unit/focus-manifest.test.ts
+++ b/test/unit/focus-manifest.test.ts
@@ -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", () => {
diff --git a/test/unit/linked-issue-hard-rules.test.ts b/test/unit/linked-issue-hard-rules.test.ts
index 707c140e92..e9093d095c 100644
--- a/test/unit/linked-issue-hard-rules.test.ts
+++ b/test/unit/linked-issue-hard-rules.test.ts
@@ -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 {
return {
@@ -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");
+ });
});
diff --git a/test/unit/migration-0102-linked-issue-gate-mode.test.ts b/test/unit/migration-0102-linked-issue-gate-mode.test.ts
new file mode 100644
index 0000000000..e619d089b7
--- /dev/null
+++ b/test/unit/migration-0102-linked-issue-gate-mode.test.ts
@@ -0,0 +1,163 @@
+import { readFileSync, readdirSync } from "node:fs";
+import { DatabaseSync } from "node:sqlite";
+import { describe, expect, it } from "vitest";
+
+const MIGRATION_FILE = "0102_fix_linked_issue_gate_mode_default.sql";
+
+// Replays every migrations/*.sql file BEFORE 0102 into a fresh in-memory DB (mirrors
+// scripts/check-schema-drift.mjs's own "replay migrations into node:sqlite" approach), so the table shape
+// this test inserts into is exactly what migration 0102 itself was written against -- not a guess. The
+// TestD1Database helper (test/helpers/d1.ts) can't be reused here: it concatenates and applies EVERY
+// migration (including 0102) up front, so the `repository_settings` table would already be empty-and-fixed
+// by the time a test could insert a "bad state" row -- there would be nothing left for 0102 to correct.
+function applyMigrationsBefore(cutoffFile: string): DatabaseSync {
+ const db = new DatabaseSync(":memory:");
+ const files = readdirSync("migrations")
+ .filter((file) => file.endsWith(".sql") && file < cutoffFile)
+ .sort();
+ for (const file of files) db.exec(readFileSync(`migrations/${file}`, "utf8"));
+ return db;
+}
+
+function applyMigration(db: DatabaseSync, file: string): void {
+ db.exec(readFileSync(`migrations/${file}`, "utf8"));
+}
+
+// created_at/updated_at are set explicitly (not left to the column's CURRENT_TIMESTAMP default) so a
+// "touched since creation" row can be simulated deterministically. Migration 0102 flips a row iff its
+// updated_at is at/before the instant migration 0023 deployed (2026-06-05T20:01:39.000Z, see the migration's
+// own header comment for why) -- so a "touched" fixture must land AFTER that cutoff to exercise the
+// "genuinely left alone" path.
+const AFTER_0023_CUTOFF = "2026-07-01T00:00:00.000Z";
+
+function insertRepositorySettingsRow(
+ db: DatabaseSync,
+ repoFullName: string,
+ linkedIssueGateMode: string,
+ requireLinkedIssue: 0 | 1,
+ touchedSinceCreation = false,
+): void {
+ db.prepare(
+ "INSERT INTO repository_settings (repo_full_name, linked_issue_gate_mode, require_linked_issue, created_at, updated_at) VALUES (?, ?, ?, '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z')",
+ ).run(repoFullName, linkedIssueGateMode, requireLinkedIssue);
+ if (touchedSinceCreation) {
+ db.prepare("UPDATE repository_settings SET updated_at = ? WHERE repo_full_name = ?").run(
+ AFTER_0023_CUTOFF,
+ repoFullName,
+ );
+ }
+}
+
+function applyMigrationsInRange(db: DatabaseSync, fromFileInclusive: string, throughFileInclusive: string): void {
+ const files = readdirSync("migrations")
+ .filter((file) => file.endsWith(".sql") && file >= fromFileInclusive && file <= throughFileInclusive)
+ .sort();
+ for (const file of files) db.exec(readFileSync(`migrations/${file}`, "utf8"));
+}
+
+function readLinkedIssueGateMode(db: DatabaseSync, repoFullName: string): string {
+ const row = db
+ .prepare("SELECT linked_issue_gate_mode FROM repository_settings WHERE repo_full_name = ?")
+ .get(repoFullName) as { linked_issue_gate_mode: string } | undefined;
+ if (!row) throw new Error(`no repository_settings row for ${repoFullName}`);
+ return row.linked_issue_gate_mode;
+}
+
+describe("migration 0102: fix linked_issue_gate_mode default drift (#selfhost-linked-issue-gate-drift)", () => {
+ it("flips a 'block' row that has never been written to since creation to 'advisory'", () => {
+ const db = applyMigrationsBefore(MIGRATION_FILE);
+ insertRepositorySettingsRow(db, "acme/drifted-repo", "block", 0);
+
+ applyMigration(db, MIGRATION_FILE);
+
+ expect(readLinkedIssueGateMode(db, "acme/drifted-repo")).toBe("advisory");
+ });
+
+ it("leaves a 'block' row alone when require_linked_issue is an explicit maintainer opt-in", () => {
+ const db = applyMigrationsBefore(MIGRATION_FILE);
+ insertRepositorySettingsRow(db, "acme/explicit-opt-in", "block", 1);
+
+ applyMigration(db, MIGRATION_FILE);
+
+ expect(readLinkedIssueGateMode(db, "acme/explicit-opt-in")).toBe("block");
+ });
+
+ // #gate-review-2727 round 1: the exact scenario the reviewer flagged -- a maintainer who chose 'block' from
+ // the settings UI's "Linked issue" dropdown without also turning on the separate "Require a linked issue"
+ // toggle, THEN saved again later (e.g. touching an unrelated field). require_linked_issue = 0 alone can't
+ // distinguish this from drift; updated_at after the 0023 cutoff can, because it proves a real settings
+ // write happened after the column -- and therefore this field -- existed to have an opinion about.
+ it("leaves a 'block' row alone when it has been saved since creation, even with require_linked_issue = 0", () => {
+ const db = applyMigrationsBefore(MIGRATION_FILE);
+ insertRepositorySettingsRow(db, "acme/explicit-block-no-require", "block", 0, true);
+
+ applyMigration(db, MIGRATION_FILE);
+
+ expect(readLinkedIssueGateMode(db, "acme/explicit-block-no-require")).toBe("block");
+ });
+
+ // #gate-review-2727 round 3: a NARROWER, more direct version of the same concern -- a repo whose very
+ // FIRST-EVER settings save (a single INSERT, so created_at = updated_at by construction) explicitly chose
+ // 'block'. An earlier draft used `updated_at = created_at` as its sole drift signal, which could not tell
+ // this apart from a genuinely untouched drifted row. Anchoring on the 0023 cutoff instead of on
+ // created_at/updated_at equality fixes this: this row's single write happened AFTER the column existed, so
+ // it is left alone regardless of whether it was ever touched again.
+ it("leaves a 'block' row alone when it was chosen on the repo's very first settings save, after the column existed", () => {
+ const db = applyMigrationsBefore(MIGRATION_FILE);
+ db.prepare(
+ "INSERT INTO repository_settings (repo_full_name, linked_issue_gate_mode, require_linked_issue, created_at, updated_at) VALUES (?, 'block', 0, ?, ?)",
+ ).run("acme/first-save-block", AFTER_0023_CUTOFF, AFTER_0023_CUTOFF);
+
+ applyMigration(db, MIGRATION_FILE);
+
+ expect(readLinkedIssueGateMode(db, "acme/first-save-block")).toBe("block");
+ });
+
+ // #gate-review-2727 round 2: a row last written to BEFORE migration 0023 ran has updated_at > created_at
+ // (an old write, from before the column existed), which an earlier "never touched since creation" signal
+ // would have skipped -- but that write could not possibly have set linked_issue_gate_mode either way.
+ // Proves this via the REAL migration sequence (0023's ADD COLUMN DEFAULT 'block' backfills the pre-existing
+ // row, exactly like production), not a hand-inserted 'block' row.
+ it("repairs a row that predates the linked_issue_gate_mode column entirely, via the real migration sequence", () => {
+ const db = applyMigrationsBefore("0023_gate_quality_modes.sql");
+ db.prepare("INSERT INTO repository_settings (repo_full_name, created_at, updated_at) VALUES (?, ?, ?)").run(
+ "acme/pre-column-repo",
+ "2026-01-01T00:00:00.000Z",
+ "2026-03-01T00:00:00.000Z",
+ );
+
+ applyMigrationsInRange(db, "0023_gate_quality_modes.sql", MIGRATION_FILE);
+
+ expect(readLinkedIssueGateMode(db, "acme/pre-column-repo")).toBe("advisory");
+ });
+
+ it("leaves an already-advisory row unchanged", () => {
+ const db = applyMigrationsBefore(MIGRATION_FILE);
+ insertRepositorySettingsRow(db, "acme/already-advisory", "advisory", 0);
+
+ applyMigration(db, MIGRATION_FILE);
+
+ expect(readLinkedIssueGateMode(db, "acme/already-advisory")).toBe("advisory");
+ });
+
+ it("leaves an 'off' row unchanged (not a drifted value at all)", () => {
+ const db = applyMigrationsBefore(MIGRATION_FILE);
+ insertRepositorySettingsRow(db, "acme/gate-off", "off", 0);
+
+ applyMigration(db, MIGRATION_FILE);
+
+ expect(readLinkedIssueGateMode(db, "acme/gate-off")).toBe("off");
+ });
+
+ it("is idempotent -- running it a second time changes nothing further", () => {
+ const db = applyMigrationsBefore(MIGRATION_FILE);
+ insertRepositorySettingsRow(db, "acme/drifted-repo", "block", 0);
+ insertRepositorySettingsRow(db, "acme/explicit-opt-in", "block", 1);
+
+ applyMigration(db, MIGRATION_FILE);
+ applyMigration(db, MIGRATION_FILE);
+
+ expect(readLinkedIssueGateMode(db, "acme/drifted-repo")).toBe("advisory");
+ expect(readLinkedIssueGateMode(db, "acme/explicit-opt-in")).toBe("block");
+ });
+});
diff --git a/test/unit/repository-settings-linked-issue-defaults.test.ts b/test/unit/repository-settings-linked-issue-defaults.test.ts
new file mode 100644
index 0000000000..ad9fe373cf
--- /dev/null
+++ b/test/unit/repository-settings-linked-issue-defaults.test.ts
@@ -0,0 +1,39 @@
+import { describe, expect, it } from "vitest";
+import { getRepositorySettings, upsertRepositorySettings } from "../../src/db/repositories";
+import { createTestEnv } from "../helpers/d1";
+
+// #selfhost-linked-issue-gate-drift: repository_settings.linked_issue_gate_mode was persisted as 'block' in
+// production for repos that never explicitly opted into it (migrations/0102_fix_linked_issue_gate_mode_default.sql
+// backfills the historically-drifted rows). These regression tests pin the two paths that must default to
+// 'advisory' going forward: a brand-new row (no DB row yet) and an explicit upsert that omits the field.
+describe("repository_settings: linked-issue gate defaults to advisory, not block (#selfhost-linked-issue-gate-drift)", () => {
+ it("getRepositorySettings returns advisory for a repo with no DB row at all", async () => {
+ const env = createTestEnv();
+ const settings = await getRepositorySettings(env, "acme/brand-new-repo");
+ expect(settings.linkedIssueGateMode).toBe("advisory");
+ expect(settings.requireLinkedIssue).toBe(false);
+ });
+
+ it("upsertRepositorySettings persists advisory when the caller omits linkedIssueGateMode entirely", async () => {
+ const env = createTestEnv();
+ await upsertRepositorySettings(env, { repoFullName: "acme/omits-gate-mode" });
+ const settings = await getRepositorySettings(env, "acme/omits-gate-mode");
+ expect(settings.linkedIssueGateMode).toBe("advisory");
+ });
+
+ it("an explicit block opt-in is persisted and read back as block -- advisory-by-default does not clobber a real opt-in", async () => {
+ const env = createTestEnv();
+ await upsertRepositorySettings(env, { repoFullName: "acme/explicit-block", linkedIssueGateMode: "block" });
+ const settings = await getRepositorySettings(env, "acme/explicit-block");
+ expect(settings.linkedIssueGateMode).toBe("block");
+ });
+
+ it("re-upserting without specifying linkedIssueGateMode keeps the row at its previously-set value (upsert defaults only apply when the field is omitted from the settings object, not merged against the existing row)", async () => {
+ const env = createTestEnv();
+ await upsertRepositorySettings(env, { repoFullName: "acme/round-trip", linkedIssueGateMode: "block" });
+ const settings = await getRepositorySettings(env, "acme/round-trip");
+ await upsertRepositorySettings(env, { ...settings, repoFullName: "acme/round-trip" });
+ const after = await getRepositorySettings(env, "acme/round-trip");
+ expect(after.linkedIssueGateMode).toBe("block");
+ });
+});
diff --git a/test/unit/schema-timestamp-defaults.test.ts b/test/unit/schema-timestamp-defaults.test.ts
index 84486d8705..aeec02d8b6 100644
--- a/test/unit/schema-timestamp-defaults.test.ts
+++ b/test/unit/schema-timestamp-defaults.test.ts
@@ -26,6 +26,12 @@ describe("timestamp column defaults", () => {
expect(row?.createdAt).toMatch(ISO);
expect(row?.updatedAt).toMatch(ISO);
expect(row?.createdAt).not.toBe("CURRENT_TIMESTAMP");
+ // #gate-review-2727: the raw SQLite column-level DEFAULT for linked_issue_gate_mode is still 'block'
+ // (migration 0023 added it that way, and SQLite has no ALTER COLUMN SET DEFAULT to fix it without a full
+ // table rebuild -- see migrations/0102_fix_linked_issue_gate_mode_default.sql's header comment). This
+ // pins that the raw default never actually fires: Drizzle's schema.ts `.default("advisory")` is injected
+ // client-side into the generated INSERT whenever the field is omitted, same as createdAt/updatedAt above.
+ expect(row?.linkedIssueGateMode).toBe("advisory");
});
it("keeps orb relay pending coalesce keys wired through the drizzle schema", async () => {