Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions src/db/repositories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4794,6 +4794,34 @@ export async function hasPublishedLinkedIssueSatisfaction(
return Boolean(row);
}

/** #one-shot-review-cadence: latest stored linked-issue satisfaction assessment for this PR + primary issue,
* regardless of head SHA or fingerprint. One-shot repeat triggers intentionally freeze the first-pass AI
* result rather than re-spending, but `block` mode still needs the prior unaddressed verdict replayed so the
* configured gate blocker cannot disappear on the next automatic evaluation. */
export async function getLatestPublishedLinkedIssueSatisfaction(
env: Env,
repoFullName: string,
pullNumber: number,
linkedIssueNumber: number,
): Promise<{ status: string; result: LinkedIssueSatisfactionResult | null; estimatedNeurons: number } | null> {
const row = await env.DB
.prepare(
`SELECT status, result_json AS resultJson, estimated_neurons AS estimatedNeurons
FROM linked_issue_satisfaction_cache
WHERE repo_full_name = ? AND pull_number = ? AND linked_issue_number = ?
ORDER BY created_at DESC, head_sha DESC
LIMIT 1`,
)
.bind(repoFullName, pullNumber, linkedIssueNumber)
.first<{ status: string; resultJson: string | null; estimatedNeurons: number }>();
if (!row) return null;
return {
status: row.status,
result: parseJson<LinkedIssueSatisfactionResult | null>(row.resultJson, null),
estimatedNeurons: row.estimatedNeurons,
};
}

/** #4499 (grounding-file-content-cache): the stored file content for (repo, path, head SHA), or null on a
* miss. Unlike linked_issue_satisfaction_cache, every stored row is durable with NO input-fingerprint
* dimension -- file content at an immutable head SHA has exactly one correct value, so a hit is always safe
Expand Down
15 changes: 15 additions & 0 deletions src/queue/processors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import {
putCachedAiSlopAdvisory,
hasPublishedAiSlopAdvisory,
getCachedLinkedIssueSatisfaction,
getLatestPublishedLinkedIssueSatisfaction,
putCachedLinkedIssueSatisfaction,
hasPublishedLinkedIssueSatisfaction,
markPullRequestsRegated,
Expand Down Expand Up @@ -10177,6 +10178,20 @@ async function maybePublishPrPublicSurface(
primaryLinkedIssueNumber !== undefined &&
(await hasPublishedLinkedIssueSatisfaction(env, repoFullName, pr.number, primaryLinkedIssueNumber).catch(() => false));
if (linkedIssueOneShotSkip) {
const priorLinkedIssueSatisfaction = await getLatestPublishedLinkedIssueSatisfaction(env, repoFullName, pr.number, primaryLinkedIssueNumber).catch(() => null);
if (priorLinkedIssueSatisfaction?.status === "ok" && priorLinkedIssueSatisfaction.result) {
linkedIssueSatisfaction = { status: priorLinkedIssueSatisfaction.result.status, rationale: priorLinkedIssueSatisfaction.result.rationale };
if (settings.linkedIssueSatisfactionGateMode === "block" && priorLinkedIssueSatisfaction.result.status === "unaddressed") {
advisory.findings.push({
code: "linked_issue_scope_mismatch",
severity: "warning",
title: "Linked issue does not appear to be satisfied",
detail: priorLinkedIssueSatisfaction.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. ${priorLinkedIssueSatisfaction.result.rationale}`,
});
}
}
await recordAuditEvent(env, {
eventType: "github_app.linked_issue_satisfaction_one_shot_skip",
actor: author,
Expand Down
37 changes: 36 additions & 1 deletion test/unit/linked-issue-satisfaction-cache.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import { getCachedLinkedIssueSatisfaction, hasPublishedLinkedIssueSatisfaction, putCachedLinkedIssueSatisfaction } from "../../src/db/repositories";
import { getCachedLinkedIssueSatisfaction, getLatestPublishedLinkedIssueSatisfaction, hasPublishedLinkedIssueSatisfaction, putCachedLinkedIssueSatisfaction } from "../../src/db/repositories";
import { linkedIssueSatisfactionCacheInputFingerprint } from "../../src/review/linked-issue-satisfaction-cache-input";
import { createTestEnv } from "../helpers/d1";

Expand Down Expand Up @@ -114,6 +114,41 @@ describe("hasPublishedLinkedIssueSatisfaction (#one-shot-review-cadence)", () =>
});
});

describe("getLatestPublishedLinkedIssueSatisfaction (#one-shot-review-cadence)", () => {
it("returns null when no row exists for the PR + linked issue number", async () => {
const env = createTestEnv();
expect(await getLatestPublishedLinkedIssueSatisfaction(env, "o/r", 30, 1)).toBeNull();
});

it("returns the latest row for the same PR + linked issue number regardless of head SHA or fingerprint", async () => {
const env = createTestEnv();
vi.useFakeTimers();
try {
vi.setSystemTime(new Date("2026-07-07T09:00:00.000Z"));
await putCachedLinkedIssueSatisfaction(env, "o/r", 31, "sha1", 5, "old-fp", {
status: "ok",
result: { status: "addressed", rationale: "old pass", confidence: 0.9 },
estimatedNeurons: 4,
});
vi.setSystemTime(new Date("2026-07-07T09:01:00.000Z"));
await putCachedLinkedIssueSatisfaction(env, "o/r", 31, "sha2", 5, "new-fp", {
status: "ok",
result: { status: "unaddressed", rationale: "latest blocker", confidence: 0.9 },
estimatedNeurons: 8,
});
} finally {
vi.useRealTimers();
}

expect(await getLatestPublishedLinkedIssueSatisfaction(env, "o/r", 31, 5)).toEqual({
status: "ok",
result: { status: "unaddressed", rationale: "latest blocker", confidence: 0.9 },
estimatedNeurons: 8,
});
expect(await getLatestPublishedLinkedIssueSatisfaction(env, "o/r", 31, 6)).toBeNull();
});
});

describe("linkedIssueSatisfactionCacheInputFingerprint", () => {
it("is stable for the same input", async () => {
const a = await linkedIssueSatisfactionCacheInputFingerprint({ byok: false, provider: null, model: null });
Expand Down
Loading