diff --git a/packages/gittensory-engine/README.md b/packages/gittensory-engine/README.md index 3232c38b6c..380ba0f111 100644 --- a/packages/gittensory-engine/README.md +++ b/packages/gittensory-engine/README.md @@ -57,3 +57,9 @@ rankOpportunities(candidates); // sorted by descending score, each annotated wit `rankOpportunities` is a stable sort with an explicit index tie-break: candidates with an equal score keep their input order. + +## AI Policy Map + +`scanAiPolicyText` and `resolveAiPolicyVerdict` provide the deterministic policy gate used by miner discovery. +They only deny on small, explicit AI-contribution ban phrases in `AI-USAGE.md` or `CONTRIBUTING.md`; ambiguous, +missing, or empty policy text stays allowed so discovery does not invent a ban. diff --git a/packages/gittensory-engine/src/ai-policy-map.ts b/packages/gittensory-engine/src/ai-policy-map.ts new file mode 100644 index 0000000000..e5bc336597 --- /dev/null +++ b/packages/gittensory-engine/src/ai-policy-map.ts @@ -0,0 +1,68 @@ +export type AiPolicySource = "AI-USAGE.md" | "CONTRIBUTING.md" | "none"; + +export type AiPolicyVerdict = { + allowed: boolean; + matchedPhrase: string | null; + source: AiPolicySource; +}; + +type BanPhrase = { + phrase: string; + pattern: RegExp; +}; + +const AI_POLICY_ALLOWED: AiPolicyVerdict = { + allowed: true, + matchedPhrase: null, + source: "none", +}; + +const BAN_PHRASES: BanPhrase[] = [ + { + phrase: "no ai-generated pull requests", + pattern: /\bno\s+ai[-\s]+generated\s+(?:pull\s+requests|prs|contributions)\b/i, + }, + { + phrase: "ai-generated prs are rejected", + pattern: + /\bai[-\s]+generated\s+(?:prs?|pull\s+requests|contributions?)\s+(?:are|will\s+be)\s+(?:banned|rejected|not\s+accepted)\b/i, + }, + { + phrase: "do not submit ai-generated code", + pattern: /\bdo\s+not\s+(?:use|submit)\s+ai[-\s]+(?:written|generated)\s+code\b/i, + }, + { + phrase: "llm-generated code is not accepted", + pattern: /\b(?:ai|llm)[-\s]+generated\s+code\s+(?:is|will\s+be)\s+(?:rejected|not\s+accepted)\b/i, + }, +]; + +/** + * Conservative by design (#2305): explicit ban phrases deny a repo, but ambiguous or absent policy text stays + * allowed. False negatives can be tightened with new literal phrases; false positives would hide valid work. + */ +export function scanAiPolicyText(content: string | null | undefined, source: AiPolicySource): AiPolicyVerdict { + const text = content ?? ""; + if (source === "none" || text.trim().length === 0) { + return { allowed: true, matchedPhrase: null, source }; + } + for (const ban of BAN_PHRASES) { + if (ban.pattern.test(text)) { + return { allowed: false, matchedPhrase: ban.phrase, source }; + } + } + return { allowed: true, matchedPhrase: null, source }; +} + +export function resolveAiPolicyVerdict(docs: { + aiUsage: string | null | undefined; + contributing: string | null | undefined; +}): AiPolicyVerdict { + if (docs.aiUsage !== null && docs.aiUsage !== undefined) { + return scanAiPolicyText(docs.aiUsage, "AI-USAGE.md"); + } + if (docs.contributing !== null && docs.contributing !== undefined) { + return scanAiPolicyText(docs.contributing, "CONTRIBUTING.md"); + } + return { ...AI_POLICY_ALLOWED }; +} diff --git a/packages/gittensory-engine/src/index.ts b/packages/gittensory-engine/src/index.ts index 6d380e9d34..22db057885 100644 --- a/packages/gittensory-engine/src/index.ts +++ b/packages/gittensory-engine/src/index.ts @@ -10,3 +10,9 @@ export { type OpportunityRankInput, } from "./opportunity-ranker.js"; export * from "./governor/rate-limit.js"; +export { + resolveAiPolicyVerdict, + scanAiPolicyText, + type AiPolicySource, + type AiPolicyVerdict, +} from "./ai-policy-map.js"; diff --git a/packages/gittensory-miner/README.md b/packages/gittensory-miner/README.md index 5e18d2552d..dd7b102c51 100644 --- a/packages/gittensory-miner/README.md +++ b/packages/gittensory-miner/README.md @@ -15,6 +15,11 @@ Current scope is intentionally small: Real miner commands land in follow-up issues. +The package also includes the first metadata-only discovery primitive: `fetchCandidateIssues` lists open issue +metadata across target repos, and `searchCandidateIssues` does the same from a GitHub issue-search query. Both +paths hard-skip repos whose `AI-USAGE.md` or `CONTRIBUTING.md` explicitly bans AI-generated PRs. They perform +GitHub GET requests only, never clone source, never upload source, and never write to GitHub. + ## Install From a local checkout: diff --git a/packages/gittensory-miner/lib/opportunity-fanout.d.ts b/packages/gittensory-miner/lib/opportunity-fanout.d.ts new file mode 100644 index 0000000000..dc1289d03a --- /dev/null +++ b/packages/gittensory-miner/lib/opportunity-fanout.d.ts @@ -0,0 +1,72 @@ +export type FanoutTarget = { + owner: string; + repo: string; +}; + +export type RawCandidateIssue = { + owner: string; + repo: string; + repoFullName: string; + issueNumber: number; + title: string; + labels: string[]; + commentsCount: number; + createdAt: string | null; + updatedAt: string | null; + htmlUrl: string | null; + aiPolicyAllowed: true; + aiPolicySource: "AI-USAGE.md" | "CONTRIBUTING.md" | "none"; +}; + +export type CandidateIssueWarning = { + repoFullName: string; + stage: string; + message: string; +}; + +export type CandidateIssueSummary = { + issues: RawCandidateIssue[]; + rateLimitRemaining: number | null; + rateLimitResetAt: string | null; + warnings: CandidateIssueWarning[]; +}; + +export function fetchCandidateIssuesWithSummary( + targets: FanoutTarget[], + githubToken: string, + options?: { + apiBaseUrl?: string; + concurrency?: number; + perPage?: number; + }, +): Promise; + +export function fetchCandidateIssues( + targets: FanoutTarget[], + githubToken: string, + options?: { + apiBaseUrl?: string; + concurrency?: number; + perPage?: number; + }, +): Promise; + +export function searchCandidateIssuesWithSummary( + searchQuery: string, + githubToken: string, + options?: { + apiBaseUrl?: string; + concurrency?: number; + perPage?: number; + }, +): Promise; + +export function searchCandidateIssues( + searchQuery: string, + githubToken: string, + options?: { + apiBaseUrl?: string; + concurrency?: number; + perPage?: number; + }, +): Promise; diff --git a/packages/gittensory-miner/lib/opportunity-fanout.js b/packages/gittensory-miner/lib/opportunity-fanout.js new file mode 100644 index 0000000000..6da7b88134 --- /dev/null +++ b/packages/gittensory-miner/lib/opportunity-fanout.js @@ -0,0 +1,368 @@ +import { Buffer } from "node:buffer"; +import { resolveAiPolicyVerdict } from "@jsonbored/gittensory-engine"; + +const defaultApiBaseUrl = "https://api.github.com"; +const defaultConcurrency = 5; +const defaultPerPage = 100; +const githubApiVersion = "2022-11-28"; + +function normalizeLimit(value, fallback, min, max) { + if (!Number.isFinite(value)) return fallback; + return Math.min(max, Math.max(min, Math.floor(value))); +} + +function targetKey(target) { + return `${target.owner.toLowerCase()}/${target.repo.toLowerCase()}`; +} + +function normalizeTargets(targets) { + const seen = new Set(); + const normalized = []; + for (const target of Array.isArray(targets) ? targets : []) { + const owner = typeof target?.owner === "string" ? target.owner.trim() : ""; + const repo = typeof target?.repo === "string" ? target.repo.trim() : ""; + if (!owner || !repo) continue; + const key = targetKey({ owner, repo }); + if (seen.has(key)) continue; + seen.add(key); + normalized.push({ owner, repo, repoFullName: `${owner}/${repo}` }); + } + return normalized; +} + +function targetFromFullName(fullName) { + if (typeof fullName !== "string") return null; + const [owner, repo, extra] = fullName.split("/"); + if (!owner || !repo || extra) return null; + return { owner, repo, repoFullName: `${owner}/${repo}` }; +} + +function targetFromSearchIssue(issue) { + const repositoryFullName = targetFromFullName(issue?.repository?.full_name); + if (repositoryFullName) return repositoryFullName; + + const repositoryUrl = + typeof issue?.repository_url === "string" + ? issue.repository_url.match(/\/repos\/([^/?#]+)\/([^/?#]+)(?:[?#].*)?$/) + : null; + if (repositoryUrl) { + const owner = decodeURIComponent(repositoryUrl[1]); + const repo = decodeURIComponent(repositoryUrl[2]); + return { owner, repo, repoFullName: `${owner}/${repo}` }; + } + + const htmlUrl = + typeof issue?.html_url === "string" + ? issue.html_url.match(/^https:\/\/github\.com\/([^/]+)\/([^/]+)\/issues\/\d+(?:[?#].*)?$/) + : null; + if (htmlUrl) { + const owner = decodeURIComponent(htmlUrl[1]); + const repo = decodeURIComponent(htmlUrl[2]); + return { owner, repo, repoFullName: `${owner}/${repo}` }; + } + + return null; +} + +function githubHeaders(githubToken) { + const headers = { + accept: "application/vnd.github+json", + "user-agent": "gittensory-miner", + "x-github-api-version": githubApiVersion, + }; + const token = typeof githubToken === "string" ? githubToken.trim() : ""; + if (token) headers.authorization = `Bearer ${token}`; + return headers; +} + +function apiUrl(apiBaseUrl, path, query = "") { + return `${apiBaseUrl.replace(/\/+$/, "")}${path}${query}`; +} + +function repoPath(target, suffix) { + return `/repos/${encodeURIComponent(target.owner)}/${encodeURIComponent(target.repo)}${suffix}`; +} + +function recordRateLimit(summary, response) { + const remaining = Number(response.headers.get("x-ratelimit-remaining")); + if (Number.isFinite(remaining)) { + summary.rateLimitRemaining = + summary.rateLimitRemaining === null + ? remaining + : Math.min(summary.rateLimitRemaining, remaining); + } + const resetSeconds = Number(response.headers.get("x-ratelimit-reset")); + if (Number.isFinite(resetSeconds) && resetSeconds > 0) { + const resetAt = new Date(resetSeconds * 1000).toISOString(); + summary.rateLimitResetAt = + summary.rateLimitResetAt === null || resetAt > summary.rateLimitResetAt + ? resetAt + : summary.rateLimitResetAt; + } +} + +async function githubGetJson(url, githubToken, summary) { + const response = await fetch(url, { + method: "GET", + headers: githubHeaders(githubToken), + }); + recordRateLimit(summary, response); + const payload = await response.json().catch(() => null); + return { response, payload }; +} + +function decodeContentPayload(payload) { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null; + if (typeof payload.content !== "string") return null; + if (payload.encoding === "base64") { + return Buffer.from(payload.content.replace(/\s/g, ""), "base64").toString("utf8"); + } + return payload.content; +} + +function warning(target, stage, message) { + return { repoFullName: target.repoFullName, stage, message }; +} + +async function fetchRepoDoc(target, path, githubToken, options, summary, warnings) { + const url = apiUrl( + options.apiBaseUrl, + repoPath(target, `/contents/${encodeURIComponent(path)}`), + ); + try { + const { response, payload } = await githubGetJson(url, githubToken, summary); + if (response.status === 404) return null; + if (!response.ok) { + warnings.push(warning(target, `policy:${path}`, `GitHub returned ${response.status}`)); + return null; + } + return decodeContentPayload(payload); + } catch (error) { + warnings.push( + warning(target, `policy:${path}`, error instanceof Error ? error.message : "policy fetch failed"), + ); + return null; + } +} + +async function resolveRepoAiPolicy(target, githubToken, options, summary, warnings) { + const aiUsage = await fetchRepoDoc(target, "AI-USAGE.md", githubToken, options, summary, warnings); + if (aiUsage !== null) { + return resolveAiPolicyVerdict({ aiUsage, contributing: null }); + } + const contributing = await fetchRepoDoc( + target, + "CONTRIBUTING.md", + githubToken, + options, + summary, + warnings, + ); + return resolveAiPolicyVerdict({ aiUsage: null, contributing }); +} + +function labelNames(labels) { + if (!Array.isArray(labels)) return []; + return labels + .map((label) => { + if (typeof label === "string") return label; + if (label && typeof label === "object" && typeof label.name === "string") return label.name; + return ""; + }) + .filter((name) => name.length > 0); +} + +function normalizeIssue(target, issue, policySource) { + if (!issue || typeof issue !== "object" || issue.pull_request) return null; + if (!Number.isInteger(issue.number) || issue.number <= 0) return null; + if (typeof issue.title !== "string" || issue.title.trim().length === 0) return null; + return { + owner: target.owner, + repo: target.repo, + repoFullName: target.repoFullName, + issueNumber: issue.number, + title: issue.title, + labels: labelNames(issue.labels), + commentsCount: Number.isFinite(issue.comments) ? issue.comments : 0, + createdAt: typeof issue.created_at === "string" ? issue.created_at : null, + updatedAt: typeof issue.updated_at === "string" ? issue.updated_at : null, + htmlUrl: typeof issue.html_url === "string" ? issue.html_url : null, + aiPolicyAllowed: true, + aiPolicySource: policySource, + }; +} + +function searchQueryWithIssueQualifiers(searchQuery) { + const trimmed = typeof searchQuery === "string" ? searchQuery.trim() : ""; + if (!trimmed) return ""; + return `${trimmed} state:open type:issue`; +} + +async function fetchTargetIssues(target, githubToken, options, summary, warnings) { + const verdict = await resolveRepoAiPolicy(target, githubToken, options, summary, warnings); + if (!verdict.allowed) return []; + + const url = apiUrl( + options.apiBaseUrl, + repoPath(target, "/issues"), + `?state=open&per_page=${options.perPage}`, + ); + try { + const { response, payload } = await githubGetJson(url, githubToken, summary); + if (!response.ok) { + warnings.push(warning(target, "issues", `GitHub returned ${response.status}`)); + return []; + } + if (!Array.isArray(payload)) { + warnings.push(warning(target, "issues", "GitHub returned a non-array issues payload")); + return []; + } + return payload + .map((issue) => normalizeIssue(target, issue, verdict.source)) + .filter((issue) => issue !== null); + } catch (error) { + warnings.push( + warning(target, "issues", error instanceof Error ? error.message : "issue fetch failed"), + ); + return []; + } +} + +async function fetchSearchIssues(searchQuery, githubToken, options, summary, warnings) { + const qualifiedQuery = searchQueryWithIssueQualifiers(searchQuery); + if (!qualifiedQuery) return []; + + const url = apiUrl( + options.apiBaseUrl, + "/search/issues", + `?q=${encodeURIComponent(qualifiedQuery)}&per_page=${options.perPage}`, + ); + try { + const { response, payload } = await githubGetJson(url, githubToken, summary); + if (!response.ok) { + warnings.push({ + repoFullName: "*", + stage: "search", + message: `GitHub returned ${response.status}`, + }); + return []; + } + if (!payload || typeof payload !== "object" || !Array.isArray(payload.items)) { + warnings.push({ + repoFullName: "*", + stage: "search", + message: "GitHub returned a non-array search payload", + }); + return []; + } + return payload.items; + } catch (error) { + warnings.push({ + repoFullName: "*", + stage: "search", + message: error instanceof Error ? error.message : "issue search failed", + }); + return []; + } +} + +async function mapWithConcurrency(items, concurrency, worker) { + const results = new Array(items.length); + let next = 0; + const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => { + while (next < items.length) { + const index = next; + next += 1; + results[index] = await worker(items[index], index); + } + }); + await Promise.all(workers); + return results; +} + +function normalizeOptions(options = {}) { + return { + apiBaseUrl: + typeof options.apiBaseUrl === "string" && options.apiBaseUrl.trim() + ? options.apiBaseUrl.trim() + : defaultApiBaseUrl, + concurrency: normalizeLimit(options.concurrency, defaultConcurrency, 1, 10), + perPage: normalizeLimit(options.perPage, defaultPerPage, 1, 100), + }; +} + +export async function fetchCandidateIssuesWithSummary(targets, githubToken, options = {}) { + const normalizedOptions = normalizeOptions(options); + const normalizedTargets = normalizeTargets(targets); + const summary = { + rateLimitRemaining: null, + rateLimitResetAt: null, + }; + const warnings = []; + const batches = await mapWithConcurrency(normalizedTargets, normalizedOptions.concurrency, (target) => + fetchTargetIssues(target, githubToken, normalizedOptions, summary, warnings), + ); + return { + issues: batches.flat(), + rateLimitRemaining: summary.rateLimitRemaining, + rateLimitResetAt: summary.rateLimitResetAt, + warnings, + }; +} + +/** + * Metadata-only GitHub discovery (#2307): never clones source, never fetches blobs beyond small policy docs, + * never uploads source, and never performs writes. Call the WithSummary variant when rate-limit telemetry is + * needed. + */ +export async function fetchCandidateIssues(targets, githubToken, options = {}) { + const result = await fetchCandidateIssuesWithSummary(targets, githubToken, options); + return result.issues; +} + +export async function searchCandidateIssuesWithSummary(searchQuery, githubToken, options = {}) { + const normalizedOptions = normalizeOptions(options); + const summary = { + rateLimitRemaining: null, + rateLimitResetAt: null, + }; + const warnings = []; + const searchItems = await fetchSearchIssues(searchQuery, githubToken, normalizedOptions, summary, warnings); + const targetsByKey = new Map(); + for (const item of searchItems) { + if (!item || typeof item !== "object" || item.pull_request) continue; + const target = targetFromSearchIssue(item); + if (target && !targetsByKey.has(targetKey(target))) targetsByKey.set(targetKey(target), target); + } + + const policyEntries = await mapWithConcurrency( + [...targetsByKey.values()], + normalizedOptions.concurrency, + async (target) => { + const verdict = await resolveRepoAiPolicy(target, githubToken, normalizedOptions, summary, warnings); + return [targetKey(target), verdict]; + }, + ); + const policiesByKey = new Map(policyEntries); + const issues = []; + for (const item of searchItems) { + const target = targetFromSearchIssue(item); + if (!target) continue; + const policy = policiesByKey.get(targetKey(target)); + if (!policy?.allowed) continue; + const normalizedIssue = normalizeIssue(target, item, policy.source); + if (normalizedIssue) issues.push(normalizedIssue); + } + + return { + issues, + rateLimitRemaining: summary.rateLimitRemaining, + rateLimitResetAt: summary.rateLimitResetAt, + warnings, + }; +} + +export async function searchCandidateIssues(searchQuery, githubToken, options = {}) { + const result = await searchCandidateIssuesWithSummary(searchQuery, githubToken, options); + return result.issues; +} diff --git a/packages/gittensory-miner/package.json b/packages/gittensory-miner/package.json index c00ac2fb76..54edb7b0da 100644 --- a/packages/gittensory-miner/package.json +++ b/packages/gittensory-miner/package.json @@ -31,7 +31,7 @@ "lib" ], "scripts": { - "build": "node --check bin/gittensory-miner.js && node --check lib/cli.js && node --check lib/update-check.js" + "build": "node --check bin/gittensory-miner.js && node --check lib/cli.js && node --check lib/update-check.js && node --check lib/opportunity-fanout.js" }, "dependencies": { "@jsonbored/gittensory-engine": "0.1.0" diff --git a/test/unit/miner-ai-policy-map.test.ts b/test/unit/miner-ai-policy-map.test.ts new file mode 100644 index 0000000000..5fbe60e769 --- /dev/null +++ b/test/unit/miner-ai-policy-map.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { resolveAiPolicyVerdict, scanAiPolicyText } from "../../packages/gittensory-engine/src/ai-policy-map"; + +describe("miner AI policy map (#2305)", () => { + it.each([ + ["We allow bug fixes, but no AI-generated pull requests.", "no ai-generated pull requests"], + ["AI-generated PRs are rejected by maintainers.", "ai-generated prs are rejected"], + ["Do not submit AI-written code in this repository.", "do not submit ai-generated code"], + ["LLM-generated code is not accepted here.", "llm-generated code is not accepted"], + ])("denies explicit ban phrase: %s", (content, phrase) => { + expect(scanAiPolicyText(content, "CONTRIBUTING.md")).toEqual({ + allowed: false, + matchedPhrase: phrase, + source: "CONTRIBUTING.md", + }); + }); + + it("allows safe or empty policy text without inventing a ban", () => { + expect(scanAiPolicyText("Please include tests and a clear description.", "CONTRIBUTING.md")).toEqual({ + allowed: true, + matchedPhrase: null, + source: "CONTRIBUTING.md", + }); + expect(scanAiPolicyText("", "AI-USAGE.md")).toEqual({ + allowed: true, + matchedPhrase: null, + source: "AI-USAGE.md", + }); + expect(scanAiPolicyText(null, "CONTRIBUTING.md")).toEqual({ + allowed: true, + matchedPhrase: null, + source: "CONTRIBUTING.md", + }); + expect(scanAiPolicyText(undefined, "none")).toEqual({ + allowed: true, + matchedPhrase: null, + source: "none", + }); + }); + + it("lets AI-USAGE.md take precedence over CONTRIBUTING.md", () => { + expect( + resolveAiPolicyVerdict({ + aiUsage: "AI-assisted contributions are allowed when reviewed.", + contributing: "No AI-generated pull requests.", + }), + ).toEqual({ + allowed: true, + matchedPhrase: null, + source: "AI-USAGE.md", + }); + }); + + it("falls back to CONTRIBUTING.md and then to the absent-doc default", () => { + expect(resolveAiPolicyVerdict({ aiUsage: null, contributing: "AI-generated PRs are not accepted." })).toEqual({ + allowed: false, + matchedPhrase: "ai-generated prs are rejected", + source: "CONTRIBUTING.md", + }); + expect(resolveAiPolicyVerdict({ aiUsage: null, contributing: null })).toEqual({ + allowed: true, + matchedPhrase: null, + source: "none", + }); + }); +}); diff --git a/test/unit/miner-opportunity-fanout.test.ts b/test/unit/miner-opportunity-fanout.test.ts new file mode 100644 index 0000000000..b6ca41fd93 --- /dev/null +++ b/test/unit/miner-opportunity-fanout.test.ts @@ -0,0 +1,291 @@ +import { Buffer } from "node:buffer"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@jsonbored/gittensory-engine", async () => { + return import("../../packages/gittensory-engine/src/index"); +}); + +import { + fetchCandidateIssues, + fetchCandidateIssuesWithSummary, + searchCandidateIssuesWithSummary, +} from "../../packages/gittensory-miner/lib/opportunity-fanout.js"; + +const API = "https://api.test"; + +function jsonResponse(body: unknown, init: ResponseInit = {}) { + return Response.json(body, { + ...init, + headers: { + "x-ratelimit-remaining": "42", + "x-ratelimit-reset": "1800000000", + ...(init.headers ?? {}), + }, + }); +} + +function contentResponse(content: string) { + return jsonResponse({ + type: "file", + encoding: "base64", + content: Buffer.from(content, "utf8").toString("base64"), + }); +} + +const issue = (number: number, title = `Issue ${number}`) => ({ + number, + title, + labels: [{ name: "help wanted" }, "good first issue", { missing: true }], + comments: 2, + created_at: "2026-07-01T00:00:00Z", + updated_at: "2026-07-01T01:00:00Z", + html_url: `https://github.com/acme/widgets/issues/${number}`, +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("fetchCandidateIssues (#2307)", () => { + it("lists open issue metadata for allowed repos and excludes pull requests", async () => { + const calls: Array<{ + url: string; + method: string | undefined; + authorization: string | null | undefined; + }> = []; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + calls.push({ + url, + method: init?.method, + authorization: + init?.headers instanceof Headers + ? init.headers.get("authorization") + : (init?.headers as Record | undefined)?.authorization, + }); + if (url.endsWith("/contents/AI-USAGE.md")) return jsonResponse({}, { status: 404 }); + if (url.endsWith("/contents/CONTRIBUTING.md")) return contentResponse("Please add tests."); + if (url.includes("/issues?")) return jsonResponse([issue(7), { ...issue(8), pull_request: {} }]); + return jsonResponse({}, { status: 404 }); + }); + + const result = await fetchCandidateIssues([{ owner: "acme", repo: "widgets" }], "placeholder-token", { + apiBaseUrl: API, + }); + + expect(result).toEqual([ + { + owner: "acme", + repo: "widgets", + repoFullName: "acme/widgets", + issueNumber: 7, + title: "Issue 7", + labels: ["help wanted", "good first issue"], + commentsCount: 2, + createdAt: "2026-07-01T00:00:00Z", + updatedAt: "2026-07-01T01:00:00Z", + htmlUrl: "https://github.com/acme/widgets/issues/7", + aiPolicyAllowed: true, + aiPolicySource: "CONTRIBUTING.md", + }, + ]); + expect(calls.every((call) => call.method === "GET")).toBe(true); + expect(calls.every((call) => call.authorization === "Bearer placeholder-token")).toBe(true); + }); + + it("hard-skips a banned repo without listing issues", async () => { + const calls: string[] = []; + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = String(input); + calls.push(url); + if (url.endsWith("/contents/AI-USAGE.md")) return contentResponse("No AI-generated pull requests."); + throw new Error("banned repo should not list issues"); + }); + + const result = await fetchCandidateIssuesWithSummary([{ owner: "acme", repo: "banned" }], "", { + apiBaseUrl: API, + }); + + expect(result.issues).toEqual([]); + expect(result.warnings).toEqual([]); + expect(calls).toHaveLength(1); + expect(calls[0]).toContain("/repos/acme/banned/contents/AI-USAGE.md"); + }); + + it("fans out allowed repos while banned repos contribute no issue calls", async () => { + const calls: string[] = []; + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = String(input); + calls.push(url); + if (url.includes("/repos/acme/banned/contents/AI-USAGE.md")) { + return contentResponse("AI-generated PRs are rejected."); + } + if (url.endsWith("/contents/AI-USAGE.md")) return jsonResponse({}, { status: 404 }); + if (url.endsWith("/contents/CONTRIBUTING.md")) return contentResponse("AI work is reviewed normally."); + if (url.includes("/repos/acme/allowed/issues?")) return jsonResponse([issue(3)]); + return jsonResponse({}, { status: 404 }); + }); + + const result = await fetchCandidateIssues( + [ + { owner: "acme", repo: "banned" }, + { owner: "acme", repo: "allowed" }, + ], + "token", + { apiBaseUrl: API }, + ); + + expect(result.map((entry) => entry.repoFullName)).toEqual(["acme/allowed"]); + expect(calls.some((url) => url.includes("/repos/acme/banned/issues?"))).toBe(false); + expect(calls.some((url) => url.includes("/repos/acme/allowed/issues?"))).toBe(true); + }); + + it("degrades a failing target to an empty list while preserving other targets and rate-limit telemetry", async () => { + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/contents/AI-USAGE.md")) return jsonResponse({}, { status: 404 }); + if (url.endsWith("/contents/CONTRIBUTING.md")) return contentResponse("Contributions welcome."); + if (url.includes("/repos/acme/down/issues?")) { + return jsonResponse( + { message: "server error" }, + { status: 503, headers: { "x-ratelimit-remaining": "9", "x-ratelimit-reset": "1800000300" } }, + ); + } + if (url.includes("/repos/acme/up/issues?")) return jsonResponse([issue(11)]); + return jsonResponse({}, { status: 404 }); + }); + + const result = await fetchCandidateIssuesWithSummary( + [ + { owner: "acme", repo: "down" }, + { owner: "acme", repo: "up" }, + ], + "token", + { apiBaseUrl: API }, + ); + + expect(result.issues.map((entry) => entry.issueNumber)).toEqual([11]); + expect(result.warnings).toEqual([ + { repoFullName: "acme/down", stage: "issues", message: "GitHub returned 503" }, + ]); + expect(result.rateLimitRemaining).toBe(9); + expect(result.rateLimitResetAt).toBe("2027-01-15T08:05:00.000Z"); + }); + + it("bounds concurrent target workers", async () => { + let active = 0; + let maxActive = 0; + vi.stubGlobal("fetch", async () => { + active += 1; + maxActive = Math.max(maxActive, active); + await new Promise((resolve) => setTimeout(resolve, 5)); + active -= 1; + return contentResponse("No AI-generated pull requests."); + }); + + await fetchCandidateIssuesWithSummary( + [ + { owner: "acme", repo: "one" }, + { owner: "acme", repo: "two" }, + { owner: "acme", repo: "three" }, + ], + "", + { apiBaseUrl: API, concurrency: 2 }, + ); + + expect(maxActive).toBeLessThanOrEqual(2); + }); + + it("deduplicates malformed and repeated targets before fetching", async () => { + const calls: string[] = []; + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + calls.push(String(input)); + return contentResponse("No AI-generated pull requests."); + }); + + await fetchCandidateIssues( + [ + { owner: "", repo: "missing-owner" }, + { owner: "acme", repo: "widgets" }, + { owner: "ACME", repo: "widgets" }, + ], + "", + { apiBaseUrl: API }, + ); + + expect(calls).toHaveLength(1); + }); + + it("searches open issue metadata and applies the AI-policy hard-skip per repo", async () => { + const calls: string[] = []; + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = String(input); + calls.push(url); + if (url.includes("/search/issues?")) { + return jsonResponse({ + items: [ + { + ...issue(21, "Search result"), + repository: { full_name: "acme/allowed" }, + html_url: "https://github.com/acme/allowed/issues/21", + }, + { + ...issue(22, "HTML fallback"), + repository: {}, + repository_url: undefined, + html_url: "https://github.com/acme/allowed/issues/22", + }, + { + ...issue(23, "Banned result"), + repository_url: `${API}/repos/acme/banned`, + html_url: "https://github.com/acme/banned/issues/23", + }, + { + ...issue(24, "Pull request result"), + repository: { full_name: "acme/allowed" }, + pull_request: {}, + }, + ], + }); + } + if (url.includes("/repos/acme/banned/contents/AI-USAGE.md")) { + return contentResponse("No AI-generated pull requests."); + } + if (url.endsWith("/contents/AI-USAGE.md")) return jsonResponse({}, { status: 404 }); + if (url.endsWith("/contents/CONTRIBUTING.md")) return contentResponse("Contributions welcome."); + throw new Error(`unexpected fanout request: ${url}`); + }); + + const result = await searchCandidateIssuesWithSummary("label:help-wanted", "token", { + apiBaseUrl: API, + perPage: 25, + }); + + expect(result.issues.map((entry) => [entry.repoFullName, entry.issueNumber])).toEqual([ + ["acme/allowed", 21], + ["acme/allowed", 22], + ]); + expect(result.warnings).toEqual([]); + expect(calls[0]).toBe( + `${API}/search/issues?q=${encodeURIComponent("label:help-wanted state:open type:issue")}&per_page=25`, + ); + expect(calls.filter((url) => url.includes("/repos/acme/allowed/contents/AI-USAGE.md"))).toHaveLength( + 1, + ); + expect(calls.some((url) => url.includes("/repos/acme/banned/issues?"))).toBe(false); + expect(calls.some((url) => url.includes("/repos/acme/allowed/issues?"))).toBe(false); + }); + + it("degrades a failed search query to an empty result with a warning", async () => { + vi.stubGlobal("fetch", async () => jsonResponse({ message: "bad gateway" }, { status: 502 })); + + const result = await searchCandidateIssuesWithSummary("label:feature", "token", { + apiBaseUrl: API, + }); + + expect(result.issues).toEqual([]); + expect(result.warnings).toEqual([ + { repoFullName: "*", stage: "search", message: "GitHub returned 502" }, + ]); + }); +});