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
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
-- Preserve empty code-search queries so freshness checks can verify whether

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Info: This migration shares the timestamp prefix 20260928000000 with the existing add_grooming_application migration; Prisma applies them in folder-name order, which works here but is fragile for baselining and squashing.

Automated finding from AI PR review.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still open after this push; carried forward. (as of af2f3e4)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not verifiable from this push's delta; carried forward as open. (as of e244bcb)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Info: The delta does not include the migration, but the PR head state shows it at 20261003000000_add_issue_groomed_search_queries, distinct from 20260928000000_add_grooming_application, so the timestamp-prefix collision appears already resolved.

Automated finding from AI PR review.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still open after this push; carried forward. (as of e244bcb)

-- repo-wide negative evidence still holds after a default-branch commit.
ALTER TABLE "Issue"
ADD COLUMN IF NOT EXISTS "groomedSearchCodeQueries" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[];

COMMENT ON COLUMN "Issue"."groomedSearchCodeQueries" IS 'Successful empty search_code queries backing global grooming evidence';
1 change: 1 addition & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ model Issue {
groomedEvidenceCapturedAt DateTime?
groomedEvidenceScope String?
groomedEvidencePaths String[] @default([])
groomedSearchCodeQueries String[] @default([])
groomedDependencyKeys String[] @default([])
groomedOpenBlockerKeys String[] @default([])
groomedRelatedWork Json?
Expand Down
21 changes: 21 additions & 0 deletions src/lib/github-ci.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,27 @@ export async function fetchLatestCommit(repoFullName: string, branch: string): P
return response.json();
}

/**
* Committer timestamp (ISO string) for an existing commit sha, or null when
* the ref does not resolve. Used by the grooming freshness pass to judge
* whether the code-search index has had time to catch up with a new head.
* GitHub always returns a committer date for a commit; the author date is
* deliberately not used as a fallback (it can make a rebased head look

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor (security): Previous P7 remains open: fetchCommitDate still interpolates repoFullName into the GitHub API URL without encoding (only ref is encodeURIComponent'd), matching the existing fetchLatestCommit pattern but relying on upstream owner/repo validation.

Automated finding from AI PR review.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still open after this push; carried forward. (as of e244bcb)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor (security): Still open: fetchCommitDate interpolates repoFullName into the API URL path without encoding (only the ref is encodeURIComponent'd); it matches the existing fetchLatestCommit pattern and repoFullName comes from tracked-repo config, so risk is low, but upstream validation or encoding is advisable.

Automated finding from AI PR review.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still open after this push; carried forward. (as of e244bcb)

* older than it is).
*/

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Info (security): Same underlying issue as P2: repoFullName is interpolated into the GitHub API URL without encoding; consistent with the existing fetchLatestCommit pattern and low exploitability from tracked-repo config, but it should be validated or encoded upstream.

Automated finding from AI PR review.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still open after this push; carried forward. (as of e244bcb)

export async function fetchCommitDate(repoFullName: string, ref: string): Promise<string | null> {
const response = await fetchWithRetry(`${GITHUB_API}/repos/${repoFullName}/commits/${encodeURIComponent(ref)}`, {
headers: await getHeadersAsync(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Info (security): fetchCommitDate interpolates repoFullName into the GitHub API URL without encoding (ref is encoded); this matches the existing fetchLatestCommit pattern, but repoFullName should be validated upstream.

Automated finding from AI PR review.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still open after this push; carried forward. (as of af2f3e4)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still open after this push; carried forward. (as of e244bcb)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Info (security): fetchCommitDate interpolates repoFullName into the API URL unencoded (ref is encoded), consistent with the existing fetchLatestCommit/fetchRepoJson pattern and sourced from tracked-repo config rather than user input.

Automated finding from AI PR review.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still open after this push; carried forward. (as of 3893e3a)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Info (security): fetchCommitDate still interpolates repoFullName unencoded into the API URL, consistent with the existing facade pattern and sourced from tracked-repo config; unchanged by this delta.

Automated finding from AI PR review.

});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: fetchCommitDate has no direct unit test for its 404-to-null and error-throw paths; it is only asserted to exist in the github-facades export list while all freshness tests mock the facade.

Automated finding from AI PR review.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still open after this push; carried forward. (as of 3893e3a)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: fetchCommitDate still has no direct unit test for its 404-to-null and error-throw paths; this delta touches neither github-ci.ts nor its tests.

Automated finding from AI PR review.

if (!response.ok) {
if (response.status === 404) return null;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: Still open: no test asserts fetchCommitDate uses only the committer date, so the committer-only behavior (correct at head) remains unverified by the suite.

Automated finding from AI PR review.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still open after this push; carried forward. (as of e244bcb)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor (bug): The delta does not touch github-ci.ts, but a direct read of head code shows fetchCommitDate returns only the committer date with no author-date fallback, so the behavior concern appears already addressed earlier in this branch; the remaining gap is the missing pinning test (P3).

Automated finding from AI PR review.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still open after this push; carried forward. (as of e244bcb)

const text = await response.text();
throw new Error(`Failed to fetch commit ${ref} for ${repoFullName}: ${response.status} ${text}`);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor (bug): fetchCommitDate falls back to the author date when the committer date is missing; a rebased/cherry-picked commit's author date can predate when it landed, making a young head look older than the 30-minute grace window and allowing a recheck before the code-search index caught up.

Automated finding from AI PR review.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still open after this push; carried forward. (as of af2f3e4)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not verifiable from this push's delta; carried forward as open. (as of e244bcb)

const data = (await response.json()) as { commit?: { committer?: { date?: string } } };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: The removal of the author-date fallback in fetchCommitDate has no test asserting that only the committer date is used, so the P3 behavior change is unverified by the suite.

Automated finding from AI PR review.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still open after this push; carried forward. (as of e244bcb)

return data.commit?.committer?.date ?? null;
}

export function jobIdFromCheckRunUrl(url: string | undefined | null): string | null {
const m = /\/job\/(\d+)/.exec(url ?? "");
return m ? m[1] : null;
Expand Down
17 changes: 16 additions & 1 deletion src/lib/github-code-search.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,14 @@ describe("github-code-search: searchRepositoryCode (the groomer repo-exploration
expect(fetchSpy).toHaveBeenCalledTimes(1);
});

it("fails an incomplete search with no items instead of reporting no matches", async () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor (bug): Only the incomplete-with-zero-items case is tested; incomplete_results=true with non-empty items still returns partial matches without any signal, and that behavior is unpinned.

Automated finding from AI PR review.

fetchSpy.mockResolvedValueOnce(mockResponse({ total_count: 0, incomplete_results: true, items: [] }));

await expect(searchRepositoryCode("org/repo", "prisma", 5)).rejects.toThrow(
/^Code search failed for org\/repo: search timed out with incomplete results and no matches$/,
);
});

// (c) HTTP 429 retried with backoff.
it("retries a 429 rate-limit response with backoff and succeeds on the next attempt", async () => {
fetchSpy
Expand Down Expand Up @@ -306,10 +314,17 @@ describe("github-code-search: compareCommits (grooming freshness, #1064)", () =>
mockResponse({
status: "ahead",
files: [{ filename: "src/new.ts", previous_filename: "src/old.ts" }, { filename: "docs/a.md" }],
commits: [{ commit: { committer: { date: "2026-09-28T00:00:00Z" } } }],
}),
);
const result = await compareCommits("org/repo", "base1", "head1");
expect(result).toEqual({ ok: true, status: "ahead", files: ["src/new.ts", "src/old.ts", "docs/a.md"], truncated: false });
expect(result).toEqual({
ok: true,
status: "ahead",
files: ["src/new.ts", "src/old.ts", "docs/a.md"],
truncated: false,
firstCommitDate: "2026-09-28T00:00:00Z",
});
expect(fetchSpy).toHaveBeenCalledTimes(1);
expect(String(fetchSpy.mock.calls[0][0])).toContain("/repos/org/repo/compare/base1...head1?per_page=1");
});
Expand Down
28 changes: 23 additions & 5 deletions src/lib/github-code-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,11 +57,19 @@ export async function searchRepositoryCode(
// so a search with more matches than fit on one page is not silently
// truncated at the first page. Each page is fetched through fetchWithRetry,
// so transient 429/5xx responses are retried here as well.
const items = await fetchPaginated<{ path?: string; html_url?: string }>(
url,
limit,
(data) => (data as { items?: { path?: string; html_url?: string }[] }).items ?? [],
);
let incomplete = false;
const items = await fetchPaginated<{ path?: string; html_url?: string }>(url, limit, (data) => {
const page = data as { items?: { path?: string; html_url?: string }[]; incomplete_results?: boolean };
if (page.incomplete_results === true) incomplete = true;
return page.items ?? [];
});
// Code search can time out and still answer 200 with incomplete_results
// and no items. That is not "no matches": the groomer treats an empty
// result as proof of absence and the freshness recheck advances on it
// (#1091), so surface it as a failure instead.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Major (bug): The delta fixes the incomplete_results-with-zero-items hazard with a throw and a unit test, but the broader error contract of searchRepositoryCode on HTTP 4xx/5xx after retries is still not verifiable from this delta, so the recheck's failed-vs-empty decision remains only partially substantiated.

Automated finding from AI PR review.

if (incomplete && items.length === 0) {
throw new Error("search timed out with incomplete results and no matches");
}
return items.map((item) => ({
path: item.path ?? "",
url: item.html_url ?? "",
Expand Down Expand Up @@ -161,6 +169,12 @@ export type CommitComparison =
files: string[];
/** True when GitHub's file cap was reached, so `files` may be incomplete. */
truncated: boolean;
/**
* Committer timestamp of the first (oldest) commit in base...head, when
* the response carried one. Lets callers bound how long a recheck may
* stay deferred on an unverified range (#1091).
*/
firstCommitDate?: string | null;
}
| {
ok: false;
Expand Down Expand Up @@ -196,18 +210,22 @@ export async function compareCommits(
const data = (await response.json()) as {
status?: string;
files?: Array<{ filename?: string; previous_filename?: string }>;
commits?: Array<{ commit?: { committer?: { date?: string } } }>;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Info: The defer bound still assumes the compare endpoint returns commits oldest-first so commits[0] with per_page=1 is the oldest unverified commit; multi-commit ordering remains untested.

Automated finding from AI PR review.

};
const rawFiles = Array.isArray(data.files) ? data.files : [];
const files = new Set<string>();
for (const file of rawFiles) {
if (typeof file.filename === "string" && file.filename) files.add(file.filename);
if (typeof file.previous_filename === "string" && file.previous_filename) files.add(file.previous_filename);
}
// per_page=1 still returns the first (oldest) commit of the range.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Major (bug): The defer bound assumes per_page=1 returns the oldest commit of the compare range, but GitHub's compare API returns commits newest-first, so firstCommitDate would be the newest commit and the two-hour defer limit could never trigger while the head stays young, leaving the busy-repo starvation the delta was meant to fix (previous P2) effectively unbounded.

Automated finding from AI PR review.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still open after this push; carried forward. (as of e244bcb)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Major (bug): Still open: the delta does not touch the compare fetch, and GitHub lists commits newest-first (observed on /commits; the compare probes 404'd), so per_page=1 likely yields the newest commit — making firstCommitDate measure head age rather than oldest-unverified-commit age and letting fast-moving repos defer past the two-hour bound indefinitely, while the new tests mock firstCommitDate directly and cannot detect this.

Automated finding from AI PR review.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still open after this push; carried forward. (as of e244bcb)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Info: The defer bound assumes GitHub's compare endpoint returns commits oldest-first so commits[0] with per_page=1 is the oldest unverified commit; tests only exercise a single-commit response, so a multi-commit ordering test would pin this assumption.

Automated finding from AI PR review.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still open after this push; carried forward. (as of 3893e3a)

const firstCommitDate = Array.isArray(data.commits) ? data.commits[0]?.commit?.committer?.date ?? null : null;
return {
ok: true,
status: typeof data.status === "string" ? data.status : "unknown",
files: [...files],
truncated: rawFiles.length >= COMPARE_MAX_FILES,
firstCommitDate,
};
} catch (err) {
return {
Expand Down
1 change: 1 addition & 0 deletions src/lib/github-facades.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ describe("github domain modules expose expected exports", () => {
it("github-ci exports CI/workflows/runs/jobs/releases/packages/commits/logs symbols", () => {
expect(Object.keys(Ci).sort()).toEqual([
"extractLogExcerpt",
"fetchCommitDate",
"fetchFailedJobLogExcerpt",
"fetchLatestCommit",
"fetchPackages",
Expand Down
184 changes: 183 additions & 1 deletion src/lib/groomer/freshness-invalidation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ function row(overrides: Partial<FreshnessIssueRow> = {}): FreshnessIssueRow {
groomedEvidenceCapturedAt: CAPTURED,
groomedEvidenceScope: "paths",
groomedEvidencePaths: ["src/a.ts"],
groomedSearchCodeQueries: [],
groomedDependencyKeys: [],
groomedOpenBlockerKeys: [],
groomedRelatedWork: null,
Expand Down Expand Up @@ -86,9 +87,12 @@ function fakeStore(rows: FreshnessIssueRow[]): FakeStore {
function fakeGitHub() {
return {
fetchHeadSha: vi.fn(async (): Promise<string | null> => "sha-1"),
searchCode: vi.fn(async (): Promise<{ path: string }[]> => []),
compareCommits: vi.fn(
async (): Promise<CommitComparison> => ({ ok: true, status: "ahead", files: [], truncated: false }),
),
// Old enough that the code-search index is assumed caught up (#1091).
fetchCommitDate: vi.fn(async (): Promise<string | null> => new Date(Date.now() - 3_600_000).toISOString()),
fetchRecentComments: vi.fn(async (): Promise<Array<{ author: string; createdAt: string }>> => []),
fetchIssueState: vi.fn(async (): Promise<"open" | "closed" | null> => "open"),
fetchPullRequestState: vi.fn(async (): Promise<"open" | "closed" | "merged" | null> => "open"),
Expand Down Expand Up @@ -263,12 +267,190 @@ describe("runGroomingFreshnessPass", () => {
expect(store.stale.get("issue-1")?.detail).toContain("src/a.ts");
});

it("conservatively stales global (negative) evidence on any commit", async () => {
it("conservatively stales global evidence without saved search queries", async () => {
github.fetchHeadSha.mockResolvedValue("sha-2");
github.compareCommits.mockResolvedValue({ ok: true, status: "ahead", files: ["docs/readme.md"], truncated: false });
const store = fakeStore([{ ...row(), groomedEvidenceScope: "global", groomedEvidencePaths: [] }]);
await pass(store, github);
expect(store.stale.get("issue-1")?.reasons).toEqual(["global_evidence_commit"]);
expect(github.searchCode).not.toHaveBeenCalled();
});

it("advances global evidence when saved empty queries remain empty", async () => {
github.fetchHeadSha.mockResolvedValue("sha-2");
github.compareCommits.mockResolvedValue({ ok: true, status: "ahead", files: ["docs/readme.md"], truncated: false });
const store = fakeStore([{ ...row(), groomedEvidenceScope: "global", groomedEvidencePaths: [], groomedSearchCodeQueries: ["new symbol", "missing call"] }]);
await pass(store, github);
expect(store.stale.size).toBe(0);
expect(store.advanced).toEqual([{ id: "issue-1", data: { groomingVerifiedSha: "sha-2" } }]);
expect(github.searchCode).toHaveBeenCalledTimes(2);
});

it("stales global evidence when a saved query now matches", async () => {
github.fetchHeadSha.mockResolvedValue("sha-2");
github.compareCommits.mockResolvedValue({ ok: true, status: "ahead", files: [], truncated: false });
github.searchCode.mockResolvedValueOnce([]).mockResolvedValueOnce([{ path: "src/new.ts" }]);
const store = fakeStore([{ ...row(), groomedEvidenceScope: "global", groomedEvidencePaths: [], groomedSearchCodeQueries: ["first", "specific missing query"] }]);
await pass(store, github);
expect(store.stale.get("issue-1")?.reasons).toEqual(["global_evidence_commit"]);
expect(store.stale.get("issue-1")?.detail).toContain("specific missing query");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: No test verifies that resolveHeadDate memoizes fetchCommitDate across issues sharing the same comparison head, so the once-per-comparison budget claim (previous P5) is unasserted.

Automated finding from AI PR review.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still open after this push; carried forward. (as of e244bcb)

});

it("defers instead of staling when the search budget runs out mid-recheck", async () => {
github.fetchHeadSha.mockResolvedValue("sha-2");
github.compareCommits.mockResolvedValue({
ok: true,
status: "ahead",
files: [],
truncated: false,
firstCommitDate: new Date(Date.now() - 5 * 60_000).toISOString(),
});
const store = fakeStore([{ ...row(), groomedEvidenceScope: "global", groomedEvidencePaths: [], groomedSearchCodeQueries: ["first", "second"] }]);
await pass(store, github, { ...DEFAULT_FRESHNESS_BUDGET, maxSearchCodeRechecks: 2 });
// One budget unit goes to the commit-date check, one to the first query;
// the second query exhausts the budget, which defers rather than stales.
expect(store.stale.size).toBe(0);
expect(store.advanced).toEqual([]);
expect(github.searchCode).toHaveBeenCalledTimes(1);
});

it("stales a persistently exhausted issue once the oldest unverified commit passes the defer limit", async () => {
github.fetchHeadSha.mockResolvedValue("sha-2");
github.compareCommits.mockResolvedValue({
ok: true,
status: "ahead",
files: [],
truncated: false,
firstCommitDate: new Date(Date.now() - 3 * 3_600_000).toISOString(),
});
const store = fakeStore([{ ...row(), groomedEvidenceScope: "global", groomedEvidencePaths: [], groomedSearchCodeQueries: ["first", "second"] }]);
await pass(store, github, { ...DEFAULT_FRESHNESS_BUDGET, maxSearchCodeRechecks: 2 });
expect(store.stale.get("issue-1")?.reasons).toEqual(["global_evidence_commit"]);
expect(store.advanced).toEqual([]);
});

it("defers within the bound when the budget is exhausted before the date fetch", async () => {
github.fetchHeadSha.mockResolvedValue("sha-2");
github.compareCommits.mockResolvedValue({
ok: true,
status: "ahead",
files: [],
truncated: false,
firstCommitDate: new Date(Date.now() - 5 * 60_000).toISOString(),
});
const store = fakeStore([{ ...row(), groomedEvidenceScope: "global", groomedEvidencePaths: [], groomedSearchCodeQueries: ["query"] }]);
const result = await pass(store, github, { ...DEFAULT_FRESHNESS_BUDGET, maxSearchCodeRechecks: 0 });
expect(store.stale.size).toBe(0);
expect(store.advanced).toEqual([]);
expect(github.searchCode).not.toHaveBeenCalled();
expect(result.deferred).toBe(1);
});

it("completes the recheck for an issue with the maximum saved queries", async () => {
github.fetchHeadSha.mockResolvedValue("sha-2");
github.compareCommits.mockResolvedValue({
ok: true,
status: "ahead",
files: [],
truncated: false,
firstCommitDate: new Date(Date.now() - 5 * 60_000).toISOString(),
});
const queries = Array.from({ length: 10 }, (_, index) => `missing ${index}`);
const store = fakeStore([{ ...row(), groomedEvidenceScope: "global", groomedEvidencePaths: [], groomedSearchCodeQueries: queries }]);
await pass(store, github);
// Commit-date fetch (1) plus all ten queries fit the default budget.
expect(store.stale.size).toBe(0);
expect(store.advanced).toEqual([{ id: "issue-1", data: { groomingVerifiedSha: "sha-2" } }]);
expect(github.searchCode).toHaveBeenCalledTimes(10);
expect(github.fetchCommitDate).toHaveBeenCalledTimes(1);
});

it("defers the recheck while the new head is younger than the index grace window", async () => {
github.fetchHeadSha.mockResolvedValue("sha-2");
github.compareCommits.mockResolvedValue({
ok: true,
status: "ahead",
files: [],
truncated: false,
firstCommitDate: new Date(Date.now() - 5 * 60_000).toISOString(),
});
github.fetchCommitDate.mockResolvedValue(new Date(Date.now() - 60_000).toISOString());
const store = fakeStore([{ ...row(), groomedEvidenceScope: "global", groomedEvidencePaths: [], groomedSearchCodeQueries: ["query"] }]);
const result = await pass(store, github);
expect(store.stale.size).toBe(0);
expect(store.advanced).toEqual([]);
expect(github.searchCode).not.toHaveBeenCalled();
expect(result.deferred).toBe(1);
});

it("bounds the deferral: stales once the oldest unverified commit outlives the defer limit", async () => {
github.fetchHeadSha.mockResolvedValue("sha-2");
github.compareCommits.mockResolvedValue({
ok: true,
status: "ahead",
files: [],
truncated: false,
firstCommitDate: new Date(Date.now() - 3 * 3_600_000).toISOString(),
});
github.fetchCommitDate.mockResolvedValue(new Date(Date.now() - 60_000).toISOString());
const store = fakeStore([{ ...row(), groomedEvidenceScope: "global", groomedEvidencePaths: [], groomedSearchCodeQueries: ["query"] }]);
const result = await pass(store, github);
expect(store.stale.get("issue-1")?.reasons).toEqual(["global_evidence_commit"]);
expect(store.advanced).toEqual([]);
expect(result.deferred).toBe(0);
});

it("stales instead of deferring when the oldest unverified commit age is unknown", async () => {
github.fetchHeadSha.mockResolvedValue("sha-2");
github.compareCommits.mockResolvedValue({ ok: true, status: "ahead", files: [], truncated: false });
github.fetchCommitDate.mockResolvedValue(new Date(Date.now() - 60_000).toISOString());
const store = fakeStore([{ ...row(), groomedEvidenceScope: "global", groomedEvidencePaths: [], groomedSearchCodeQueries: ["query"] }]);
await pass(store, github);
expect(store.stale.get("issue-1")?.reasons).toEqual(["global_evidence_commit"]);
});

it("falls back to global staleness when the commit date is missing or unavailable", async () => {
github.fetchHeadSha.mockResolvedValue("sha-2");
github.compareCommits.mockResolvedValue({ ok: true, status: "ahead", files: [], truncated: false });
const issue = { ...row(), groomedEvidenceScope: "global", groomedEvidencePaths: [], groomedSearchCodeQueries: ["query"] };
github.fetchCommitDate.mockResolvedValue(null);
const missing = fakeStore([issue]);
await pass(missing, github);
expect(missing.stale.get("issue-1")?.reasons).toEqual(["global_evidence_commit"]);

github.fetchCommitDate.mockRejectedValueOnce(new Error("boom"));
const failed = fakeStore([issue]);
await pass(failed, github);
expect(failed.stale.get("issue-1")?.reasons).toEqual(["global_evidence_commit"]);
});

it("stales a global result whose relied-on read path the commit touched, without rechecking", async () => {
github.fetchHeadSha.mockResolvedValue("sha-2");
github.compareCommits.mockResolvedValue({ ok: true, status: "ahead", files: ["src/a.ts"], truncated: false });
const store = fakeStore([
{ ...row(), groomedEvidenceScope: "global", groomedEvidencePaths: ["src/a.ts"], groomedSearchCodeQueries: ["query"] },
]);
await pass(store, github);
expect(store.stale.get("issue-1")?.reasons).toEqual(["global_evidence_commit"]);
expect(store.stale.get("issue-1")?.detail).toContain("src/a.ts");
expect(github.searchCode).not.toHaveBeenCalled();
});

it("falls back to global staleness when search is missing or fails", async () => {
github.fetchHeadSha.mockResolvedValue("sha-2");
github.compareCommits.mockResolvedValue({ ok: true, status: "ahead", files: [], truncated: false });
const issue = { ...row(), groomedEvidenceScope: "global", groomedEvidencePaths: [], groomedSearchCodeQueries: ["query"] };
const missing = fakeStore([issue]);
const withoutSearch = { ...github } as { searchCode?: typeof github.searchCode } & Omit<ReturnType<typeof fakeGitHub>, "searchCode">;
delete withoutSearch.searchCode;
await pass(missing, withoutSearch as ReturnType<typeof fakeGitHub>);
expect(missing.stale.get("issue-1")?.reasons).toEqual(["global_evidence_commit"]);

github.searchCode.mockRejectedValueOnce(new Error("rate limited"));
const failed = fakeStore([issue]);
await pass(failed, github);
expect(failed.stale.get("issue-1")?.reasons).toEqual(["global_evidence_commit"]);
expect(failed.stale.get("issue-1")?.detail).toBe("default branch moved sha-1...sha-2 and the result relied on repo-wide evidence");
});

it("ignores commits for a result that used no repository evidence", async () => {
Expand Down
Loading
Loading