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
17 changes: 17 additions & 0 deletions packages/loopover-miner/lib/contribution-profile-extract.d.ts
Original file line number Diff line number Diff line change
@@ -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<ContributionProfile>;
246 changes: 246 additions & 0 deletions packages/loopover-miner/lib/contribution-profile-extract.js
Original file line number Diff line number Diff line change
@@ -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<import("./contribution-profile.js").ContributionProfile>}
*/
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,
]),
};
}
2 changes: 1 addition & 1 deletion packages/loopover-miner/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading