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
13 changes: 13 additions & 0 deletions scripts/run-serious-scout.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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 || [];
Expand Down
10 changes: 10 additions & 0 deletions src/core/serious-scout.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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." }]
};
}
Expand All @@ -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;
Expand Down
53 changes: 46 additions & 7 deletions src/github/client.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 || [])
Expand Down Expand Up @@ -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)]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Match repository qualifiers case-insensitively

When a query uses a valid mixed-case qualifier such as Repo:old-owner/old-repo, the initial search can still return 422, but this case-sensitive matcher finds no repositories, so resolution.query remains unchanged and the transfer recovery immediately rethrows the error. Since normalizeOpenIssueSearchQuery preserves qualifier spelling and already treats qualifiers case-insensitively, make both the extraction and replacement regexes case-insensitive so these queries receive the canonical-name retry.

Useful? React with 👍 / 👎.

.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);
Expand Down
94 changes: 94 additions & 0 deletions test/github-client.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [];
Expand Down
25 changes: 25 additions & 0 deletions test/serious-scout.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down