Skip to content
Closed
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
59 changes: 49 additions & 10 deletions packages/loopover-miner/lib/contribution-profile-extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type {
ContributionSignalConfidence,
ContributionSignalProvenance,
ContributionSignalRule,
ContributionSignalSource,
} from "./contribution-profile.js";
import {
CONTRIBUTION_PROFILE_SCHEMA_VERSION,
Expand Down Expand Up @@ -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<string, string>,
fetchImpl: typeof fetch,
sleepFn: ((ms: number) => Promise<unknown>) | undefined,
): Promise<string | null> {
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<ContributionPrBodyRequirements> {
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 }],
};
}

Expand Down Expand Up @@ -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}`,
Expand Down
131 changes: 129 additions & 2 deletions test/unit/contribution-profile-extract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand All @@ -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");
});
Expand Down Expand Up @@ -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<Response>)(
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: [],
});
});
});