diff --git a/packages/loopover-miner/lib/contribution-profile-extract.ts b/packages/loopover-miner/lib/contribution-profile-extract.ts index 4a2976fbdb..f1816b3be4 100644 --- a/packages/loopover-miner/lib/contribution-profile-extract.ts +++ b/packages/loopover-miner/lib/contribution-profile-extract.ts @@ -11,6 +11,7 @@ import type { ContributionSignalConfidence, ContributionSignalProvenance, ContributionSignalRule, + ContributionSignalSource, } from "./contribution-profile.js"; import { CONTRIBUTION_PROFILE_SCHEMA_VERSION, @@ -254,25 +255,51 @@ async function fetchContributing( return null; } -/** Extract the PR-body linked-issue requirement from CONTRIBUTING.md. A very small file is a signpost, not the - * rules, so it yields `absent` rather than a false negative dressed as a real one. */ +/** Fetch an AI-agent doc, probing repo-root `AGENTS.md` then `CLAUDE.md` (#8316: a fallback source for the + * linked-issue rule when the repo publishes no CONTRIBUTING.md). Root only — no `.github/` variants exist for + * these. Mirrors `fetchContributing`'s shape: returns the first decoded body, or null. */ +async function fetchAgentDocs( + base: string, + target: { owner: string; repo: string }, + headers: Record, + fetchImpl: typeof fetch, + sleepFn: ((ms: number) => Promise) | undefined, +): Promise { + for (const path of ["AGENTS.md", "CLAUDE.md"]) { + const payload = await getJson( + `${base}/repos/${target.owner}/${target.repo}/contents/${path}`, + headers, + fetchImpl, + sleepFn, + ); + const text = decodeContents(payload); + if (text !== null) return text; + } + return null; +} + +/** Extract the PR-body linked-issue requirement from a contribution doc. A very small file is a signpost, not + * the rules, so it yields `absent` rather than a false negative dressed as a real one. `source`/`detail` tag + * the provenance so the same logic serves CONTRIBUTING.md (default) and the #8316 agent-docs fallback. */ function extractPrBody( - contributing: string | null, + doc: string | null, + source: ContributionSignalSource = "contributing_md", + detail = "CONTRIBUTING.md", ): ContributionSignalRule { - if (contributing === null) + if (doc === null) return { value: null, confidence: "absent", provenance: [] }; - if (contributing.length < CONTRIBUTING_SIGNPOST_MAX_BYTES) + if (doc.length < CONTRIBUTING_SIGNPOST_MAX_BYTES) return { value: null, confidence: "unknown", provenance: [] }; - const lower = contributing.toLowerCase(); + const lower = doc.toLowerCase(); const requiresLinkedIssue = LINKED_ISSUE_TERMS.some((term) => lower.includes(term), ); - // A real, sufficiently-sized CONTRIBUTING.md is an explicit source either way: present-with-keyword is an - // explicit requirement, present-without is an explicit "no such rule". + // A real, sufficiently-sized doc is an explicit source either way: present-with-keyword is an explicit + // requirement, present-without is an explicit "no such rule". return { value: { requiresLinkedIssue }, confidence: "explicit", - provenance: [{ source: "contributing_md", detail: "CONTRIBUTING.md" }], + provenance: [{ source, detail }], }; } @@ -321,7 +348,19 @@ export async function extractContributionProfile( "explicit", ); const exclusionLabels = classifyLabels(labels, EXCLUSION_TERMS, "inferred"); - const prBody = extractPrBody(contributing); + let prBody = extractPrBody(contributing); + // #8316: agent docs are a fallback source, evaluated ONLY when there is no CONTRIBUTING.md at all (prBody + // absent). A real CONTRIBUTING.md — even one that yields "no linked-issue rule" — stays authoritative. + if (prBody.confidence === "absent") { + const agentDocs = await fetchAgentDocs( + base, + target, + headers, + fetchImpl, + sleepFn, + ); + prBody = extractPrBody(agentDocs, "agent_docs", "AGENTS.md"); + } return { repoFullName: `${target.owner}/${target.repo}`, diff --git a/test/unit/contribution-profile-extract.test.ts b/test/unit/contribution-profile-extract.test.ts index 8ffaafc3ea..45d8276efe 100644 --- a/test/unit/contribution-profile-extract.test.ts +++ b/test/unit/contribution-profile-extract.test.ts @@ -16,12 +16,34 @@ function stubFetch( labels?: Label[] | number; contributing?: string | null; contributingGithubDir?: string | null; + agentsMd?: string | null; + claudeMd?: string | null; } = {}, ) { const emptyHeaders = { get: (_name: string) => null }; + const contentsResponse = (body: string | null | undefined) => { + if (body == null) + return { + ok: false, + status: 404, + headers: emptyHeaders, + json: async () => ({}), + } as unknown as Response; + return { + ok: true, + status: 200, + headers: emptyHeaders, + json: async () => ({ + encoding: "base64", + content: Buffer.from(String(body)).toString("base64"), + }), + } as unknown as Response; + }; return asFetch( vi.fn(async (url: string) => { const u = String(url); + if (u.includes("/contents/AGENTS.md")) return contentsResponse(opts.agentsMd); + if (u.includes("/contents/CLAUDE.md")) return contentsResponse(opts.claudeMd); if (u.includes("/labels")) { if (typeof opts.labels === "number") return { @@ -600,7 +622,7 @@ describe("extractContributionProfile (#6796)", () => { status: 200, json: async () => [], } as unknown as Response; - docCalls += 1; + if (u.includes("CONTRIBUTING.md")) docCalls += 1; return { ok: false, status: 500, @@ -612,7 +634,8 @@ describe("extractContributionProfile (#6796)", () => { generatedAt: AT, sleepFn: async () => {}, }); - // Both the root and `.github/` probes are each retried to exhaustion (3 attempts × 2 paths). + // Both the root and `.github/` CONTRIBUTING probes are each retried to exhaustion (3 attempts × 2 paths). + // The #8316 agent-docs fallback then also 5xx-exhausts (AGENTS.md/CLAUDE.md), leaving prBody absent. expect(docCalls).toBe(6); expect(profile.prBody.confidence).toBe("absent"); }); @@ -763,4 +786,108 @@ describe("extractContributionProfile (#6796)", () => { expect(profile.prBody.confidence).toBe("explicit"); expect(profile.completeness).toBe("absent"); }); + + // #8316: agent docs (AGENTS.md/CLAUDE.md) are a fallback linked-issue source used ONLY when no CONTRIBUTING.md + // exists. These cover the `prBody.confidence === "absent"` gate (both operands), the two agent-doc probe + // paths, and the signpost-size floor. + it("keeps CONTRIBUTING.md authoritative and never consults agent docs when a real CONTRIBUTING.md exists (#8316)", async () => { + const agentsSeen: string[] = []; + const fetchImpl = stubFetch({ + labels: [], + contributing: bigContributing("No linked-issue rule here."), + // AGENTS.md carries a linked-issue term, but must be ignored while CONTRIBUTING.md is present. + agentsMd: bigContributing("Every PR must Closes #1 an issue."), + }); + const wrapped = asFetch( + vi.fn(async (url: string, init?: RequestInit) => { + const u = String(url); + if (u.includes("/contents/AGENTS.md") || u.includes("/contents/CLAUDE.md")) + agentsSeen.push(u); + return (fetchImpl as unknown as (u: string, i?: RequestInit) => Promise)( + u, + init, + ); + }), + ); + const profile = await extractContributionProfile("acme/widgets", { + fetchImpl: wrapped, + generatedAt: AT, + }); + expect(agentsSeen).toEqual([]); + expect(profile.prBody).toEqual({ + value: { requiresLinkedIssue: false }, + confidence: "explicit", + provenance: [{ source: "contributing_md", detail: "CONTRIBUTING.md" }], + }); + }); + + it("falls back to AGENTS.md for the linked-issue rule when no CONTRIBUTING.md exists (#8316)", async () => { + const fetchImpl = stubFetch({ + labels: [], + contributing: null, + agentsMd: bigContributing("Reference an issue with Closes #42 in every PR."), + }); + const profile = await extractContributionProfile("acme/agentic", { + fetchImpl: asFetch(fetchImpl), + generatedAt: AT, + }); + expect(profile.prBody).toEqual({ + value: { requiresLinkedIssue: true }, + confidence: "explicit", + provenance: [{ source: "agent_docs", detail: "AGENTS.md" }], + }); + }); + + it("probes CLAUDE.md when AGENTS.md is also absent (#8316)", async () => { + const fetchImpl = stubFetch({ + labels: [], + contributing: null, + agentsMd: null, + claudeMd: bigContributing("Please link the issue your PR fixes #7."), + }); + const profile = await extractContributionProfile("acme/claudely", { + fetchImpl: asFetch(fetchImpl), + generatedAt: AT, + }); + expect(profile.prBody.confidence).toBe("explicit"); + expect(profile.prBody.value).toEqual({ requiresLinkedIssue: true }); + expect(profile.prBody.provenance).toEqual([ + { source: "agent_docs", detail: "AGENTS.md" }, + ]); + }); + + it("stays absent when neither CONTRIBUTING.md nor any agent doc exists (#8316)", async () => { + const fetchImpl = stubFetch({ + labels: [], + contributing: null, + agentsMd: null, + claudeMd: null, + }); + const profile = await extractContributionProfile("acme/bare", { + fetchImpl: asFetch(fetchImpl), + generatedAt: AT, + }); + expect(profile.prBody).toEqual({ + value: null, + confidence: "absent", + provenance: [], + }); + }); + + it("treats a tiny agent doc as a signpost (unknown), not a real rule (#8316)", async () => { + const fetchImpl = stubFetch({ + labels: [], + contributing: null, + agentsMd: "See AGENTS guide: https://example.org/agents", + }); + const profile = await extractContributionProfile("acme/tiny", { + fetchImpl: asFetch(fetchImpl), + generatedAt: AT, + }); + expect(profile.prBody).toEqual({ + value: null, + confidence: "unknown", + provenance: [], + }); + }); });