From 073dfa861ec584a301bedfc6c20f182d251c8df4 Mon Sep 17 00:00:00 2001
From: JSONbored <49853598+JSONbored@users.noreply.github.com>
Date: Tue, 7 Jul 2026 15:44:41 -0700
Subject: [PATCH] feat(review): wire linked-issue satisfaction into the
deterministic gate
Wires the pure linked-issue satisfaction analysis core (src/services/linked-issue-satisfaction.ts,
#2172) into a full model-calling orchestration + cache + gate pipeline: a new
runGittensoryLinkedIssueSatisfaction service mirroring ai-slop.ts's multi-model retry/BYOK shape, a
linked_issue_satisfaction_cache table keyed on (repo, pull, head SHA, linked issue number), and a new
DB-backed gate.linkedIssueSatisfaction mode (off/advisory/block, default off) wired through the full
aiReviewMode-style template (migration, schema, repositories resolver, OpenAPI, settings-preview,
.gittensory.yml, docs).
Extends fetchLinkedIssueFacts to also return the linked issue's title/body (same call, additive).
Adds a linked_issue_scope_mismatch finding + isConfiguredGateBlocker branch: in block mode, an
above-confidence-floor "unaddressed" verdict now fails the gate instead of only the AI reviewer's
free-text prose flagging a scope mismatch while the structured Linked issue signal stays green.
Closes #1961, Closes #3906
---
.gittensory.yml.example | 11 +
apps/gittensory-ui/public/openapi.json | 18 +
.../src/routes/docs.github-app.tsx | 8 +
.../src/routes/docs.how-reviews-work.tsx | 9 +
apps/gittensory-ui/src/routes/docs.tuning.tsx | 8 +
config/examples/gittensory.full.yml | 11 +
...23_linked_issue_satisfaction_gate_mode.sql | 8 +
.../0124_linked_issue_satisfaction_cache.sql | 20 +
.../gittensory-engine/src/focus-manifest.ts | 13 +
scripts/check-docs-drift.mjs | 1 +
src/api/routes.ts | 1 +
src/db/repositories.ts | 61 ++
src/db/schema.ts | 31 +
src/github/backfill.ts | 20 +-
src/openapi/schemas.ts | 2 +
src/queue/processors.ts | 203 ++++
.../linked-issue-satisfaction-cache-input.ts | 27 +
src/review/unified-comment-bridge.ts | 8 +
src/rules/advisory.ts | 12 +
src/services/linked-issue-satisfaction-run.ts | 181 ++++
src/services/linked-issue-satisfaction.ts | 6 +-
src/signals/focus-manifest.ts | 1 +
src/signals/settings-preview.ts | 2 +
src/types.ts | 9 +
test/unit/backfill.test.ts | 39 +-
test/unit/check-docs-drift-script.test.ts | 3 +-
test/unit/focus-manifest.test.ts | 5 +-
test/unit/gate-check-policy.test.ts | 27 +
.../linked-issue-satisfaction-cache.test.ts | 131 +++
.../linked-issue-satisfaction-run.test.ts | 919 ++++++++++++++++++
test/unit/maintainer-activation.test.ts | 1 +
test/unit/policy-sanitizer.test.ts | 1 +
test/unit/registration-readiness.test.ts | 1 +
test/unit/repo-policy-readiness.test.ts | 1 +
.../repository-settings-enforcement.test.ts | 1 +
...settings-linked-issue-satisfaction.test.ts | 49 +
test/unit/schema-timestamp-defaults.test.ts | 18 +-
.../self-dogfood-registration-pack.test.ts | 1 +
test/unit/settings-preview.test.ts | 1 +
test/unit/signals-coverage.test.ts | 1 +
test/unit/signals-v2.test.ts | 1 +
test/unit/signals.test.ts | 6 +
test/unit/unified-comment-bridge.test.ts | 24 +
test/unit/unified-comment-parity.test.ts | 1 +
44 files changed, 1894 insertions(+), 8 deletions(-)
create mode 100644 migrations/0123_linked_issue_satisfaction_gate_mode.sql
create mode 100644 migrations/0124_linked_issue_satisfaction_cache.sql
create mode 100644 src/review/linked-issue-satisfaction-cache-input.ts
create mode 100644 src/services/linked-issue-satisfaction-run.ts
create mode 100644 test/unit/linked-issue-satisfaction-cache.test.ts
create mode 100644 test/unit/linked-issue-satisfaction-run.test.ts
create mode 100644 test/unit/repository-settings-linked-issue-satisfaction.test.ts
diff --git a/.gittensory.yml.example b/.gittensory.yml.example
index f5a955b7f9..b146ffe25e 100644
--- a/.gittensory.yml.example
+++ b/.gittensory.yml.example
@@ -246,6 +246,17 @@ gate:
# DB-backed (dashboard-settable too); this overrides the stored value.
selfAuthoredLinkedIssue: advisory
+ # Linked-issue satisfaction gate (#1961/#3906) — an AI assessment of whether
+ # this PR's diff actually satisfies its PRIMARY linked issue's intent/
+ # acceptance criteria (distinct from linkedIssue above, which only checks a
+ # link EXISTS). off = the assessment never runs; advisory = it runs and
+ # renders as a collapsible "Linked issue satisfaction" section in the review
+ # comment, but never blocks; block = a confidence-floor-passing "unaddressed"
+ # verdict ALSO becomes a hard blocker (linked_issue_scope_mismatch).
+ # off | advisory | block. Default: off. DB-backed (dashboard-settable too);
+ # this overrides the stored value.
+ linkedIssueSatisfaction: off
+
# Gate-check dry-run. When true, the posted check conclusion remains the real
# non-enforcing verdict while comments/check text may also show the would-be
# stricter verdict for AI-review blocker mode. It does not disable downstream
diff --git a/apps/gittensory-ui/public/openapi.json b/apps/gittensory-ui/public/openapi.json
index 20888d29c8..38510522b3 100644
--- a/apps/gittensory-ui/public/openapi.json
+++ b/apps/gittensory-ui/public/openapi.json
@@ -9298,6 +9298,14 @@
"audit",
"enforce"
]
+ },
+ "linkedIssueSatisfactionGateMode": {
+ "type": "string",
+ "enum": [
+ "off",
+ "advisory",
+ "block"
+ ]
}
},
"required": [
@@ -9318,6 +9326,7 @@
"mergeReadinessGateMode",
"manifestPolicyGateMode",
"selfAuthoredLinkedIssueGateMode",
+ "linkedIssueSatisfactionGateMode",
"firstTimeContributorGrace",
"slopAiAdvisory",
"aiReviewMode",
@@ -10026,6 +10035,14 @@
},
"publicQualityMetrics": {
"type": "boolean"
+ },
+ "linkedIssueSatisfactionGateMode": {
+ "type": "string",
+ "enum": [
+ "off",
+ "advisory",
+ "block"
+ ]
}
},
"required": [
@@ -10046,6 +10063,7 @@
"mergeReadinessGateMode",
"manifestPolicyGateMode",
"selfAuthoredLinkedIssueGateMode",
+ "linkedIssueSatisfactionGateMode",
"firstTimeContributorGrace",
"autoLabelEnabled",
"typeLabelsEnabled",
diff --git a/apps/gittensory-ui/src/routes/docs.github-app.tsx b/apps/gittensory-ui/src/routes/docs.github-app.tsx
index 9f9ffc5d76..004ac0c161 100644
--- a/apps/gittensory-ui/src/routes/docs.github-app.tsx
+++ b/apps/gittensory-ui/src/routes/docs.github-app.tsx
@@ -223,6 +223,14 @@ GET /v1/installations/:id/repair`}
selfAuthoredLinkedIssueGateMode — flags or blocks a PR whose author also
opened the linked issue. Default advisory.
+
+ linkedIssueSatisfactionGateMode — an AI assessment of whether the PR's diff
+ actually satisfies its primary linked issue's intent, distinct from{" "}
+ linkedIssueGateMode (which only checks a link exists). Default{" "}
+ off; advisory renders in the review comment without blocking,{" "}
+ block additionally lets a confidence-floor-passing "unaddressed" verdict
+ become a blocker.
+
moderationGateMode — whether the moderation-rules engine (contributor cap,
blacklist, review-nag feeding a shared cross-repo violation tally) runs on this repo.
diff --git a/apps/gittensory-ui/src/routes/docs.how-reviews-work.tsx b/apps/gittensory-ui/src/routes/docs.how-reviews-work.tsx
index 1fa303c055..a8aedc8918 100644
--- a/apps/gittensory-ui/src/routes/docs.how-reviews-work.tsx
+++ b/apps/gittensory-ui/src/routes/docs.how-reviews-work.tsx
@@ -138,6 +138,15 @@ function HowReviewsWork() {
selfAuthoredLinkedIssueGateMode, default advisory) — flags or
blocks a PR whose author also opened the linked issue.
+
+ Linked-issue satisfaction gate (
+ linkedIssueSatisfactionGateMode, default off) — an AI assessment
+ of whether the PR's diff actually satisfies its primary linked issue's intent (distinct
+ from the linked-issue gate above, which only checks that a link exists).{" "}
+ advisory renders the assessment in the review comment without ever blocking;{" "}
+ block additionally lets a confidence-floor-passing "unaddressed" verdict
+ become a hard blocker.
+
Moderation-rules engine (moderationGateMode, default{" "}
inherit) — whether the contributor-cap / blacklist / review-nag mechanisms
diff --git a/apps/gittensory-ui/src/routes/docs.tuning.tsx b/apps/gittensory-ui/src/routes/docs.tuning.tsx
index 13e6f61052..6b47af0cca 100644
--- a/apps/gittensory-ui/src/routes/docs.tuning.tsx
+++ b/apps/gittensory-ui/src/routes/docs.tuning.tsx
@@ -333,6 +333,14 @@ function Tuning() {
gate.selfAuthoredLinkedIssue — whether a PR may link an issue opened by the
same author. Default advisory.
+
+ gate.linkedIssueSatisfaction — an AI assessment of whether the PR's diff
+ actually satisfies its primary linked issue's intent, distinct from{" "}
+ gate.linkedIssue (which only checks a link exists). Default off.{" "}
+ advisory renders the assessment in the review comment without blocking;{" "}
+ block additionally lets a confidence-floor-passing "unaddressed" verdict
+ become a blocker.
+
settings.moderationGateMode — whether the moderation-rules engine
(contributor cap, blacklist, review-nag feeding a shared cross-repo violation tally) runs
diff --git a/config/examples/gittensory.full.yml b/config/examples/gittensory.full.yml
index 124d96d809..32d162622d 100644
--- a/config/examples/gittensory.full.yml
+++ b/config/examples/gittensory.full.yml
@@ -259,6 +259,17 @@ gate:
# DB-backed (dashboard-settable too); this overrides the stored value.
selfAuthoredLinkedIssue: advisory
+ # Linked-issue satisfaction gate (#1961/#3906) — an AI assessment of whether
+ # this PR's diff actually satisfies its PRIMARY linked issue's intent/
+ # acceptance criteria (distinct from linkedIssue above, which only checks a
+ # link EXISTS). off = the assessment never runs; advisory = it runs and
+ # renders as a collapsible "Linked issue satisfaction" section in the review
+ # comment, but never blocks; block = a confidence-floor-passing "unaddressed"
+ # verdict ALSO becomes a hard blocker (linked_issue_scope_mismatch).
+ # off | advisory | block. Default: off. DB-backed (dashboard-settable too);
+ # this overrides the stored value.
+ linkedIssueSatisfaction: off
+
# Gate-check dry-run. When true, the posted check conclusion remains the real
# non-enforcing verdict while comments/check text may also show the would-be
# stricter verdict for AI-review blocker mode. It does not disable downstream
diff --git a/migrations/0123_linked_issue_satisfaction_gate_mode.sql b/migrations/0123_linked_issue_satisfaction_gate_mode.sql
new file mode 100644
index 0000000000..79ee105bd3
--- /dev/null
+++ b/migrations/0123_linked_issue_satisfaction_gate_mode.sql
@@ -0,0 +1,8 @@
+-- Linked-issue satisfaction gate (#1961/#3906). Off by default -- byte-identical to today for every repo
+-- that doesn't opt in. When "advisory", an AI assessment of whether the PR's diff satisfies its primary
+-- linked issue's intent renders in the review comment but never blocks. When "block", a confidence-floor-
+-- passing "unaddressed" verdict additionally becomes a hard blocker (linked_issue_scope_mismatch), closing
+-- the gap where the deterministic linked-issue check only verified existence/openness, never scope match
+-- (JSONbored/metagraphed PR #3910's repro: a cited issue asked for an SSE stream, the PR delivered an
+-- unrelated REST endpoint, and the structured "Linked issue" signal still read "Linked" with no blocker).
+ALTER TABLE repository_settings ADD COLUMN linked_issue_satisfaction_gate_mode TEXT NOT NULL DEFAULT 'off';
diff --git a/migrations/0124_linked_issue_satisfaction_cache.sql b/migrations/0124_linked_issue_satisfaction_cache.sql
new file mode 100644
index 0000000000..435386668d
--- /dev/null
+++ b/migrations/0124_linked_issue_satisfaction_cache.sql
@@ -0,0 +1,20 @@
+-- Linked-issue satisfaction assessment cache (#1961/#3906): mirrors ai_slop_cache (migration 0119) -- the
+-- assessment makes a real, bounded-retry LLM call with no caching, so a repeated scheduled sweep pass would
+-- re-spend it on every tick even at an unchanged head SHA. The PRIMARY KEY additionally includes
+-- linked_issue_number (unlike ai_slop_cache) because a PR's cited PRIMARY linked issue can change between
+-- passes (an edited body re-links a different issue) -- reusing a stored verdict for a DIFFERENT issue would
+-- silently answer the wrong question.
+CREATE TABLE IF NOT EXISTS linked_issue_satisfaction_cache (
+ repo_full_name TEXT NOT NULL,
+ pull_number INTEGER NOT NULL,
+ head_sha TEXT NOT NULL,
+ linked_issue_number INTEGER NOT NULL,
+ -- Fingerprints the one input that can change independently of the head SHA + issue number: which provider
+ -- produced the opinion (free/default reviewer vs. a maintainer's BYOK key/model).
+ input_fingerprint TEXT NOT NULL,
+ status TEXT NOT NULL,
+ result_json TEXT,
+ estimated_neurons INTEGER NOT NULL DEFAULT 0,
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (repo_full_name, pull_number, head_sha, linked_issue_number)
+);
diff --git a/packages/gittensory-engine/src/focus-manifest.ts b/packages/gittensory-engine/src/focus-manifest.ts
index 9afc2bf03f..e04f99007f 100644
--- a/packages/gittensory-engine/src/focus-manifest.ts
+++ b/packages/gittensory-engine/src/focus-manifest.ts
@@ -119,6 +119,15 @@ export type FocusManifestGateConfig = {
mergeReadiness: GateRuleMode | null;
manifestPolicy: GateRuleMode | null;
selfAuthoredLinkedIssue: GateRuleMode | null;
+ /** `gate.linkedIssueSatisfaction` (#1961/#3906): off|advisory|block, off by default. When not off, an AI
+ * assessment of whether the PR's diff satisfies its primary linked issue's intent runs and renders as a
+ * collapsible section in the review comment; `block` additionally lets a confidence-floor-passing
+ * "unaddressed" verdict become a hard blocker. DB-backed (dashboard-settable too); this overrides the
+ * stored value -- mirrors `aiReviewMode` above, not the config-as-code-only `unlinkedIssueGuardrail`
+ * pattern. Distinct from the pre-existing, config-as-code-only `review.linkedIssueSatisfaction` (#2173,
+ * below) -- that field is parsed but not yet consumed by any decision path; this `gate:` field is the one
+ * the merge/close decision actually reads. */
+ linkedIssueSatisfaction: GateRuleMode | null;
dryRun: boolean | null;
firstTimeContributorGrace: boolean | null;
/** `gate.premergeContentRecheck` (#2550): for a PR touching `migrations/**`, re-verify against a live,
@@ -790,6 +799,7 @@ const EMPTY_GATE_CONFIG: FocusManifestGateConfig = {
mergeReadiness: null,
manifestPolicy: null,
selfAuthoredLinkedIssue: null,
+ linkedIssueSatisfaction: null,
dryRun: null,
firstTimeContributorGrace: null,
premergeContentRecheck: null,
@@ -1119,6 +1129,7 @@ function parseGateConfig(value: JsonValue | undefined, warnings: string[]): Focu
mergeReadiness: normalizeOptionalGateMode(record.mergeReadiness, "gate.mergeReadiness", warnings),
manifestPolicy: normalizeOptionalGateMode(record.manifestPolicy, "gate.manifestPolicy", warnings),
selfAuthoredLinkedIssue: normalizeOptionalGateMode(record.selfAuthoredLinkedIssue, "gate.selfAuthoredLinkedIssue", warnings),
+ linkedIssueSatisfaction: normalizeOptionalGateMode(record.linkedIssueSatisfaction, "gate.linkedIssueSatisfaction", warnings),
dryRun: normalizeOptionalBoolean(record.dryRun, "gate.dryRun", warnings),
firstTimeContributorGrace: normalizeOptionalBoolean(record.firstTimeContributorGrace, "gate.firstTimeContributorGrace", warnings),
premergeContentRecheck: normalizeOptionalBoolean(record.premergeContentRecheck, "gate.premergeContentRecheck", warnings),
@@ -1161,6 +1172,7 @@ function parseGateConfig(value: JsonValue | undefined, warnings: string[]): Focu
gate.mergeReadiness !== null ||
gate.manifestPolicy !== null ||
gate.selfAuthoredLinkedIssue !== null ||
+ gate.linkedIssueSatisfaction !== null ||
gate.dryRun !== null ||
gate.firstTimeContributorGrace !== null ||
gate.premergeContentRecheck !== null ||
@@ -1230,6 +1242,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.linkedIssueSatisfaction !== null) out.linkedIssueSatisfaction = gate.linkedIssueSatisfaction;
if (gate.dryRun !== null) out.dryRun = gate.dryRun;
if (gate.firstTimeContributorGrace !== null) out.firstTimeContributorGrace = gate.firstTimeContributorGrace;
if (gate.premergeContentRecheck !== null) out.premergeContentRecheck = gate.premergeContentRecheck;
diff --git a/scripts/check-docs-drift.mjs b/scripts/check-docs-drift.mjs
index c9f6346a5b..d7527fd26b 100644
--- a/scripts/check-docs-drift.mjs
+++ b/scripts/check-docs-drift.mjs
@@ -59,6 +59,7 @@ export const GATE_MODE_MANIFEST = [
{ field: "mergeReadinessGateMode", aliases: ["mergeReadinessGateMode", "gate.mergeReadiness"], pages: ["docs.how-reviews-work.tsx", "docs.tuning.tsx"] },
{ field: "manifestPolicyGateMode", aliases: ["manifestPolicyGateMode", "gate.manifestPolicy"], pages: ["docs.how-reviews-work.tsx", "docs.tuning.tsx"] },
{ field: "selfAuthoredLinkedIssueGateMode", aliases: ["selfAuthoredLinkedIssueGateMode", "gate.selfAuthoredLinkedIssue"], pages: ["docs.how-reviews-work.tsx", "docs.tuning.tsx", "docs.github-app.tsx"] },
+ { field: "linkedIssueSatisfactionGateMode", aliases: ["linkedIssueSatisfactionGateMode", "gate.linkedIssueSatisfaction"], pages: ["docs.how-reviews-work.tsx", "docs.tuning.tsx", "docs.github-app.tsx"] },
{ field: "moderationGateMode", aliases: ["moderationGateMode", "settings.moderationGateMode"], pages: ["docs.how-reviews-work.tsx", "docs.tuning.tsx", "docs.github-app.tsx"] },
];
diff --git a/src/api/routes.ts b/src/api/routes.ts
index 299e96601f..db2d865be5 100644
--- a/src/api/routes.ts
+++ b/src/api/routes.ts
@@ -728,6 +728,7 @@ const maintainerSettingsSchema = z
mergeReadinessGateMode: z.enum(["off", "advisory", "block"]),
manifestPolicyGateMode: z.enum(["off", "advisory", "block"]),
selfAuthoredLinkedIssueGateMode: z.enum(["off", "advisory", "block"]),
+ linkedIssueSatisfactionGateMode: z.enum(["off", "advisory", "block"]),
firstTimeContributorGrace: z.boolean(),
slopGateMode: z.enum(["off", "advisory", "block"]),
slopGateMinScore: z.number().int().min(0).max(100).nullable(),
diff --git a/src/db/repositories.ts b/src/db/repositories.ts
index ad7c33026d..92a75d0b0e 100644
--- a/src/db/repositories.ts
+++ b/src/db/repositories.ts
@@ -61,6 +61,7 @@ import {
webhookEvents,
} from "./schema";
import { DEFAULT_REVIEW_EVASION_LABEL, MAX_REVIEW_NAG_COOLDOWN_DAYS } from "../settings/agent-actions";
+import type { LinkedIssueSatisfactionResult } from "../services/linked-issue-satisfaction";
import { MAX_CONTRIBUTOR_OPEN_ITEM_CAP } from "../types";
import type {
Advisory,
@@ -513,6 +514,7 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise
mergeReadinessGateMode: "off",
manifestPolicyGateMode: "off",
selfAuthoredLinkedIssueGateMode: "advisory",
+ linkedIssueSatisfactionGateMode: "off",
firstTimeContributorGrace: false,
slopGateMinScore: null,
slopAiAdvisory: false,
@@ -590,6 +592,7 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise
mergeReadinessGateMode: parseGateRuleMode(row.mergeReadinessGateMode),
manifestPolicyGateMode: parseGateRuleMode(row.manifestPolicyGateMode),
selfAuthoredLinkedIssueGateMode: parseGateRuleMode(row.selfAuthoredLinkedIssueGateMode),
+ linkedIssueSatisfactionGateMode: parseGateRuleMode(row.linkedIssueSatisfactionGateMode),
firstTimeContributorGrace: row.firstTimeContributorGrace,
slopGateMinScore: normalizeQualityGateMinScore(row.slopGateMinScore),
slopAiAdvisory: row.slopAiAdvisory,
@@ -711,6 +714,7 @@ export async function upsertRepositorySettings(env: Env, settings: Partial {
+ if (!headSha) return null;
+ const row = await env.DB
+ .prepare(
+ "SELECT status, result_json AS resultJson, estimated_neurons AS estimatedNeurons, input_fingerprint AS inputFingerprint FROM linked_issue_satisfaction_cache WHERE repo_full_name = ? AND pull_number = ? AND head_sha = ? AND linked_issue_number = ?",
+ )
+ .bind(repoFullName, pullNumber, headSha, linkedIssueNumber)
+ .first<{ status: string; resultJson: string | null; estimatedNeurons: number; inputFingerprint: string }>();
+ if (!row || row.inputFingerprint !== expectedInputFingerprint) return null;
+ return {
+ status: row.status,
+ result: parseJson(row.resultJson, null),
+ estimatedNeurons: row.estimatedNeurons,
+ };
+}
+
+/** #linked-issue-satisfaction-cache: upsert the linked-issue satisfaction result for (repo, pull, head SHA,
+ * linked issue number). A nullish head SHA is a no-op (mirrors putCachedAiSlopAdvisory). Only call this for a
+ * result that actually spent the LLM call/attempts (status "ok") -- the caller is responsible for not caching
+ * a pre-call short-circuit (disabled/unavailable/quota_exceeded), since those return before any provider call
+ * and caching them would suppress a legitimate retry once the condition clears without having saved anything. */
+export async function putCachedLinkedIssueSatisfaction(
+ env: Env,
+ repoFullName: string,
+ pullNumber: number,
+ headSha: string | null | undefined,
+ linkedIssueNumber: number,
+ inputFingerprint: string,
+ result: { status: string; result: LinkedIssueSatisfactionResult | null; estimatedNeurons: number },
+): Promise {
+ if (!headSha) return;
+ await env.DB
+ .prepare(
+ `INSERT INTO linked_issue_satisfaction_cache (repo_full_name, pull_number, head_sha, linked_issue_number, input_fingerprint, status, result_json, estimated_neurons, created_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT(repo_full_name, pull_number, head_sha, linked_issue_number) DO UPDATE SET
+ input_fingerprint = excluded.input_fingerprint, status = excluded.status, result_json = excluded.result_json, estimated_neurons = excluded.estimated_neurons, created_at = excluded.created_at`,
+ )
+ .bind(repoFullName, pullNumber, headSha, linkedIssueNumber, inputFingerprint, result.status, jsonString(result.result), result.estimatedNeurons, nowIso())
+ .run();
+}
+
export async function replaceCollisionEdges(env: Env, repoFullName: string, edges: CollisionEdgeRecord[]): Promise {
const db = getDb(env.DB);
await env.DB.prepare("DELETE FROM collision_edges WHERE repo_full_name = ?").bind(repoFullName).run();
diff --git a/src/db/schema.ts b/src/db/schema.ts
index 0fe463164f..bd0908e420 100644
--- a/src/db/schema.ts
+++ b/src/db/schema.ts
@@ -75,6 +75,11 @@ export const repositorySettings = sqliteTable("repository_settings", {
mergeReadinessGateMode: text("merge_readiness_gate_mode").notNull().default("off"),
manifestPolicyGateMode: text("manifest_policy_gate_mode").notNull().default("off"),
selfAuthoredLinkedIssueGateMode: text("self_authored_linked_issue_gate_mode").notNull().default("advisory"),
+ // Linked-issue satisfaction gate (#1961/#3906). off = the assessment never runs (byte-identical to today,
+ // and the default); advisory = it runs and renders in the comment but never blocks; block = an above-
+ // confidence-floor "unaddressed" verdict additionally becomes a hard blocker. See src/rules/advisory.ts's
+ // isConfiguredGateBlocker (linked_issue_scope_mismatch) and gittensory-gate-setting-wiring for the pattern.
+ linkedIssueSatisfactionGateMode: text("linked_issue_satisfaction_gate_mode").notNull().default("off"),
firstTimeContributorGrace: integer("first_time_contributor_grace", { mode: "boolean" }).notNull().default(false),
slopGateMinScore: integer("slop_gate_min_score"),
slopAiAdvisory: integer("slop_ai_advisory", { mode: "boolean" }).notNull().default(false),
@@ -1406,3 +1411,29 @@ export const aiSlopCache = sqliteTable(
primary: primaryKey({ columns: [table.repoFullName, table.pullNumber, table.headSha] }),
}),
);
+
+// Linked-issue satisfaction assessment cache (#1961/#3906): mirrors aiSlopCache above, but the primary key
+// ADDITIONALLY includes linkedIssueNumber -- unlike the slop advisory, this assessment's verdict is scoped to
+// a SPECIFIC linked issue, and a PR's primary linked issue can change between passes (an edited body re-links
+// a different issue). Reusing a stored verdict for a different issue number would silently answer the wrong
+// question, so a changed primary issue must miss the cache rather than replay a stale verdict.
+export const linkedIssueSatisfactionCache = sqliteTable(
+ "linked_issue_satisfaction_cache",
+ {
+ repoFullName: text("repo_full_name").notNull(),
+ pullNumber: integer("pull_number").notNull(),
+ headSha: text("head_sha").notNull(),
+ linkedIssueNumber: integer("linked_issue_number").notNull(),
+ // Fingerprints the one input that can change independently of the head SHA + issue number: which provider
+ // produced the opinion (free/default reviewer vs. a maintainer's BYOK key/model) -- see
+ // linked-issue-satisfaction-cache-input.ts.
+ inputFingerprint: text("input_fingerprint").notNull(),
+ status: text("status").notNull(),
+ resultJson: text("result_json"),
+ estimatedNeurons: integer("estimated_neurons").notNull().default(0),
+ createdAt: text("created_at").notNull().$defaultFn(() => nowIso()),
+ },
+ (table) => ({
+ primary: primaryKey({ columns: [table.repoFullName, table.pullNumber, table.headSha, table.linkedIssueNumber] }),
+ }),
+);
diff --git a/src/github/backfill.ts b/src/github/backfill.ts
index 0d4f22e7e2..f6a3f9ffc0 100644
--- a/src/github/backfill.ts
+++ b/src/github/backfill.ts
@@ -3803,8 +3803,20 @@ export function isOwnReviewThreadAuthor(login: string | null | undefined): boole
return /^gittensory[-\w]*\[bot\]$/i.test(login ?? "") || /^(gittensory|gittensory-orb)$/i.test(login ?? "");
}
-/** The deterministic linked-issue facts the hard-rule evaluator needs (labels / assignees / open-state). */
-export type LinkedIssueFactsResult = { number: number; labels: string[]; assignees: string[]; state: string; authorLogin: string | null };
+/** The deterministic linked-issue facts the hard-rule evaluator needs (labels / assignees / open-state), plus
+ * the issue's title/body text (#1961/#3906) for the linked-issue satisfaction assessment -- purely additive:
+ * the hard-rule evaluator and label-propagation callers never read these two fields, so widening this shape
+ * does not change their behavior. Same endpoint, same call -- REST already returns the full issue payload
+ * (there is no sparse-fieldset param on `/issues/{number}`), so this is a type-level extension only. */
+export type LinkedIssueFactsResult = {
+ number: number;
+ labels: string[];
+ assignees: string[];
+ state: string;
+ authorLogin: string | null;
+ title?: string | null;
+ body?: string | null;
+};
/** Tri-state outcome of fetching one linked issue's facts (#2136). `not_found` is a CONFIRMED 404 seen with a
* genuine, repo-scoped token — GitHub told an authenticated caller this issue number does not exist. `fetch_error`
@@ -3847,6 +3859,8 @@ export async function fetchLinkedIssueFacts(
labels?: Array<{ name?: string | null } | string | null> | null;
assignees?: Array<{ login?: string | null } | null> | null;
user?: { login?: string | null } | null;
+ title?: string | null;
+ body?: string | null;
}>(env, repoFullName, `/issues/${issueNumber}`, token, githubRateLimitOptions(admissionKey));
} catch (error) {
if (!(error instanceof GitHubApiError) || error.statusCode !== 404) return { status: "fetch_error" };
@@ -3867,6 +3881,8 @@ export async function fetchLinkedIssueFacts(
assignees,
state: String(data.state ?? "open").toLowerCase(),
authorLogin: data.user?.login ?? null,
+ title: typeof data.title === "string" && data.title.length > 0 ? data.title : null,
+ body: typeof data.body === "string" && data.body.length > 0 ? data.body : null,
},
};
}
diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts
index d37b123248..489c202c55 100644
--- a/src/openapi/schemas.ts
+++ b/src/openapi/schemas.ts
@@ -657,6 +657,7 @@ export const RepositorySettingsSchema = z
mergeReadinessGateMode: z.enum(["off", "advisory", "block"]),
manifestPolicyGateMode: z.enum(["off", "advisory", "block"]),
selfAuthoredLinkedIssueGateMode: z.enum(["off", "advisory", "block"]),
+ linkedIssueSatisfactionGateMode: z.enum(["off", "advisory", "block"]),
firstTimeContributorGrace: z.boolean(),
slopGateMinScore: z.number().nullable().optional(),
slopAiAdvisory: z.boolean(),
@@ -809,6 +810,7 @@ export const RepoSettingsPreviewSchema = z
mergeReadinessGateMode: z.enum(["off", "advisory", "block"]),
manifestPolicyGateMode: z.enum(["off", "advisory", "block"]),
selfAuthoredLinkedIssueGateMode: z.enum(["off", "advisory", "block"]),
+ linkedIssueSatisfactionGateMode: z.enum(["off", "advisory", "block"]),
firstTimeContributorGrace: z.boolean(),
slopGateMinScore: z.number().nullable().optional(),
autoLabelEnabled: z.boolean(),
diff --git a/src/queue/processors.ts b/src/queue/processors.ts
index 2468f3613d..c05f3172f9 100644
--- a/src/queue/processors.ts
+++ b/src/queue/processors.ts
@@ -51,6 +51,8 @@ import {
markAiReviewPublished,
getCachedAiSlopAdvisory,
putCachedAiSlopAdvisory,
+ getCachedLinkedIssueSatisfaction,
+ putCachedLinkedIssueSatisfaction,
markPullRequestsRegated,
markPullRequestReviewsInvalidated,
markPullRequestSurfacePublished,
@@ -277,6 +279,7 @@ import {
} from "../selfhost/queue-common";
import { aiReviewCacheInputFingerprint } from "../review/ai-review-cache-input";
import { aiSlopCacheInputFingerprint } from "../review/ai-slop-cache-input";
+import { linkedIssueSatisfactionCacheInputFingerprint } from "../review/linked-issue-satisfaction-cache-input";
import {
AGENT_LABEL_NEEDS_REVIEW,
DEFAULT_REVIEW_EVASION_LABEL,
@@ -369,6 +372,7 @@ import {
type SlopBand,
} from "../signals/slop";
import { runGittensoryAiSlopAdvisory } from "../services/ai-slop";
+import { runGittensoryLinkedIssueSatisfaction } from "../services/linked-issue-satisfaction-run";
import { decidePublicSurface } from "../signals/settings-preview";
import {
buildFocusManifestGuidance,
@@ -6518,6 +6522,7 @@ export function gateCheckPolicy(
mergeReadinessGateMode: settings.mergeReadinessGateMode,
manifestPolicyGateMode: settings.manifestPolicyGateMode,
selfAuthoredLinkedIssueGateMode: settings.selfAuthoredLinkedIssueGateMode,
+ linkedIssueSatisfactionGateMode: settings.linkedIssueSatisfactionGateMode,
firstTimeContributorGrace: settings.firstTimeContributorGrace,
authorMergedPrCount: authorHistory?.mergedPrCount,
authorClosedUnmergedPrCount: authorHistory?.closedUnmergedPrCount,
@@ -7662,6 +7667,181 @@ export async function runAiSlopForAdvisory(
}
}
+/**
+ * Run the linked-issue satisfaction assessment for advisory purposes (#1961/#3906) — opt-in via
+ * `linkedIssueSatisfactionGateMode != "off"`. Assesses only the PR's PRIMARY (first) linked issue: v1 chooses
+ * cost/complexity over completeness for the multi-linked-issue case (each additional issue would need its own
+ * bounded model-call budget on top of an already-bounded retry/fallback loop), and the concrete repro this
+ * closes (JSONbored/metagraphed PR #3910) cited exactly one issue. A future slice could widen this to assess
+ * every linked issue independently; documented here rather than built speculatively.
+ *
+ * Returns the resolved `{status, rationale}` for the caller to thread into the comment's dedicated "Linked
+ * issue satisfaction" section (both `advisory` and `block` modes render it) — or `null` when nothing usable
+ * was produced (no linked issue, the issue couldn't be fetched, the model produced nothing publishable, or a
+ * low-confidence "unaddressed" call degraded to no finding — see buildLinkedIssueSatisfactionResult's own
+ * fail-safe contract). In `block` mode, an above-confidence-floor "unaddressed" verdict ALSO pushes a
+ * `linked_issue_scope_mismatch` finding into `args.advisory.findings` so `isConfiguredGateBlocker` can block
+ * the gate; `advisory` mode never pushes a finding — the dedicated rendered section is the only surface, so a
+ * repo running advisory-only never ALSO sees the same gap restated as a generic Nit line.
+ *
+ * Like `runAiSlopForAdvisory`, this runs ONLY for confirmed contributors so an unconfirmed/untrusted PR author
+ * cannot spend either the shared Workers AI budget or the maintainer-paid BYOK quota. Fail-safe: any error is
+ * swallowed so the gate still finalizes.
+ */
+export async function runLinkedIssueSatisfactionForAdvisory(
+ env: Env,
+ args: {
+ settings: RepositorySettings;
+ advisory: Awaited>;
+ repoFullName: string;
+ pr: { number: number; title: string; body?: string | null | undefined; linkedIssues: number[] };
+ author: string | null;
+ files: Awaited>;
+ confirmedContributor: boolean;
+ installationId: number;
+ },
+): Promise<{ status: "addressed" | "partial" | "unaddressed"; rationale: string } | null> {
+ if (!args.confirmedContributor || !args.advisory.headSha) return null;
+ const primaryIssueNumber = args.pr.linkedIssues[0];
+ if (primaryIssueNumber === undefined) return null;
+ try {
+ // Dedicated fetch (independent of resolveLinkedIssueAdvisoryContext's own, narrower, conditional fetch) so
+ // this feature's issue-text needs stay self-contained regardless of whether linkedIssueGateMode is also
+ // configured for this repo. A modest bounded extra GitHub call when BOTH features are enabled for the same
+ // repo is an acceptable, minor cost for keeping each feature isolated and easy to reason about.
+ const token = (await createInstallationToken(env, args.installationId).catch(() => undefined)) ?? env.GITHUB_PUBLIC_TOKEN;
+ const admissionKey = githubAdmissionKeyForToken(env, args.installationId, token);
+ const issueFetch = await fetchLinkedIssueFacts(env, args.repoFullName, primaryIssueNumber, token, admissionKey);
+ // Fail-safe: no confirmed issue text -> no assessment (mirrors buildLinkedIssueSatisfactionResult's own
+ // contract). A fetch error or a confirmed-not-found issue both yield no assessment rather than a guess.
+ if (issueFetch.status !== "found") return null;
+ const issueText = [issueFetch.facts.title, issueFetch.facts.body]
+ .filter((part): part is string => Boolean(part?.trim()))
+ .join("\n\n");
+ if (!issueText.trim()) return null;
+
+ // BYOK (opt-in): reuse the repo's encrypted key + aiReviewByok flag, exactly like runAiSlopForAdvisory —
+ // one BYOK key serves every AI feature.
+ const storedKey = args.settings.aiReviewByok
+ ? await getDecryptedRepositoryAiKey(env, args.repoFullName)
+ : null;
+ const providerKey =
+ storedKey &&
+ (!args.settings.aiReviewProvider ||
+ args.settings.aiReviewProvider === storedKey.provider)
+ ? {
+ provider: storedKey.provider,
+ key: storedKey.key,
+ model: args.settings.aiReviewModel ?? storedKey.model,
+ }
+ : null;
+ // #linked-issue-satisfaction-cache: the assessment's LLM call is fully deterministic given the same head SHA
+ // + linked issue number (no RAG/grounding/enrichment feeds into it), so a repeated scheduled sweep pass at
+ // an unchanged head+issue reuses the stored result instead of re-spending up to 6 free-tier attempts (or a
+ // BYOK call) on every tick — mirrors ai_slop_cache's confirmed-in-production motivation exactly.
+ const inputFingerprint = await linkedIssueSatisfactionCacheInputFingerprint({
+ byok: Boolean(providerKey),
+ provider: providerKey?.provider,
+ model: providerKey?.model,
+ });
+ const cached = await getCachedLinkedIssueSatisfaction(
+ env,
+ args.repoFullName,
+ args.pr.number,
+ args.advisory.headSha,
+ primaryIssueNumber,
+ inputFingerprint,
+ ).catch(() => null);
+ let result: Awaited>;
+ if (cached) {
+ result = { status: "ok", result: cached.result, estimatedNeurons: cached.estimatedNeurons };
+ incr("gittensory_linked_issue_satisfaction_cache_hit_total");
+ await recordAuditEvent(env, {
+ eventType: "github_app.linked_issue_satisfaction_cache_hit",
+ actor: args.author,
+ targetKey: `${args.repoFullName}#${args.pr.number}`,
+ outcome: "completed",
+ detail: "reused a stored linked-issue satisfaction assessment instead of re-spending an LLM call",
+ /* v8 ignore next -- reached only past this function's own `!args.advisory.headSha` early return, so headSha is always truthy here; the `?? null` is a type-level fallback for an unreachable branch. */
+ metadata: { repoFullName: args.repoFullName, headSha: args.advisory.headSha ?? null, linkedIssueNumber: primaryIssueNumber },
+ }).catch(() => undefined);
+ } else {
+ incr("gittensory_linked_issue_satisfaction_cache_miss_total");
+ await recordAuditEvent(env, {
+ eventType: "github_app.linked_issue_satisfaction_cache_miss",
+ actor: args.author,
+ targetKey: `${args.repoFullName}#${args.pr.number}`,
+ outcome: "completed",
+ detail: "no reusable stored linked-issue satisfaction assessment for this head+issue+fingerprint; running a fresh assessment",
+ /* v8 ignore next -- reached only past this function's own `!args.advisory.headSha` early return, so headSha is always truthy here; the `?? null` is a type-level fallback for an unreachable branch. */
+ metadata: { repoFullName: args.repoFullName, headSha: args.advisory.headSha ?? null, linkedIssueNumber: primaryIssueNumber },
+ }).catch(() => undefined);
+ result = await runGittensoryLinkedIssueSatisfaction(env, {
+ repoFullName: args.repoFullName,
+ prNumber: args.pr.number,
+ issueText,
+ prTitle: args.pr.title,
+ prBody: args.pr.body ?? undefined,
+ diff: buildAiReviewDiff(args.files),
+ actor: args.author,
+ providerKey,
+ });
+ // Only "ok" actually spent the LLM call (free-tier attempts or a BYOK call) — disabled/unavailable/
+ // quota_exceeded all short-circuit BEFORE any provider call, so caching them would suppress a legitimate
+ // retry once the condition clears without having saved anything.
+ if (result.status === "ok") {
+ await putCachedLinkedIssueSatisfaction(
+ env,
+ args.repoFullName,
+ args.pr.number,
+ args.advisory.headSha,
+ primaryIssueNumber,
+ inputFingerprint,
+ { status: result.status, result: result.result, estimatedNeurons: result.estimatedNeurons },
+ ).catch((error) => {
+ incr("gittensory_linked_issue_satisfaction_cache_write_error_total");
+ return recordAuditEvent(env, {
+ eventType: "github_app.linked_issue_satisfaction_cache_write_error",
+ actor: args.author,
+ targetKey: `${args.repoFullName}#${args.pr.number}`,
+ outcome: "error",
+ detail: errorMessage(error),
+ /* v8 ignore next -- reached only past this function's own `!args.advisory.headSha` early return, so headSha is always truthy here; the `?? null` is a type-level fallback for an unreachable branch. */
+ metadata: { repoFullName: args.repoFullName, headSha: args.advisory.headSha ?? null, linkedIssueNumber: primaryIssueNumber },
+ }).catch(() => undefined);
+ });
+ }
+ }
+ if (result.status !== "ok" || !result.result) return null;
+ // `block` mode: an above-confidence-floor "unaddressed" verdict becomes a hard blocker. `advisory` mode
+ // never pushes a finding here — the dedicated rendered section (populated via this function's return
+ // value, regardless of mode) is the only surface for that mode, so the same gap is never ALSO shown as a
+ // generic advisory Nit line.
+ if (args.settings.linkedIssueSatisfactionGateMode === "block" && result.result.status === "unaddressed") {
+ args.advisory.findings.push({
+ code: "linked_issue_scope_mismatch",
+ severity: "warning",
+ title: "Linked issue does not appear to be satisfied",
+ detail: result.result.rationale,
+ action: "Confirm this PR actually addresses the linked issue's scope, or link the correct issue.",
+ publicText: `AI assessment: this PR does not appear to satisfy its linked issue's scope. ${result.result.rationale}`,
+ });
+ }
+ return { status: result.result.status, rationale: result.result.rationale };
+ } catch (error) {
+ console.error(
+ JSON.stringify({
+ level: "warn",
+ event: "linked_issue_satisfaction_failed",
+ repository: args.repoFullName,
+ pullNumber: args.pr.number,
+ error: errorMessage(error),
+ }),
+ );
+ return null;
+ }
+}
+
/**
* Duplicate-winner adjudication (#dup-winner) seam for the close-reason disposition. Given a PR's open
* duplicate-sibling numbers (from {@link linkedIssueDuplicatePullRequestsForGate}, open-only), return the
@@ -8273,6 +8453,11 @@ async function maybePublishPrPublicSurface(
let queueHealth!: ReturnType;
let preflight!: ReturnType;
let gateEvaluation: ReturnType | undefined;
+ // Linked-issue satisfaction assessment (#1961/#3906) result, hoisted to function scope (like gateEvaluation
+ // above) because it is computed inside the try block below but consumed later, outside it, when building the
+ // unified comment. Declared undefined/null-equivalent by default so an unopted-in repo (linkedIssueSatisfactionGateMode:
+ // "off", the default) or a caught error never threads a section into the comment.
+ let linkedIssueSatisfaction: { status: "addressed" | "partial" | "unaddressed"; rationale: string } | null = null;
// inlineFindings is present ONLY on a FRESH review (cache miss) with inline comments enabled; the AI cache
// round-trips notes + reviewerCount + the gate findings (so a cache hit replays consensus/split/inconclusive
// blockers — see below), but NOT inlineFindings, so a cache hit never re-posts inline comments (#inline-comments).
@@ -8657,6 +8842,23 @@ async function maybePublishPrPublicSurface(
});
}
}
+ // Linked-issue satisfaction assessment (#1961/#3906, opt-in via linkedIssueSatisfactionGateMode). Assesses
+ // only the PR's primary linked issue -- see runLinkedIssueSatisfactionForAdvisory's own doc comment for
+ // the multi-linked-issue rationale. `off` (default) short-circuits before any fetch or model call, so this
+ // is byte-identical to before this feature existed for every repo that hasn't opted in. (Declared/hoisted
+ // to function scope above, alongside gateEvaluation, since it is consumed later outside this try block.)
+ if (settings.linkedIssueSatisfactionGateMode !== "off" && pr.linkedIssues.length > 0) {
+ linkedIssueSatisfaction = await runLinkedIssueSatisfactionForAdvisory(env, {
+ settings,
+ advisory,
+ repoFullName,
+ pr,
+ author,
+ files: await getReviewFiles(),
+ confirmedContributor,
+ installationId,
+ });
+ }
// Focus-manifest policy (#555, opt-in via manifestPolicyGateMode). Reload the CACHED manifest (the
// settings resolver discards the raw manifest, but loadRepoFocusManifest is cached so this is cheap),
// recompute the guidance over the PR's changed files, and push ONLY the three enforceable policy
@@ -10121,6 +10323,7 @@ async function maybePublishPrPublicSurface(
gate: renderedGate,
...(aiReview !== undefined ? { aiReview } : {}),
advisoryFindings: advisory.findings,
+ ...(linkedIssueSatisfaction !== null ? { linkedIssueSatisfaction } : {}),
panelRows: rows,
...(reviewConfig?.fields !== undefined
? { reviewFields: reviewConfig.fields }
diff --git a/src/review/linked-issue-satisfaction-cache-input.ts b/src/review/linked-issue-satisfaction-cache-input.ts
new file mode 100644
index 0000000000..252802f06e
--- /dev/null
+++ b/src/review/linked-issue-satisfaction-cache-input.ts
@@ -0,0 +1,27 @@
+import { sha256Hex } from "../utils/crypto";
+
+// #linked-issue-satisfaction-cache: mirrors ai-slop-cache-input.ts's fingerprint discipline exactly (kept as
+// its own small module -- not reused directly -- so the two caches' version strings never collide/alias each
+// other in stored rows). The satisfaction assessment's only input that can change independently of the PR's
+// head SHA is which provider writes the opinion: the free/default reviewer vs. a maintainer's BYOK key/model
+// (see LinkedIssueSatisfactionRunInput in ../services/linked-issue-satisfaction-run). Issue title/body/diff are
+// pinned to the head SHA (a fresh commit is what invalidates the cache row itself) and the linked issue number
+// is a SEPARATE primary-key column (not folded into this fingerprint) -- see the cache table's migration doc
+// for why a changed primary linked issue must miss the cache rather than replay a different issue's verdict.
+export const LINKED_ISSUE_SATISFACTION_CACHE_INPUT_VERSION = "linked-issue-satisfaction-input:v1";
+
+export type LinkedIssueSatisfactionCacheInput = {
+ byok: boolean;
+ provider: string | null | undefined;
+ model: string | null | undefined;
+};
+
+export async function linkedIssueSatisfactionCacheInputFingerprint(input: LinkedIssueSatisfactionCacheInput): Promise {
+ const payload = [
+ LINKED_ISSUE_SATISFACTION_CACHE_INPUT_VERSION,
+ input.byok ? "1" : "0",
+ input.provider ?? "",
+ input.model ?? "",
+ ].join("|");
+ return `${LINKED_ISSUE_SATISFACTION_CACHE_INPUT_VERSION}:${await sha256Hex(payload)}`;
+}
diff --git a/src/review/unified-comment-bridge.ts b/src/review/unified-comment-bridge.ts
index 866bb8f3cb..78337f99f3 100644
--- a/src/review/unified-comment-bridge.ts
+++ b/src/review/unified-comment-bridge.ts
@@ -366,6 +366,13 @@ export type UnifiedCommentBridgeArgs = {
preflightHeld?: boolean | undefined;
/** Public freshness marker for the posted/updated review comment. Defaults to the current publish time. */
reviewedAt?: string | number | Date | undefined;
+ /** Linked-issue satisfaction advisory (#1961/#3906): the resolved {status, rationale} the processor computed
+ * via runLinkedIssueSatisfactionForAdvisory, passed straight through to buildUnifiedReviewInput's field of
+ * the same name. Presentation only — never changes `decision`/the gate verdict, which the `block`-mode
+ * blocker (linked_issue_scope_mismatch, src/rules/advisory.ts) already folded into `gate` above when it
+ * applies. Absent (default; the processor only resolves this when linkedIssueSatisfactionGateMode !=
+ * "off") ⇒ no section is rendered, byte-identical to today. */
+ linkedIssueSatisfaction?: { status: "addressed" | "partial" | "unaddressed"; rationale: string } | undefined;
};
/**
@@ -693,6 +700,7 @@ export function buildUnifiedCommentBody(args: UnifiedCommentBridgeArgs): string
...(args.reviewEffort !== undefined ? { reviewEffort: args.reviewEffort } : {}),
...(args.maxFindingsCaps !== undefined ? { maxFindingsCaps: args.maxFindingsCaps } : {}),
...(args.findingCategories !== undefined ? { inlineFindings: args.findingCategories } : {}),
+ ...(args.linkedIssueSatisfaction !== undefined ? { linkedIssueSatisfaction: args.linkedIssueSatisfaction } : {}),
});
// The gate already produced 0/1 reviewer notes from a synthesis of the model pair; reflect the caller's
// actual reviewer count (for the chip + the "N reviewers, synthesized" evidence) without re-deriving it.
diff --git a/src/rules/advisory.ts b/src/rules/advisory.ts
index 0ec00332ac..0541007b36 100644
--- a/src/rules/advisory.ts
+++ b/src/rules/advisory.ts
@@ -55,6 +55,13 @@ export type GateCheckPolicy = {
* 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;
+ /** Linked-issue satisfaction gate (#1961/#3906). When `block`, a `linked_issue_scope_mismatch` finding —
+ * raised when the AI assessment judged (above its confidence floor) that the PR's diff does NOT satisfy
+ * its primary linked issue's intent — becomes a hard blocker. Defaults to `advisory` — the finding is
+ * never even produced under `advisory`/`off` (the caller gates the assessment itself on this mode; see
+ * runLinkedIssueSatisfactionForAdvisory, src/queue/processors.ts), so this branch only matters once a
+ * repo has explicitly opted into `block`. */
+ linkedIssueSatisfactionGateMode?: GateRuleMode | undefined;
/** CLA / license-compatibility gate (#2564). When `block`, a `cla_consent_missing` finding — raised when
* neither configured detection method (a consent phrase in the PR body, or a named CLA-bot check-run
* conclusion) confirms consent — becomes a hard blocker. `off` (default) = no finding at all; `advisory` =
@@ -898,6 +905,11 @@ function isConfiguredGateBlocker(finding: AdvisoryFinding, policy: GateCheckPoli
// 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";
+ // Linked-issue satisfaction gate (#1961/#3906): blocks only when the maintainer opts in with `block`. The
+ // finding itself is only ever produced when the caller already resolved `block` mode (see
+ // runLinkedIssueSatisfactionForAdvisory), so this is a defense-in-depth mirror of that gate, not the
+ // primary enforcement point.
+ if (code === "linked_issue_scope_mismatch") return gateMode(policy.linkedIssueSatisfactionGateMode ?? "advisory") === "block";
// Lockfile-tamper-risk gate (#2563): blocks only when the maintainer opts in with `block`. Defaults to `off`
// (the finding is never even produced — see maybeAddLockfileTamperFinding's mode gate in queue/processors.ts),
// so this branch only matters once a repo has explicitly turned the scan on.
diff --git a/src/services/linked-issue-satisfaction-run.ts b/src/services/linked-issue-satisfaction-run.ts
new file mode 100644
index 0000000000..62879f3270
--- /dev/null
+++ b/src/services/linked-issue-satisfaction-run.ts
@@ -0,0 +1,181 @@
+// Linked-issue satisfaction assessment -- model-calling orchestration (#1961/#3906). This is the "separate,
+// maintainer-only slice" the pure analysis core (./linked-issue-satisfaction.ts, #2172) explicitly forward-
+// references in its own module doc: "That orchestration (budget, provider selection, usage accounting, and --
+// eventually -- a `gate.linkedIssueSatisfaction` mode wiring) is a separate, maintainer-only slice." Mirrors
+// ai-slop.ts's runGittensoryAiSlopAdvisory shape exactly (same budget/provider/retry discipline), but calls
+// the pure module's own buildLinkedIssueSatisfactionResult as the single source of truth for "is this attempt's
+// raw model text a valid, publishable result" -- never re-implements its parsing/confidence-floor/public-safe
+// logic here.
+//
+// Hard guarantees (mirrors ai-slop.ts's fail-safe discipline):
+// • AI off / no binding / over-budget / every attempt unparseable -> no result, never throws.
+// • This module NEVER decides whether a result blocks the gate or how it renders -- it only returns the
+// bounded, public-safe {status, rationale} (or null). The caller (src/queue/processors.ts) decides.
+import type { LinkedIssueSatisfactionResult } from "./linked-issue-satisfaction";
+import { SATISFACTION_SYSTEM_PROMPT, buildLinkedIssueSatisfactionPrompt, buildLinkedIssueSatisfactionResult } from "./linked-issue-satisfaction";
+import { countByokAiEventsForRepoSince, recordAiUsageEvent, sumAiEstimatedNeuronsSince } from "../db/repositories";
+import {
+ type AiReviewActualUsage,
+ type AiReviewProviderKey,
+ BEST_REVIEW_MODELS,
+ DEFAULT_BYOK_DAILY_REPO_LIMIT,
+ RELIABLE_FALLBACK_MODELS,
+ callAiProvider,
+ clampNumber,
+ coerceAiText,
+ coerceAiUsage,
+ estimateNeurons,
+ isEnabled,
+ utcDayStartIso,
+} from "./ai-review";
+
+export type LinkedIssueSatisfactionRunInput = {
+ repoFullName: string;
+ prNumber: number;
+ /** The already-fetched linked (primary) issue's title + body, joined into one text blob by the caller. */
+ issueText: string | null | undefined;
+ prTitle: string;
+ prBody?: string | null | undefined;
+ /** A bounded unified-diff-ish string (filenames + patches), built by the caller (buildAiReviewDiff). */
+ diff: string;
+ actor?: string | null | undefined;
+ /** Optional BYOK: when present, the maintainer's frontier model writes the assessment (billed to their
+ * account, counted against the shared per-repo/day BYOK cap) instead of the free/default reviewer. */
+ providerKey?: AiReviewProviderKey | null | undefined;
+};
+
+export type LinkedIssueSatisfactionRunResult =
+ | { status: "disabled"; reason: string }
+ | { status: "unavailable"; reason: string }
+ | { status: "quota_exceeded"; estimatedNeurons: number; remainingBudget: number }
+ | { status: "ok"; result: LinkedIssueSatisfactionResult | null; estimatedNeurons: number };
+
+const LINKED_ISSUE_SATISFACTION_MODELS = [BEST_REVIEW_MODELS[0], RELIABLE_FALLBACK_MODELS[0]] as const;
+const LINKED_ISSUE_SATISFACTION_ATTEMPTS_PER_MODEL = 3;
+const LINKED_ISSUE_SATISFACTION_MAX_CALLS = LINKED_ISSUE_SATISFACTION_MODELS.length * LINKED_ISSUE_SATISFACTION_ATTEMPTS_PER_MODEL;
+
+type AiGatewayOptions = { gateway?: { id: string } };
+type AiRunner = { run?: (model: string, options: Record, extra?: AiGatewayOptions) => Promise };
+
+type WorkersSatisfactionOpinionResult = { result: LinkedIssueSatisfactionResult | null; usage?: AiReviewActualUsage | undefined };
+
+/** One free/default-reviewer satisfaction opinion (whichever provider `env.AI` resolves to) with bounded
+ * retry/fallback attempts, all pre-budgeted. Each attempt's raw text is validated via the pure module's own
+ * buildLinkedIssueSatisfactionResult -- a structurally-invalid response AND a below-confidence-floor
+ * "unaddressed" call both fall through to the next attempt (the floor is re-checked fresh on every independent
+ * attempt; retrying never lowers it), so the loop only ever stops on a genuinely valid, publishable result or
+ * on exhausting every attempt. */
+async function runWorkersSatisfactionOpinion(
+ env: Env,
+ issueText: string | null | undefined,
+ system: string,
+ user: string,
+ maxTokens: number,
+): Promise {
+ const ai = env.AI as unknown as AiRunner | undefined;
+ if (!ai || typeof ai.run !== "function") return { result: null };
+ const gatewayId = env.AI_GATEWAY_ID?.trim();
+ const extra: AiGatewayOptions | undefined = gatewayId ? { gateway: { id: gatewayId } } : undefined;
+ for (const model of LINKED_ISSUE_SATISFACTION_MODELS) {
+ for (let attempt = 0; attempt < LINKED_ISSUE_SATISFACTION_ATTEMPTS_PER_MODEL; attempt += 1) {
+ try {
+ const raw = await ai.run(
+ model,
+ { max_tokens: maxTokens, temperature: 0, messages: [{ role: "system", content: system }, { role: "user", content: user }] },
+ extra,
+ );
+ const result = buildLinkedIssueSatisfactionResult(issueText, coerceAiText(raw));
+ if (result) return { result, usage: coerceAiUsage(raw) };
+ } catch {
+ /* retry / fall through to fallback */
+ }
+ }
+ }
+ return { result: null };
+}
+
+/**
+ * Run the linked-issue satisfaction assessment. Returns the bounded, public-safe result (or null) plus the
+ * estimated neuron spend. Fail-safe on every path: no result and no thrown error ever reaches the caller.
+ */
+export async function runGittensoryLinkedIssueSatisfaction(env: Env, input: LinkedIssueSatisfactionRunInput): Promise {
+ if (!isEnabled(env.AI_SUMMARIES_ENABLED)) return { status: "disabled", reason: "AI summaries are disabled." };
+ if (!isEnabled(env.AI_PUBLIC_COMMENTS_ENABLED)) return { status: "disabled", reason: "Public AI comments are disabled." };
+ if (!env.AI) return { status: "unavailable", reason: "AI provider is not configured." };
+ // Fail-safe (mirrors buildLinkedIssueSatisfactionResult's own contract): no issue text means there is
+ // nothing to assess, so short-circuit before spending any budget or making a model call.
+ if (!(input.issueText ?? "").trim()) return { status: "ok", result: null, estimatedNeurons: 0 };
+
+ const maxTokens = clampNumber(Number(env.AI_MAX_OUTPUT_TOKENS || 256), 256, 1024);
+ const user = buildLinkedIssueSatisfactionPrompt({
+ issueText: input.issueText,
+ prTitle: input.prTitle,
+ prBody: input.prBody,
+ diff: input.diff,
+ });
+ // BYOK bills the maintainer's own account (separate per-repo/day cap shared with AI review + slop). Free/
+ // default-reviewer retry/fallback attempts are pre-budgeted at their worst case so malformed output or
+ // transient failures cannot amplify spend beyond the daily neuron budget. This draws from the SAME shared
+ // daily neuron counter as AI review + AI slop (sumAiEstimatedNeuronsSince has no per-feature scope).
+ const freeCalls = input.providerKey ? 0 : LINKED_ISSUE_SATISFACTION_MAX_CALLS;
+ const estimatedNeurons = freeCalls === 0 ? 0 : estimateNeurons(SATISFACTION_SYSTEM_PROMPT.length + user.length, maxTokens, freeCalls);
+ const rawNeuronBudget = Number(env.AI_DAILY_NEURON_BUDGET);
+ const budget = clampNumber(env.AI_DAILY_NEURON_BUDGET && Number.isFinite(rawNeuronBudget) ? rawNeuronBudget : 10_000_000, 0, 10_000_000);
+ const used = await sumAiEstimatedNeuronsSince(env, utcDayStartIso());
+ const remainingBudget = Math.max(0, budget - used);
+ if (estimatedNeurons > remainingBudget) {
+ await record(env, input, "quota_exceeded", 0, `estimated ${estimatedNeurons} neurons exceeds remaining ${remainingBudget}`);
+ return { status: "quota_exceeded", estimatedNeurons, remainingBudget };
+ }
+ if (input.providerKey) {
+ const byokDailyLimit = clampNumber(Number(env.AI_BYOK_DAILY_REPO_LIMIT || DEFAULT_BYOK_DAILY_REPO_LIMIT), 0, 10_000);
+ const byokUsed = await countByokAiEventsForRepoSince(env, input.repoFullName, utcDayStartIso());
+ if (byokUsed >= byokDailyLimit) {
+ await record(env, input, "quota_exceeded", 0, `BYOK daily repo limit ${byokDailyLimit} reached`);
+ return { status: "quota_exceeded", estimatedNeurons, remainingBudget };
+ }
+ }
+
+ // BYOK frontier model if configured, else the free/default-reviewer primary (with fallback). Both fail-safe
+ // to null via buildLinkedIssueSatisfactionResult.
+ let result: LinkedIssueSatisfactionResult | null;
+ let usage: AiReviewActualUsage | undefined;
+ if (input.providerKey) {
+ const { text, usage: byokUsage } = await callAiProvider(input.providerKey, SATISFACTION_SYSTEM_PROMPT, user, maxTokens);
+ result = text ? buildLinkedIssueSatisfactionResult(input.issueText, text) : null;
+ usage = byokUsage;
+ } else {
+ ({ result, usage } = await runWorkersSatisfactionOpinion(env, input.issueText, SATISFACTION_SYSTEM_PROMPT, user, maxTokens));
+ }
+ await record(env, input, "ok", estimatedNeurons, result ? `advisory finding (${result.status})` : "no usable output", { status: result?.status ?? null, surfaced: Boolean(result), byok: Boolean(input.providerKey) }, usage);
+ return { status: "ok", result, estimatedNeurons };
+}
+
+async function record(
+ env: Env,
+ input: LinkedIssueSatisfactionRunInput,
+ status: string,
+ estimatedNeurons: number,
+ detail: string,
+ metadata?: Record,
+ usage?: AiReviewActualUsage | undefined,
+): Promise {
+ await recordAiUsageEvent(env, {
+ feature: "linked_issue_satisfaction",
+ actor: input.actor ?? null,
+ route: "github_app.linked_issue_satisfaction",
+ model: input.providerKey ? `byok:${input.providerKey.provider}` : LINKED_ISSUE_SATISFACTION_MODELS.join("+"),
+ status,
+ estimatedNeurons,
+ provider: usage?.provider,
+ effort: usage?.effort,
+ inputTokens: usage?.inputTokens,
+ outputTokens: usage?.outputTokens,
+ totalTokens: usage?.totalTokens,
+ costUsd: usage?.costUsd,
+ detail,
+ metadata: { repoFullName: input.repoFullName, pullNumber: input.prNumber, ...(metadata ?? {}) },
+ });
+}
+
+export const __linkedIssueSatisfactionRunInternals = { runWorkersSatisfactionOpinion };
diff --git a/src/services/linked-issue-satisfaction.ts b/src/services/linked-issue-satisfaction.ts
index ce55795d0c..45d31f839c 100644
--- a/src/services/linked-issue-satisfaction.ts
+++ b/src/services/linked-issue-satisfaction.ts
@@ -66,7 +66,11 @@ function parseConfidence(value: unknown): number {
return n;
}
-const SATISFACTION_SYSTEM_PROMPT = [
+// Exported (additive only -- no behavior change) so the model-calling orchestration slice (#1961's
+// maintainer-only remainder, src/services/linked-issue-satisfaction-run.ts) can reuse this exact system
+// prompt instead of duplicating it -- this module's own doc comment above explicitly calls out that
+// orchestration as a separate slice that supplies the model call this text feeds.
+export const SATISFACTION_SYSTEM_PROMPT = [
"You are a senior open-source maintainer judging whether a pull request satisfies the intent and acceptance",
"criteria of a SINGLE linked issue. Judge ONLY the issue text and the PR's title/description/diff provided.",
"Be conservative: 'addressed' requires the diff to visibly fulfill the issue's own ask; 'partial' means it",
diff --git a/src/signals/focus-manifest.ts b/src/signals/focus-manifest.ts
index de3ff8d6e5..927c8d1306 100644
--- a/src/signals/focus-manifest.ts
+++ b/src/signals/focus-manifest.ts
@@ -466,6 +466,7 @@ function applyGateConfigOverrides(effective: RepositorySettings, gate: FocusMani
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.linkedIssueSatisfaction !== null) effective.linkedIssueSatisfactionGateMode = gate.linkedIssueSatisfaction;
if (gate.dryRun !== null) effective.gateDryRun = gate.dryRun;
if (gate.firstTimeContributorGrace !== null) effective.firstTimeContributorGrace = gate.firstTimeContributorGrace;
if (gate.premergeContentRecheck !== null) effective.premergeContentRecheck = gate.premergeContentRecheck;
diff --git a/src/signals/settings-preview.ts b/src/signals/settings-preview.ts
index 7f4361218d..eff163cfab 100644
--- a/src/signals/settings-preview.ts
+++ b/src/signals/settings-preview.ts
@@ -200,6 +200,7 @@ export type RepoSettingsPreview = {
mergeReadinessGateMode: RepositorySettings["mergeReadinessGateMode"];
manifestPolicyGateMode: RepositorySettings["manifestPolicyGateMode"];
selfAuthoredLinkedIssueGateMode: RepositorySettings["selfAuthoredLinkedIssueGateMode"];
+ linkedIssueSatisfactionGateMode: RepositorySettings["linkedIssueSatisfactionGateMode"];
firstTimeContributorGrace: boolean;
slopGateMinScore?: number | null | undefined;
autoLabelEnabled: boolean;
@@ -329,6 +330,7 @@ export function buildRepoSettingsPreview(args: {
mergeReadinessGateMode: settings.mergeReadinessGateMode,
manifestPolicyGateMode: settings.manifestPolicyGateMode,
selfAuthoredLinkedIssueGateMode: settings.selfAuthoredLinkedIssueGateMode,
+ linkedIssueSatisfactionGateMode: settings.linkedIssueSatisfactionGateMode,
firstTimeContributorGrace: settings.firstTimeContributorGrace,
slopGateMinScore: settings.slopGateMinScore ?? null,
autoLabelEnabled: settings.autoLabelEnabled,
diff --git a/src/types.ts b/src/types.ts
index 3af634f15a..93b0a22570 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -736,6 +736,15 @@ export type RepositorySettings = {
* 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;
+ /** Linked-issue satisfaction gate (#1961/#3906). `off` = the AI assessment of whether the PR's diff
+ * satisfies its primary linked issue's intent never runs (byte-identical to today). `advisory` = it runs
+ * and renders as a collapsible section in the review comment, but never blocks. `block` = ALSO let a
+ * confidence-floor-passing "unaddressed" verdict become a gate blocker (`linked_issue_scope_mismatch`,
+ * confirmed-contributors only, like every other blocker). Distinct from the config-as-code-only
+ * `review.linkedIssueSatisfaction` manifest field (#2173) — this is the DB-backed, dashboard-settable
+ * gate-mode counterpart; `.gittensory.yml gate.linkedIssueSatisfaction` overrides it exactly like every
+ * other `gate:` field overrides its `RepositorySettings` counterpart. Default `off` — opt-in. */
+ linkedIssueSatisfactionGateMode: GateRuleMode;
/** First-time-contributor grace (#552). RESERVED / currently INERT (#2266): parsed, clamped, and threaded
* end-to-end, but the gate evaluator never reads it — a genuine newcomer with a real blocker is still
* one-shot closed exactly like a repeat contributor (blocker findings must remain closure outcomes).
diff --git a/test/unit/backfill.test.ts b/test/unit/backfill.test.ts
index 3666963751..031bf38254 100644
--- a/test/unit/backfill.test.ts
+++ b/test/unit/backfill.test.ts
@@ -5913,10 +5913,47 @@ describe("GitHub backfill", () => {
const result = await fetchLinkedIssueFacts(env, "JSONbored/gittensory", 42, "tok");
expect(result).toEqual({
status: "found",
- facts: { number: 42, labels: ["bug", "manual-string-label"], assignees: ["maintainer"], state: "open", authorLogin: "reporter" },
+ facts: { number: 42, labels: ["bug", "manual-string-label"], assignees: ["maintainer"], state: "open", authorLogin: "reporter", title: null, body: null },
});
});
+ it("extracts title + body (#1961/#3906, linked-issue satisfaction assessment) from the same REST payload — no second fetch", async () => {
+ const env = createTestEnv({});
+ vi.stubGlobal("fetch", async () =>
+ Response.json({
+ number: 1275,
+ state: "open",
+ labels: [],
+ assignees: [],
+ user: { login: "reporter" },
+ title: "Enrich SN74 Gittensor — add SSE stream",
+ body: "We need a live SSE stream surface for SN74 Gittensor.",
+ }),
+ );
+ const result = await fetchLinkedIssueFacts(env, "JSONbored/metagraphed", 1275, "tok");
+ expect(result).toEqual({
+ status: "found",
+ facts: {
+ number: 1275,
+ labels: [],
+ assignees: [],
+ state: "open",
+ authorLogin: "reporter",
+ title: "Enrich SN74 Gittensor — add SSE stream",
+ body: "We need a live SSE stream surface for SN74 Gittensor.",
+ },
+ });
+ });
+
+ it("falls back to null for title/body when the payload omits them or they are empty strings", async () => {
+ const env = createTestEnv({});
+ vi.stubGlobal("fetch", async () => Response.json({ number: 7, state: "open", title: "", body: "" }));
+ const result = await fetchLinkedIssueFacts(env, "JSONbored/gittensory", 7, "tok");
+ expect(result.status).toBe("found");
+ expect(result.status === "found" && result.facts.title).toBeNull();
+ expect(result.status === "found" && result.facts.body).toBeNull();
+ });
+
it("returns not_found on a confirmed 404, distinct from a transient fetch error", async () => {
const env = createTestEnv({});
vi.stubGlobal("fetch", async () => new Response("missing", { status: 404 }));
diff --git a/test/unit/check-docs-drift-script.test.ts b/test/unit/check-docs-drift-script.test.ts
index 7573acf6f4..78010ca3cd 100644
--- a/test/unit/check-docs-drift-script.test.ts
+++ b/test/unit/check-docs-drift-script.test.ts
@@ -141,7 +141,8 @@ describe("check-docs-drift script", () => {
const result = checkDocsDrift({ root: "/fake", readFile: makeReadFile(files) });
expect(result.failures).toEqual([]);
- expect(result.counts).toEqual({ flags: 10, commands: 19, gateModes: 11 });
+ // gateModes bumped 11 -> 12 for linkedIssueSatisfactionGateMode (#1961/#3906).
+ expect(result.counts).toEqual({ flags: 10, commands: 19, gateModes: 12 });
});
it("catches an unmapped *GateMode field missing from GATE_MODE_MANIFEST", () => {
diff --git a/test/unit/focus-manifest.test.ts b/test/unit/focus-manifest.test.ts
index 48bc13e80a..5a4abb98f3 100644
--- a/test/unit/focus-manifest.test.ts
+++ b/test/unit/focus-manifest.test.ts
@@ -264,6 +264,7 @@ describe(".gittensory.yml.example field-exhaustiveness (#1670)", () => {
mergeReadiness: "mergeReadiness:",
manifestPolicy: "manifestPolicy:",
selfAuthoredLinkedIssue: "selfAuthoredLinkedIssue:",
+ linkedIssueSatisfaction: "linkedIssueSatisfaction:",
dryRun: "dryRun:",
firstTimeContributorGrace: "firstTimeContributorGrace:",
premergeContentRecheck: "premergeContentRecheck:",
@@ -797,7 +798,7 @@ describe("compileFocusManifestPolicy", () => {
issueDiscoveryPolicy: "neutral",
maintainerNotes: [],
publicNotes: ["Keep PRs focused.", "Maximize your reward payout"],
- gate: { present: false, enabled: null, checkMode: null, pack: null, linkedIssue: null, duplicates: null, readinessMode: null, readinessMinScore: null, slopMode: null, slopMinScore: null, slopAiAdvisory: null, sizeMode: null, lockfileIntegrityMode: null, aiReviewMode: null, aiReviewByok: null, aiReviewProvider: null, aiReviewModel: null, aiReviewAllAuthors: null, aiReviewCloseConfidence: null, aiReviewCombine: null, aiReviewOnMerge: null, aiReviewReviewers: null, mergeReadiness: null, selfAuthoredLinkedIssue: null, manifestPolicy: null, dryRun: null, firstTimeContributorGrace: null, premergeContentRecheck: null, requireFreshRebaseWindowMinutes: null, claMode: null, claConsentPhrase: null, claCheckRunName: null, claCheckRunAppSlug: null, expectedCiContexts: null },
+ gate: { present: false, enabled: null, checkMode: null, pack: null, linkedIssue: null, duplicates: null, readinessMode: null, readinessMinScore: null, slopMode: null, slopMinScore: null, slopAiAdvisory: null, sizeMode: null, lockfileIntegrityMode: null, aiReviewMode: null, aiReviewByok: null, aiReviewProvider: null, aiReviewModel: null, aiReviewAllAuthors: null, aiReviewCloseConfidence: null, aiReviewCombine: null, aiReviewOnMerge: null, aiReviewReviewers: null, mergeReadiness: null, selfAuthoredLinkedIssue: null, linkedIssueSatisfaction: null, manifestPolicy: null, dryRun: null, firstTimeContributorGrace: null, premergeContentRecheck: null, requireFreshRebaseWindowMinutes: null, claMode: null, claConsentPhrase: null, claCheckRunName: null, claCheckRunAppSlug: null, expectedCiContexts: null },
settings: {},
review: { present: false, footerText: null, note: null, fields: {}, enrichmentAnalyzers: {}, profile: null, tone: null, securityFocus: null, inlineComments: null, fixHandoff: null, autoMergeSummary: null, suggestions: null, changedFilesSummary: null, effortScore: null, testGeneration: null, impactMap: null, cultureProfile: null, reviewMemory: null, findingCategories: null, inlineCommentsPerCategory: null, minFindingSeverity: null, maxFindings: { blockers: null, nits: null }, commentVerbosity: null, pathInstructions: [], instructions: null, excludePaths: [], pathFilters: [], preMergeChecks: [], autoReview: { ...EMPTY_AUTO_REVIEW_CONFIG }, labelingRules: [], aiModel: { ...EMPTY_SELF_HOST_AI_MODEL_CONFIG }, visual: { ...EMPTY_VISUAL_CONFIG }, linkedIssueSatisfaction: null, sharedConfigSource: null },
features: { present: false, rag: null, reputation: null, unifiedComment: null, safety: null },
@@ -1107,7 +1108,7 @@ describe("parseFocusManifest gate config", () => {
// the block→advisory deprecation-downgrade behavior itself is covered separately below.
const m = parseFocusManifest({ gate: { linkedIssue: "block", duplicates: "advisory", readiness: { mode: "advisory", minScore: 70 } } });
expect(m.present).toBe(true);
- expect(m.gate).toEqual({ present: true, enabled: null, checkMode: null, pack: null, linkedIssue: "block", duplicates: "advisory", readinessMode: "advisory", readinessMinScore: 70, slopMode: null, slopMinScore: null, slopAiAdvisory: null, sizeMode: null, lockfileIntegrityMode: null, aiReviewMode: null, aiReviewByok: null, aiReviewProvider: null, aiReviewModel: null, aiReviewAllAuthors: null, aiReviewCloseConfidence: null, aiReviewCombine: null, aiReviewOnMerge: null, aiReviewReviewers: null, mergeReadiness: null, selfAuthoredLinkedIssue: null, manifestPolicy: null, dryRun: null, firstTimeContributorGrace: null, premergeContentRecheck: null, requireFreshRebaseWindowMinutes: null, claMode: null, claConsentPhrase: null, claCheckRunName: null, claCheckRunAppSlug: null, expectedCiContexts: null });
+ expect(m.gate).toEqual({ present: true, enabled: null, checkMode: null, pack: null, linkedIssue: "block", duplicates: "advisory", readinessMode: "advisory", readinessMinScore: 70, slopMode: null, slopMinScore: null, slopAiAdvisory: null, sizeMode: null, lockfileIntegrityMode: null, aiReviewMode: null, aiReviewByok: null, aiReviewProvider: null, aiReviewModel: null, aiReviewAllAuthors: null, aiReviewCloseConfidence: null, aiReviewCombine: null, aiReviewOnMerge: null, aiReviewReviewers: null, mergeReadiness: null, selfAuthoredLinkedIssue: null, linkedIssueSatisfaction: null, manifestPolicy: null, dryRun: null, firstTimeContributorGrace: null, premergeContentRecheck: null, requireFreshRebaseWindowMinutes: null, claMode: null, claConsentPhrase: null, claCheckRunName: null, claCheckRunAppSlug: null, expectedCiContexts: 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 fd3bc73648..61ba1f0a95 100644
--- a/test/unit/gate-check-policy.test.ts
+++ b/test/unit/gate-check-policy.test.ts
@@ -861,6 +861,33 @@ describe("lockfile-tamper-risk gate blocker (#2563)", () => {
});
});
+describe("linked-issue satisfaction gate blocker (#1961/#3906)", () => {
+ const satisfactionAdvisory = (): Advisory => ({
+ ...missingIssueAdvisory(),
+ findings: [{ code: "linked_issue_scope_mismatch", title: "Linked issue does not appear to be satisfied", severity: "warning", detail: "The cited issue asks for an SSE stream; this PR adds an unrelated REST endpoint.", action: "Confirm this PR actually addresses the linked issue's scope, or link the correct issue." }],
+ });
+
+ it("blocks (failure) under linkedIssueSatisfactionGateMode: block, confirmed contributor", () => {
+ const result = evaluateGateCheck(satisfactionAdvisory(), { linkedIssueSatisfactionGateMode: "block", confirmedContributor: true });
+ expect(result.conclusion).toBe("failure");
+ expect(result.blockers.map((b) => b.code)).toContain("linked_issue_scope_mismatch");
+ });
+
+ it("stays advisory (never blocks) under off/unset (default) or advisory mode, even if a finding exists", () => {
+ expect(evaluateGateCheck(satisfactionAdvisory(), {}).conclusion).toBe("success"); // unset ⇒ defaults to advisory
+ expect(evaluateGateCheck(satisfactionAdvisory(), { linkedIssueSatisfactionGateMode: "off" }).conclusion).toBe("success");
+ const advisoryResult = evaluateGateCheck(satisfactionAdvisory(), { linkedIssueSatisfactionGateMode: "advisory" });
+ expect(advisoryResult.conclusion).toBe("success");
+ expect(advisoryResult.warnings.map((w) => w.code)).toContain("linked_issue_scope_mismatch");
+ });
+
+ it("resolveEffectiveSettings maps gate.linkedIssueSatisfaction → linkedIssueSatisfactionGateMode, and gateCheckPolicy threads it", () => {
+ const eff = resolveEffectiveSettings(settings({}), parseFocusManifest({ gate: { linkedIssueSatisfaction: "block" } }));
+ expect(eff.linkedIssueSatisfactionGateMode).toBe("block");
+ expect(gateCheckPolicy(settings({ linkedIssueSatisfactionGateMode: "block" }), null, true).linkedIssueSatisfactionGateMode).toBe("block");
+ });
+});
+
describe("dry-run disposition (#gate-dryrun): would-be verdict without enforcing", () => {
// #disposition-redesign: the dry-run shadow promotes ONLY the AI sub-gate. CLOSE is driven by AI confidence; the
// advisory signals (linked issue, readiness/quality, slop, duplicates) can NEVER drive a would-be close.
diff --git a/test/unit/linked-issue-satisfaction-cache.test.ts b/test/unit/linked-issue-satisfaction-cache.test.ts
new file mode 100644
index 0000000000..eb265cf53c
--- /dev/null
+++ b/test/unit/linked-issue-satisfaction-cache.test.ts
@@ -0,0 +1,131 @@
+import { describe, expect, it, vi } from "vitest";
+import { getCachedLinkedIssueSatisfaction, putCachedLinkedIssueSatisfaction } from "../../src/db/repositories";
+import { linkedIssueSatisfactionCacheInputFingerprint } from "../../src/review/linked-issue-satisfaction-cache-input";
+import { createTestEnv } from "../helpers/d1";
+
+const fp = () => linkedIssueSatisfactionCacheInputFingerprint({ byok: false, provider: null, model: null });
+
+describe("linked-issue satisfaction cache (#1961/#3906)", () => {
+ it("misses on a nullish head SHA (read returns null; write is a no-op)", async () => {
+ const env = createTestEnv();
+ const fingerprint = await fp();
+ expect(await getCachedLinkedIssueSatisfaction(env, "o/r", 1, null, 5, fingerprint)).toBeNull();
+ expect(await getCachedLinkedIssueSatisfaction(env, "o/r", 1, undefined, 5, fingerprint)).toBeNull();
+ await putCachedLinkedIssueSatisfaction(env, "o/r", 1, null, 5, fingerprint, { status: "ok", result: null, estimatedNeurons: 5 }); // no-op, no throw
+ expect(await getCachedLinkedIssueSatisfaction(env, "o/r", 1, "sha", 5, fingerprint)).toBeNull(); // nothing was stored
+ });
+
+ it("reuses a stored assessment ONLY on the same (repo, pull, head SHA, linked issue number)", async () => {
+ const env = createTestEnv();
+ const fingerprint = await fp();
+ await putCachedLinkedIssueSatisfaction(env, "o/r", 7, "sha1", 42, fingerprint, {
+ status: "ok",
+ result: { status: "addressed", rationale: "looks done", confidence: 0.9 },
+ estimatedNeurons: 12,
+ });
+ expect(await getCachedLinkedIssueSatisfaction(env, "o/r", 7, "sha1", 42, fingerprint)).toEqual({
+ status: "ok",
+ result: { status: "addressed", rationale: "looks done", confidence: 0.9 },
+ estimatedNeurons: 12,
+ });
+ expect(await getCachedLinkedIssueSatisfaction(env, "o/r", 7, "sha2", 42, fingerprint)).toBeNull(); // new head SHA → miss
+ expect(await getCachedLinkedIssueSatisfaction(env, "o/r", 8, "sha1", 42, fingerprint)).toBeNull(); // different PR → miss
+ expect(await getCachedLinkedIssueSatisfaction(env, "o/r2", 7, "sha1", 42, fingerprint)).toBeNull(); // different repo → miss
+ // Same (repo, pull, head) but a DIFFERENT primary linked issue number → miss. This is the dimension that
+ // distinguishes this cache from ai_slop_cache: a PR's cited primary issue can change between passes.
+ expect(await getCachedLinkedIssueSatisfaction(env, "o/r", 7, "sha1", 99, fingerprint)).toBeNull();
+ });
+
+ it("misses when the input fingerprint does not match (e.g. BYOK toggled on/off since the row was written)", async () => {
+ const env = createTestEnv();
+ const freeFingerprint = await linkedIssueSatisfactionCacheInputFingerprint({ byok: false, provider: null, model: null });
+ const byokFingerprint = await linkedIssueSatisfactionCacheInputFingerprint({ byok: true, provider: "anthropic", model: "claude-sonnet-5" });
+ expect(freeFingerprint).not.toBe(byokFingerprint);
+
+ await putCachedLinkedIssueSatisfaction(env, "o/r", 9, "sha1", 1, freeFingerprint, { status: "ok", result: { status: "partial", rationale: "r", confidence: 0.7 }, estimatedNeurons: 6 });
+ expect(await getCachedLinkedIssueSatisfaction(env, "o/r", 9, "sha1", 1, byokFingerprint)).toBeNull();
+ expect(await getCachedLinkedIssueSatisfaction(env, "o/r", 9, "sha1", 1, freeFingerprint)).toEqual({ status: "ok", result: { status: "partial", rationale: "r", confidence: 0.7 }, estimatedNeurons: 6 });
+ });
+
+ it("upserts — a re-run at the same key replaces the stored assessment", async () => {
+ const env = createTestEnv();
+ const fingerprint = await fp();
+ await putCachedLinkedIssueSatisfaction(env, "o/r", 10, "sha1", 1, fingerprint, { status: "ok", result: { status: "partial", rationale: "first pass", confidence: 0.6 }, estimatedNeurons: 3 });
+ await putCachedLinkedIssueSatisfaction(env, "o/r", 10, "sha1", 1, fingerprint, { status: "ok", result: { status: "addressed", rationale: "second pass", confidence: 0.95 }, estimatedNeurons: 9 });
+ expect(await getCachedLinkedIssueSatisfaction(env, "o/r", 10, "sha1", 1, fingerprint)).toEqual({
+ status: "ok",
+ result: { status: "addressed", rationale: "second pass", confidence: 0.95 },
+ estimatedNeurons: 9,
+ });
+ });
+
+ it("round-trips a null result (no usable model output surfaced)", async () => {
+ const env = createTestEnv();
+ const fingerprint = await fp();
+ await putCachedLinkedIssueSatisfaction(env, "o/r", 11, "sha1", 1, fingerprint, { status: "ok", result: null, estimatedNeurons: 6 });
+ expect(await getCachedLinkedIssueSatisfaction(env, "o/r", 11, "sha1", 1, fingerprint)).toEqual({ status: "ok", result: null, estimatedNeurons: 6 });
+ });
+
+ it("stores an ISO created_at value on insert and conflict update", async () => {
+ const env = createTestEnv();
+ const fingerprint = await fp();
+
+ vi.useFakeTimers();
+ try {
+ vi.setSystemTime(new Date("2026-07-07T09:00:00.123Z"));
+ await putCachedLinkedIssueSatisfaction(env, "o/r", 12, "sha1", 1, fingerprint, { status: "ok", result: null, estimatedNeurons: 6 });
+ const inserted = await env.DB.prepare("SELECT created_at AS createdAt FROM linked_issue_satisfaction_cache WHERE repo_full_name = ? AND pull_number = ? AND head_sha = ? AND linked_issue_number = ?")
+ .bind("o/r", 12, "sha1", 1)
+ .first<{ createdAt: string }>();
+ expect(inserted?.createdAt).toBe("2026-07-07T09:00:00.123Z");
+
+ vi.setSystemTime(new Date("2026-07-07T09:05:00.456Z"));
+ await putCachedLinkedIssueSatisfaction(env, "o/r", 12, "sha1", 1, fingerprint, { status: "ok", result: { status: "addressed", rationale: "r", confidence: 0.9 }, estimatedNeurons: 9 });
+ const updated = await env.DB.prepare("SELECT created_at AS createdAt FROM linked_issue_satisfaction_cache WHERE repo_full_name = ? AND pull_number = ? AND head_sha = ? AND linked_issue_number = ?")
+ .bind("o/r", 12, "sha1", 1)
+ .first<{ createdAt: string }>();
+ expect(updated?.createdAt).toBe("2026-07-07T09:05:00.456Z");
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+});
+
+describe("linkedIssueSatisfactionCacheInputFingerprint", () => {
+ it("is stable for the same input", async () => {
+ const a = await linkedIssueSatisfactionCacheInputFingerprint({ byok: false, provider: null, model: null });
+ const b = await linkedIssueSatisfactionCacheInputFingerprint({ byok: false, provider: null, model: null });
+ expect(a).toBe(b);
+ });
+
+ it("differs when byok flips", async () => {
+ const free = await linkedIssueSatisfactionCacheInputFingerprint({ byok: false, provider: null, model: null });
+ const byok = await linkedIssueSatisfactionCacheInputFingerprint({ byok: true, provider: null, model: null });
+ expect(free).not.toBe(byok);
+ });
+
+ it("differs when the BYOK provider changes", async () => {
+ const anthropic = await linkedIssueSatisfactionCacheInputFingerprint({ byok: true, provider: "anthropic", model: null });
+ const openai = await linkedIssueSatisfactionCacheInputFingerprint({ byok: true, provider: "openai", model: null });
+ expect(anthropic).not.toBe(openai);
+ });
+
+ it("differs when the BYOK model changes", async () => {
+ const sonnet = await linkedIssueSatisfactionCacheInputFingerprint({ byok: true, provider: "anthropic", model: "claude-sonnet-5" });
+ const opus = await linkedIssueSatisfactionCacheInputFingerprint({ byok: true, provider: "anthropic", model: "claude-opus-5" });
+ expect(sonnet).not.toBe(opus);
+ });
+
+ it("treats a nullish provider/model the same as an absent one", async () => {
+ const withUndefined = await linkedIssueSatisfactionCacheInputFingerprint({ byok: false, provider: undefined, model: undefined });
+ const withNull = await linkedIssueSatisfactionCacheInputFingerprint({ byok: false, provider: null, model: null });
+ expect(withUndefined).toBe(withNull);
+ });
+
+ it("never collides with the ai_slop_cache fingerprint namespace even for identical inputs", async () => {
+ const { aiSlopCacheInputFingerprint } = await import("../../src/review/ai-slop-cache-input");
+ const slop = await aiSlopCacheInputFingerprint({ byok: false, provider: null, model: null });
+ const satisfaction = await linkedIssueSatisfactionCacheInputFingerprint({ byok: false, provider: null, model: null });
+ expect(slop).not.toBe(satisfaction);
+ });
+});
diff --git a/test/unit/linked-issue-satisfaction-run.test.ts b/test/unit/linked-issue-satisfaction-run.test.ts
new file mode 100644
index 0000000000..4366600681
--- /dev/null
+++ b/test/unit/linked-issue-satisfaction-run.test.ts
@@ -0,0 +1,919 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { runGittensoryLinkedIssueSatisfaction, type LinkedIssueSatisfactionRunInput } from "../../src/services/linked-issue-satisfaction-run";
+import { processJob, runLinkedIssueSatisfactionForAdvisory } from "../../src/queue/processors";
+import { evaluateGateCheck } from "../../src/rules/advisory";
+import {
+ getCachedLinkedIssueSatisfaction,
+ putCachedLinkedIssueSatisfaction,
+ recordAiUsageEvent,
+ upsertRepositoryAiKey,
+ upsertRepositoryFromGitHub,
+ upsertRepositorySettings,
+} from "../../src/db/repositories";
+import { linkedIssueSatisfactionCacheInputFingerprint } from "../../src/review/linked-issue-satisfaction-cache-input";
+import { clearInstallationTokenCacheForTest } from "../../src/github/app";
+import { normalizeRegistryPayload } from "../../src/registry/normalize";
+import { persistRegistrySnapshot } from "../../src/registry/sync";
+import type { Advisory, PullRequestFileRecord, RepositorySettings } from "../../src/types";
+import { createTestEnv } from "../helpers/d1";
+
+// Split so the literal PEM marker text never appears contiguous in source -- the review-safety secrets
+// scanner's private_key_block pattern is a pure text match with no awareness that the bytes between these
+// markers are freshly generated per test run, not a real credential (src/review/safety.ts). Mirrors the
+// identical helper duplicated across other test files (e.g. test/unit/queue.test.ts).
+const PEM_HEADER = ["-----BEGIN", "PRIVATE KEY-----"].join(" ");
+const PEM_FOOTER = ["-----END", "PRIVATE KEY-----"].join(" ");
+
+async function generatePrivateKeyPem(): Promise {
+ const key = (await crypto.subtle.generateKey(
+ { name: "RSASSA-PKCS1-v1_5", modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: "SHA-256" },
+ true,
+ ["sign", "verify"],
+ )) as CryptoKeyPair;
+ const exported = await crypto.subtle.exportKey("pkcs8", key.privateKey);
+ const base64 = Buffer.from(exported as ArrayBuffer)
+ .toString("base64")
+ .replace(/(.{64})/g, "$1\n");
+ return `${PEM_HEADER}\n${base64}\n${PEM_FOOTER}`;
+}
+
+function satisfactionJson(over: Partial<{ status: string; rationale: string; confidence: number }> = {}): string {
+ return JSON.stringify({
+ status: over.status ?? "addressed",
+ rationale: over.rationale ?? "The diff adds the requested endpoint and matches the issue's acceptance criteria.",
+ confidence: over.confidence ?? 0.9,
+ });
+}
+
+const baseInput: LinkedIssueSatisfactionRunInput = {
+ repoFullName: "acme/widgets",
+ prNumber: 7,
+ issueText: "Title: Add SSE stream\n\nWe need a live SSE stream surface.",
+ prTitle: "Add SSE stream endpoint",
+ prBody: "Implements the requested SSE stream.",
+ diff: "### src/a.ts (modified) +40/-2\n@@\n+app.get('/stream', sse);",
+ actor: "alice",
+};
+
+const enabledEnv = (run: unknown) =>
+ createTestEnv({
+ AI: { run } as unknown as Ai,
+ AI_SUMMARIES_ENABLED: "true",
+ AI_PUBLIC_COMMENTS_ENABLED: "true",
+ AI_DAILY_NEURON_BUDGET: "100000",
+ });
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+});
+
+describe("runGittensoryLinkedIssueSatisfaction gating + fail-safe", () => {
+ it("is disabled when AI_SUMMARIES_ENABLED itself is unset (the FIRST gate, not just the second)", async () => {
+ const run = vi.fn();
+ const env = createTestEnv({ AI: { run } as unknown as Ai, AI_PUBLIC_COMMENTS_ENABLED: "true" });
+ await expect(runGittensoryLinkedIssueSatisfaction(env, baseInput)).resolves.toMatchObject({ status: "disabled", reason: "AI summaries are disabled." });
+ expect(run).not.toHaveBeenCalled();
+ });
+
+ it("is disabled until both AI flags are on, and never calls the model", async () => {
+ const run = vi.fn();
+ const env = createTestEnv({ AI: { run } as unknown as Ai, AI_SUMMARIES_ENABLED: "true" });
+ await expect(runGittensoryLinkedIssueSatisfaction(env, baseInput)).resolves.toMatchObject({ status: "disabled" });
+ expect(run).not.toHaveBeenCalled();
+ });
+
+ it("reports unavailable when the Workers AI binding is missing", async () => {
+ const env = createTestEnv({ AI_SUMMARIES_ENABLED: "true", AI_PUBLIC_COMMENTS_ENABLED: "true" });
+ await expect(runGittensoryLinkedIssueSatisfaction(env, baseInput)).resolves.toMatchObject({ status: "unavailable" });
+ });
+
+ it("short-circuits to ok/null with zero spend when there is no issue text (fail-safe, mirrors the pure module's own contract)", async () => {
+ const run = vi.fn();
+ const env = enabledEnv(run);
+ const result = await runGittensoryLinkedIssueSatisfaction(env, { ...baseInput, issueText: " " });
+ expect(result).toEqual({ status: "ok", result: null, estimatedNeurons: 0 });
+ expect(run).not.toHaveBeenCalled();
+ });
+
+ it("treats an absent issueText (undefined) the same as blank", async () => {
+ const run = vi.fn();
+ const env = enabledEnv(run);
+ const result = await runGittensoryLinkedIssueSatisfaction(env, { ...baseInput, issueText: undefined });
+ expect(result).toEqual({ status: "ok", result: null, estimatedNeurons: 0 });
+ expect(run).not.toHaveBeenCalled();
+ });
+
+ it("enforces the shared daily neuron budget before calling the model", async () => {
+ const run = vi.fn();
+ const env = createTestEnv({ AI: { run } as unknown as Ai, AI_SUMMARIES_ENABLED: "true", AI_PUBLIC_COMMENTS_ENABLED: "true", AI_DAILY_NEURON_BUDGET: "1" });
+ const result = await runGittensoryLinkedIssueSatisfaction(env, baseInput);
+ expect(result).toMatchObject({ status: "quota_exceeded" });
+ expect(run).not.toHaveBeenCalled();
+ });
+
+ it("draws from the SAME shared daily neuron counter as ai_review/ai_slop (no per-feature budget)", async () => {
+ const run = vi.fn(async () => ({ response: satisfactionJson() }));
+ const env = createTestEnv({ AI: { run } as unknown as Ai, AI_SUMMARIES_ENABLED: "true", AI_PUBLIC_COMMENTS_ENABLED: "true", AI_DAILY_NEURON_BUDGET: "2000000" });
+ await recordAiUsageEvent(env, { feature: "ai_slop_pr", model: "m", status: "ok", estimatedNeurons: 1_999_999 });
+ const result = await runGittensoryLinkedIssueSatisfaction(env, baseInput);
+ expect(result.status).toBe("quota_exceeded");
+ expect(run).not.toHaveBeenCalled();
+ });
+
+ it("degrades to no result when env.AI is present but not a valid runner (no .run function)", async () => {
+ const env = createTestEnv({ AI: {} as unknown as Ai, AI_SUMMARIES_ENABLED: "true", AI_PUBLIC_COMMENTS_ENABLED: "true", AI_DAILY_NEURON_BUDGET: "100000" });
+ const result = await runGittensoryLinkedIssueSatisfaction(env, baseInput);
+ expect(result).toMatchObject({ status: "ok", result: null });
+ });
+
+ it("returns the parsed, public-safe result when the model responds well", async () => {
+ const run = vi.fn(async () => ({ response: satisfactionJson({ status: "addressed" }) }));
+ const result = await runGittensoryLinkedIssueSatisfaction(enabledEnv(run), baseInput);
+ expect(result.status).toBe("ok");
+ if (result.status !== "ok") throw new Error("unreachable");
+ expect(result.result).toMatchObject({ status: "addressed" });
+ expect(result.estimatedNeurons).toBeGreaterThan(0);
+ });
+
+ it("records the pre-budgeted retry/fallback estimate under the linked_issue_satisfaction feature", async () => {
+ const run = vi.fn(async () => ({ response: "not json" }));
+ const env = enabledEnv(run);
+ const result = await runGittensoryLinkedIssueSatisfaction(env, baseInput);
+ expect(result.status).toBe("ok");
+ if (result.status !== "ok") throw new Error("unreachable");
+ expect(run).toHaveBeenCalledTimes(6);
+ const row = await env.DB.prepare("select estimated_neurons, feature, route from ai_usage_events where feature = ? order by rowid desc limit 1")
+ .bind("linked_issue_satisfaction")
+ .first<{ estimated_neurons: number; feature: string; route: string }>();
+ expect(row?.estimated_neurons).toBe(result.estimatedNeurons);
+ expect(row?.route).toBe("github_app.linked_issue_satisfaction");
+ });
+
+ it("CONFIDENCE FLOOR: a below-floor 'unaddressed' on every attempt degrades to no result, never a shaky block signal", async () => {
+ const run = vi.fn(async () => ({ response: satisfactionJson({ status: "unaddressed", confidence: 0.2 }) }));
+ const result = await runGittensoryLinkedIssueSatisfaction(enabledEnv(run), baseInput);
+ expect(result.status).toBe("ok");
+ if (result.status !== "ok") throw new Error("unreachable");
+ expect(result.result).toBeNull();
+ expect(run).toHaveBeenCalledTimes(6); // exhausted every retry/fallback attempt
+ });
+
+ it("CONFIDENCE FLOOR: retries past an early below-floor 'unaddressed' and accepts a later above-floor call", async () => {
+ let call = 0;
+ const run = vi.fn(async () => {
+ call += 1;
+ return { response: call < 3 ? satisfactionJson({ status: "unaddressed", confidence: 0.1 }) : satisfactionJson({ status: "unaddressed", confidence: 0.9 }) };
+ });
+ const result = await runGittensoryLinkedIssueSatisfaction(enabledEnv(run), baseInput);
+ expect(result.status).toBe("ok");
+ if (result.status !== "ok") throw new Error("unreachable");
+ expect(result.result).toMatchObject({ status: "unaddressed", confidence: 0.9 });
+ });
+
+ it("addressed/partial verdicts are never floor-gated, even at zero confidence", async () => {
+ const run = vi.fn(async () => ({ response: satisfactionJson({ status: "partial", confidence: 0 }) }));
+ const result = await runGittensoryLinkedIssueSatisfaction(enabledEnv(run), baseInput);
+ expect(result.status).toBe("ok");
+ if (result.status !== "ok") throw new Error("unreachable");
+ expect(result.result).toMatchObject({ status: "partial" });
+ expect(run).toHaveBeenCalledTimes(1); // no retry needed — not floor-gated
+ });
+
+ it("is fail-safe: a throwing model yields ok with no result (never throws)", async () => {
+ const run = vi.fn(async () => {
+ throw new Error("model exploded");
+ });
+ const result = await runGittensoryLinkedIssueSatisfaction(enabledEnv(run), baseInput);
+ expect(result.status).toBe("ok");
+ if (result.status !== "ok") throw new Error("unreachable");
+ expect(result.result).toBeNull();
+ expect(run).toHaveBeenCalled();
+ });
+
+ it("falls back to the reliable model when the primary keeps returning garbage", async () => {
+ const run = vi.fn(async (model: string) => ({ response: model.includes("gpt-oss") ? "not json" : satisfactionJson({ status: "partial" }) }));
+ const result = await runGittensoryLinkedIssueSatisfaction(enabledEnv(run), baseInput);
+ expect(result.status).toBe("ok");
+ if (result.status !== "ok") throw new Error("unreachable");
+ expect(result.result).toMatchObject({ status: "partial" });
+ });
+
+ it("enforces the shared BYOK daily repo cap before any provider call", async () => {
+ const run = vi.fn();
+ const env = createTestEnv({ AI: { run } as unknown as Ai, AI_SUMMARIES_ENABLED: "true", AI_PUBLIC_COMMENTS_ENABLED: "true", AI_DAILY_NEURON_BUDGET: "1", AI_BYOK_DAILY_REPO_LIMIT: "1" });
+ await recordAiUsageEvent(env, { feature: "ai_review_pr", actor: null, route: "x", model: "byok:anthropic", status: "ok", estimatedNeurons: 1, detail: "seed", metadata: { repoFullName: baseInput.repoFullName } });
+ const fetchMock = vi.fn(async () => new Response("{}", { status: 200 }));
+ vi.stubGlobal("fetch", fetchMock);
+ const result = await runGittensoryLinkedIssueSatisfaction(env, { ...baseInput, providerKey: { provider: "anthropic", key: "sk-ant-x" } });
+ expect(result.status).toBe("quota_exceeded");
+ expect(fetchMock).not.toHaveBeenCalled();
+ expect(run).not.toHaveBeenCalled();
+ });
+
+ it("records real BYOK usage (tokens + cost) on the durable audit row", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async () => new Response(JSON.stringify({ content: [{ type: "text", text: satisfactionJson({ status: "addressed" }) }], usage: { input_tokens: 400, output_tokens: 40 } }), { status: 200 })),
+ );
+ const env = enabledEnv(vi.fn());
+ const result = await runGittensoryLinkedIssueSatisfaction(env, { ...baseInput, providerKey: { provider: "anthropic", key: "sk-ant-x", model: "claude-sonnet-5" } });
+ expect(result.status).toBe("ok");
+ const row = await env.DB.prepare(`select provider, input_tokens, output_tokens, total_tokens from ai_usage_events where feature = ? order by rowid desc limit 1`)
+ .bind("linked_issue_satisfaction")
+ .first<{ provider: string | null; input_tokens: number; output_tokens: number; total_tokens: number }>();
+ expect(row).toMatchObject({ provider: "anthropic", input_tokens: 400, output_tokens: 40, total_tokens: 440 });
+ });
+
+ it("defaults the budget HIGH (10M) when AI_DAILY_NEURON_BUDGET is unset/invalid — no starvation", async () => {
+ const run = vi.fn(async () => ({ response: satisfactionJson({ status: "addressed" }) }));
+ const env = createTestEnv({ AI: { run } as unknown as Ai, AI_SUMMARIES_ENABLED: "true", AI_PUBLIC_COMMENTS_ENABLED: "true", AI_DAILY_NEURON_BUDGET: "" });
+ // 2M prior spend — over any tiny fallback but well under the 10M default the fix uses.
+ await recordAiUsageEvent(env, { feature: "ai_review", model: "m", status: "ok", estimatedNeurons: 2_000_000 });
+ const result = await runGittensoryLinkedIssueSatisfaction(env, baseInput);
+ expect(result.status).not.toBe("quota_exceeded");
+ expect(run).toHaveBeenCalled();
+ });
+
+ it("passes the configured AI_GATEWAY_ID through to the Workers AI call", async () => {
+ const run = vi.fn(async (_model: string, _options: unknown, extra?: { gateway?: { id: string } }) => {
+ expect(extra).toEqual({ gateway: { id: "my-gateway" } });
+ return { response: satisfactionJson({ status: "addressed" }) };
+ });
+ const env = createTestEnv({ AI: { run } as unknown as Ai, AI_SUMMARIES_ENABLED: "true", AI_PUBLIC_COMMENTS_ENABLED: "true", AI_DAILY_NEURON_BUDGET: "100000", AI_GATEWAY_ID: "my-gateway" });
+ const result = await runGittensoryLinkedIssueSatisfaction(env, baseInput);
+ expect(result.status).toBe("ok");
+ expect(run).toHaveBeenCalledTimes(1);
+ });
+
+ it("degrades to no result when the BYOK provider returns no usable text (empty/falsy)", async () => {
+ vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({ content: [{ type: "text", text: "" }] }), { status: 200 })));
+ const env = enabledEnv(vi.fn());
+ const result = await runGittensoryLinkedIssueSatisfaction(env, { ...baseInput, providerKey: { provider: "anthropic", key: "sk-ant-x" } });
+ expect(result.status).toBe("ok");
+ if (result.status !== "ok") throw new Error("unreachable");
+ expect(result.result).toBeNull();
+ });
+
+ it("records a null actor as null (not undefined) when the caller omits it", async () => {
+ const run = vi.fn(async () => ({ response: satisfactionJson({ status: "addressed" }) }));
+ const env = enabledEnv(run);
+ const { actor: _omit, ...withoutActor } = baseInput;
+ await runGittensoryLinkedIssueSatisfaction(env, withoutActor);
+ const row = await env.DB.prepare("select actor from ai_usage_events where feature = ? order by rowid desc limit 1")
+ .bind("linked_issue_satisfaction")
+ .first<{ actor: string | null }>();
+ expect(row?.actor).toBeNull();
+ });
+});
+
+describe("runLinkedIssueSatisfactionForAdvisory (processor wiring, #1961/#3906)", () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ clearInstallationTokenCacheForTest();
+ });
+
+ function advisory(over: Partial = {}): Advisory {
+ return {
+ id: "adv-satisfaction",
+ targetType: "pull_request",
+ targetKey: "acme/widgets#7",
+ repoFullName: "acme/widgets",
+ pullNumber: 7,
+ headSha: "sha7",
+ conclusion: "neutral",
+ severity: "info",
+ title: "Gittensory advisory available",
+ summary: "ok",
+ findings: [],
+ generatedAt: "2026-07-07T00:00:00.000Z",
+ ...over,
+ };
+ }
+
+ const files: PullRequestFileRecord[] = [
+ { repoFullName: "acme/widgets", pullNumber: 7, path: "src/a.ts", status: "modified", additions: 40, deletions: 2, changes: 42, payload: { patch: "@@\n+app.get('/stream', sse);" } },
+ ];
+ const pr = { number: 7, title: "Add SSE stream endpoint", body: "Implements the requested SSE stream.", linkedIssues: [1275] };
+ const advisoryMode = { linkedIssueSatisfactionGateMode: "advisory", aiReviewByok: false } as RepositorySettings;
+ const blockMode = { linkedIssueSatisfactionGateMode: "block", aiReviewByok: false } as RepositorySettings;
+
+ function stubFetch(handler: (url: string) => Response | Promise): void {
+ vi.stubGlobal("fetch", async (input: RequestInfo | URL) => handler(input.toString()));
+ }
+
+ function stubIssueFetch(issue: { title?: string; body?: string; state?: string } = {}): void {
+ stubFetch((url) => {
+ if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" });
+ if (url.endsWith("/issues/1275")) return Response.json({ number: 1275, state: issue.state ?? "open", title: issue.title ?? "Enrich SN74 Gittensor — add SSE stream", body: issue.body ?? "We need a live SSE stream surface for SN74 Gittensor." });
+ return new Response("not found", { status: 404 });
+ });
+ }
+
+ it("no-ops (returns null) for an unconfirmed contributor — no fetch, no model spend", async () => {
+ stubIssueFetch();
+ const run = vi.fn();
+ const adv = advisory();
+ const result = await runLinkedIssueSatisfactionForAdvisory(enabledEnv(run), { settings: advisoryMode, advisory: adv, repoFullName: "acme/widgets", pr, author: "mallory", files, confirmedContributor: false, installationId: 1 });
+ expect(result).toBeNull();
+ expect(run).not.toHaveBeenCalled();
+ expect(adv.findings).toEqual([]);
+ });
+
+ it("no-ops when the advisory has no head SHA", async () => {
+ stubIssueFetch();
+ const noSha = advisory();
+ delete (noSha as Partial).headSha;
+ const run = vi.fn();
+ const result = await runLinkedIssueSatisfactionForAdvisory(enabledEnv(run), { settings: advisoryMode, advisory: noSha, repoFullName: "acme/widgets", pr, author: "alice", files, confirmedContributor: true, installationId: 1 });
+ expect(result).toBeNull();
+ expect(run).not.toHaveBeenCalled();
+ });
+
+ it("no-ops when the PR has no linked issues (defense-in-depth; the call site itself also gates on this)", async () => {
+ const run = vi.fn();
+ const env = enabledEnv(run);
+ vi.stubGlobal("fetch", vi.fn());
+ const adv = advisory();
+ const result = await runLinkedIssueSatisfactionForAdvisory(env, { settings: advisoryMode, advisory: adv, repoFullName: "acme/widgets", pr: { ...pr, linkedIssues: [] }, author: "alice", files, confirmedContributor: true, installationId: 1 });
+ expect(result).toBeNull();
+ expect(run).not.toHaveBeenCalled();
+ });
+
+ it("fetches the primary linked issue and returns the resolved status+rationale", async () => {
+ stubIssueFetch();
+ const run = vi.fn(async () => ({ response: satisfactionJson({ status: "addressed" }) }));
+ const adv = advisory();
+ const result = await runLinkedIssueSatisfactionForAdvisory(enabledEnv(run), { settings: advisoryMode, advisory: adv, repoFullName: "acme/widgets", pr, author: "alice", files, confirmedContributor: true, installationId: 1 });
+ expect(result).toMatchObject({ status: "addressed" });
+ });
+
+ it("passes a nullish PR body through as undefined (not null) to the fresh assessment call on a cache miss", async () => {
+ stubIssueFetch();
+ const run = vi.fn(async () => ({ response: satisfactionJson({ status: "addressed" }) }));
+ const adv = advisory();
+ const { body: _omit, ...prWithoutBody } = pr;
+ const result = await runLinkedIssueSatisfactionForAdvisory(enabledEnv(run), { settings: advisoryMode, advisory: adv, repoFullName: "acme/widgets", pr: prWithoutBody, author: "alice", files, confirmedContributor: true, installationId: 1 });
+ expect(result).toMatchObject({ status: "addressed" });
+ expect(run).toHaveBeenCalledTimes(1);
+ });
+
+ it("returns null when the linked issue cannot be confirmed found (fetch_error/not_found)", async () => {
+ stubFetch((url) => (url.includes("/access_tokens") ? Response.json({ token: "t" }) : new Response("missing", { status: 404 })));
+ const run = vi.fn();
+ const adv = advisory();
+ const result = await runLinkedIssueSatisfactionForAdvisory(enabledEnv(run), { settings: advisoryMode, advisory: adv, repoFullName: "acme/widgets", pr, author: "alice", files, confirmedContributor: true, installationId: 1 });
+ expect(result).toBeNull();
+ expect(run).not.toHaveBeenCalled();
+ });
+
+ it("returns null when the fetched issue has no usable title/body text", async () => {
+ stubIssueFetch({ title: "", body: "" });
+ const run = vi.fn();
+ const adv = advisory();
+ const result = await runLinkedIssueSatisfactionForAdvisory(enabledEnv(run), { settings: advisoryMode, advisory: adv, repoFullName: "acme/widgets", pr, author: "alice", files, confirmedContributor: true, installationId: 1 });
+ expect(result).toBeNull();
+ expect(run).not.toHaveBeenCalled();
+ });
+
+ it("is fail-safe: a thrown error yields null and never throws", async () => {
+ stubIssueFetch();
+ const env = { ...enabledEnv(async () => ({ response: satisfactionJson() })), DB: undefined } as unknown as Env;
+ const adv = advisory();
+ await expect(
+ runLinkedIssueSatisfactionForAdvisory(env, { settings: advisoryMode, advisory: adv, repoFullName: "acme/widgets", pr, author: "alice", files, confirmedContributor: true, installationId: 1 }),
+ ).resolves.toBeNull();
+ expect(adv.findings).toEqual([]);
+ });
+
+ describe("block mode gate wiring (metagraphed PR #3910 repro shape)", () => {
+ it("BLOCK mode: an above-floor 'unaddressed' verdict pushes linked_issue_scope_mismatch AND the gate blocks", async () => {
+ stubIssueFetch();
+ const run = vi.fn(async () => ({ response: satisfactionJson({ status: "unaddressed", confidence: 0.9, rationale: "The linked issue asks for an SSE stream; this PR adds an unrelated REST endpoint." }) }));
+ const adv = advisory();
+ const result = await runLinkedIssueSatisfactionForAdvisory(enabledEnv(run), { settings: blockMode, advisory: adv, repoFullName: "acme/widgets", pr, author: "alice", files, confirmedContributor: true, installationId: 1 });
+
+ expect(result).toMatchObject({ status: "unaddressed" });
+ expect(adv.findings).toHaveLength(1);
+ expect(adv.findings[0]).toMatchObject({ code: "linked_issue_scope_mismatch", severity: "warning" });
+
+ const gate = evaluateGateCheck(adv, { linkedIssueSatisfactionGateMode: "block" });
+ expect(gate.conclusion).toBe("failure");
+ expect(gate.blockers.map((b) => b.code)).toContain("linked_issue_scope_mismatch");
+ });
+
+ it("ADVISORY mode: the SAME above-floor 'unaddressed' verdict renders (via the return value) but pushes NO finding and never blocks", async () => {
+ stubIssueFetch();
+ const run = vi.fn(async () => ({ response: satisfactionJson({ status: "unaddressed", confidence: 0.9 }) }));
+ const adv = advisory();
+ const result = await runLinkedIssueSatisfactionForAdvisory(enabledEnv(run), { settings: advisoryMode, advisory: adv, repoFullName: "acme/widgets", pr, author: "alice", files, confirmedContributor: true, installationId: 1 });
+
+ expect(result).toMatchObject({ status: "unaddressed" });
+ expect(adv.findings).toEqual([]); // advisory mode never restates the gap as a generic finding/Nit
+
+ const gate = evaluateGateCheck(adv, { linkedIssueSatisfactionGateMode: "advisory" });
+ expect(gate.conclusion).not.toBe("failure");
+ expect(gate.blockers).toHaveLength(0);
+ });
+
+ it("BLOCK mode: an 'addressed'/'partial' verdict never pushes a finding (nothing to block)", async () => {
+ stubIssueFetch();
+ const run = vi.fn(async () => ({ response: satisfactionJson({ status: "partial" }) }));
+ const adv = advisory();
+ const result = await runLinkedIssueSatisfactionForAdvisory(enabledEnv(run), { settings: blockMode, advisory: adv, repoFullName: "acme/widgets", pr, author: "alice", files, confirmedContributor: true, installationId: 1 });
+ expect(result).toMatchObject({ status: "partial" });
+ expect(adv.findings).toEqual([]);
+ const gate = evaluateGateCheck(adv, { linkedIssueSatisfactionGateMode: "block" });
+ expect(gate.conclusion).not.toBe("failure");
+ });
+
+ it("BLOCK mode: a below-floor 'unaddressed' degrades to no result and never blocks (confidence-floor fail-safe)", async () => {
+ stubIssueFetch();
+ const run = vi.fn(async () => ({ response: satisfactionJson({ status: "unaddressed", confidence: 0.1 }) }));
+ const adv = advisory();
+ const result = await runLinkedIssueSatisfactionForAdvisory(enabledEnv(run), { settings: blockMode, advisory: adv, repoFullName: "acme/widgets", pr, author: "alice", files, confirmedContributor: true, installationId: 1 });
+ expect(result).toBeNull();
+ expect(adv.findings).toEqual([]);
+ const gate = evaluateGateCheck(adv, { linkedIssueSatisfactionGateMode: "block" });
+ expect(gate.conclusion).not.toBe("failure");
+ });
+ });
+
+ describe("BYOK routing", () => {
+ it("uses the maintainer's BYOK frontier model (not Workers AI) when aiReviewByok is on and a key is configured", async () => {
+ const workersRun = vi.fn(async () => ({ response: satisfactionJson({ status: "partial" }) })); // must NOT be called
+ const env = createTestEnv({
+ AI: { run: workersRun } as unknown as Ai,
+ AI_SUMMARIES_ENABLED: "true",
+ AI_PUBLIC_COMMENTS_ENABLED: "true",
+ AI_DAILY_NEURON_BUDGET: "100000",
+ TOKEN_ENCRYPTION_SECRET: "linked-issue-satisfaction-byok-test-encryption-secret-32b",
+ });
+ await upsertRepositoryAiKey(env, { repoFullName: "acme/widgets", provider: "anthropic", key: "sk-ant-byok-satisfaction-9999", model: null });
+ stubFetch((url) => {
+ if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" });
+ if (url.endsWith("/issues/1275")) return Response.json({ number: 1275, state: "open", title: "Add SSE stream", body: "We need a live SSE stream." });
+ if (url === "https://api.anthropic.com/v1/messages") return Response.json({ content: [{ type: "text", text: satisfactionJson({ status: "addressed" }) }] });
+ return new Response("not found", { status: 404 });
+ });
+ const adv = advisory();
+ const result = await runLinkedIssueSatisfactionForAdvisory(env, { settings: { linkedIssueSatisfactionGateMode: "advisory", aiReviewByok: true } as RepositorySettings, advisory: adv, repoFullName: "acme/widgets", pr, author: "alice", files, confirmedContributor: true, installationId: 1 });
+ expect(result).toMatchObject({ status: "addressed" });
+ expect(workersRun).not.toHaveBeenCalled();
+ });
+
+ it("uses BYOK when aiReviewProvider is explicitly set AND matches the stored key's provider", async () => {
+ const workersRun = vi.fn(); // must NOT be called
+ const env = createTestEnv({
+ AI: { run: workersRun } as unknown as Ai,
+ AI_SUMMARIES_ENABLED: "true",
+ AI_PUBLIC_COMMENTS_ENABLED: "true",
+ AI_DAILY_NEURON_BUDGET: "100000",
+ TOKEN_ENCRYPTION_SECRET: "linked-issue-satisfaction-byok-match-test-encryption-secret-32",
+ });
+ await upsertRepositoryAiKey(env, { repoFullName: "acme/widgets", provider: "anthropic", key: "sk-ant-byok-match-9999", model: null });
+ stubFetch((url) => {
+ if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" });
+ if (url.endsWith("/issues/1275")) return Response.json({ number: 1275, state: "open", title: "Add SSE stream", body: "We need a live SSE stream." });
+ if (url === "https://api.anthropic.com/v1/messages") return Response.json({ content: [{ type: "text", text: satisfactionJson({ status: "addressed" }) }] });
+ return new Response("not found", { status: 404 });
+ });
+ const adv = advisory();
+ const result = await runLinkedIssueSatisfactionForAdvisory(env, {
+ settings: { linkedIssueSatisfactionGateMode: "advisory", aiReviewByok: true, aiReviewProvider: "anthropic" } as RepositorySettings,
+ advisory: adv,
+ repoFullName: "acme/widgets",
+ pr,
+ author: "alice",
+ files,
+ confirmedContributor: true,
+ installationId: 1,
+ });
+ expect(result).toMatchObject({ status: "addressed" });
+ expect(workersRun).not.toHaveBeenCalled();
+ });
+
+ it("falls back to Workers AI when aiReviewProvider is explicitly set but MISMATCHES the stored key's provider", async () => {
+ const workersRun = vi.fn(async () => ({ response: satisfactionJson({ status: "partial" }) }));
+ const env = createTestEnv({
+ AI: { run: workersRun } as unknown as Ai,
+ AI_SUMMARIES_ENABLED: "true",
+ AI_PUBLIC_COMMENTS_ENABLED: "true",
+ AI_DAILY_NEURON_BUDGET: "100000",
+ TOKEN_ENCRYPTION_SECRET: "linked-issue-satisfaction-byok-mismatch-test-encryption-secret-3",
+ });
+ // Stored key's provider is anthropic, but the repo declared openai — the declared provider must match
+ // the stored key's own provider, or BYOK is skipped entirely (falls back to Workers AI).
+ await upsertRepositoryAiKey(env, { repoFullName: "acme/widgets", provider: "anthropic", key: "sk-ant-byok-mismatch-9999", model: null });
+ const fetchSpy = vi.fn(async () => new Response("must not be called", { status: 500 }));
+ stubFetch((url) => {
+ if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" });
+ if (url.endsWith("/issues/1275")) return Response.json({ number: 1275, state: "open", title: "Add SSE stream", body: "We need a live SSE stream." });
+ return fetchSpy();
+ });
+ const adv = advisory();
+ const result = await runLinkedIssueSatisfactionForAdvisory(env, {
+ settings: { linkedIssueSatisfactionGateMode: "advisory", aiReviewByok: true, aiReviewProvider: "openai" } as RepositorySettings,
+ advisory: adv,
+ repoFullName: "acme/widgets",
+ pr,
+ author: "alice",
+ files,
+ confirmedContributor: true,
+ installationId: 1,
+ });
+ expect(result).toMatchObject({ status: "partial" });
+ expect(workersRun).toHaveBeenCalled();
+ expect(fetchSpy).not.toHaveBeenCalled(); // never reached api.anthropic.com
+ });
+ });
+
+ describe("cache wiring (#linked-issue-satisfaction-cache)", () => {
+ it("reuses a stored assessment for an unchanged head+issue instead of calling the model again", async () => {
+ stubIssueFetch();
+ const run = vi.fn(async () => ({ response: satisfactionJson({ status: "unaddressed", confidence: 0.9 }) }));
+ const env = enabledEnv(run);
+ const fingerprint = await linkedIssueSatisfactionCacheInputFingerprint({ byok: false, provider: null, model: null });
+ await putCachedLinkedIssueSatisfaction(env, "acme/widgets", 7, "sha7", 1275, fingerprint, {
+ status: "ok",
+ result: { status: "addressed", rationale: "cached: looks done", confidence: 0.8 },
+ estimatedNeurons: 12,
+ });
+ const adv = advisory();
+ const result = await runLinkedIssueSatisfactionForAdvisory(env, { settings: advisoryMode, advisory: adv, repoFullName: "acme/widgets", pr, author: "alice", files, confirmedContributor: true, installationId: 1 });
+ expect(run).not.toHaveBeenCalled();
+ expect(result).toEqual({ status: "addressed", rationale: "cached: looks done" });
+ });
+
+ it("swallows a throwing audit-event write on a cache HIT (fail-safe, the result still returns)", async () => {
+ stubIssueFetch();
+ const run = vi.fn();
+ const env = enabledEnv(run);
+ const fingerprint = await linkedIssueSatisfactionCacheInputFingerprint({ byok: false, provider: null, model: null });
+ await putCachedLinkedIssueSatisfaction(env, "acme/widgets", 7, "sha7", 1275, fingerprint, {
+ status: "ok",
+ result: { status: "addressed", rationale: "cached: looks done", confidence: 0.8 },
+ estimatedNeurons: 12,
+ });
+ const repositoriesModule = await import("../../src/db/repositories");
+ const auditSpy = vi.spyOn(repositoriesModule, "recordAuditEvent").mockRejectedValueOnce(new Error("D1 audit write error"));
+ const adv = advisory();
+ const result = await runLinkedIssueSatisfactionForAdvisory(env, { settings: advisoryMode, advisory: adv, repoFullName: "acme/widgets", pr, author: "alice", files, confirmedContributor: true, installationId: 1 });
+ expect(run).not.toHaveBeenCalled(); // still a cache hit despite the audit-write failure
+ expect(result).toEqual({ status: "addressed", rationale: "cached: looks done" });
+ auditSpy.mockRestore();
+ });
+
+ it("is fail-safe when BOTH the cache WRITE and its own error-audit write throw (doubly-nested fail-safe)", async () => {
+ stubIssueFetch();
+ const run = vi.fn(async () => ({ response: satisfactionJson({ status: "addressed" }) }));
+ const env = enabledEnv(run);
+ const repositoriesModule = await import("../../src/db/repositories");
+ const writeSpy = vi.spyOn(repositoriesModule, "putCachedLinkedIssueSatisfaction").mockRejectedValueOnce(new Error("D1 write error"));
+ const auditSpy = vi.spyOn(repositoriesModule, "recordAuditEvent").mockImplementation(async (_env, event) => {
+ if (event.eventType === "github_app.linked_issue_satisfaction_cache_write_error") throw new Error("D1 audit write error");
+ return undefined;
+ });
+ const adv = advisory();
+ await expect(
+ runLinkedIssueSatisfactionForAdvisory(env, { settings: advisoryMode, advisory: adv, repoFullName: "acme/widgets", pr, author: "alice", files, confirmedContributor: true, installationId: 1 }),
+ ).resolves.toMatchObject({ status: "addressed" }); // never throws, even with both the cache write AND its own audit write failing
+ writeSpy.mockRestore();
+ auditSpy.mockRestore();
+ });
+
+ it("misses the cache and writes back a fresh result so the NEXT call at this head+issue is a hit", async () => {
+ stubIssueFetch();
+ const run = vi.fn(async () => ({ response: satisfactionJson({ status: "addressed" }) }));
+ const env = enabledEnv(run);
+ const adv = advisory();
+ await runLinkedIssueSatisfactionForAdvisory(env, { settings: advisoryMode, advisory: adv, repoFullName: "acme/widgets", pr, author: "alice", files, confirmedContributor: true, installationId: 1 });
+ expect(run).toHaveBeenCalledTimes(1);
+
+ const fingerprint = await linkedIssueSatisfactionCacheInputFingerprint({ byok: false, provider: null, model: null });
+ const cached = await getCachedLinkedIssueSatisfaction(env, "acme/widgets", 7, "sha7", 1275, fingerprint);
+ expect(cached).toMatchObject({ status: "ok", result: { status: "addressed" } });
+
+ const adv2 = advisory();
+ await runLinkedIssueSatisfactionForAdvisory(env, { settings: advisoryMode, advisory: adv2, repoFullName: "acme/widgets", pr, author: "alice", files, confirmedContributor: true, installationId: 1 });
+ expect(run).toHaveBeenCalledTimes(1); // still 1 — second pass was a cache hit
+ });
+
+ it("misses the cache when the PR's primary linked issue number changes, even at the same head SHA", async () => {
+ stubIssueFetch();
+ const run = vi.fn(async () => ({ response: satisfactionJson({ status: "addressed" }) }));
+ const env = enabledEnv(run);
+ const fingerprint = await linkedIssueSatisfactionCacheInputFingerprint({ byok: false, provider: null, model: null });
+ await putCachedLinkedIssueSatisfaction(env, "acme/widgets", 7, "sha7", 999, fingerprint, {
+ status: "ok",
+ result: { status: "unaddressed", rationale: "stale verdict for a different issue", confidence: 0.9 },
+ estimatedNeurons: 5,
+ });
+ const adv = advisory();
+ const result = await runLinkedIssueSatisfactionForAdvisory(env, { settings: advisoryMode, advisory: adv, repoFullName: "acme/widgets", pr, author: "alice", files, confirmedContributor: true, installationId: 1 });
+ // pr.linkedIssues is [1275] here, not 999 — must be a fresh call, not the stale row for issue #999.
+ expect(run).toHaveBeenCalledTimes(1);
+ expect(result).toMatchObject({ status: "addressed" });
+ });
+
+ it("does not cache a quota_exceeded short-circuit — a later call still tries the model once quota allows", async () => {
+ stubIssueFetch();
+ const run = vi.fn(async () => ({ response: satisfactionJson({ status: "addressed" }) }));
+ const budgetedEnv = createTestEnv({ AI: { run } as unknown as Ai, AI_SUMMARIES_ENABLED: "true", AI_PUBLIC_COMMENTS_ENABLED: "true", AI_DAILY_NEURON_BUDGET: "1" });
+ const adv = advisory();
+ await runLinkedIssueSatisfactionForAdvisory(budgetedEnv, { settings: advisoryMode, advisory: adv, repoFullName: "acme/widgets", pr, author: "alice", files, confirmedContributor: true, installationId: 1 });
+ expect(run).not.toHaveBeenCalled();
+
+ const richEnv = createTestEnv({ AI: { run } as unknown as Ai, AI_SUMMARIES_ENABLED: "true", AI_PUBLIC_COMMENTS_ENABLED: "true", AI_DAILY_NEURON_BUDGET: "100000" });
+ const adv2 = advisory();
+ await runLinkedIssueSatisfactionForAdvisory(richEnv, { settings: advisoryMode, advisory: adv2, repoFullName: "acme/widgets", pr, author: "alice", files, confirmedContributor: true, installationId: 1 });
+ expect(run).toHaveBeenCalledTimes(1);
+ });
+
+ it("is fail-safe when the cache READ throws — falls through to a fresh model call", async () => {
+ stubIssueFetch();
+ const run = vi.fn(async () => ({ response: satisfactionJson({ status: "addressed" }) }));
+ const env = enabledEnv(run);
+ const repositoriesModule = await import("../../src/db/repositories");
+ const readSpy = vi.spyOn(repositoriesModule, "getCachedLinkedIssueSatisfaction").mockRejectedValueOnce(new Error("D1 read error"));
+ const adv = advisory();
+ const result = await runLinkedIssueSatisfactionForAdvisory(env, { settings: advisoryMode, advisory: adv, repoFullName: "acme/widgets", pr, author: "alice", files, confirmedContributor: true, installationId: 1 });
+ expect(run).toHaveBeenCalledTimes(1);
+ expect(result).toMatchObject({ status: "addressed" });
+ readSpy.mockRestore();
+ });
+
+ it("is fail-safe when the cache WRITE throws — the fresh result still returns, and the failure is audited", async () => {
+ stubIssueFetch();
+ const run = vi.fn(async () => ({ response: satisfactionJson({ status: "addressed" }) }));
+ const env = enabledEnv(run);
+ const repositoriesModule = await import("../../src/db/repositories");
+ const writeSpy = vi.spyOn(repositoriesModule, "putCachedLinkedIssueSatisfaction").mockRejectedValueOnce(new Error("D1 write error"));
+ const adv = advisory();
+ const result = await runLinkedIssueSatisfactionForAdvisory(env, { settings: advisoryMode, advisory: adv, repoFullName: "acme/widgets", pr, author: "alice", files, confirmedContributor: true, installationId: 1 });
+ expect(result).toMatchObject({ status: "addressed" });
+ writeSpy.mockRestore();
+
+ const audit = await env.DB.prepare("select outcome, detail from audit_events where event_type = ? and target_key = ?")
+ .bind("github_app.linked_issue_satisfaction_cache_write_error", "acme/widgets#7")
+ .first<{ outcome: string; detail: string }>();
+ expect(audit?.outcome).toBe("error");
+ expect(audit?.detail).toContain("D1 write error");
+ });
+ });
+});
+
+describe("linked-issue satisfaction wired end-to-end through the real webhook pipeline (metagraphed PR #3910 repro shape)", () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
+ clearInstallationTokenCacheForTest();
+ });
+
+ async function stubGittensorMinerFetch(
+ pull: { number: number; headSha: string; state?: string },
+ handlers: Record Response | Promise>,
+ ) {
+ vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => {
+ const url = input.toString();
+ const method = init?.method ?? "GET";
+ if (url === "https://api.gittensor.io/miners") {
+ return Response.json([
+ { uid: 7, githubUsername: "confirmed-dev", githubId: "123", totalPrs: 4, totalMergedPrs: 3, totalOpenPrs: 1, totalClosedPrs: 0, totalOpenIssues: 0, totalClosedIssues: 0, totalSolvedIssues: 0, totalValidSolvedIssues: 0, isEligible: true, credibility: 1, eligibleRepoCount: 1 },
+ ]);
+ }
+ if (url === "https://api.gittensor.io/miners/123") {
+ return Response.json({ repositories: [{ repositoryFullName: "JSONbored/metagraphed", totalPrs: "4", totalMergedPrs: "3", totalOpenPrs: "1", totalClosedPrs: "0", totalOpenIssues: "0", totalClosedIssues: "0", isEligible: true, credibility: "1.000000" }] });
+ }
+ if (url === "https://api.gittensor.io/miners/123/prs") return Response.json([]);
+ if (url === "https://mirror.gittensor.io/api/v1/miners/123/issues") return Response.json({ issues: [] });
+ if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" });
+ if (url.includes("/commits/") && url.includes("/check-runs")) return Response.json({ total_count: 0, check_runs: [] });
+ // Live PR-state freshness check (fetchLivePullRequestResult, GET /pulls/{n}) -- gates whether a review
+ // output publish is stale/superseded. Must resolve to the SAME head SHA the webhook payload carries, or
+ // maybePublishPrPublicSurface throws a retryable "unavailable" freshness error before ever reaching this
+ // feature's own call site.
+ if (url.endsWith(`/pulls/${pull.number}`) && method === "GET") {
+ return Response.json({ number: pull.number, state: pull.state ?? "open", draft: false, head: { sha: pull.headSha }, labels: [] });
+ }
+ for (const [suffix, handler] of Object.entries(handlers)) {
+ if (url.endsWith(suffix) || url.includes(suffix)) return handler();
+ }
+ return new Response("not found", { status: 404 });
+ });
+ }
+
+ it("BLOCK mode: an SSE-vs-REST scope-mismatch verdict fails the real Gate check run, reproducing metagraphed PR #3910's shape (rendering itself is covered separately in unified-comment-bridge.test.ts)", async () => {
+ const env = createTestEnv({
+ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(),
+ AI: { run: async () => ({ response: satisfactionJson({ status: "unaddressed", confidence: 0.9, rationale: "The linked issue asks for an SSE stream; this PR adds an unrelated REST endpoint." }) }) } as unknown as Ai,
+ AI_SUMMARIES_ENABLED: "true",
+ AI_PUBLIC_COMMENTS_ENABLED: "true",
+ AI_DAILY_NEURON_BUDGET: "100000",
+ // The converged unified-comment renderer (which folds in the "Linked issue satisfaction" section this
+ // feature populates) is itself behind BOTH the global kill-switch AND the (back-compat, manifest-absent)
+ // GITTENSORY_REVIEW_REPOS allowlist -- see convergedFeatureActive/resolveConvergedFeature
+ // (src/review/feature-activation.ts). Both are required for a repo with no `.gittensory.yml` manifest.
+ GITTENSORY_REVIEW_UNIFIED_COMMENT: "true",
+ GITTENSORY_REVIEW_REPOS: "JSONbored/metagraphed",
+ });
+ await persistRegistrySnapshot(
+ env,
+ normalizeRegistryPayload(
+ { "JSONbored/metagraphed": { emission_share: 0.01, issue_discovery_share: 0 } },
+ { kind: "raw-github", url: "https://example.test" },
+ "2026-07-07T00:00:00.000Z",
+ ),
+ );
+ await upsertRepositoryFromGitHub(env, { name: "metagraphed", full_name: "JSONbored/metagraphed", private: false, owner: { login: "JSONbored" } }, 123);
+ await upsertRepositorySettings(env, {
+ repoFullName: "JSONbored/metagraphed",
+ commentMode: "all_prs",
+ publicSurface: "comment_only",
+ autoLabelEnabled: false,
+ checkRunMode: "off",
+ gateCheckMode: "enabled",
+ gatePack: "oss-anti-slop",
+ linkedIssueGateMode: "off",
+ // The gate under test: off by default, opted into "block" here so an above-floor "unaddressed" verdict
+ // becomes a real Gate-check failure -- the exact gap #3906 filed against.
+ linkedIssueSatisfactionGateMode: "block",
+ });
+
+ let gatePatchBody: { conclusion?: string; output?: { title?: string; text?: string } } = {};
+ await stubGittensorMinerFetch({ number: 3910, headSha: "realvenus3910" }, {
+ "/issues/1275": () => Response.json({ number: 1275, state: "open", title: "Enrich SN74 Gittensor — add SSE stream", body: "We need a live SSE stream surface for SN74 Gittensor." }),
+ "/check-runs/950": () => {
+ // PATCH updates the gate check-run with the final conclusion.
+ return Response.json({ id: 950 });
+ },
+ });
+ // Layer a PATCH-capturing handler on top (the generic stub above doesn't distinguish POST vs PATCH).
+ const baseFetch = globalThis.fetch;
+ vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => {
+ const url = input.toString();
+ const method = init?.method ?? "GET";
+ if (url.includes("/check-runs/950") && method === "PATCH") {
+ gatePatchBody = JSON.parse(String(init?.body ?? "{}")) as typeof gatePatchBody;
+ return Response.json({ id: 950 });
+ }
+ if (url.includes("/check-runs") && method === "POST") return Response.json({ id: 950 }, { status: 201 });
+ if (url.includes("/issues/3910/comments") && method === "GET") return Response.json([]); // no existing bot comment -> POST a new one
+ if (url.includes("/issues/3910/comments") && method === "POST") return Response.json({ id: 1 }, { status: 201 });
+ return baseFetch(input, init);
+ });
+
+ await processJob(env, {
+ type: "github-webhook",
+ deliveryId: "linked-issue-satisfaction-3910-repro",
+ eventName: "pull_request",
+ payload: {
+ action: "opened",
+ installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } },
+ repository: { name: "metagraphed", full_name: "JSONbored/metagraphed", private: false, owner: { login: "JSONbored" } },
+ pull_request: {
+ number: 3910,
+ title: "feat(registry): add SN74 Gittensor per-repo commits subnet-api surface (#1275)",
+ state: "open",
+ user: { login: "confirmed-dev" },
+ head: { sha: "realvenus3910" },
+ labels: [],
+ body: "Closes #1275",
+ },
+ },
+ });
+
+ expect(gatePatchBody.conclusion).toBe("failure");
+ expect(gatePatchBody.output?.title).toContain("Linked issue does not appear to be satisfied");
+
+ // The assessment was cached under the PR's primary linked issue number.
+ const fingerprint = await linkedIssueSatisfactionCacheInputFingerprint({ byok: false, provider: null, model: null });
+ const cached = await getCachedLinkedIssueSatisfaction(env, "JSONbored/metagraphed", 3910, "realvenus3910", 1275, fingerprint);
+ expect(cached).toMatchObject({ status: "ok", result: { status: "unaddressed" } });
+ });
+
+ it("OFF mode (default): no fetch, no model spend, no cache row, and the comment never mentions linked-issue satisfaction at all — byte-identical to before this feature existed", async () => {
+ const run = vi.fn();
+ const env = createTestEnv({
+ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(),
+ AI: { run } as unknown as Ai,
+ AI_SUMMARIES_ENABLED: "true",
+ AI_PUBLIC_COMMENTS_ENABLED: "true",
+ AI_DAILY_NEURON_BUDGET: "100000",
+ GITTENSORY_REVIEW_UNIFIED_COMMENT: "true",
+ GITTENSORY_REVIEW_REPOS: "JSONbored/metagraphed",
+ });
+ await persistRegistrySnapshot(
+ env,
+ normalizeRegistryPayload(
+ { "JSONbored/metagraphed": { emission_share: 0.01, issue_discovery_share: 0 } },
+ { kind: "raw-github", url: "https://example.test" },
+ "2026-07-07T00:00:00.000Z",
+ ),
+ );
+ await upsertRepositoryFromGitHub(env, { name: "metagraphed", full_name: "JSONbored/metagraphed", private: false, owner: { login: "JSONbored" } }, 123);
+ await upsertRepositorySettings(env, {
+ repoFullName: "JSONbored/metagraphed",
+ commentMode: "all_prs",
+ publicSurface: "comment_only",
+ autoLabelEnabled: false,
+ checkRunMode: "off",
+ gateCheckMode: "enabled",
+ gatePack: "oss-anti-slop",
+ linkedIssueGateMode: "off",
+ // No override -- linkedIssueSatisfactionGateMode is omitted, so upsertRepositorySettings persists its
+ // default "off".
+ });
+
+ let postedCommentBody = "";
+ const issuesFetchSpy = vi.fn(() => Response.json({ number: 1275, state: "open", title: "x", body: "y" }));
+ await stubGittensorMinerFetch({ number: 3912, headSha: "offmode3912" }, { "/issues/1275": issuesFetchSpy });
+ const baseFetch = globalThis.fetch;
+ vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => {
+ const url = input.toString();
+ const method = init?.method ?? "GET";
+ if (url.includes("/check-runs") && method === "POST") return Response.json({ id: 951 }, { status: 201 });
+ if (url.includes("/check-runs/") && method === "PATCH") return Response.json({ id: 951 });
+ if (url.endsWith("/issues/3912/comments") && method === "GET") return Response.json([]);
+ if (url.endsWith("/issues/3912/comments") && method === "POST") {
+ postedCommentBody = JSON.parse(String(init?.body ?? "{}"))?.body ?? "";
+ return Response.json({ id: 2 }, { status: 201 });
+ }
+ return baseFetch(input, init);
+ });
+
+ await processJob(env, {
+ type: "github-webhook",
+ deliveryId: "linked-issue-satisfaction-off-mode",
+ eventName: "pull_request",
+ payload: {
+ action: "opened",
+ installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } },
+ repository: { name: "metagraphed", full_name: "JSONbored/metagraphed", private: false, owner: { login: "JSONbored" } },
+ pull_request: { number: 3912, title: "Unrelated change", state: "open", user: { login: "confirmed-dev" }, head: { sha: "offmode3912" }, labels: [], body: "Closes #1275" },
+ },
+ });
+
+ expect(run).not.toHaveBeenCalled();
+ expect(issuesFetchSpy).not.toHaveBeenCalled();
+ expect(postedCommentBody).not.toContain("Linked issue satisfaction");
+ const fingerprint = await linkedIssueSatisfactionCacheInputFingerprint({ byok: false, provider: null, model: null });
+ expect(await getCachedLinkedIssueSatisfaction(env, "JSONbored/metagraphed", 3912, "offmode3912", 1275, fingerprint)).toBeNull();
+ });
+
+ it("ADVISORY mode (not block): the SAME scope-mismatch verdict never fails the Gate check run", async () => {
+ const env = createTestEnv({
+ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(),
+ AI: { run: async () => ({ response: satisfactionJson({ status: "unaddressed", confidence: 0.9 }) }) } as unknown as Ai,
+ AI_SUMMARIES_ENABLED: "true",
+ AI_PUBLIC_COMMENTS_ENABLED: "true",
+ AI_DAILY_NEURON_BUDGET: "100000",
+ });
+ await persistRegistrySnapshot(
+ env,
+ normalizeRegistryPayload(
+ { "JSONbored/metagraphed": { emission_share: 0.01, issue_discovery_share: 0 } },
+ { kind: "raw-github", url: "https://example.test" },
+ "2026-07-07T00:00:00.000Z",
+ ),
+ );
+ await upsertRepositoryFromGitHub(env, { name: "metagraphed", full_name: "JSONbored/metagraphed", private: false, owner: { login: "JSONbored" } }, 123);
+ await upsertRepositorySettings(env, {
+ repoFullName: "JSONbored/metagraphed",
+ commentMode: "off",
+ publicSurface: "off",
+ autoLabelEnabled: false,
+ checkRunMode: "off",
+ gateCheckMode: "enabled",
+ linkedIssueGateMode: "off",
+ linkedIssueSatisfactionGateMode: "advisory",
+ });
+
+ let gatePatchBody: { conclusion?: string } = {};
+ await stubGittensorMinerFetch({ number: 3911, headSha: "advisorymode3911" }, { "/issues/1275": () => Response.json({ number: 1275, state: "open", title: "Add SSE stream", body: "We need a live SSE stream." }) });
+ const baseFetch = globalThis.fetch;
+ vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => {
+ const url = input.toString();
+ const method = init?.method ?? "GET";
+ if (url.includes("/check-runs/950") && method === "PATCH") {
+ gatePatchBody = JSON.parse(String(init?.body ?? "{}")) as typeof gatePatchBody;
+ return Response.json({ id: 950 });
+ }
+ if (url.includes("/check-runs") && method === "POST") return Response.json({ id: 950 }, { status: 201 });
+ return baseFetch(input, init);
+ });
+
+ await processJob(env, {
+ type: "github-webhook",
+ deliveryId: "linked-issue-satisfaction-advisory-mode",
+ eventName: "pull_request",
+ payload: {
+ action: "opened",
+ installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } },
+ repository: { name: "metagraphed", full_name: "JSONbored/metagraphed", private: false, owner: { login: "JSONbored" } },
+ pull_request: { number: 3911, title: "Add REST endpoint", state: "open", user: { login: "confirmed-dev" }, head: { sha: "advisorymode3911" }, labels: [], body: "Closes #1275" },
+ },
+ });
+
+ expect(gatePatchBody.conclusion).not.toBe("failure");
+ });
+});
diff --git a/test/unit/maintainer-activation.test.ts b/test/unit/maintainer-activation.test.ts
index 997001b235..072b5e8bf0 100644
--- a/test/unit/maintainer-activation.test.ts
+++ b/test/unit/maintainer-activation.test.ts
@@ -38,6 +38,7 @@ function settings(overrides: Partial = {}): RepositorySettin
mergeReadinessGateMode: "off",
manifestPolicyGateMode: "off",
selfAuthoredLinkedIssueGateMode: "advisory",
+ linkedIssueSatisfactionGateMode: "off",
firstTimeContributorGrace: false,
slopAiAdvisory: false,
qualityGateMinScore: null,
diff --git a/test/unit/policy-sanitizer.test.ts b/test/unit/policy-sanitizer.test.ts
index 72f3678a81..b8371b9aae 100644
--- a/test/unit/policy-sanitizer.test.ts
+++ b/test/unit/policy-sanitizer.test.ts
@@ -72,6 +72,7 @@ function settingsFor(repoFullName: string, overrides: Partial = {}): RepositorySettin
mergeReadinessGateMode: "off",
manifestPolicyGateMode: "off",
selfAuthoredLinkedIssueGateMode: "advisory",
+ linkedIssueSatisfactionGateMode: "off",
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 b5508eea16..153f3c8385 100644
--- a/test/unit/repository-settings-enforcement.test.ts
+++ b/test/unit/repository-settings-enforcement.test.ts
@@ -26,6 +26,7 @@ function settings(over: Partial = {}): RepositorySettings {
mergeReadinessGateMode: "off",
manifestPolicyGateMode: "off",
selfAuthoredLinkedIssueGateMode: "advisory",
+ linkedIssueSatisfactionGateMode: "off",
firstTimeContributorGrace: false,
slopAiAdvisory: false,
qualityGateMinScore: null,
diff --git a/test/unit/repository-settings-linked-issue-satisfaction.test.ts b/test/unit/repository-settings-linked-issue-satisfaction.test.ts
new file mode 100644
index 0000000000..2d47c7b19d
--- /dev/null
+++ b/test/unit/repository-settings-linked-issue-satisfaction.test.ts
@@ -0,0 +1,49 @@
+import { describe, expect, it } from "vitest";
+import { getRepositorySettings, upsertRepositorySettings } from "../../src/db/repositories";
+import { createTestEnv } from "../helpers/d1";
+
+// #1961/#3906: linkedIssueSatisfactionGateMode is the DB-backed, dashboard-settable gate-mode counterpart to
+// aiReviewMode/selfAuthoredLinkedIssueGateMode -- off (default, byte-identical) | advisory (renders, never
+// blocks) | block (an above-confidence-floor "unaddressed" verdict becomes a hard blocker).
+describe("repository_settings: linkedIssueSatisfactionGateMode default + round-trip (#1961/#3906)", () => {
+ it("getRepositorySettings returns off for a repo with no DB row at all (conservative, opt-in default)", async () => {
+ const env = createTestEnv();
+ const settings = await getRepositorySettings(env, "acme/brand-new-repo");
+ expect(settings.linkedIssueSatisfactionGateMode).toBe("off");
+ });
+
+ it("upsertRepositorySettings persists off when the caller omits the field entirely", async () => {
+ const env = createTestEnv();
+ await upsertRepositorySettings(env, { repoFullName: "acme/omits-field" });
+ const settings = await getRepositorySettings(env, "acme/omits-field");
+ expect(settings.linkedIssueSatisfactionGateMode).toBe("off");
+ });
+
+ it("an explicit advisory/block opt-in round-trips through a re-upsert that carries it forward explicitly", async () => {
+ const env = createTestEnv();
+ await upsertRepositorySettings(env, { repoFullName: "acme/round-trip", linkedIssueSatisfactionGateMode: "advisory" });
+ const settings = await getRepositorySettings(env, "acme/round-trip");
+ expect(settings.linkedIssueSatisfactionGateMode).toBe("advisory");
+ // A true read-modify-write caller (the route-handler pattern: spread current settings, then override) must
+ // carry the persisted value forward explicitly -- upsertRepositorySettings never merges against the DB row.
+ await upsertRepositorySettings(env, { ...settings, repoFullName: "acme/round-trip" });
+ const after = await getRepositorySettings(env, "acme/round-trip");
+ expect(after.linkedIssueSatisfactionGateMode).toBe("advisory");
+ });
+
+ it("block round-trips distinctly from advisory, including through an UPDATE (onConflictDoUpdate) of an existing row", async () => {
+ const env = createTestEnv();
+ await upsertRepositorySettings(env, { repoFullName: "acme/block-mode", linkedIssueSatisfactionGateMode: "advisory" });
+ await upsertRepositorySettings(env, { repoFullName: "acme/block-mode", linkedIssueSatisfactionGateMode: "block" });
+ const settings = await getRepositorySettings(env, "acme/block-mode");
+ expect(settings.linkedIssueSatisfactionGateMode).toBe("block");
+ });
+
+ it("an invalid persisted DB value fails closed to advisory on read (parseGateRuleMode's shared fallback)", async () => {
+ const env = createTestEnv();
+ await upsertRepositorySettings(env, { repoFullName: "acme/malformed" });
+ await env.DB.prepare("UPDATE repository_settings SET linked_issue_satisfaction_gate_mode = ? WHERE repo_full_name = ?").bind("sometimes", "acme/malformed").run();
+ const settings = await getRepositorySettings(env, "acme/malformed");
+ expect(settings.linkedIssueSatisfactionGateMode).toBe("advisory");
+ });
+});
diff --git a/test/unit/schema-timestamp-defaults.test.ts b/test/unit/schema-timestamp-defaults.test.ts
index efa3a11e9a..9b80af4906 100644
--- a/test/unit/schema-timestamp-defaults.test.ts
+++ b/test/unit/schema-timestamp-defaults.test.ts
@@ -1,7 +1,7 @@
import { eq } from "drizzle-orm";
import { describe, expect, it } from "vitest";
import { getDb } from "../../src/db/client";
-import { aiReviewCache, aiSlopCache, orbRelayPending, repositorySettings, webhookEvents } from "../../src/db/schema";
+import { aiReviewCache, aiSlopCache, linkedIssueSatisfactionCache, orbRelayPending, repositorySettings, webhookEvents } from "../../src/db/schema";
import { createTestEnv } from "../helpers/d1";
const ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/;
@@ -92,4 +92,20 @@ describe("timestamp column defaults", () => {
expect(row?.createdAt).toMatch(ISO);
expect(row?.createdAt).not.toBe("CURRENT_TIMESTAMP");
});
+
+ it("applies the linked-issue satisfaction cache createdAt default on omit (#1961/#3906)", async () => {
+ const env = createTestEnv();
+ const db = getDb(env.DB);
+ await db.insert(linkedIssueSatisfactionCache).values({
+ repoFullName: "acme/widgets",
+ pullNumber: 3,
+ headSha: "sha",
+ linkedIssueNumber: 1275,
+ inputFingerprint: "fp-v1",
+ status: "ok",
+ });
+ const [row] = await db.select().from(linkedIssueSatisfactionCache).where(eq(linkedIssueSatisfactionCache.repoFullName, "acme/widgets")).limit(1);
+ expect(row?.createdAt).toMatch(ISO);
+ expect(row?.createdAt).not.toBe("CURRENT_TIMESTAMP");
+ });
});
diff --git a/test/unit/self-dogfood-registration-pack.test.ts b/test/unit/self-dogfood-registration-pack.test.ts
index f666afc1bb..043ad8e8d4 100644
--- a/test/unit/self-dogfood-registration-pack.test.ts
+++ b/test/unit/self-dogfood-registration-pack.test.ts
@@ -65,6 +65,7 @@ function settingsFor(repoFullName: string, overrides: Partial = {}): RepositorySettin
mergeReadinessGateMode: "off",
manifestPolicyGateMode: "off",
selfAuthoredLinkedIssueGateMode: "advisory",
+ linkedIssueSatisfactionGateMode: "off",
firstTimeContributorGrace: false,
slopAiAdvisory: false,
qualityGateMinScore: null,
diff --git a/test/unit/signals-coverage.test.ts b/test/unit/signals-coverage.test.ts
index 2fead07fcc..724ff803e1 100644
--- a/test/unit/signals-coverage.test.ts
+++ b/test/unit/signals-coverage.test.ts
@@ -2217,6 +2217,7 @@ function repoSettings(repoFullName: string): RepositorySettings {
mergeReadinessGateMode: "off",
manifestPolicyGateMode: "off",
selfAuthoredLinkedIssueGateMode: "advisory",
+ linkedIssueSatisfactionGateMode: "off",
firstTimeContributorGrace: false,
slopAiAdvisory: false,
qualityGateMinScore: null,
diff --git a/test/unit/signals-v2.test.ts b/test/unit/signals-v2.test.ts
index 8231874e74..d88a90f5c7 100644
--- a/test/unit/signals-v2.test.ts
+++ b/test/unit/signals-v2.test.ts
@@ -2112,6 +2112,7 @@ describe("v2 signal builders", () => {
mergeReadinessGateMode: "off",
manifestPolicyGateMode: "off",
selfAuthoredLinkedIssueGateMode: "advisory",
+ linkedIssueSatisfactionGateMode: "off",
firstTimeContributorGrace: false,
slopAiAdvisory: false,
qualityGateMinScore: null,
diff --git a/test/unit/signals.test.ts b/test/unit/signals.test.ts
index da94eb2702..ae3ab70be3 100644
--- a/test/unit/signals.test.ts
+++ b/test/unit/signals.test.ts
@@ -513,6 +513,7 @@ describe("world-class backend signals", () => {
mergeReadinessGateMode: "off" as const,
manifestPolicyGateMode: "off" as const,
selfAuthoredLinkedIssueGateMode: "advisory" as const,
+ linkedIssueSatisfactionGateMode: "off" as const,
firstTimeContributorGrace: false,
slopAiAdvisory: false,
qualityGateMinScore: null,
@@ -567,6 +568,7 @@ describe("world-class backend signals", () => {
mergeReadinessGateMode: "off" as const,
manifestPolicyGateMode: "off" as const,
selfAuthoredLinkedIssueGateMode: "advisory" as const,
+ linkedIssueSatisfactionGateMode: "off" as const,
firstTimeContributorGrace: false,
slopAiAdvisory: false,
qualityGateMinScore: null,
@@ -641,6 +643,7 @@ describe("world-class backend signals", () => {
mergeReadinessGateMode: "off",
manifestPolicyGateMode: "off",
selfAuthoredLinkedIssueGateMode: "advisory",
+ linkedIssueSatisfactionGateMode: "off",
firstTimeContributorGrace: false,
slopAiAdvisory: false,
qualityGateMinScore: null,
@@ -769,6 +772,7 @@ describe("world-class backend signals", () => {
mergeReadinessGateMode: "off",
manifestPolicyGateMode: "off",
selfAuthoredLinkedIssueGateMode: "advisory",
+ linkedIssueSatisfactionGateMode: "off",
firstTimeContributorGrace: false,
slopAiAdvisory: false,
qualityGateMinScore: null,
@@ -839,6 +843,7 @@ describe("world-class backend signals", () => {
mergeReadinessGateMode: "off",
manifestPolicyGateMode: "off",
selfAuthoredLinkedIssueGateMode: "advisory",
+ linkedIssueSatisfactionGateMode: "off",
firstTimeContributorGrace: false,
slopAiAdvisory: false,
qualityGateMinScore: null,
@@ -953,6 +958,7 @@ describe("world-class backend signals", () => {
mergeReadinessGateMode: "off",
manifestPolicyGateMode: "off",
selfAuthoredLinkedIssueGateMode: "advisory",
+ linkedIssueSatisfactionGateMode: "off",
firstTimeContributorGrace: false,
slopAiAdvisory: false,
qualityGateMinScore: null,
diff --git a/test/unit/unified-comment-bridge.test.ts b/test/unit/unified-comment-bridge.test.ts
index eeac0f2682..46256aa3de 100644
--- a/test/unit/unified-comment-bridge.test.ts
+++ b/test/unit/unified-comment-bridge.test.ts
@@ -297,6 +297,30 @@ describe("buildUnifiedCommentBody", () => {
expect(withoutEffort).not.toContain("review effort:");
});
+ it("forwards the linked-issue satisfaction result into the rendered collapsible section, and omits it otherwise (#1961/#3906)", () => {
+ const withResult = buildUnifiedCommentBody({
+ gate: gate(),
+ aiReview: { notes: "Clean change." },
+ panelRows,
+ readinessTotal: 88,
+ changedFiles: 3,
+ footerMarkdown: footer,
+ linkedIssueSatisfaction: { status: "unaddressed", rationale: "The linked issue asks for an SSE stream; this PR adds an unrelated REST endpoint." },
+ });
+ expect(withResult).toContain("Linked issue satisfaction");
+ expect(withResult).toContain("Not yet addressed");
+ expect(withResult).toContain("The linked issue asks for an SSE stream");
+ const without = buildUnifiedCommentBody({
+ gate: gate(),
+ aiReview: { notes: "Clean change." },
+ panelRows,
+ readinessTotal: 88,
+ changedFiles: 3,
+ footerMarkdown: footer,
+ });
+ expect(without).not.toContain("Linked issue satisfaction");
+ });
+
it("forwards maxFindings caps into the rendered blocker/nit sections (#2049)", () => {
const body = buildUnifiedCommentBody({
gate: gate({
diff --git a/test/unit/unified-comment-parity.test.ts b/test/unit/unified-comment-parity.test.ts
index b9040f7a86..48c936aae6 100644
--- a/test/unit/unified-comment-parity.test.ts
+++ b/test/unit/unified-comment-parity.test.ts
@@ -62,6 +62,7 @@ const settings: RepositorySettings = {
mergeReadinessGateMode: "off",
manifestPolicyGateMode: "off",
selfAuthoredLinkedIssueGateMode: "advisory",
+ linkedIssueSatisfactionGateMode: "off",
firstTimeContributorGrace: false,
slopAiAdvisory: false,
qualityGateMinScore: null,