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,