Skip to content

Commit 307a525

Browse files
authored
Merge branch 'main' into claude/reopen-reclose-audit-fidelity
2 parents 9794a5a + 88513ec commit 307a525

5 files changed

Lines changed: 136 additions & 26 deletions

File tree

src/github/backfill.ts

Lines changed: 47 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2885,40 +2885,68 @@ export function isOwnReviewThreadAuthor(login: string | null | undefined): boole
28852885
/** The deterministic linked-issue facts the hard-rule evaluator needs (labels / assignees / open-state). */
28862886
export type LinkedIssueFactsResult = { number: number; labels: string[]; assignees: string[]; state: string; authorLogin: string | null };
28872887

2888+
/** Tri-state outcome of fetching one linked issue's facts (#2136). `not_found` is a CONFIRMED 404 seen with a
2889+
* genuine, repo-scoped token — GitHub told an authenticated caller this issue number does not exist. `fetch_error`
2890+
* is everything else that prevented a read (network, 5xx, rate-limit, malformed body, or a 404 seen with only
2891+
* the public/anonymous token, which GitHub also returns for a real-but-inaccessible private issue) — a genuine
2892+
* outage or an unproven access gap, not confirmed evidence about the issue itself. Callers that treat an
2893+
* ALL-not_found result as significant (the linked-issue hard rule) must never extend that same treatment to
2894+
* fetch_error, or a GitHub outage would spuriously look like a fabricated reference. */
2895+
export type LinkedIssueFactsFetch =
2896+
| { status: "found"; facts: LinkedIssueFactsResult }
2897+
| { status: "not_found" }
2898+
| { status: "fetch_error" };
2899+
28882900
/**
2889-
* FETCH the facts for one linked issue via the REST issues endpoint. FAIL-OPEN: any fetch/parse error returns
2890-
* undefined so the caller skips that issue — a deterministic auto-close must NEVER fire (or be blocked) on a
2891-
* transient fetch failure. Uses the same authenticated REST client + public-token 404-fallback as the other
2892-
* live fetches. (Note: GitHub's issues endpoint also returns pull requests, which carry a `pull_request` field;
2893-
* a PR number passed here would simply fail the rules — we only treat real issues' labels/assignees.)
2901+
* FETCH the facts for one linked issue via the REST issues endpoint. Distinguishes a CONFIRMED-nonexistent
2902+
* issue (404) from a transient fetch failure (#2136) — a deterministic auto-close must never fire on a
2903+
* transient failure, but a fabricated issue number is real, verifiable information the hard-rule evaluator
2904+
* needs. Uses the same authenticated REST client + public-token 404-fallback as the other live fetches. (Note:
2905+
* GitHub's issues endpoint also returns pull requests, which carry a `pull_request` field; a PR number passed
2906+
* here would simply fail the rules — we only treat real issues' labels/assignees.)
2907+
*
2908+
* GitHub returns 404 for BOTH a genuinely nonexistent issue and a real-but-inaccessible one (private repo, no
2909+
* grant) — it deliberately doesn't distinguish the two, to avoid leaking a private repo's existence to a caller
2910+
* without access. So a 404 is only trustworthy as CONFIRMED absence when `token` is a genuine, repo-scoped
2911+
* credential; the public/anonymous fallback token proves nothing about access. Without that, treat the 404 as
2912+
* `fetch_error` (fails open) rather than risk closing a PR over a real linked issue our token just can't see.
28942913
*/
28952914
export async function fetchLinkedIssueFacts(
28962915
env: Env,
28972916
repoFullName: string,
28982917
issueNumber: number,
28992918
token: string | undefined,
29002919
admissionKey?: GitHubRateLimitAdmissionKey,
2901-
): Promise<LinkedIssueFactsResult | undefined> {
2902-
const result = await githubJsonWithHeaders<{
2903-
number?: number;
2904-
state?: string | null;
2905-
labels?: Array<{ name?: string | null } | string | null> | null;
2906-
assignees?: Array<{ login?: string | null } | null> | null;
2907-
user?: { login?: string | null } | null;
2908-
}>(env, repoFullName, `/issues/${issueNumber}`, token, githubRateLimitOptions(admissionKey)).catch(() => undefined);
2909-
if (!result) return undefined;
2920+
): Promise<LinkedIssueFactsFetch> {
2921+
let result;
2922+
try {
2923+
result = await githubJsonWithHeaders<{
2924+
number?: number;
2925+
state?: string | null;
2926+
labels?: Array<{ name?: string | null } | string | null> | null;
2927+
assignees?: Array<{ login?: string | null } | null> | null;
2928+
user?: { login?: string | null } | null;
2929+
}>(env, repoFullName, `/issues/${issueNumber}`, token, githubRateLimitOptions(admissionKey));
2930+
} catch (error) {
2931+
if (!(error instanceof GitHubApiError) || error.statusCode !== 404) return { status: "fetch_error" };
2932+
const hasProvenAccess = Boolean(token) && token !== env.GITHUB_PUBLIC_TOKEN;
2933+
return { status: hasProvenAccess ? "not_found" : "fetch_error" };
2934+
}
29102935
const data = result.data;
29112936
const labels = (data.labels ?? []).flatMap((label) => {
29122937
if (typeof label === "string") return label.length > 0 ? [label] : [];
29132938
return label?.name ? [label.name] : [];
29142939
});
29152940
const assignees = (data.assignees ?? []).flatMap((assignee) => (assignee?.login ? [assignee.login] : []));
29162941
return {
2917-
number: data.number ?? issueNumber,
2918-
labels,
2919-
assignees,
2920-
state: String(data.state ?? "open").toLowerCase(),
2921-
authorLogin: data.user?.login ?? null,
2942+
status: "found",
2943+
facts: {
2944+
number: data.number ?? issueNumber,
2945+
labels,
2946+
assignees,
2947+
state: String(data.state ?? "open").toLowerCase(),
2948+
authorLogin: data.user?.login ?? null,
2949+
},
29222950
};
29232951
}
29242952

src/queue/processors.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4334,8 +4334,7 @@ export async function resolveLinkedIssueAuthorLogins(
43344334
login != null
43354335
? Promise.resolve(login)
43364336
: fetchLinkedIssueFacts(env, repoFullName, linkedIssues[index]!, token, admissionKey)
4337-
.then((facts) => facts?.authorLogin ?? null)
4338-
.catch(() => null),
4337+
.then((result) => (result.status === "found" ? result.facts.authorLogin : null)),
43394338
),
43404339
);
43414340
}

src/review/linked-issue-hard-rules.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,21 @@ export async function resolveLinkedIssueHardRule(args: {
173173
if (args.linkedIssues.length === 0) return undefined;
174174
const token = args.ciToken ?? args.env.GITHUB_PUBLIC_TOKEN;
175175
const admissionKey = githubRateLimitAdmissionKeyForToken(args.env, token, args.installationId);
176-
const issueFacts = (await Promise.all(args.linkedIssues.map((issueNumber) => fetchLinkedIssueFacts(args.env, args.repoFullName, issueNumber, token, admissionKey)))).flatMap((facts) => (facts ? [facts] : []));
177-
if (issueFacts.length === 0) return undefined;
176+
const fetchResults = await Promise.all(args.linkedIssues.map((issueNumber) => fetchLinkedIssueFacts(args.env, args.repoFullName, issueNumber, token, admissionKey)));
177+
const issueFacts = fetchResults.flatMap((result) => (result.status === "found" ? [result.facts] : []));
178+
if (issueFacts.length === 0) {
179+
// Every reference resolved to a CONFIRMED 404 — never a transient fetch_error (#2136). Mirrors the overflow
180+
// treatment above: a contributor citing a fabricated issue number must not silently satisfy the hard rule
181+
// the same way a genuinely-linked-but-unfetchable issue fails open. A single fetch_error in the mix still
182+
// fails open (we cannot rule out a real, rule-violating issue behind that failure).
183+
const allConfirmedNotFound = fetchResults.every((result) => result.status === "not_found");
184+
if (allConfirmedNotFound) {
185+
return {
186+
violated: true,
187+
reason: "The linked issue reference could not be found — please link a real, open issue or request maintainer review.",
188+
};
189+
}
190+
return undefined;
191+
}
178192
return evaluateLinkedIssueHardRules({ issues: issueFacts, config: args.config, repoOwner: args.repoOwner });
179193
}

test/unit/backfill.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import {
3434
enqueueRepositoryOpenDataBackfill,
3535
enrichInstallationHealth,
3636
fetchAndStorePullRequestFilesForReview,
37+
fetchLinkedIssueFacts,
3738
fetchLiveCiAggregate,
3839
fetchLiveReviewThreadBlockers,
3940
fetchRequiredStatusContexts,
@@ -5007,6 +5008,47 @@ describe("GitHub backfill", () => {
50075008
});
50085009
});
50095010

5011+
describe("fetchLinkedIssueFacts (#2136)", () => {
5012+
it("returns a found result with the extracted facts, falling back to the requested number and open state when the payload omits them", async () => {
5013+
const env = createTestEnv({});
5014+
// Sparse payload: no `number`, no `state` — exercises the `data.number ?? issueNumber` and
5015+
// `data.state ?? "open"` defensive fallbacks.
5016+
vi.stubGlobal("fetch", async () => Response.json({ labels: [{ name: "bug" }, "manual-string-label"], assignees: [{ login: "maintainer" }], user: { login: "reporter" } }));
5017+
const result = await fetchLinkedIssueFacts(env, "JSONbored/gittensory", 42, "tok");
5018+
expect(result).toEqual({
5019+
status: "found",
5020+
facts: { number: 42, labels: ["bug", "manual-string-label"], assignees: ["maintainer"], state: "open", authorLogin: "reporter" },
5021+
});
5022+
});
5023+
5024+
it("returns not_found on a confirmed 404, distinct from a transient fetch error", async () => {
5025+
const env = createTestEnv({});
5026+
vi.stubGlobal("fetch", async () => new Response("missing", { status: 404 }));
5027+
expect(await fetchLinkedIssueFacts(env, "JSONbored/gittensory", 999999, "tok")).toEqual({ status: "not_found" });
5028+
});
5029+
5030+
it("REGRESSION: treats a 404 seen with the public/anonymous token as fetch_error, not not_found — GitHub also returns 404 for a real but inaccessible private issue", async () => {
5031+
const env = createTestEnv({ GITHUB_PUBLIC_TOKEN: "public-tok" });
5032+
vi.stubGlobal("fetch", async () => new Response("missing", { status: 404 }));
5033+
// The public token proves nothing about repo access, so a 404 here could just as easily mean "this issue
5034+
// is real but private and this token can't see it" -- treating it as CONFIRMED absence risks closing a PR
5035+
// over a genuinely-linked issue.
5036+
expect(await fetchLinkedIssueFacts(env, "JSONbored/gittensory", 42, env.GITHUB_PUBLIC_TOKEN)).toEqual({ status: "fetch_error" });
5037+
});
5038+
5039+
it("REGRESSION: treats a 404 seen with no token at all as fetch_error, not not_found", async () => {
5040+
const env = createTestEnv({});
5041+
vi.stubGlobal("fetch", async () => new Response("missing", { status: 404 }));
5042+
expect(await fetchLinkedIssueFacts(env, "JSONbored/gittensory", 42, undefined)).toEqual({ status: "fetch_error" });
5043+
});
5044+
5045+
it("returns fetch_error on a transient failure (5xx), never conflating it with not_found", async () => {
5046+
const env = createTestEnv({});
5047+
vi.stubGlobal("fetch", async () => new Response("server error", { status: 500 }));
5048+
expect(await fetchLinkedIssueFacts(env, "JSONbored/gittensory", 42, "tok")).toEqual({ status: "fetch_error" });
5049+
});
5050+
});
5051+
50105052
describe("isRateLimitedGitHubFailure", () => {
50115053
it("does not treat a bare permission 403 (remaining > 0, no Retry-After, no secondary body) as a rate limit", () => {
50125054
expect(

test/unit/linked-issue-hard-rules.test.ts

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -288,9 +288,36 @@ describe("resolveLinkedIssueHardRule (#1144 — overflow + orchestration)", () =
288288
expect(await resolveLinkedIssueHardRule(args({ config: config({ ownerAssignedClose: "block" }), body: null, linkedIssues: [] }))).toBeUndefined();
289289
});
290290

291-
it("is fail-open: undefined when every fetch fails (404), with no CI token → public-token fallback", async () => {
291+
it("treats a confirmed-nonexistent linked issue as a violation, not a silent pass (#2136)", async () => {
292+
// Every reference 404s with a GENUINE installation token (proven repo access) — CONFIRMED not-found, not a
293+
// transient error — a contributor citing a fabricated issue number must not silently satisfy the hard rule
294+
// the same way a genuine fetch outage fails open.
292295
vi.stubGlobal("fetch", async () => new Response("missing", { status: 404 }));
293-
expect(await resolveLinkedIssueHardRule(args({ config: config({ ownerAssignedClose: "block" }), ciToken: undefined, linkedIssues: [1, 2] }))).toBeUndefined();
296+
const r = await resolveLinkedIssueHardRule(args({ config: config({ ownerAssignedClose: "block" }), ciToken: "installation-token", linkedIssues: [1, 2] }));
297+
expect(r?.violated).toBe(true);
298+
expect(r?.reason).toMatch(/could not be found/i);
299+
});
300+
301+
it("REGRESSION: does NOT violate when every reference 404s but ciToken is unavailable (falls back to the public token) — a 404 without proven repo access is not confirmed absence", async () => {
302+
// GitHub also returns 404 for a real-but-inaccessible private issue, not just a genuinely nonexistent one.
303+
// Without a genuine ciToken, this call falls back to env.GITHUB_PUBLIC_TOKEN, which proves nothing about
304+
// repo access — closing the PR here would risk punishing a contributor for a real linked issue our token
305+
// just can't see.
306+
vi.stubGlobal("fetch", async () => new Response("missing", { status: 404 }));
307+
const r = await resolveLinkedIssueHardRule(args({ config: config({ ownerAssignedClose: "block" }), ciToken: undefined, linkedIssues: [1, 2] }));
308+
expect(r).toBeUndefined();
309+
});
310+
311+
it("still fails open (undefined) when a linked-issue fetch fails transiently (5xx), not confirmed-nonexistent", async () => {
312+
vi.stubGlobal("fetch", async () => new Response("server error", { status: 500 }));
313+
expect(await resolveLinkedIssueHardRule(args({ config: config({ ownerAssignedClose: "block" }), ciToken: "tok", linkedIssues: [1, 2] }))).toBeUndefined();
314+
});
315+
316+
it("fails open when the linked issues are a MIX of confirmed-not-found and a transient fetch error", async () => {
317+
// Cannot rule out a real, rule-violating issue behind the transient failure — must not treat this the same
318+
// as an all-confirmed-not-found set.
319+
vi.stubGlobal("fetch", async (input: RequestInfo | URL) => (input.toString().endsWith("/issues/1") ? new Response("missing", { status: 404 }) : new Response("server error", { status: 500 })));
320+
expect(await resolveLinkedIssueHardRule(args({ config: config({ ownerAssignedClose: "block" }), ciToken: "tok", linkedIssues: [1, 2] }))).toBeUndefined();
294321
});
295322

296323
it("fetches with the CI token and runs the deterministic evaluator over the facts", async () => {
@@ -305,7 +332,7 @@ describe("resolveLinkedIssueHardRule (#1144 — overflow + orchestration)", () =
305332
});
306333

307334
it("derives the installation admission key from the ci token + installation id so installation reads attribute to the installation bucket, not 'unknown' (#1951 blocker)", async () => {
308-
const spy = vi.spyOn(backfillModule, "fetchLinkedIssueFacts").mockResolvedValue(undefined);
335+
const spy = vi.spyOn(backfillModule, "fetchLinkedIssueFacts").mockResolvedValue({ status: "fetch_error" });
309336
await resolveLinkedIssueHardRule(
310337
args({ config: config({ ownerAssignedClose: "block" }), ciToken: "installation-token", installationId: 143010787, linkedIssues: [7] }),
311338
);

0 commit comments

Comments
 (0)