From efa981f7ed6ef0246cc057a90bd8de8198f92da3 Mon Sep 17 00:00:00 2001 From: Rage Lopez Date: Wed, 29 Jul 2026 22:37:54 -0500 Subject: [PATCH] fix: recover scout searches after repo transfers --- scripts/run-serious-scout.mjs | 13 +++++ src/core/serious-scout.mjs | 10 ++++ src/github/client.mjs | 53 +++++++++++++++++--- test/github-client.test.mjs | 94 +++++++++++++++++++++++++++++++++++ test/serious-scout.test.mjs | 25 ++++++++++ 5 files changed, 188 insertions(+), 7 deletions(-) diff --git a/scripts/run-serious-scout.mjs b/scripts/run-serious-scout.mjs index 984c8b1..f850a38 100644 --- a/scripts/run-serious-scout.mjs +++ b/scripts/run-serious-scout.mjs @@ -123,11 +123,13 @@ export async function runSeriousScoutCli(args = process.argv.slice(2)) { async function collectIssuesFromGithub({ queries, perQueryLimit, githubClient }) { const issues = []; const errors = []; + const repositoryRedirects = []; let incompleteResults = 0; for (const query of queries) { try { const result = await githubClient.searchOpenIssues({ query, limit: perQueryLimit }); issues.push(...(result.items || [])); + repositoryRedirects.push(...(result.repositoryRedirects || [])); if (result.incompleteResults) { incompleteResults += 1; errors.push({ @@ -147,11 +149,22 @@ async function collectIssuesFromGithub({ queries, perQueryLimit, githubClient }) complete: errors.length === 0 && incompleteResults === 0, queries: queries.length, incompleteResults, + repositoryRedirects: dedupeRepositoryRedirects(repositoryRedirects), errors } }; } +function dedupeRepositoryRedirects(redirects = []) { + const seen = new Set(); + return redirects.filter((redirect) => { + const key = `${redirect?.from || ""}->${redirect?.to || ""}`; + if (seen.has(key)) return false; + seen.add(key); + return Boolean(redirect?.from && redirect?.to); + }); +} + async function enrichOpenPullRequestOverlap({ issues, githubClient, maxChecks, preliminaryReport = {} }) { const rows = [...issues]; const preliminaryRows = preliminaryReport.candidates || []; diff --git a/src/core/serious-scout.mjs b/src/core/serious-scout.mjs index 2b7334a..810df20 100644 --- a/src/core/serious-scout.mjs +++ b/src/core/serious-scout.mjs @@ -558,6 +558,7 @@ function normalizeCollectionIntegrity(value = null) { complete: false, queries: 0, incompleteResults: 0, + repositoryRedirects: [], errors: [{ scope: "integrity", message: "Collection integrity metadata was not supplied." }] }; } @@ -568,10 +569,19 @@ function normalizeCollectionIntegrity(value = null) { complete: value.complete !== false && errors.length === 0 && incompleteResults === 0, queries: clampNumber(value.queries, 0, 0, 10_000), incompleteResults, + repositoryRedirects: Array.isArray(value.repositoryRedirects) + ? value.repositoryRedirects.map(normalizeRepositoryRedirect).filter(Boolean) + : [], errors }; } +function normalizeRepositoryRedirect(value) { + const from = String(value?.from || ""); + const to = String(value?.to || ""); + return from && to ? { from, to } : null; +} + function normalizeOverlapIntegrity(value = null, issues = []) { const rowStatuses = issues.map((issue) => issue.overlapStatus).filter(Boolean); const inferredRequired = rowStatuses.length > 0; diff --git a/src/github/client.mjs b/src/github/client.mjs index 77d74be..c7f89cc 100644 --- a/src/github/client.mjs +++ b/src/github/client.mjs @@ -339,14 +339,22 @@ export function createGitHubClient(config = {}) { async function searchOpenIssues({ query, limit = 20, installationId = "", signal = null }) { const safeLimit = Math.max(1, Math.min(100, Number(limit) || 20)); const normalizedQuery = normalizeOpenIssueSearchQuery(query); - await waitForSearchSlot(); - const data = await readRequest( - installationId, - `/search/issues?q=${encodeURIComponent(normalizedQuery)}&sort=updated&order=desc&per_page=${safeLimit}`, - { signal } - ); + let searchQuery = normalizedQuery; + let repositoryRedirects = []; + let data; + try { + data = await searchOpenIssueData({ query: searchQuery, limit: safeLimit, installationId, signal }); + } catch (error) { + if (error?.status !== 422) throw error; + const resolution = await resolveSearchRepositoryQualifiers({ query: searchQuery, installationId, signal }); + if (resolution.query === searchQuery) throw error; + searchQuery = resolution.query; + repositoryRedirects = resolution.redirects; + data = await searchOpenIssueData({ query: searchQuery, limit: safeLimit, installationId, signal }); + } return { - query: normalizedQuery, + query: searchQuery, + repositoryRedirects, totalCount: Number(data.total_count) || 0, incompleteResults: Boolean(data.incomplete_results), items: (data.items || []) @@ -374,6 +382,37 @@ export function createGitHubClient(config = {}) { }; } + async function searchOpenIssueData({ query, limit, installationId, signal }) { + await waitForSearchSlot(); + return readRequest( + installationId, + `/search/issues?q=${encodeURIComponent(query)}&sort=updated&order=desc&per_page=${limit}`, + { signal } + ); + } + + async function resolveSearchRepositoryQualifiers({ query, installationId, signal }) { + const repositories = [...new Set( + [...String(query || "").matchAll(/(?:^|\s)repo:([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)(?=\s|$)/g)] + .map((match) => match[1]) + )]; + let resolvedQuery = query; + const redirects = []; + for (const repository of repositories) { + try { + const data = await readRequest(installationId, `/repos/${repository}`, { signal }); + const canonical = String(data?.full_name || ""); + if (!canonical || canonical.toLowerCase() === repository.toLowerCase()) continue; + const token = new RegExp(`(^|\\s)repo:${escapeRegExp(repository)}(?=\\s|$)`, "g"); + resolvedQuery = resolvedQuery.replace(token, `$1repo:${canonical}`); + redirects.push({ from: repository, to: canonical }); + } catch { + // Preserve the original 422 when repository identity cannot be resolved. + } + } + return { query: resolvedQuery, redirects }; + } + async function ensureLabel({ owner, repo, label, installationId }) { const definition = labelDefinitionFor(label); const encoded = encodeURIComponent(label); diff --git a/test/github-client.test.mjs b/test/github-client.test.mjs index 102395e..361cfb1 100644 --- a/test/github-client.test.mjs +++ b/test/github-client.test.mjs @@ -128,6 +128,100 @@ test("GitHub client uses optional read token for public API calls", async () => } }); +test("GitHub client retries issue search with a transferred repository's canonical name", async () => { + const originalFetch = globalThis.fetch; + const calls = []; + globalThis.fetch = async (url) => { + const parsed = new URL(url); + const query = parsed.searchParams.get("q") || ""; + calls.push({ pathname: parsed.pathname, query }); + if (parsed.pathname === "/search/issues" && query.includes("repo:old-owner/old-repo")) { + return jsonErrorResponse(422, { message: "Validation Failed" }); + } + if (parsed.pathname === "/repos/old-owner/old-repo") { + return jsonResponse({ full_name: "new-owner/new-repo" }); + } + if (parsed.pathname === "/search/issues" && query.includes("repo:new-owner/new-repo")) { + return jsonResponse({ + total_count: 1, + incomplete_results: false, + items: [{ + id: 42, + repository_url: "https://api.github.test/repos/new-owner/new-repo", + number: 9, + title: "Parser crashes on nested input", + body: "Steps to reproduce the parser crash.", + user: { login: "reporter", type: "User" }, + labels: [{ name: "bug" }], + state: "open", + assignees: [], + html_url: "https://github.test/new-owner/new-repo/issues/9" + }] + }); + } + throw new Error(`unexpected fetch ${parsed.pathname} ${query}`); + }; + + try { + const client = createGitHubClient({ + githubApiBase: "https://api.github.test", + githubCacheTtlMs: 0, + githubSearchDelayMs: 0 + }); + const result = await client.searchOpenIssues({ + query: "repo:old-owner/old-repo is:issue is:open label:bug" + }); + + assert.equal(result.query, "repo:new-owner/new-repo is:issue is:open label:bug"); + assert.deepEqual(result.repositoryRedirects, [{ + from: "old-owner/old-repo", + to: "new-owner/new-repo" + }]); + assert.equal(result.items[0].repository, "new-owner/new-repo"); + assert.deepEqual(calls.map(({ pathname }) => pathname), [ + "/search/issues", + "/repos/old-owner/old-repo", + "/search/issues" + ]); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("GitHub client preserves a 422 when repository identity did not change", async () => { + const originalFetch = globalThis.fetch; + let searches = 0; + globalThis.fetch = async (url) => { + const parsed = new URL(url); + if (parsed.pathname === "/search/issues") { + searches += 1; + return jsonErrorResponse(422, { message: "Validation Failed" }); + } + if (parsed.pathname === "/repos/owner/repo") { + return jsonResponse({ full_name: "owner/repo" }); + } + throw new Error(`unexpected fetch ${parsed.pathname}`); + }; + + try { + const client = createGitHubClient({ + githubApiBase: "https://api.github.test", + githubCacheTtlMs: 0, + githubSearchDelayMs: 0 + }); + + await assert.rejects( + client.searchOpenIssues({ + query: "repo:owner/repo is:issue is:open malformed:qualifier" + }), + /GitHub API 422/ + ); + assert.equal(searches, 1); + } finally { + globalThis.fetch = originalFetch; + } +}); + test("GitHub client spaces repository-context search requests when configured", async () => { const originalFetch = globalThis.fetch; const searchTimes = []; diff --git a/test/serious-scout.test.mjs b/test/serious-scout.test.mjs index 3ca26e4..40eb6c8 100644 --- a/test/serious-scout.test.mjs +++ b/test/serious-scout.test.mjs @@ -361,6 +361,31 @@ test("serious scout fails closed when GitHub issue search is incomplete", async assert.match(report.automation.reason, /partial search results/i); }); +test("serious scout preserves canonical repository redirects in collection evidence", async () => { + const report = await runSeriousScout({ + queries: ["repo:old-owner/old-repo is:issue is:open"], + githubClient: { + async searchOpenIssues() { + return { + items: [seriousIssue()], + incompleteResults: false, + repositoryRedirects: [{ + from: "old-owner/old-repo", + to: "new-owner/new-repo" + }] + }; + } + } + }); + + assert.equal(report.collection.complete, true); + assert.deepEqual(report.collection.repositoryRedirects, [{ + from: "old-owner/old-repo", + to: "new-owner/new-repo" + }]); + assert.equal(report.automation.status, "PROMOTE"); +}); + test("serious scout stops remaining issue queries after an exhausted rate limit", async () => { const calls = []; const report = await runSeriousScout({