diff --git a/packages/loopover-miner/lib/contribution-profile-extract.d.ts b/packages/loopover-miner/lib/contribution-profile-extract.d.ts new file mode 100644 index 0000000000..b48067f309 --- /dev/null +++ b/packages/loopover-miner/lib/contribution-profile-extract.d.ts @@ -0,0 +1,17 @@ +import type { ContributionProfile } from "./contribution-profile.js"; + +/** + * Extract a best-effort ContributionProfile for a repo from its published label taxonomy and contribution docs. + * Never throws: any fetch/parse failure degrades the relevant signal to `absent`/`unknown`. Generic — no + * loopover-specific hardcoding. + */ +export function extractContributionProfile( + repoFullName: string, + options?: { + fetchImpl?: typeof fetch; + githubToken?: string; + apiBaseUrl?: string; + /** ISO timestamp for the profile's generatedAt; defaults to now. Injected so tests stay deterministic. */ + generatedAt?: string; + }, +): Promise; diff --git a/packages/loopover-miner/lib/contribution-profile-extract.js b/packages/loopover-miner/lib/contribution-profile-extract.js new file mode 100644 index 0000000000..ca256c7de4 --- /dev/null +++ b/packages/loopover-miner/lib/contribution-profile-extract.js @@ -0,0 +1,246 @@ +// ContributionProfile extraction (#6796). Reads a repo's real, published signals — label taxonomy + contribution +// docs — and produces a populated ContributionProfile per the #6795 schema. GENERIC by design: it recognizes +// conventional OSS eligibility/exclusion vocabulary and matches over label name AND description, with NO +// loopover-specific keyword hardcoding (the #6794 inventory found loopover's own `gittensor:*` labels are the +// exception, not the shape to generalize from). Never throws: any fetch/parse failure degrades a signal to +// `absent`/`unknown` rather than erroring, so an unreachable or docs-less repo yields a low-confidence profile. +import { + CONTRIBUTION_PROFILE_SCHEMA_VERSION, + emptyContributionProfile, + weakestConfidence, +} from "./contribution-profile.js"; + +const DEFAULT_API_BASE_URL = "https://api.github.com"; +const GITHUB_API_VERSION = "2022-11-28"; +const REQUEST_TIMEOUT_MS = 10_000; +/** A CONTRIBUTING.md smaller than this is treated as a signpost (a link to an external guide), not the rules + * themselves — #6794 found react's is 208 B and kubernetes' 525 B, both just pointers. */ +const CONTRIBUTING_SIGNPOST_MAX_BYTES = 600; + +/** Canonical eligibility vocabulary — recognized OSS "contributor-workable" conventions. Matched case-insensitively + * as a substring over a label's name AND description. Not loopover-specific. */ +const ELIGIBILITY_TERMS = Object.freeze([ + "good first issue", + "good-first-issue", + "help wanted", + "help-wanted", + "up for grabs", + "beginner", + "easy", + "starter", +]); + +/** Conventional exclusion/off-limits vocabulary. These are UNstated conventions (#6794 found no repo names + * exclusion in a label NAME explicitly), so a match yields `inferred`, never `explicit`. */ +const EXCLUSION_TERMS = Object.freeze([ + "blocked", + "on hold", + "on-hold", + "do not merge", + "wontfix", + "invalid", + "needs triage", + "work in progress", + "wip", + "maintainer only", + "internal", +]); + +/** Closing-keyword / linked-issue language in a CONTRIBUTING.md. */ +const LINKED_ISSUE_TERMS = Object.freeze([ + "closes #", + "fixes #", + "resolves #", + "linked issue", + "reference an issue", + "link to an issue", +]); + +/** @param {string} repoFullName @returns {{owner:string,repo:string}|null} */ +function parseRepoFullName(repoFullName) { + if (typeof repoFullName !== "string") return null; + const [owner, repo, extra] = repoFullName.split("/"); + if (!owner?.trim() || !repo?.trim() || extra !== undefined) return null; + return { owner: owner.trim(), repo: repo.trim() }; +} + +/** @param {string|undefined} githubToken */ +function githubHeaders(githubToken) { + const headers = { + accept: "application/vnd.github+json", + "user-agent": "loopover-miner", + "x-github-api-version": GITHUB_API_VERSION, + }; + if (githubToken) headers.authorization = `Bearer ${githubToken}`; + return headers; +} + +/** Bounded, never-throwing JSON GET. Returns null on any transport/HTTP/parse failure. */ +async function getJson(url, headers, fetchImpl) { + let response; + try { + response = await fetchImpl(url, { + method: "GET", + headers, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + } catch { + return null; + } + if (!response.ok) return null; + return response.json().catch(() => null); +} + +/** + * Match one label against a term list, preferring the NAME but falling back to the DESCRIPTION (the rust + * `E-easy` finding: a label can carry its eligibility meaning only in the description). Returns the matcher + + * a provenance detail, or null when neither field matches. + */ +function matchLabel(label, terms) { + const rawName = typeof label?.name === "string" ? label.name : ""; + const name = rawName.toLowerCase(); + const description = + typeof label?.description === "string" + ? label.description.toLowerCase() + : ""; + const detail = rawName || "(unnamed label)"; + const nameTerm = terms.find((term) => name.includes(term)); + if (nameTerm !== undefined) + return { matcher: { field: "name", contains: nameTerm }, detail }; + const descriptionTerm = terms.find((term) => description.includes(term)); + if (descriptionTerm !== undefined) + return { + matcher: { field: "description", contains: descriptionTerm }, + detail, + }; + return null; +} + +/** Classify labels into a SignalRule of the given confidence. Recognized labels build an OR-list of matchers; + * no match ⇒ `absent`. Eligibility passes `explicit` (a recognized convention IS an explicit statement); + * exclusion passes `inferred` (conventional but unstated). */ +function classifyLabels(labels, terms, matchedConfidence) { + const matchers = []; + const provenance = []; + for (const label of labels) { + const hit = matchLabel(label, terms); + if (hit === null) continue; + matchers.push(hit.matcher); + provenance.push({ source: "labels", detail: hit.detail }); + } + if (matchers.length === 0) + return { value: null, confidence: "absent", provenance: [] }; + return { value: matchers, confidence: matchedConfidence, provenance }; +} + +/** Decode a GitHub contents API response body to text. Returns null when absent or not base64. Buffer.from over + * a string never throws, so no error path is needed here. */ +function decodeContents(payload) { + if ( + !payload || + typeof payload.content !== "string" || + payload.encoding !== "base64" + ) + return null; + return Buffer.from(payload.content, "base64").toString("utf8"); +} + +/** Fetch CONTRIBUTING.md, probing the repo root then `.github/` (#6794: 6/10 at root, 2/10 under `.github/`). */ +async function fetchContributing(base, target, headers, fetchImpl) { + for (const path of ["CONTRIBUTING.md", ".github/CONTRIBUTING.md"]) { + const payload = await getJson( + `${base}/repos/${target.owner}/${target.repo}/contents/${path}`, + headers, + fetchImpl, + ); + const text = decodeContents(payload); + if (text !== null) return text; + } + 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. */ +function extractPrBody(contributing) { + if (contributing === null) + return { value: null, confidence: "absent", provenance: [] }; + if (contributing.length < CONTRIBUTING_SIGNPOST_MAX_BYTES) + return { value: null, confidence: "unknown", provenance: [] }; + const lower = contributing.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". + return { + value: { requiresLinkedIssue }, + confidence: "explicit", + provenance: [{ source: "contributing_md", detail: "CONTRIBUTING.md" }], + }; +} + +/** + * Extract a best-effort ContributionProfile for a repo from what it actually publishes. + * + * @param {string} repoFullName owner/repo + * @param {{ fetchImpl?: typeof fetch, githubToken?: string, apiBaseUrl?: string, generatedAt?: string }} [options] + * @returns {Promise} + */ +export async function extractContributionProfile(repoFullName, options = {}) { + const generatedAt = + typeof options.generatedAt === "string" + ? options.generatedAt + : new Date().toISOString(); + const target = parseRepoFullName(repoFullName); + // A malformed name can't be fetched — return the safe, fully-absent default rather than throwing. + if (target === null) + return emptyContributionProfile( + typeof repoFullName === "string" ? repoFullName : "", + generatedAt, + ); + + /* v8 ignore next -- the global-fetch default is the production path; every test injects fetchImpl. */ + const fetchImpl = options.fetchImpl ?? fetch; + const base = + typeof options.apiBaseUrl === "string" && options.apiBaseUrl.trim() + ? options.apiBaseUrl.replace(/\/+$/, "") + : DEFAULT_API_BASE_URL; + const headers = githubHeaders( + options.githubToken ?? process.env.GITHUB_TOKEN, + ); + + const labelsPayload = await getJson( + `${base}/repos/${target.owner}/${target.repo}/labels?per_page=100`, + headers, + fetchImpl, + ); + const labels = Array.isArray(labelsPayload) ? labelsPayload : []; + const contributing = await fetchContributing( + base, + target, + headers, + fetchImpl, + ); + + const eligibilityLabels = classifyLabels( + labels, + ELIGIBILITY_TERMS, + "explicit", + ); + const exclusionLabels = classifyLabels(labels, EXCLUSION_TERMS, "inferred"); + const prBody = extractPrBody(contributing); + + return { + repoFullName: `${target.owner}/${target.repo}`, + schemaVersion: CONTRIBUTION_PROFILE_SCHEMA_VERSION, + generatedAt, + eligibilityLabels, + exclusionLabels, + prBody, + completeness: weakestConfidence([ + eligibilityLabels.confidence, + exclusionLabels.confidence, + prBody.confidence, + ]), + }; +} diff --git a/packages/loopover-miner/package.json b/packages/loopover-miner/package.json index 8cbff69a0d..4d9a286bde 100644 --- a/packages/loopover-miner/package.json +++ b/packages/loopover-miner/package.json @@ -38,7 +38,7 @@ "scripts": { "benchmark": "node scripts/benchmark.mjs", "cross-repo-eval": "node scripts/cross-repo-evaluation.mjs", - "build": "node --check bin/loopover-miner.js && node --check bin/loopover-miner-mcp.js && node --check lib/ams-policy.js && node --check lib/attempt-cli.js && node --check lib/attempt-input-builder.js && node --check lib/attempt-log.js && node --check lib/attempt-runner.js && node --check lib/attempt-worktree.js && node --check lib/calibration-run.js && node --check lib/calibration-types.js && node --check lib/calibration.js && node --check lib/chat-action-dispatch.js && node --check lib/chat-action-registry.js && node --check lib/chat-discover-attempt-actions.js && node --check lib/chat-governor-actions.js && node --check lib/chat-portfolio-actions.js && node --check lib/ci-poller.js && node --check lib/claim-adjudication.js && node --check lib/claim-conflict-resolver.js && node --check lib/claim-ledger-cli.js && node --check lib/claim-ledger-expiry.js && node --check lib/claim-ledger.js && node --check lib/cli.js && node --check lib/coding-agent-construction.js && node --check lib/coding-agent-house-rules.js && node --check lib/coding-task-spec.js && node --check lib/contribution-profile.js && node --check lib/cross-repo-evaluation.js && node --check lib/deny-check.js && node --check lib/deny-hook-synthesis.js && node --check lib/deny-hooks.js && node --check lib/deployment-docs-audit.js && node --check lib/discover-cli.js && node --check lib/event-ledger-cli.js && node --check lib/event-ledger.js && node --check lib/execute-local-write.js && node --check lib/feasibility-cli.js && node --check lib/governor-chokepoint-persisted.js && node --check lib/governor-chokepoint.js && node --check lib/governor-kill-switch.js && node --check lib/governor-ledger-cli.js && node --check lib/governor-ledger.js && node --check lib/governor-metrics-cli.js && node --check lib/governor-pause-cli.js && node --check lib/governor-run-halt.js && node --check lib/governor-state.js && node --check lib/harness-submission-trigger.js && node --check lib/init-wizard.js && node --check lib/laptop-init.js && node --check lib/live-issue-snapshot.js && node --check lib/local-store.js && node --check lib/logger.js && node --check lib/loop-cli.js && node --check lib/loop-closure.js && node --check lib/loop-reentry.js && node --check lib/manage-poll.js && node --check lib/manage-status.js && node --check lib/metrics-cli.js && node --check lib/miner-goal-spec.js && node --check lib/opportunity-fanout.js && node --check lib/opportunity-ranker.js && node --check lib/orb-export.js && node --check lib/plan-store-cli.js && node --check lib/plan-store.js && node --check lib/policy-doc-cache.js && node --check lib/policy-verdict-cache.js && node --check lib/portfolio-dashboard.js && node --check lib/portfolio-discovery.js && node --check lib/portfolio-queue-cli.js && node --check lib/portfolio-queue-manager.js && node --check lib/portfolio-queue.js && node --check lib/portfolio-queue-expiry.js && node --check lib/pr-disposition-poller.js && node --check lib/pr-number-parse.js && node --check lib/pr-outcome.js && node --check lib/prediction-ledger.js && node --check lib/pretooluse-hook.js && node --check lib/purge-cli.js && node --check lib/ranked-candidates.js && node --check lib/rejection-signal.js && node --check lib/rejection-state-machine.js && node --check lib/rejection-templates.js && node --check lib/replay-objective-anchor.js && node --check lib/replay-snapshot.js && node --check lib/replay-task-bridge.js && node --check lib/replay-task-generation.js && node --check lib/repo-clone.js && node --check lib/run-state-cli.js && node --check lib/run-state.js && node --check lib/self-review-context.js && node --check lib/sentry.js && node --check lib/slop-assessment.js && node --check lib/stack-detection.js && node --check lib/status.js && node --check lib/submission-freshness-check.js && node --check lib/update-check.js && node --check lib/version.js && node --check lib/worktree-allocator.js" + "build": "node --check bin/loopover-miner.js && node --check bin/loopover-miner-mcp.js && node --check lib/ams-policy.js && node --check lib/attempt-cli.js && node --check lib/attempt-input-builder.js && node --check lib/attempt-log.js && node --check lib/attempt-runner.js && node --check lib/attempt-worktree.js && node --check lib/calibration-run.js && node --check lib/calibration-types.js && node --check lib/calibration.js && node --check lib/chat-action-dispatch.js && node --check lib/chat-action-registry.js && node --check lib/chat-discover-attempt-actions.js && node --check lib/chat-governor-actions.js && node --check lib/chat-portfolio-actions.js && node --check lib/ci-poller.js && node --check lib/claim-adjudication.js && node --check lib/claim-conflict-resolver.js && node --check lib/claim-ledger-cli.js && node --check lib/claim-ledger-expiry.js && node --check lib/claim-ledger.js && node --check lib/cli.js && node --check lib/coding-agent-construction.js && node --check lib/coding-agent-house-rules.js && node --check lib/coding-task-spec.js && node --check lib/contribution-profile.js && node --check lib/contribution-profile-extract.js && node --check lib/cross-repo-evaluation.js && node --check lib/deny-check.js && node --check lib/deny-hook-synthesis.js && node --check lib/deny-hooks.js && node --check lib/deployment-docs-audit.js && node --check lib/discover-cli.js && node --check lib/event-ledger-cli.js && node --check lib/event-ledger.js && node --check lib/execute-local-write.js && node --check lib/feasibility-cli.js && node --check lib/governor-chokepoint-persisted.js && node --check lib/governor-chokepoint.js && node --check lib/governor-kill-switch.js && node --check lib/governor-ledger-cli.js && node --check lib/governor-ledger.js && node --check lib/governor-metrics-cli.js && node --check lib/governor-pause-cli.js && node --check lib/governor-run-halt.js && node --check lib/governor-state.js && node --check lib/harness-submission-trigger.js && node --check lib/init-wizard.js && node --check lib/laptop-init.js && node --check lib/live-issue-snapshot.js && node --check lib/local-store.js && node --check lib/logger.js && node --check lib/loop-cli.js && node --check lib/loop-closure.js && node --check lib/loop-reentry.js && node --check lib/manage-poll.js && node --check lib/manage-status.js && node --check lib/metrics-cli.js && node --check lib/miner-goal-spec.js && node --check lib/opportunity-fanout.js && node --check lib/opportunity-ranker.js && node --check lib/orb-export.js && node --check lib/plan-store-cli.js && node --check lib/plan-store.js && node --check lib/policy-doc-cache.js && node --check lib/policy-verdict-cache.js && node --check lib/portfolio-dashboard.js && node --check lib/portfolio-discovery.js && node --check lib/portfolio-queue-cli.js && node --check lib/portfolio-queue-manager.js && node --check lib/portfolio-queue.js && node --check lib/portfolio-queue-expiry.js && node --check lib/pr-disposition-poller.js && node --check lib/pr-number-parse.js && node --check lib/pr-outcome.js && node --check lib/prediction-ledger.js && node --check lib/pretooluse-hook.js && node --check lib/purge-cli.js && node --check lib/ranked-candidates.js && node --check lib/rejection-signal.js && node --check lib/rejection-state-machine.js && node --check lib/rejection-templates.js && node --check lib/replay-objective-anchor.js && node --check lib/replay-snapshot.js && node --check lib/replay-task-bridge.js && node --check lib/replay-task-generation.js && node --check lib/repo-clone.js && node --check lib/run-state-cli.js && node --check lib/run-state.js && node --check lib/self-review-context.js && node --check lib/sentry.js && node --check lib/slop-assessment.js && node --check lib/stack-detection.js && node --check lib/status.js && node --check lib/submission-freshness-check.js && node --check lib/update-check.js && node --check lib/version.js && node --check lib/worktree-allocator.js" }, "dependencies": { "@loopover/engine": "^3.0.0", diff --git a/test/unit/contribution-profile-extract.test.ts b/test/unit/contribution-profile-extract.test.ts new file mode 100644 index 0000000000..55d97c54c5 --- /dev/null +++ b/test/unit/contribution-profile-extract.test.ts @@ -0,0 +1,394 @@ +import { describe, expect, it, vi } from "vitest"; + +import { extractContributionProfile } from "../../packages/loopover-miner/lib/contribution-profile-extract.js"; + +const AT = "2026-07-18T00:00:00.000Z"; + +type Label = { name: string; description?: string | null }; + +/** The repo's global `fetch` type (Cloudflare Workers) has a wider input type than a plain `(url: string)` + * mock; cast through unknown so a url-string stub satisfies the `fetchImpl?: typeof fetch` option. */ +const asFetch = (fn: unknown): typeof fetch => fn as unknown as typeof fetch; + +/** Build a fetch stub whose /labels response is `labels` and whose CONTRIBUTING.md is `contributing` (or 404). */ +function stubFetch( + opts: { + labels?: Label[] | number; + contributing?: string | null; + contributingGithubDir?: string | null; + } = {}, +) { + return asFetch( + vi.fn(async (url: string) => { + const u = String(url); + if (u.includes("/labels")) { + if (typeof opts.labels === "number") + return { + ok: false, + status: opts.labels, + json: async () => ({}), + } as unknown as Response; + return { + ok: true, + status: 200, + json: async () => opts.labels ?? [], + } as unknown as Response; + } + if (u.includes("/contents/CONTRIBUTING.md")) { + if (opts.contributing == null) + return { + ok: false, + status: 404, + json: async () => ({}), + } as unknown as Response; + return { + ok: true, + status: 200, + json: async () => ({ + encoding: "base64", + content: Buffer.from(String(opts.contributing)).toString("base64"), + }), + } as unknown as Response; + } + if (u.includes("/contents/.github/CONTRIBUTING.md")) { + if (opts.contributingGithubDir == null) + return { + ok: false, + status: 404, + json: async () => ({}), + } as unknown as Response; + return { + ok: true, + status: 200, + json: async () => ({ + encoding: "base64", + content: Buffer.from(String(opts.contributingGithubDir)).toString( + "base64", + ), + }), + } as unknown as Response; + } + return { + ok: false, + status: 404, + json: async () => ({}), + } as unknown as Response; + }), + ); +} + +const bigContributing = (body: string) => + `${body}\n${"filler line to exceed the signpost threshold.\n".repeat(30)}`; + +describe("extractContributionProfile (#6796)", () => { + it("extracts loopover's own convention (help wanted label) as an explicit eligibility rule", async () => { + const fetchImpl = stubFetch({ + labels: [ + { name: "help wanted", description: "Extra attention is needed" }, + { name: "gittensor", description: "Gittensor contributor context" }, + ], + }); + const profile = await extractContributionProfile("JSONbored/loopover", { + fetchImpl: asFetch(fetchImpl), + generatedAt: AT, + }); + expect(profile.eligibilityLabels.confidence).toBe("explicit"); + expect(profile.eligibilityLabels.value).toEqual([ + { field: "name", contains: "help wanted" }, + ]); + expect(profile.eligibilityLabels.provenance).toEqual([ + { source: "labels", detail: "help wanted" }, + ]); + expect(profile.repoFullName).toBe("JSONbored/loopover"); + expect(profile.schemaVersion).toBe(1); + }); + + it("extracts a DIFFERENT but explicit convention where the meaning is in the description, not the name", async () => { + // A label whose NAME carries no recognized eligibility term, but whose DESCRIPTION does (the #6794 finding + // that rust encodes eligibility in descriptions) — a name-only extractor would miss this entirely. + const fetchImpl = stubFetch({ + labels: [ + { + name: "mentored", + description: "A good first issue with a mentor assigned.", + }, + ], + }); + const profile = await extractContributionProfile("rust-lang/rust", { + fetchImpl: asFetch(fetchImpl), + generatedAt: AT, + }); + expect(profile.eligibilityLabels.confidence).toBe("explicit"); + expect(profile.eligibilityLabels.value).toEqual([ + { field: "description", contains: "good first issue" }, + ]); + }); + + it("produces a low-confidence, fully-absent profile for a repo with no discoverable signals — not a false guess", async () => { + const fetchImpl = stubFetch({ + labels: [{ name: "bug", description: "Something is broken" }], + contributing: null, + }); + const profile = await extractContributionProfile("sindresorhus/slugify", { + fetchImpl: asFetch(fetchImpl), + generatedAt: AT, + }); + expect(profile.eligibilityLabels).toEqual({ + value: null, + confidence: "absent", + provenance: [], + }); + expect(profile.exclusionLabels).toEqual({ + value: null, + confidence: "absent", + provenance: [], + }); + expect(profile.prBody).toEqual({ + value: null, + confidence: "absent", + provenance: [], + }); + expect(profile.completeness).toBe("absent"); + }); + + it("classifies conventional exclusion labels as inferred (weaker than eligibility)", async () => { + const fetchImpl = stubFetch({ + labels: [ + { name: "blocked", description: "Waiting on something else" }, + { name: "wontfix", description: null }, + ], + }); + const profile = await extractContributionProfile("acme/widgets", { + fetchImpl: asFetch(fetchImpl), + generatedAt: AT, + }); + expect(profile.exclusionLabels.confidence).toBe("inferred"); + expect(profile.exclusionLabels.value).toEqual([ + { field: "name", contains: "blocked" }, + { field: "name", contains: "wontfix" }, + ]); + }); + + it("reads the linked-issue requirement from a real-sized CONTRIBUTING.md and marks it explicit", async () => { + const fetchImpl = stubFetch({ + labels: [], + contributing: bigContributing( + "Every PR must reference an issue with Closes #123.", + ), + }); + const profile = await extractContributionProfile("acme/widgets", { + fetchImpl: asFetch(fetchImpl), + generatedAt: AT, + }); + expect(profile.prBody).toEqual({ + value: { requiresLinkedIssue: true }, + confidence: "explicit", + provenance: [{ source: "contributing_md", detail: "CONTRIBUTING.md" }], + }); + }); + + it("marks prBody explicit-false when a real CONTRIBUTING.md states no linked-issue rule", async () => { + const fetchImpl = stubFetch({ + labels: [], + contributing: bigContributing( + "Please run the tests before opening a PR.", + ), + }); + const profile = await extractContributionProfile("acme/widgets", { + fetchImpl: asFetch(fetchImpl), + generatedAt: AT, + }); + expect(profile.prBody).toEqual({ + value: { requiresLinkedIssue: false }, + confidence: "explicit", + provenance: [{ source: "contributing_md", detail: "CONTRIBUTING.md" }], + }); + }); + + it("treats a tiny CONTRIBUTING.md as a signpost, not the rules (unknown, not a false negative)", async () => { + // react's is 208 B / kubernetes' 525 B -- just a link to an external guide AMS cannot read. + const fetchImpl = stubFetch({ + labels: [], + contributing: "See our guide: https://example.org/contributing", + }); + const profile = await extractContributionProfile("facebook/react", { + fetchImpl: asFetch(fetchImpl), + generatedAt: AT, + }); + expect(profile.prBody).toEqual({ + value: null, + confidence: "unknown", + provenance: [], + }); + }); + + it("falls back to .github/CONTRIBUTING.md when the root file is absent", async () => { + const fetchImpl = stubFetch({ + labels: [], + contributing: null, + contributingGithubDir: bigContributing("Reference an issue in your PR."), + }); + const profile = await extractContributionProfile("denoland/deno", { + fetchImpl: asFetch(fetchImpl), + generatedAt: AT, + }); + expect(profile.prBody.value).toEqual({ requiresLinkedIssue: true }); + }); + + it("degrades to absent labels when the labels fetch fails (HTTP error), without throwing", async () => { + const fetchImpl = stubFetch({ labels: 500, contributing: null }); + const profile = await extractContributionProfile("acme/widgets", { + fetchImpl: asFetch(fetchImpl), + generatedAt: AT, + }); + expect(profile.eligibilityLabels.confidence).toBe("absent"); + expect(profile.completeness).toBe("absent"); + }); + + it("degrades to absent when the transport throws, without propagating the error", async () => { + const fetchImpl = vi.fn(async () => { + throw new Error("network down"); + }); + const profile = await extractContributionProfile("acme/widgets", { + fetchImpl: asFetch(fetchImpl), + generatedAt: AT, + }); + expect(profile.eligibilityLabels.confidence).toBe("absent"); + expect(profile.prBody.confidence).toBe("absent"); + }); + + it("returns a safe empty profile for a malformed repo name, without any fetch", async () => { + const fetchImpl = vi.fn(); + const profile = await extractContributionProfile("not-a-repo", { + fetchImpl: asFetch(fetchImpl), + generatedAt: AT, + }); + expect(profile.repoFullName).toBe("not-a-repo"); + expect(profile.completeness).toBe("absent"); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it("returns an empty profile with an empty repoFullName for a non-string input, without fetching", async () => { + const fetchImpl = vi.fn(); + const profile = await extractContributionProfile(123 as unknown as string, { + fetchImpl: asFetch(fetchImpl), + generatedAt: AT, + }); + expect(profile.repoFullName).toBe(""); + expect(profile.completeness).toBe("absent"); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it("ignores an undecodable/wrong-encoding contents payload, treating the doc as absent", async () => { + const fetchImpl = vi.fn(async (url: string) => { + const u = String(url); + if (u.includes("/labels")) + return { + ok: true, + status: 200, + json: async () => [], + } as unknown as Response; + // A non-base64 encoding (e.g. a large file returned as a download URL) must not throw. + return { + ok: true, + status: 200, + json: async () => ({ encoding: "none", content: "" }), + } as unknown as Response; + }); + const profile = await extractContributionProfile("acme/widgets", { + fetchImpl: asFetch(fetchImpl), + generatedAt: AT, + }); + expect(profile.prBody.confidence).toBe("absent"); + }); + + it("sends an Authorization header when a token is supplied, and hits the configured apiBaseUrl", async () => { + const seen: Array<{ url: string; auth: string | undefined }> = []; + const fetchImpl = vi.fn(async (url: string, init: RequestInit) => { + seen.push({ + url: String(url), + auth: (init.headers as Record).authorization, + }); + return { + ok: true, + status: 200, + json: async () => (String(url).includes("/labels") ? [] : {}), + } as unknown as Response; + }); + await extractContributionProfile("acme/widgets", { + fetchImpl: asFetch(fetchImpl), + githubToken: "tok123", + apiBaseUrl: "https://ghe.example.com/api/v3/", + generatedAt: AT, + }); + expect(seen[0]?.url).toContain( + "https://ghe.example.com/api/v3/repos/acme/widgets/labels", + ); + expect(seen[0]?.auth).toBe("Bearer tok123"); + }); + + it("labels a matched label with no name as an unnamed label in provenance", async () => { + // A label object missing `name` but matching via description must not crash the provenance detail. + const fetchImpl = stubFetch({ + labels: [{ description: "good first issue" } as Label], + }); + const profile = await extractContributionProfile("acme/widgets", { + fetchImpl: asFetch(fetchImpl), + generatedAt: AT, + }); + expect(profile.eligibilityLabels.provenance).toEqual([ + { source: "labels", detail: "(unnamed label)" }, + ]); + expect(profile.eligibilityLabels.value).toEqual([ + { field: "description", contains: "good first issue" }, + ]); + }); + + it("defaults generatedAt to a fresh ISO timestamp when none is supplied", async () => { + const fetchImpl = stubFetch({ labels: [] }); + const profile = await extractContributionProfile("acme/widgets", { + fetchImpl: asFetch(fetchImpl), + }); + expect(profile.generatedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); + }); + + it("degrades a labels response whose body fails to parse as JSON, without throwing", async () => { + const fetchImpl = vi.fn(async (url: string) => { + if (String(url).includes("/labels")) { + return { + ok: true, + status: 200, + json: async () => { + throw new Error("bad json"); + }, + } as unknown as Response; + } + return { + ok: false, + status: 404, + json: async () => ({}), + } as unknown as Response; + }); + const profile = await extractContributionProfile("acme/widgets", { + fetchImpl: asFetch(fetchImpl), + generatedAt: AT, + }); + expect(profile.eligibilityLabels.confidence).toBe("absent"); + }); + + it("computes completeness as the weakest of the three spine signals", async () => { + // Explicit eligibility + absent exclusion + explicit prBody ⇒ weakest is absent. + const fetchImpl = stubFetch({ + labels: [{ name: "good first issue", description: null }], + contributing: bigContributing("Reference an issue with Closes #1."), + }); + const profile = await extractContributionProfile("acme/widgets", { + fetchImpl: asFetch(fetchImpl), + generatedAt: AT, + }); + expect(profile.eligibilityLabels.confidence).toBe("explicit"); + expect(profile.exclusionLabels.confidence).toBe("absent"); + expect(profile.prBody.confidence).toBe("explicit"); + expect(profile.completeness).toBe("absent"); + }); +});