From 418b1e7c76f859168a6e8c0d302e22fdbbc5ba49 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Fri, 17 Jul 2026 11:46:37 -0700 Subject: [PATCH] feat(miner): AMS contribution-eligibility filtering for discover Closes #6798: wires eligibility filtering into `discover` -- resolves each target repo's ContributionProfile (cache hit when fresh, extracted live otherwise, via the already-merged #6797 store) and excludes a candidate missing a required eligibility label, carrying an exclusion label, or assigned to the repo's own owner, before ranking/enqueueing. Safe-default posture: a label rule only excludes when the profile actually resolved a matcher for it -- a repo whose conventions couldn't be read excludes nothing via those rules, so discover never silently skips real work on an unreadable repo. Conflicting signals (a candidate that is both eligible and excluded) resolve conservatively: exclusion always wins. Both --dry-run and the real run apply the same filtering; dry-run never opens the cache, matching the existing policy-doc/policy-verdict cache convention. Excluded candidates and their reasons are surfaced in both --json and the human-readable summary. Extends RawCandidateIssue with `assignees` (already present in the same GitHub list/search payload discover already fetches -- no extra request) to support the assignee-exclusion check. Label-description matching (the "rust E-easy" case from the #6794 signal inventory) uses a separate, conditional per-repo label fetch rather than widening the candidate shape further, since descriptions are needed far less often than names. --- packages/loopover-miner/DEPLOYMENT.md | 3 +- packages/loopover-miner/README.md | 10 + .../lib/contribution-profile-eligibility.d.ts | 45 +++ .../lib/contribution-profile-eligibility.js | 174 +++++++++++ .../lib/contribution-profile-resolution.d.ts | 25 ++ .../lib/contribution-profile-resolution.js | 74 +++++ packages/loopover-miner/lib/discover-cli.d.ts | 21 ++ packages/loopover-miner/lib/discover-cli.js | 77 ++++- .../lib/opportunity-fanout.d.ts | 2 + .../loopover-miner/lib/opportunity-fanout.js | 11 + packages/loopover-miner/package.json | 2 +- ...r-contribution-profile-eligibility.test.ts | 281 ++++++++++++++++++ ...er-contribution-profile-resolution.test.ts | 163 ++++++++++ test/unit/miner-discover-cli.test.ts | 194 +++++++++++- test/unit/miner-extension-content.test.ts | 1 + test/unit/miner-opportunity-fanout.test.ts | 25 ++ test/unit/miner-opportunity-ranker.test.ts | 1 + 17 files changed, 1104 insertions(+), 5 deletions(-) create mode 100644 packages/loopover-miner/lib/contribution-profile-eligibility.d.ts create mode 100644 packages/loopover-miner/lib/contribution-profile-eligibility.js create mode 100644 packages/loopover-miner/lib/contribution-profile-resolution.d.ts create mode 100644 packages/loopover-miner/lib/contribution-profile-resolution.js create mode 100644 test/unit/miner-contribution-profile-eligibility.test.ts create mode 100644 test/unit/miner-contribution-profile-resolution.test.ts diff --git a/packages/loopover-miner/DEPLOYMENT.md b/packages/loopover-miner/DEPLOYMENT.md index 1de71eff1d..0a877b3006 100644 --- a/packages/loopover-miner/DEPLOYMENT.md +++ b/packages/loopover-miner/DEPLOYMENT.md @@ -66,11 +66,12 @@ For provider selection and the CLI-specific model/timeout overrides, see policy-verdict-cache.sqlite3 # cache of resolved AI-usage-policy verdicts (#4843) deny-hook-synthesis.sqlite3 # synthesized PreToolUse deny-hook proposals (#4522) orb-export.sqlite3 # opt-in anonymized Orb telemetry export state (#4277) + contribution-profile-cache.sqlite3 # cached per-repo AMS contribution-eligibility signals (#6797) ``` Not every file appears immediately: `laptop-state` is written by `init`, and each of the others is created the first time its subsystem actually runs (an attempt, a discovery pass, a replay, an Orb export, …), so a - fresh install that has only run `status`/`doctor` will show a subset. All sixteen default into this one + fresh install that has only run `status`/`doctor` will show a subset. All seventeen default into this one directory. Override the directory for every store at once with `LOOPOVER_MINER_CONFIG_DIR` or `XDG_CONFIG_HOME` (same resolution chain as `@loopover/mcp`); every store except `laptop-state.sqlite3` (directory only) also honors its own `LOOPOVER_MINER__DB` path override — e.g. diff --git a/packages/loopover-miner/README.md b/packages/loopover-miner/README.md index 4f20495de3..e92d9b87eb 100644 --- a/packages/loopover-miner/README.md +++ b/packages/loopover-miner/README.md @@ -45,6 +45,16 @@ tenant goal spec to the ranker, printing `usedDefaultGoalSpec` so a fall-back to rather than silent. See [`docs/repo-agnostic-capability-audit.md`](docs/repo-agnostic-capability-audit.md) for the #4780 audit this executes. +`discover` also filters candidates against each target repo's own contribution-eligibility conventions before +ranking/enqueueing (#6793/#6798): it resolves a `ContributionProfile` per repo — generic label-taxonomy/ +CONTRIBUTING.md extraction (`lib/contribution-profile-extract.js`), cached locally with a 7-day TTL +(`lib/contribution-profile-cache.js`) — and excludes a candidate missing a required eligibility label, carrying +an exclusion label, or assigned to the repo's own owner login. **Safe-default posture:** a label rule only +excludes when the profile actually resolved a matcher for it; a repo whose conventions couldn't be read at all +(no eligibility/exclusion signal found) excludes nothing via those rules rather than silently skipping real +work. `--json` and the human-readable summary both surface any excluded candidates and why +(`excludedByEligibility`). See [`docs/contribution-profile.md`](docs/contribution-profile.md) for the schema. + The package also includes repo stack auto-detection: `detectRepoStack` (`lib/stack-detection.js`) inspects an already-cloned target repo's manifest / lockfile / config files and returns a structured description — language, package manager, and the build / test / lint / format commands — for Node (npm/yarn/pnpm/bun), Python diff --git a/packages/loopover-miner/lib/contribution-profile-eligibility.d.ts b/packages/loopover-miner/lib/contribution-profile-eligibility.d.ts new file mode 100644 index 0000000000..e4084500b0 --- /dev/null +++ b/packages/loopover-miner/lib/contribution-profile-eligibility.d.ts @@ -0,0 +1,45 @@ +import type { ContributionProfile } from "./contribution-profile.js"; +import type { RawCandidateIssue } from "./opportunity-fanout.js"; + +export type CandidateEligibilityResult = { + excluded: boolean; + reasons: string[]; +}; + +export type ExcludedCandidate = { + issue: RawCandidateIssue; + reasons: string[]; +}; + +export type FilterCandidatesResult = { + eligible: RawCandidateIssue[]; + excluded: ExcludedCandidate[]; +}; + +export function evaluateCandidateEligibility( + issue: RawCandidateIssue, + profile: ContributionProfile | null | undefined, + options?: { + labelDescriptionsByName?: Map; + excludeAssignedLogins?: string[]; + }, +): CandidateEligibilityResult; + +export function filterEligibleCandidates( + issues: RawCandidateIssue[], + profilesByRepo: Map, + options?: { + labelDescriptionsByRepo?: Map>; + }, +): FilterCandidatesResult; + +export function profileNeedsLabelDescriptions(profile: ContributionProfile | null | undefined): boolean; + +export function fetchRepoLabelDescriptions( + repoFullName: string, + options?: { + fetchImpl?: typeof fetch; + githubToken?: string; + apiBaseUrl?: string; + }, +): Promise>; diff --git a/packages/loopover-miner/lib/contribution-profile-eligibility.js b/packages/loopover-miner/lib/contribution-profile-eligibility.js new file mode 100644 index 0000000000..a3028362b7 --- /dev/null +++ b/packages/loopover-miner/lib/contribution-profile-eligibility.js @@ -0,0 +1,174 @@ +// Contribution-eligibility filtering (#6798): the last piece of the AMS contribution-profile epic (#6793). +// Applies a target repo's ContributionProfile (#6795/#6796) to `discover`'s fanned-out candidates, excluding ones +// that fail the profile's eligibility rules -- BEFORE ranking/enqueueing, so a real repo's own conventions are +// respected instead of surfacing a candidate its maintainer would reject on arrival. +// +// Safe-default posture: a label-based rule only ever excludes when the profile actually resolved a matcher list +// for it (`value` non-null). A repo with no discoverable eligibility/exclusion label signal at all -- `value: +// null`, the "degrade honestly" outcome #6796 produces for an unreadable or convention-less repo -- excludes +// nothing via that rule. This falls straight out of "there is nothing to check a candidate against," not a +// separate confidence gate bolted on top: `discover` never silently skips real work on a repo whose conventions +// it simply couldn't read. The one check that is NOT gated on profile confidence is assignee-exclusion: per the +// schema (#6795), it is a live, structural fact ("this issue is assigned to the repo owner"), not something +// extraction infers, so it applies to every candidate regardless of how much (or how little) the profile knows. +// +// Conflicting signals (a candidate matches both an eligibility AND an exclusion matcher) resolve conservatively: +// exclusion always wins. The eligibility check only ever ADDS a reason when NO matcher matched; the exclusion +// check only ever ADDS a reason when one DID -- so a candidate that clears eligibility but also trips exclusion +// carries just the exclusion reason, and is excluded either way. + +const DEFAULT_API_BASE_URL = "https://api.github.com"; +const GITHUB_API_VERSION = "2022-11-28"; +const REQUEST_TIMEOUT_MS = 10_000; + +function labelMatchesMatcher(matcher, labelNames, labelDescriptionsByName) { + const term = matcher.contains.toLowerCase(); + if (matcher.field === "description") { + if (!labelDescriptionsByName) return false; + return labelNames.some((name) => { + const description = labelDescriptionsByName.get(name.toLowerCase()); + return typeof description === "string" && description.toLowerCase().includes(term); + }); + } + return labelNames.some((name) => name.toLowerCase().includes(term)); +} + +function anyMatcherMatches(matchers, labelNames, labelDescriptionsByName) { + return matchers.some((matcher) => labelMatchesMatcher(matcher, labelNames, labelDescriptionsByName)); +} + +/** + * Evaluate one candidate issue against a repo's ContributionProfile. Never throws; a missing/malformed `profile` + * behaves exactly like a fully-absent one (excludes nothing via the label rules). + * + * @param {import("./opportunity-fanout.js").RawCandidateIssue} issue + * @param {import("./contribution-profile.js").ContributionProfile | null | undefined} profile + * @param {{ labelDescriptionsByName?: Map, excludeAssignedLogins?: string[] }} [options] + * @returns {{ excluded: boolean, reasons: string[] }} + */ +export function evaluateCandidateEligibility(issue, profile, options = {}) { + const reasons = []; + + const eligibilityMatchers = profile?.eligibilityLabels?.value; + if (Array.isArray(eligibilityMatchers) && eligibilityMatchers.length > 0) { + if (!anyMatcherMatches(eligibilityMatchers, issue.labels, options.labelDescriptionsByName)) { + reasons.push("missing eligibility label"); + } + } + + const exclusionMatchers = profile?.exclusionLabels?.value; + if (Array.isArray(exclusionMatchers) && exclusionMatchers.length > 0) { + if (anyMatcherMatches(exclusionMatchers, issue.labels, options.labelDescriptionsByName)) { + reasons.push("exclusion label present"); + } + } + + // Structural, always-on (not gated on profile confidence, see header comment): defaults to the repo's own + // owner login, already present on the candidate -- no extra lookup needed. + const excludeAssignedLogins = options.excludeAssignedLogins ?? [issue.owner]; + const normalizedExcludedLogins = new Set(excludeAssignedLogins.map((login) => String(login).toLowerCase())); + if (issue.assignees.some((login) => normalizedExcludedLogins.has(String(login).toLowerCase()))) { + reasons.push("excluded assignee"); + } + + return { excluded: reasons.length > 0, reasons }; +} + +/** + * Split fanned-out candidates into eligible and excluded, per each candidate's own repo profile. + * + * @param {import("./opportunity-fanout.js").RawCandidateIssue[]} issues + * @param {Map} profilesByRepo + * @param {{ labelDescriptionsByRepo?: Map> }} [options] + * @returns {{ + * eligible: import("./opportunity-fanout.js").RawCandidateIssue[], + * excluded: Array<{ issue: import("./opportunity-fanout.js").RawCandidateIssue, reasons: string[] }>, + * }} + */ +export function filterEligibleCandidates(issues, profilesByRepo, options = {}) { + const eligible = []; + const excluded = []; + for (const issue of issues) { + const profile = profilesByRepo.get(issue.repoFullName) ?? null; + const labelDescriptionsByName = options.labelDescriptionsByRepo?.get(issue.repoFullName); + const result = evaluateCandidateEligibility(issue, profile, { labelDescriptionsByName }); + if (result.excluded) { + excluded.push({ issue, reasons: result.reasons }); + } else { + eligible.push(issue); + } + } + return { eligible, excluded }; +} + +/** True when a profile carries at least one description-field matcher -- the only case that needs the repo's + * full label list (name + description), not just the names `discover` already has on each candidate. Keeps the + * extra `/labels` fetch conditional on actually needing it, instead of paying it for every repo. */ +export function profileNeedsLabelDescriptions(profile) { + const matcherLists = [profile?.eligibilityLabels?.value, profile?.exclusionLabels?.value]; + return matcherLists.some( + (matchers) => Array.isArray(matchers) && matchers.some((matcher) => matcher.field === "description"), + ); +} + +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; +} + +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() }; +} + +/** + * Fetch a repo's full label list (name + description) as a `Map`. Never throws: + * any transport/HTTP/parse failure or malformed repo name yields an empty map, so a fetch problem degrades a + * description-field matcher to "no match" rather than aborting discovery. Only worth calling when + * `profileNeedsLabelDescriptions` is true for the target repo. + * + * @param {string} repoFullName + * @param {{ fetchImpl?: typeof fetch, githubToken?: string, apiBaseUrl?: string }} [options] + * @returns {Promise>} + */ +export async function fetchRepoLabelDescriptions(repoFullName, options = {}) { + const target = parseRepoFullName(repoFullName); + if (target === null) return new Map(); + + /* 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); + + let response; + try { + response = await fetchImpl(`${base}/repos/${target.owner}/${target.repo}/labels?per_page=100`, { + method: "GET", + headers, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + } catch { + return new Map(); + } + if (!response.ok) return new Map(); + const payload = await response.json().catch(() => null); + if (!Array.isArray(payload)) return new Map(); + + const byName = new Map(); + for (const label of payload) { + if (typeof label?.name === "string") { + byName.set(label.name.toLowerCase(), typeof label.description === "string" ? label.description : null); + } + } + return byName; +} diff --git a/packages/loopover-miner/lib/contribution-profile-resolution.d.ts b/packages/loopover-miner/lib/contribution-profile-resolution.d.ts new file mode 100644 index 0000000000..38be1e4695 --- /dev/null +++ b/packages/loopover-miner/lib/contribution-profile-resolution.d.ts @@ -0,0 +1,25 @@ +import type { ContributionProfile } from "./contribution-profile.js"; +import type { ContributionProfileCache } from "./contribution-profile-cache.js"; + +export type ResolveContributionProfilesResult = { + profilesByRepo: Map; + labelDescriptionsByRepo: Map>; +}; + +/** The read/write surface this module actually needs to inject a cache without depending on the SQLite store + * (`dbPath`/`close` are lifecycle concerns the caller, not the resolver, owns) -- mirrors the narrower + * `PolicyDocCache`/`PolicyVerdictCache` convenience types those sibling caches export; contribution-profile- + * cache.js doesn't export an equivalent narrowed type itself, so it's defined locally here instead. */ +export type ContributionProfileCacheReader = Pick; + +export function resolveContributionProfiles( + repoFullNames: string[], + options?: { + cache?: ContributionProfileCacheReader | null; + fetchImpl?: typeof fetch; + githubToken?: string; + apiBaseUrl?: string; + nowMs?: number; + generatedAt?: string; + }, +): Promise; diff --git a/packages/loopover-miner/lib/contribution-profile-resolution.js b/packages/loopover-miner/lib/contribution-profile-resolution.js new file mode 100644 index 0000000000..a88dc8aada --- /dev/null +++ b/packages/loopover-miner/lib/contribution-profile-resolution.js @@ -0,0 +1,74 @@ +import { extractContributionProfile } from "./contribution-profile-extract.js"; +import { fetchRepoLabelDescriptions, profileNeedsLabelDescriptions } from "./contribution-profile-eligibility.js"; + +// Per-repo ContributionProfile resolution for `discover` (#6798): reads a fresh profile from the cache (#6797) +// when one exists and is not stale, otherwise extracts (#6796) live and writes the result back. A caller with no +// cache (dry-run, matching the existing policyDocCache/policyVerdictCache convention of never opening a +// not-yet-existing store during a dry run) always extracts live -- same "read-only GETs are fine, but no local +// store writes" rule discover-cli.js already applies to the other two caches. + +/** + * Resolve one repo's profile: cache hit (fresh) wins; otherwise extract live and best-effort write the result + * back to the cache. A cache write failure is non-fatal (the profile itself is still returned) -- the cache is a + * pure performance optimization, never a requirement for discover to have a profile to filter with. + */ +async function resolveOneProfile(repoFullName, options) { + if (options.cache) { + const cached = options.cache.get(repoFullName, options.nowMs); + if (cached && !cached.stale) return cached.profile; + } + const profile = await extractContributionProfile(repoFullName, { + fetchImpl: options.fetchImpl, + githubToken: options.githubToken, + apiBaseUrl: options.apiBaseUrl, + generatedAt: options.generatedAt, + }); + if (options.cache) { + try { + options.cache.put(profile, options.nowMs); + } catch { + // Non-fatal: a corrupt/unwritable cache must never block discover from using the profile it just extracted. + } + } + return profile; +} + +/** + * Resolve every distinct repo's ContributionProfile (and, only where the profile actually needs it, its full + * label list for description-field matching) in parallel. + * + * @param {string[]} repoFullNames + * @param {{ + * cache?: import("./contribution-profile-cache.js").ContributionProfileCache | null, + * fetchImpl?: typeof fetch, + * githubToken?: string, + * apiBaseUrl?: string, + * nowMs?: number, + * generatedAt?: string, + * }} [options] + * @returns {Promise<{ + * profilesByRepo: Map, + * labelDescriptionsByRepo: Map>, + * }>} + */ +export async function resolveContributionProfiles(repoFullNames, options = {}) { + const profilesByRepo = new Map(); + const labelDescriptionsByRepo = new Map(); + + await Promise.all( + repoFullNames.map(async (repoFullName) => { + const profile = await resolveOneProfile(repoFullName, options); + profilesByRepo.set(repoFullName, profile); + if (profileNeedsLabelDescriptions(profile)) { + const descriptions = await fetchRepoLabelDescriptions(repoFullName, { + fetchImpl: options.fetchImpl, + githubToken: options.githubToken, + apiBaseUrl: options.apiBaseUrl, + }); + labelDescriptionsByRepo.set(repoFullName, descriptions); + } + }), + ); + + return { profilesByRepo, labelDescriptionsByRepo }; +} diff --git a/packages/loopover-miner/lib/discover-cli.d.ts b/packages/loopover-miner/lib/discover-cli.d.ts index 4d065c56d1..c1e2679029 100644 --- a/packages/loopover-miner/lib/discover-cli.d.ts +++ b/packages/loopover-miner/lib/discover-cli.d.ts @@ -12,6 +12,8 @@ import type { } from "./opportunity-ranker.js"; import type { PolicyDocCacheStore } from "./policy-doc-cache.js"; import type { PolicyVerdictCacheStore } from "./policy-verdict-cache.js"; +import type { ContributionProfileCache } from "./contribution-profile-cache.js"; +import type { ResolveContributionProfilesResult } from "./contribution-profile-resolution.js"; import type { EnqueueRankedDiscoverySummary } from "./portfolio-discovery.js"; import type { PortfolioQueueStore } from "./portfolio-queue.js"; import type { RankedCandidatesStore } from "./ranked-candidates.js"; @@ -41,6 +43,14 @@ export type DiscoverFanOutSummary = { /** The subset of a ranked entry that `renderDiscoverSummary` reads for its top-candidates listing. */ export type DiscoverRankedEntry = Pick; +/** One candidate `applyEligibilityFilter` (#6798) dropped before ranking, and why. */ +export type DiscoverExcludedCandidate = { + repoFullName: string; + issueNumber: number; + title: string; + reasons: string[]; +}; + export type DiscoverResult = { fanOutCount: number; warnings: CandidateIssueWarning[]; @@ -50,6 +60,8 @@ export type DiscoverResult = { /** True when ranking fell back to the built-in default goal spec because no per-tenant spec was supplied (#4784). */ usedDefaultGoalSpec?: boolean; enqueueSummary: EnqueueRankedDiscoverySummary; + /** Candidates a target repo's own ContributionProfile excluded before ranking (#6798), and why. */ + excludedByEligibility: DiscoverExcludedCandidate[]; }; export type RunDiscoverOptions = { @@ -67,6 +79,7 @@ export type RunDiscoverOptions = { initPolicyDocCache?: () => PolicyDocCacheStore; initPolicyVerdictCache?: () => PolicyVerdictCacheStore; initRankedCandidatesStore?: () => RankedCandidatesStore; + initContributionProfileCache?: () => ContributionProfileCache; fetchCandidateIssuesWithSummary?: ( targets: FanoutTarget[], githubToken: string, @@ -85,6 +98,14 @@ export type RunDiscoverOptions = { rankedIssues: RankedCandidateIssue[], options: { queueStore: PortfolioQueueStore }, ) => EnqueueRankedDiscoverySummary; + /** Overrides the real per-repo ContributionProfile resolution (#6798) — same "test injects the pipeline + * stage, not the transport" convention as the fan-out/rank/enqueue overrides above. Defaults to the real + * `resolveContributionProfiles`, which calls the network directly (no `fetchImpl` plumbing exists elsewhere + * in this file either); a caller that wants to avoid real GitHub calls in a test provides this instead. */ + resolveContributionProfiles?: ( + repoFullNames: string[], + options: Record, + ) => Promise; /** Invoked with the real structured result at each success return point (dry-run and full-run), in addition * to (never instead of) the plain exit-code return -- mirrors `RunAttemptOptions.onResult`. Never fires on a * parse-error/unexpected-error `reportCliFailure` branch, matching runAttempt's own asymmetry (#6522). */ diff --git a/packages/loopover-miner/lib/discover-cli.js b/packages/loopover-miner/lib/discover-cli.js index 2ddcd6f4fa..9f1e3c6f28 100644 --- a/packages/loopover-miner/lib/discover-cli.js +++ b/packages/loopover-miner/lib/discover-cli.js @@ -8,6 +8,9 @@ import { import { rankCandidateIssuesWithSummary } from "./opportunity-ranker.js"; import { initPolicyDocCacheStore } from "./policy-doc-cache.js"; import { initPolicyVerdictCacheStore } from "./policy-verdict-cache.js"; +import { initContributionProfileCache } from "./contribution-profile-cache.js"; +import { resolveContributionProfiles } from "./contribution-profile-resolution.js"; +import { filterEligibleCandidates } from "./contribution-profile-eligibility.js"; import { enqueueRankedDiscovery } from "./portfolio-discovery.js"; import { initPortfolioQueueStore } from "./portfolio-queue.js"; import { initRankedCandidatesStore } from "./ranked-candidates.js"; @@ -140,9 +143,48 @@ export function renderDiscoverSummary(result) { const title = sanitizeDiscoverDisplayText(entry.title); lines.push(` ${entry.repoFullName}#${entry.issueNumber} score=${entry.rankScore.toFixed(4)} ${title}`); } + if (result.excludedByEligibility.length > 0) { + lines.push("", `excluded by contribution-profile eligibility: ${result.excludedByEligibility.length}`); + for (const entry of result.excludedByEligibility.slice(0, 10)) { + const title = sanitizeDiscoverDisplayText(entry.title); + lines.push(` ${entry.repoFullName}#${entry.issueNumber} ${title} (${entry.reasons.join(", ")})`); + } + } return lines.join("\n"); } +// Contribution-eligibility filtering (#6798): resolves each distinct target repo's ContributionProfile (cached +// (#6797) when fresh, extracted live (#6796) otherwise) and excludes candidates that fail it -- BEFORE ranking, +// so an ineligible candidate never occupies a ranked/enqueued slot. `cache: null` (the dry-run caller) always +// extracts live, matching the same "no local-store write during a dry run" rule the other two caches already +// follow. Never throws: extraction and the cache are both individually fail-safe (see contribution-profile- +// resolution.js), so a network or disk problem degrades to "profile absent, filter nothing" rather than aborting +// discovery. +async function applyEligibilityFilter(issues, options) { + const resolveProfiles = options.resolveContributionProfiles ?? resolveContributionProfiles; + const repoFullNames = [...new Set(issues.map((issue) => issue.repoFullName))]; + const { profilesByRepo, labelDescriptionsByRepo } = await resolveProfiles(repoFullNames, { + cache: options.contributionProfileCache, + githubToken: options.githubToken, + apiBaseUrl: options.apiBaseUrl, + nowMs: options.nowMs, + generatedAt: options.nowMs !== undefined ? new Date(options.nowMs).toISOString() : undefined, + }); + return filterEligibleCandidates(issues, profilesByRepo, { labelDescriptionsByRepo }); +} + +/** Shapes `filterEligibleCandidates`'s excluded list into the plain, JSON/text-friendly rows `result` and + * `renderDiscoverSummary` surface -- flattening each candidate's own fields alongside its exclusion reasons, + * rather than the nested `{ issue, reasons }` the filter itself returns. */ +function excludedCandidateRows(excluded) { + return excluded.map(({ issue, reasons }) => ({ + repoFullName: issue.repoFullName, + issueNumber: issue.issueNumber, + title: issue.title, + reasons, + })); +} + export async function runDiscover(args, options = {}) { const parsed = parseDiscoverArgs(args); if ("error" in parsed) { @@ -175,7 +217,14 @@ export async function runDiscover(args, options = {}) { parsed.search !== null ? await searchTargets(parsed.search, githubToken, fanOutOptions) : await fetchTargets(parsed.targets, githubToken, fanOutOptions); - const rankedSummary = rankIssues(fanOut.issues, { + const { eligible, excluded } = await applyEligibilityFilter(fanOut.issues, { + contributionProfileCache: null, + githubToken, + apiBaseUrl, + nowMs: options.nowMs, + resolveContributionProfiles: options.resolveContributionProfiles, + }); + const rankedSummary = rankIssues(eligible, { nowMs: options.nowMs, goalSpecsByRepo: options.goalSpecsByRepo, goalSpecContentByRepo: options.goalSpecContentByRepo, @@ -191,6 +240,7 @@ export async function runDiscover(args, options = {}) { ranked: rankedSummary.issues, usedDefaultGoalSpec: rankedSummary.usedDefaultGoalSpec, enqueueSummary, + excludedByEligibility: excludedCandidateRows(excluded), }; // Structured-outcome hook (#6522), mirroring runAttempt's onResult convention: fires only at a real // structured success point (never the reportCliFailure branches), in addition to -- never instead of -- @@ -260,6 +310,19 @@ export async function runDiscover(args, options = {}) { rankedCandidatesStore = null; ownsRankedCandidatesStore = false; } + + // Cache of extracted per-repo ContributionProfiles (#6797), same "own try/catch, degrade to null" discipline as + // the two policy caches above: a corrupt/unwritable cache DB must degrade to "extract live every run" rather + // than fail discovery outright -- eligibility filtering below still works, just without the cache's speedup. + let contributionProfileCache = null; + let ownsContributionProfileCache = false; + try { + ownsContributionProfileCache = options.initContributionProfileCache === undefined; + contributionProfileCache = (options.initContributionProfileCache ?? initContributionProfileCache)(); + } catch { + contributionProfileCache = null; + ownsContributionProfileCache = false; + } const fanOutOptions = { apiBaseUrl, forge: options.forge, policyDocCache, policyVerdictCache }; try { @@ -268,10 +331,18 @@ export async function runDiscover(args, options = {}) { ? await searchTargets(parsed.search, githubToken, fanOutOptions) : await fetchTargets(parsed.targets, githubToken, fanOutOptions); + const { eligible, excluded } = await applyEligibilityFilter(fanOut.issues, { + contributionProfileCache, + githubToken, + apiBaseUrl, + nowMs: options.nowMs, + resolveContributionProfiles: options.resolveContributionProfiles, + }); + // Pass any caller-supplied per-tenant goal specs through to the ranker so lane fit uses the tenant's // conventions instead of silently falling back to loopover's defaults (#4784); the fallback is surfaced via // `usedDefaultGoalSpec` below rather than hidden. - const rankedSummary = rankIssues(fanOut.issues, { + const rankedSummary = rankIssues(eligible, { nowMs: options.nowMs, goalSpecsByRepo: options.goalSpecsByRepo, goalSpecContentByRepo: options.goalSpecContentByRepo, @@ -296,6 +367,7 @@ export async function runDiscover(args, options = {}) { ranked: rankedSummary.issues, usedDefaultGoalSpec: rankedSummary.usedDefaultGoalSpec, enqueueSummary, + excludedByEligibility: excludedCandidateRows(excluded), }; // Structured-outcome hook (#6522) for the full-run success point -- same convention as the dry-run branch @@ -314,5 +386,6 @@ export async function runDiscover(args, options = {}) { if (ownsPolicyDocCache && policyDocCache) policyDocCache.close(); if (ownsPolicyVerdictCache && policyVerdictCache) policyVerdictCache.close(); if (ownsRankedCandidatesStore && rankedCandidatesStore) rankedCandidatesStore.close(); + if (ownsContributionProfileCache && contributionProfileCache) contributionProfileCache.close(); } } diff --git a/packages/loopover-miner/lib/opportunity-fanout.d.ts b/packages/loopover-miner/lib/opportunity-fanout.d.ts index 15fbedf95b..1c80a52fda 100644 --- a/packages/loopover-miner/lib/opportunity-fanout.d.ts +++ b/packages/loopover-miner/lib/opportunity-fanout.d.ts @@ -33,6 +33,8 @@ export type RawCandidateIssue = { issueNumber: number; title: string; labels: string[]; + /** Assignee logins (#6798), already present in the same list/search payload as labels — no extra request. */ + assignees: string[]; commentsCount: number; createdAt: string | null; updatedAt: string | null; diff --git a/packages/loopover-miner/lib/opportunity-fanout.js b/packages/loopover-miner/lib/opportunity-fanout.js index e3f572e699..8d36db3760 100644 --- a/packages/loopover-miner/lib/opportunity-fanout.js +++ b/packages/loopover-miner/lib/opportunity-fanout.js @@ -301,6 +301,16 @@ function labelNames(labels) { .filter((name) => name.length > 0); } +// Assignee logins (#6798): GitHub's issue-list/search payloads already carry `assignees` in the same response +// that supplies labels/comments/etc. -- no extra request needed. Contribution-eligibility filtering uses this to +// exclude candidates assigned to a login the target repo's profile marks off-limits (e.g. the repo owner). +function assigneeLogins(assignees) { + if (!Array.isArray(assignees)) return []; + return assignees + .map((assignee) => (assignee && typeof assignee === "object" && typeof assignee.login === "string" ? assignee.login : "")) + .filter((login) => login.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; @@ -312,6 +322,7 @@ function normalizeIssue(target, issue, policySource) { issueNumber: issue.number, title: issue.title, labels: labelNames(issue.labels), + assignees: assigneeLogins(issue.assignees), 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, diff --git a/packages/loopover-miner/package.json b/packages/loopover-miner/package.json index 6792bcff47..a6e26e42a4 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/contribution-profile-cache.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" + "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-cache.js && node --check lib/contribution-profile-eligibility.js && node --check lib/contribution-profile-extract.js && node --check lib/contribution-profile-resolution.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/miner-contribution-profile-eligibility.test.ts b/test/unit/miner-contribution-profile-eligibility.test.ts new file mode 100644 index 0000000000..610e17ede6 --- /dev/null +++ b/test/unit/miner-contribution-profile-eligibility.test.ts @@ -0,0 +1,281 @@ +import { describe, expect, it } from "vitest"; +import { + evaluateCandidateEligibility, + fetchRepoLabelDescriptions, + filterEligibleCandidates, + profileNeedsLabelDescriptions, +} from "../../packages/loopover-miner/lib/contribution-profile-eligibility.js"; +import { emptyContributionProfile } from "../../packages/loopover-miner/lib/contribution-profile.js"; +import type { ContributionProfile } from "../../packages/loopover-miner/lib/contribution-profile.js"; +import type { RawCandidateIssue } from "../../packages/loopover-miner/lib/opportunity-fanout.js"; + +const GENERATED_AT = "2026-07-01T00:00:00.000Z"; + +function candidate(overrides: Partial = {}): RawCandidateIssue { + return { + owner: "acme", + repo: "widgets", + repoFullName: "acme/widgets", + issueNumber: 42, + title: "Fix the thing", + labels: [], + assignees: [], + commentsCount: 0, + createdAt: null, + updatedAt: null, + htmlUrl: null, + aiPolicyAllowed: true, + aiPolicySource: "none", + ...overrides, + }; +} + +function profileWith(overrides: Partial = {}): ContributionProfile { + return { ...emptyContributionProfile("acme/widgets", GENERATED_AT), ...overrides }; +} + +const eligibilityByName = (contains: string) => + profileWith({ + eligibilityLabels: { value: [{ field: "name" as const, contains }], confidence: "explicit", provenance: [] }, + }); + +const exclusionByName = (contains: string) => + profileWith({ + exclusionLabels: { value: [{ field: "name" as const, contains }], confidence: "inferred", provenance: [] }, + }); + +describe("evaluateCandidateEligibility (#6798)", () => { + it("excludes nothing via label rules when there is no profile at all (fully-absent degrade)", () => { + expect(evaluateCandidateEligibility(candidate(), null)).toEqual({ excluded: false, reasons: [] }); + expect(evaluateCandidateEligibility(candidate(), undefined)).toEqual({ excluded: false, reasons: [] }); + }); + + it("excludes nothing via label rules when the profile's own label signals are absent (value: null)", () => { + expect(evaluateCandidateEligibility(candidate(), emptyContributionProfile("acme/widgets", GENERATED_AT))).toEqual( + { excluded: false, reasons: [] }, + ); + }); + + it("excludes a candidate missing the profile's required eligibility label", () => { + const result = evaluateCandidateEligibility(candidate({ labels: ["bug"] }), eligibilityByName("help wanted")); + expect(result).toEqual({ excluded: true, reasons: ["missing eligibility label"] }); + }); + + it("does not exclude a candidate that carries a matching eligibility label (case-insensitive substring)", () => { + const result = evaluateCandidateEligibility( + candidate({ labels: ["Help Wanted"] }), + eligibilityByName("help wanted"), + ); + expect(result).toEqual({ excluded: false, reasons: [] }); + }); + + it("matches an eligibility rule by label DESCRIPTION when the name itself does not match", () => { + const profile = profileWith({ + eligibilityLabels: { + value: [{ field: "description", contains: "good first issue" }], + confidence: "explicit", + provenance: [], + }, + }); + const labelDescriptionsByName = new Map([["e-easy", "good first issue material"]]); + const result = evaluateCandidateEligibility(candidate({ labels: ["E-easy"] }), profile, { + labelDescriptionsByName, + }); + expect(result).toEqual({ excluded: false, reasons: [] }); + }); + + it("treats an unresolved description matcher (no labelDescriptionsByName supplied) as unmatched", () => { + const profile = profileWith({ + eligibilityLabels: { + value: [{ field: "description", contains: "good first issue" }], + confidence: "explicit", + provenance: [], + }, + }); + const result = evaluateCandidateEligibility(candidate({ labels: ["E-easy"] }), profile); + expect(result).toEqual({ excluded: true, reasons: ["missing eligibility label"] }); + }); + + it("excludes a candidate carrying the profile's exclusion label", () => { + const result = evaluateCandidateEligibility(candidate({ labels: ["blocked"] }), exclusionByName("blocked")); + expect(result).toEqual({ excluded: true, reasons: ["exclusion label present"] }); + }); + + it("does not exclude a candidate with no exclusion-matching label", () => { + const result = evaluateCandidateEligibility(candidate({ labels: ["bug"] }), exclusionByName("blocked")); + expect(result).toEqual({ excluded: false, reasons: [] }); + }); + + it("CONFLICTING SIGNALS: a candidate matching both eligibility and exclusion resolves conservatively (exclusion wins, one reason)", () => { + const profile = profileWith({ + eligibilityLabels: { value: [{ field: "name", contains: "help wanted" }], confidence: "explicit", provenance: [] }, + exclusionLabels: { value: [{ field: "name", contains: "blocked" }], confidence: "inferred", provenance: [] }, + }); + const result = evaluateCandidateEligibility(candidate({ labels: ["help wanted", "blocked"] }), profile); + expect(result).toEqual({ excluded: true, reasons: ["exclusion label present"] }); + }); + + it("excludes a candidate assigned to the repo's own owner login by default", () => { + const result = evaluateCandidateEligibility(candidate({ owner: "acme", assignees: ["ACME"] }), null); + expect(result).toEqual({ excluded: true, reasons: ["excluded assignee"] }); + }); + + it("does not exclude a candidate assigned to someone other than the repo owner", () => { + const result = evaluateCandidateEligibility(candidate({ owner: "acme", assignees: ["someone-else"] }), null); + expect(result).toEqual({ excluded: false, reasons: [] }); + }); + + it("honors a caller-supplied excludeAssignedLogins list instead of the default owner-only rule", () => { + const result = evaluateCandidateEligibility(candidate({ owner: "acme", assignees: ["core-maintainer"] }), null, { + excludeAssignedLogins: ["core-maintainer"], + }); + expect(result).toEqual({ excluded: true, reasons: ["excluded assignee"] }); + }); + + it("collects multiple independent reasons at once", () => { + const result = evaluateCandidateEligibility( + candidate({ owner: "acme", labels: ["bug"], assignees: ["acme"] }), + eligibilityByName("help wanted"), + ); + expect(result).toEqual({ excluded: true, reasons: ["missing eligibility label", "excluded assignee"] }); + }); +}); + +describe("filterEligibleCandidates (#6798)", () => { + it("splits candidates into eligible and excluded per their own repo's profile", () => { + const issues = [ + candidate({ issueNumber: 1, repoFullName: "acme/widgets", owner: "acme", labels: ["help wanted"] }), + candidate({ issueNumber: 2, repoFullName: "acme/widgets", owner: "acme", labels: ["bug"] }), + ]; + const profilesByRepo = new Map([["acme/widgets", eligibilityByName("help wanted")]]); + const result = filterEligibleCandidates(issues, profilesByRepo); + expect(result.eligible.map((issue) => issue.issueNumber)).toEqual([1]); + expect(result.excluded).toEqual([{ issue: issues[1], reasons: ["missing eligibility label"] }]); + }); + + it("falls back to a fully-absent profile for a repo with no entry in profilesByRepo", () => { + const issues = [candidate({ repoFullName: "unknown/repo", owner: "unknown", labels: [] })]; + const result = filterEligibleCandidates(issues, new Map()); + expect(result.eligible).toEqual(issues); + expect(result.excluded).toEqual([]); + }); + + it("resolves label descriptions per repo from labelDescriptionsByRepo", () => { + const profile = profileWith({ + exclusionLabels: { + value: [{ field: "description", contains: "not ready" }], + confidence: "inferred", + provenance: [], + }, + }); + const issue = candidate({ repoFullName: "acme/widgets", labels: ["status: hold"] }); + const result = filterEligibleCandidates( + [issue], + new Map([["acme/widgets", profile]]), + { labelDescriptionsByRepo: new Map([["acme/widgets", new Map([["status: hold", "not ready for contributors"]])]]) }, + ); + expect(result.excluded).toEqual([{ issue, reasons: ["exclusion label present"] }]); + }); +}); + +describe("profileNeedsLabelDescriptions (#6798)", () => { + it("is false for a fully-absent profile", () => { + expect(profileNeedsLabelDescriptions(emptyContributionProfile("acme/widgets", GENERATED_AT))).toBe(false); + }); + + it("is false when every matcher is name-based", () => { + expect(profileNeedsLabelDescriptions(eligibilityByName("help wanted"))).toBe(false); + }); + + it("is true when the eligibility matchers include a description-field matcher", () => { + const profile = profileWith({ + eligibilityLabels: { value: [{ field: "description", contains: "good first issue" }], confidence: "explicit", provenance: [] }, + }); + expect(profileNeedsLabelDescriptions(profile)).toBe(true); + }); + + it("is true when the exclusion matchers include a description-field matcher", () => { + const profile = profileWith({ + exclusionLabels: { value: [{ field: "description", contains: "not ready" }], confidence: "inferred", provenance: [] }, + }); + expect(profileNeedsLabelDescriptions(profile)).toBe(true); + }); + + it("is false for a missing/undefined profile", () => { + expect(profileNeedsLabelDescriptions(null)).toBe(false); + expect(profileNeedsLabelDescriptions(undefined)).toBe(false); + }); +}); + +describe("fetchRepoLabelDescriptions (#6798)", () => { + it("returns a lowercased name -> description map for a successful fetch", async () => { + const fetchImpl = async () => + Response.json([ + { name: "E-easy", description: "Good first issue material" }, + { name: "bug", description: null }, + ]); + const result = await fetchRepoLabelDescriptions("acme/widgets", { fetchImpl }); + expect(result).toEqual( + new Map([ + ["e-easy", "Good first issue material"], + ["bug", null], + ]), + ); + }); + + it("sends the github token and a custom api base url when supplied", async () => { + const calls: Array<{ url: string; authorization: string | undefined }> = []; + const fetchImpl = async (input: RequestInfo | URL, init?: RequestInit) => { + calls.push({ + url: String(input), + authorization: (init?.headers as Record | undefined)?.authorization, + }); + return Response.json([]); + }; + await fetchRepoLabelDescriptions("acme/widgets", { + fetchImpl, + githubToken: "tok", + apiBaseUrl: "https://api.example.test/", + }); + expect(calls).toEqual([ + { url: "https://api.example.test/repos/acme/widgets/labels?per_page=100", authorization: "Bearer tok" }, + ]); + }); + + it("returns an empty map for a malformed or non-string repo full name, without fetching", async () => { + const fetchImpl = async () => { + throw new Error("must not be called"); + }; + expect(await fetchRepoLabelDescriptions("not-a-repo", { fetchImpl })).toEqual(new Map()); + expect(await fetchRepoLabelDescriptions("a/b/c", { fetchImpl })).toEqual(new Map()); + // @ts-expect-error deliberately passing a non-string to exercise the guard. + expect(await fetchRepoLabelDescriptions(42, { fetchImpl })).toEqual(new Map()); + }); + + it("returns an empty map when the fetch throws", async () => { + const fetchImpl = async () => { + throw new Error("network down"); + }; + expect(await fetchRepoLabelDescriptions("acme/widgets", { fetchImpl })).toEqual(new Map()); + }); + + it("returns an empty map for a non-ok response", async () => { + const fetchImpl = async () => new Response("nope", { status: 404 }); + expect(await fetchRepoLabelDescriptions("acme/widgets", { fetchImpl })).toEqual(new Map()); + }); + + it("returns an empty map when the response body is not an array", async () => { + const fetchImpl = async () => Response.json({ not: "an array" }); + expect(await fetchRepoLabelDescriptions("acme/widgets", { fetchImpl })).toEqual(new Map()); + }); + + it("returns an empty map when the response body fails to parse as JSON", async () => { + const fetchImpl = async () => new Response("not json", { status: 200 }); + expect(await fetchRepoLabelDescriptions("acme/widgets", { fetchImpl })).toEqual(new Map()); + }); + + it("skips a label entry whose name is not a string", async () => { + const fetchImpl = async () => Response.json([{ name: 42, description: "x" }, { name: "ok", description: "y" }]); + expect(await fetchRepoLabelDescriptions("acme/widgets", { fetchImpl })).toEqual(new Map([["ok", "y"]])); + }); +}); diff --git a/test/unit/miner-contribution-profile-resolution.test.ts b/test/unit/miner-contribution-profile-resolution.test.ts new file mode 100644 index 0000000000..14b4cc886b --- /dev/null +++ b/test/unit/miner-contribution-profile-resolution.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it, vi } from "vitest"; +import { resolveContributionProfiles } from "../../packages/loopover-miner/lib/contribution-profile-resolution.js"; +import { emptyContributionProfile } from "../../packages/loopover-miner/lib/contribution-profile.js"; +import type { ContributionProfile } from "../../packages/loopover-miner/lib/contribution-profile.js"; +import type { ContributionProfileCacheReader } from "../../packages/loopover-miner/lib/contribution-profile-resolution.js"; + +const REPO = "acme/widgets"; +const GENERATED_AT = "2026-07-01T00:00:00.000Z"; + +function fakeCache(overrides: Partial = {}): ContributionProfileCacheReader & { + puts: Array<{ profile: ContributionProfile; nowMs: number | undefined }>; +} { + const puts: Array<{ profile: ContributionProfile; nowMs: number | undefined }> = []; + return { + get: overrides.get ?? (() => null), + put: (profile: ContributionProfile, nowMs?: number) => { + puts.push({ profile, nowMs }); + return ( + (overrides.put as ContributionProfileCacheReader["put"] | undefined)?.(profile, nowMs) ?? { + repoFullName: profile.repoFullName, + fetchedAt: new Date(nowMs ?? Date.parse(GENERATED_AT)).toISOString(), + } + ); + }, + puts, + }; +} + +function labelsResponse(labels: Array<{ name: string; description?: string | null }>) { + return async () => Response.json(labels); +} + +describe("resolveContributionProfiles (#6798)", () => { + it("uses a fresh cached profile without extracting live", async () => { + const cached = emptyContributionProfile(REPO, GENERATED_AT); + const cache = fakeCache({ get: () => ({ profile: cached, fetchedAt: GENERATED_AT, stale: false }) }); + const fetchImpl = vi.fn(async () => { + throw new Error("must not fetch — cache is fresh"); + }); + + const { profilesByRepo } = await resolveContributionProfiles([REPO], { cache, fetchImpl }); + + expect(profilesByRepo.get(REPO)).toBe(cached); + expect(fetchImpl).not.toHaveBeenCalled(); + expect(cache.puts).toEqual([]); + }); + + it("extracts live and writes back to the cache on a stale cache entry", async () => { + const stale = emptyContributionProfile(REPO, "2026-01-01T00:00:00.000Z"); + const cache = fakeCache({ get: () => ({ profile: stale, fetchedAt: "2026-01-01T00:00:00.000Z", stale: true }) }); + const fetchImpl = labelsResponse([]); + + const { profilesByRepo } = await resolveContributionProfiles([REPO], { + cache, + fetchImpl, + generatedAt: GENERATED_AT, + }); + + const profile = profilesByRepo.get(REPO); + expect(profile?.repoFullName).toBe(REPO); + expect(profile).not.toBe(stale); + expect(cache.puts).toEqual([{ profile, nowMs: undefined }]); + }); + + it("extracts live and writes back to the cache on a cache miss (null)", async () => { + const cache = fakeCache({ get: () => null }); + const fetchImpl = labelsResponse([]); + + const { profilesByRepo } = await resolveContributionProfiles([REPO], { + cache, + fetchImpl, + generatedAt: GENERATED_AT, + }); + + expect(profilesByRepo.get(REPO)?.repoFullName).toBe(REPO); + expect(cache.puts).toHaveLength(1); + }); + + it("always extracts live and never touches a cache when none is supplied (dry-run posture)", async () => { + const fetchImpl = labelsResponse([]); + const { profilesByRepo } = await resolveContributionProfiles([REPO], { fetchImpl, generatedAt: GENERATED_AT }); + expect(profilesByRepo.get(REPO)?.repoFullName).toBe(REPO); + }); + + it("still returns the extracted profile when the cache write throws (non-fatal)", async () => { + const cache = fakeCache({ + get: () => null, + put: () => { + throw new Error("disk full"); + }, + }); + const fetchImpl = labelsResponse([]); + + const { profilesByRepo } = await resolveContributionProfiles([REPO], { + cache, + fetchImpl, + generatedAt: GENERATED_AT, + }); + + expect(profilesByRepo.get(REPO)?.repoFullName).toBe(REPO); + }); + + it("does not fetch label descriptions when the extracted profile has no label matchers at all", async () => { + let labelsCallCount = 0; + const fetchImpl = async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/labels")) { + labelsCallCount += 1; + return Response.json([]); + } + return new Response("", { status: 404 }); + }; + + const { profilesByRepo, labelDescriptionsByRepo } = await resolveContributionProfiles([REPO], { + fetchImpl, + generatedAt: GENERATED_AT, + }); + + expect(profilesByRepo.get(REPO)?.eligibilityLabels.value).toBeNull(); + expect(labelDescriptionsByRepo.has(REPO)).toBe(false); + // The extractor itself calls /labels once (for classification); the resolution helper adds no second + // call when the profile doesn't need descriptions. + expect(labelsCallCount).toBe(1); + }); + + it("fetches and populates label descriptions when the extracted profile needed a description-field matcher (the rust E-easy case)", async () => { + let labelsCallCount = 0; + const fetchImpl = async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/labels")) { + labelsCallCount += 1; + // Name alone doesn't match any recognized term; the description does -- forces a description-field + // eligibility matcher out of the real extractor, exactly like rust's real-world E-easy label (#6794). + return Response.json([{ name: "E-approachable", description: "good first issue material" }]); + } + return new Response("", { status: 404 }); + }; + + const { profilesByRepo, labelDescriptionsByRepo } = await resolveContributionProfiles([REPO], { + fetchImpl, + generatedAt: GENERATED_AT, + }); + + expect(profilesByRepo.get(REPO)?.eligibilityLabels.value).toEqual([ + { field: "description", contains: "good first issue" }, + ]); + expect(labelDescriptionsByRepo.get(REPO)).toEqual(new Map([["e-approachable", "good first issue material"]])); + // Once from the extractor's own classification fetch, once more from resolveContributionProfiles's + // additional fetchRepoLabelDescriptions call now that the profile needs it. + expect(labelsCallCount).toBe(2); + }); + + it("resolves multiple distinct repos in parallel, keyed by their own repoFullName", async () => { + const fetchImpl = labelsResponse([]); + const { profilesByRepo } = await resolveContributionProfiles(["acme/widgets", "acme/gadgets"], { + fetchImpl, + generatedAt: GENERATED_AT, + }); + + expect(profilesByRepo.get("acme/widgets")?.repoFullName).toBe("acme/widgets"); + expect(profilesByRepo.get("acme/gadgets")?.repoFullName).toBe("acme/gadgets"); + }); +}); diff --git a/test/unit/miner-discover-cli.test.ts b/test/unit/miner-discover-cli.test.ts index 86c4261984..4e042f31bc 100644 --- a/test/unit/miner-discover-cli.test.ts +++ b/test/unit/miner-discover-cli.test.ts @@ -1,7 +1,7 @@ import { existsSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { initPolicyDocCacheStore } from "../../packages/loopover-miner/lib/policy-doc-cache.js"; import { initPolicyVerdictCacheStore } from "../../packages/loopover-miner/lib/policy-verdict-cache.js"; import { @@ -9,6 +9,7 @@ import { initPortfolioQueueStore, } from "../../packages/loopover-miner/lib/portfolio-queue.js"; import { initRankedCandidatesStore } from "../../packages/loopover-miner/lib/ranked-candidates.js"; +import { initContributionProfileCache } from "../../packages/loopover-miner/lib/contribution-profile-cache.js"; import { parseDiscoverArgs, renderDiscoverSummary, @@ -22,6 +23,21 @@ const NOW = Date.parse("2026-07-09T12:00:00.000Z"); const roots: string[] = []; const stores: Array<{ close(): void }> = []; +// Contribution-profile resolution (#6798) calls the real GitHub API directly (no fetchImpl plumbing exists for +// it, matching every other network call in this file) -- default every test to a fast, deterministic 404 so +// eligibility filtering degrades to "profile absent, nothing excluded" instead of a real (or hanging) network +// call. Tests that specifically exercise eligibility filtering override this with their own stub or the +// resolveContributionProfiles injection point instead. Also points the cache at a fresh per-test temp file +// (mirroring the other three caches' tempXCacheStore() convention, without needing an initContributionProfileCache +// override in every one of this file's many existing runDiscover calls) so no test ever touches the real +// ~/.config/loopover-miner default path. +beforeEach(() => { + vi.stubGlobal("fetch", async () => new Response("", { status: 404 })); + const root = mkdtempSync(join(tmpdir(), "loopover-miner-discover-cli-cpc-default-")); + roots.push(root); + vi.stubEnv("LOOPOVER_MINER_CONTRIBUTION_PROFILE_CACHE_DB", join(root, "contribution-profile-cache.sqlite3")); +}); + function tempQueueStore() { const root = mkdtempSync(join(tmpdir(), "loopover-miner-discover-cli-")); roots.push(root); @@ -59,6 +75,15 @@ function tempRankedCandidatesStore() { return store; } +// Same reasoning as tempPolicyDocCacheStore above, for the contribution-profile cache (#6797). +function tempContributionProfileCacheStore() { + const root = mkdtempSync(join(tmpdir(), "loopover-miner-discover-cli-cpc-")); + roots.push(root); + const store = initContributionProfileCache(join(root, "contribution-profile-cache.sqlite3")); + stores.push(store); + return store; +} + function fanOutIssue(overrides: Record = {}) { return { owner: "acme", @@ -67,6 +92,7 @@ function fanOutIssue(overrides: Record = {}) { issueNumber: 1, title: "Add queue retry helper", labels: ["help wanted"], + assignees: [], commentsCount: 1, createdAt: "2026-07-09T10:00:00.000Z", updatedAt: "2026-07-09T10:00:00.000Z", @@ -81,6 +107,8 @@ afterEach(() => { for (const store of stores.splice(0)) store.close(); closeDefaultPortfolioQueueStore(); vi.restoreAllMocks(); + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); @@ -201,6 +229,7 @@ describe("renderDiscoverSummary (#4247)", () => { { repoFullName: "acme/widgets", issueNumber: 2, title: "Fix flaky test", rankScore: 0.4 }, ], enqueueSummary: { enqueued: 2, skippedBelowMinRank: 0, skippedInvalid: 0, eventsAppended: 0 }, + excludedByEligibility: [], }); expect(text).toContain("fanned out: 2 candidate issue(s)"); expect(text).toContain("ai-policy warnings: 1"); @@ -227,6 +256,7 @@ describe("renderDiscoverSummary (#4247)", () => { }, ], enqueueSummary: { enqueued: 1, skippedBelowMinRank: 0, skippedInvalid: 0, eventsAppended: 0 }, + excludedByEligibility: [], }); expect(text).toContain("normal SPOOFED: enqueued: 999 red CLICK codexe"); @@ -249,6 +279,7 @@ describe("renderDiscoverSummary (#4247)", () => { rateLimitResetAt: null, ranked: [{ repoFullName: "acme/widgets", issueNumber: 1, title: "x", rankScore: 0.1 }], enqueueSummary: { enqueued: 0, skippedBelowMinRank: 1, skippedInvalid: 0, eventsAppended: 0 }, + excludedByEligibility: [], }); expect(withSkips).toContain("skipped (below min rank): 1"); @@ -259,6 +290,7 @@ describe("renderDiscoverSummary (#4247)", () => { rateLimitResetAt: null, ranked: [], enqueueSummary: { enqueued: 0, skippedBelowMinRank: 0, skippedInvalid: 0, eventsAppended: 0 }, + excludedByEligibility: [], }); expect(empty).toContain("no candidates found."); // Without the flag the fall-back note is absent (the default-goal-spec branch is opt-in on the result). @@ -274,6 +306,7 @@ describe("renderDiscoverSummary (#4247)", () => { ranked: [{ repoFullName: "acme/widgets", issueNumber: 1, title: "x", rankScore: 0.8 }], usedDefaultGoalSpec: true, enqueueSummary: { enqueued: 1, skippedBelowMinRank: 0, skippedInvalid: 0, eventsAppended: 0 }, + excludedByEligibility: [], }); expect(text).toContain("ranked with the built-in default goal spec"); }); @@ -286,6 +319,7 @@ describe("renderDiscoverSummary (#4247)", () => { rateLimitResetAt: "2026-07-09T13:30:00.000Z", ranked: [], enqueueSummary: { enqueued: 0, skippedBelowMinRank: 0, skippedInvalid: 0, eventsAppended: 0 }, + excludedByEligibility: [], }); expect(withTelemetry).toContain("rate-limit remaining: 12 (resets 2026-07-09T13:30:00.000Z)"); @@ -297,6 +331,7 @@ describe("renderDiscoverSummary (#4247)", () => { rateLimitResetAt: null, ranked: [], enqueueSummary: { enqueued: 0, skippedBelowMinRank: 0, skippedInvalid: 0, eventsAppended: 0 }, + excludedByEligibility: [], }); expect(throttled).toContain("rate-limit remaining: 0"); expect(throttled).not.toContain("resets"); @@ -308,6 +343,7 @@ describe("renderDiscoverSummary (#4247)", () => { rateLimitResetAt: null, ranked: [], enqueueSummary: { enqueued: 0, skippedBelowMinRank: 0, skippedInvalid: 0, eventsAppended: 0 }, + excludedByEligibility: [], }); expect(noTelemetry).toContain("rate-limit remaining: unknown"); }); @@ -1126,3 +1162,159 @@ describe("runDiscover onResult hook (#6522)", () => { expect(onResult).not.toHaveBeenCalled(); }); }); + +describe("runDiscover contribution-profile eligibility filtering (#6798)", () => { + function eligibilityProfile(repoFullName: string) { + return { + repoFullName, + schemaVersion: 1, + generatedAt: "2026-07-09T12:00:00.000Z", + eligibilityLabels: { + value: [{ field: "name" as const, contains: "help wanted" }], + confidence: "explicit" as const, + provenance: [], + }, + exclusionLabels: { value: null, confidence: "absent" as const, provenance: [] }, + prBody: { value: null, confidence: "absent" as const, provenance: [] }, + completeness: "explicit" as const, + }; + } + + it("excludes an ineligible candidate before ranking/enqueueing and surfaces it in the result", async () => { + const portfolioQueue = tempQueueStore(); + const fetchCandidateIssuesWithSummary = vi.fn(async () => ({ + issues: [ + fanOutIssue({ issueNumber: 1, title: "Eligible one", labels: ["help wanted"] }), + fanOutIssue({ issueNumber: 2, title: "No label here", labels: ["bug"] }), + ], + warnings: [], + rateLimitRemaining: null, + rateLimitResetAt: null, + })); + vi.spyOn(console, "log").mockImplementation(() => undefined); + const resolveContributionProfiles = vi.fn(async (repoFullNames: string[]) => ({ + profilesByRepo: new Map(repoFullNames.map((name) => [name, eligibilityProfile(name)])), + labelDescriptionsByRepo: new Map(), + })); + + const exitCode = await runDiscover(["acme/widgets"], { + nowMs: NOW, + fetchCandidateIssuesWithSummary, + resolveContributionProfiles, + initPortfolioQueue: () => portfolioQueue, + initPolicyDocCache: () => tempPolicyDocCacheStore(), + initPolicyVerdictCache: () => tempPolicyVerdictCacheStore(), + initRankedCandidatesStore: () => tempRankedCandidatesStore(), + }); + + expect(exitCode).toBe(0); + expect(portfolioQueue.listQueue().map((entry) => entry.identifier)).toEqual(["issue:1"]); + const logged = (console.log as unknown as { mock: { calls: unknown[][] } }).mock.calls + .map((call) => call[0]) + .join("\n"); + expect(logged).toContain("excluded by contribution-profile eligibility: 1"); + expect(logged).toContain("acme/widgets#2"); + expect(logged).toContain("missing eligibility label"); + }); + + it("--dry-run also applies eligibility filtering, and resolves profiles with cache: null", async () => { + const fetchCandidateIssuesWithSummary = vi.fn(async () => ({ + issues: [ + fanOutIssue({ issueNumber: 1, title: "Eligible one", labels: ["help wanted"] }), + fanOutIssue({ issueNumber: 2, title: "No label here", labels: ["bug"] }), + ], + warnings: [], + rateLimitRemaining: null, + rateLimitResetAt: null, + })); + vi.spyOn(console, "log").mockImplementation(() => undefined); + const resolveContributionProfiles = vi.fn(async (repoFullNames: string[]) => ({ + profilesByRepo: new Map(repoFullNames.map((name) => [name, eligibilityProfile(name)])), + labelDescriptionsByRepo: new Map(), + })); + const onResult = vi.fn(); + + const exitCode = await runDiscover(["acme/widgets", "--dry-run"], { + nowMs: NOW, + fetchCandidateIssuesWithSummary, + resolveContributionProfiles, + onResult, + }); + + expect(exitCode).toBe(0); + expect(resolveContributionProfiles).toHaveBeenCalledWith( + ["acme/widgets"], + expect.objectContaining({ cache: null }), + ); + expect(onResult).toHaveBeenCalledWith( + expect.objectContaining({ + ranked: [expect.objectContaining({ issueNumber: 1 })], + excludedByEligibility: [expect.objectContaining({ issueNumber: 2, reasons: ["missing eligibility label"] })], + }), + ); + }); + + it("opens and closes the default on-disk contribution-profile cache when no override is supplied", async () => { + const portfolioQueue = tempQueueStore(); + const fetchCandidateIssuesWithSummary = vi.fn(async () => ({ + issues: [fanOutIssue()], + warnings: [], + rateLimitRemaining: null, + rateLimitResetAt: null, + })); + vi.spyOn(console, "log").mockImplementation(() => undefined); + + // No initContributionProfileCache override: runDiscover opens the default on-disk cache at the (per-test + // stubbed) env path and closes it in its finally block. The stubbed global fetch (404) makes the underlying + // extraction fully-absent, so no candidate is excluded -- this test only cares that the cache file exists + // and is a valid, reopenable store afterward. + const exitCode = await runDiscover(["acme/widgets"], { + nowMs: NOW, + fetchCandidateIssuesWithSummary, + initPortfolioQueue: () => portfolioQueue, + initPolicyDocCache: () => tempPolicyDocCacheStore(), + initPolicyVerdictCache: () => tempPolicyVerdictCacheStore(), + initRankedCandidatesStore: () => tempRankedCandidatesStore(), + }); + expect(exitCode).toBe(0); + + const cacheDbPath = process.env.LOOPOVER_MINER_CONTRIBUTION_PROFILE_CACHE_DB; + expect(cacheDbPath).toBeDefined(); + expect(existsSync(cacheDbPath!)).toBe(true); + + const reopened = initContributionProfileCache(cacheDbPath); + stores.push(reopened); + expect(reopened.get("acme/widgets")).not.toBeNull(); + }); + + it("REGRESSION: a corrupt/unopenable contribution-profile cache degrades to no cache instead of failing discovery", async () => { + const portfolioQueue = tempQueueStore(); + const fetchCandidateIssuesWithSummary = vi.fn(async () => ({ + issues: [fanOutIssue()], + warnings: [], + rateLimitRemaining: null, + rateLimitResetAt: null, + })); + vi.spyOn(console, "log").mockImplementation(() => undefined); + const initContributionProfileCache = vi.fn(() => { + throw new Error("disk full"); + }); + + // Deliberately omits nowMs (unlike this file's other runDiscover calls) to also cover the branch where + // applyEligibilityFilter derives no explicit generatedAt override, falling through to extraction's own + // real-clock default. + const exitCode = await runDiscover(["acme/widgets"], { + initPortfolioQueue: () => portfolioQueue, + initPolicyDocCache: () => tempPolicyDocCacheStore(), + initPolicyVerdictCache: () => tempPolicyVerdictCacheStore(), + initRankedCandidatesStore: () => tempRankedCandidatesStore(), + initContributionProfileCache, + fetchCandidateIssuesWithSummary, + }); + + // Same discipline as the other caches above: a pure performance optimization, so an open failure must never + // abort discovery -- eligibility filtering just falls back to extracting live every run. + expect(exitCode).toBe(0); + expect(initContributionProfileCache).toHaveBeenCalledTimes(1); + }); +}); diff --git a/test/unit/miner-extension-content.test.ts b/test/unit/miner-extension-content.test.ts index e414ad260f..4b36c5036f 100644 --- a/test/unit/miner-extension-content.test.ts +++ b/test/unit/miner-extension-content.test.ts @@ -20,6 +20,7 @@ function rawIssue(overrides: Record = {}) { issueNumber: 145, title: "Add miner extension badge", labels: ["help wanted", "gittensor:feature"], + assignees: [], commentsCount: 1, createdAt: "2026-07-01T00:00:00.000Z", updatedAt: "2026-07-02T00:00:00.000Z", diff --git a/test/unit/miner-opportunity-fanout.test.ts b/test/unit/miner-opportunity-fanout.test.ts index 137b9fdbe6..c1be39e1da 100644 --- a/test/unit/miner-opportunity-fanout.test.ts +++ b/test/unit/miner-opportunity-fanout.test.ts @@ -81,6 +81,7 @@ describe("fetchCandidateIssues (#2307)", () => { issueNumber: 7, title: "Issue 7", labels: ["help wanted", "good first issue"], + assignees: [], commentsCount: 2, createdAt: "2026-07-01T00:00:00Z", updatedAt: "2026-07-01T01:00:00Z", @@ -93,6 +94,30 @@ describe("fetchCandidateIssues (#2307)", () => { expect(calls.every((call) => call.authorization === "Bearer placeholder-token")).toBe(true); }); + it("maps assignee logins from the same issue payload, ignoring a malformed entry (#6798)", 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 jsonResponse({}, { status: 404 }); + if (url.includes("/issues?")) { + return jsonResponse([ + { + ...issue(9), + assignees: [{ login: "repo-owner" }, { missing: true }, "not-an-object"], + }, + ]); + } + return jsonResponse({}, { status: 404 }); + }); + + const result = await fetchCandidateIssues([{ owner: "acme", repo: "widgets" }], "placeholder-token", { + apiBaseUrl: API, + }); + + expect(result).toHaveLength(1); + expect(result[0]?.assignees).toEqual(["repo-owner"]); + }); + it("hard-skips a banned repo without listing issues", async () => { const calls: string[] = []; vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { diff --git a/test/unit/miner-opportunity-ranker.test.ts b/test/unit/miner-opportunity-ranker.test.ts index 2ce1f7546a..dcf1ead4b7 100644 --- a/test/unit/miner-opportunity-ranker.test.ts +++ b/test/unit/miner-opportunity-ranker.test.ts @@ -20,6 +20,7 @@ function rawIssue(overrides: Record = {}) { issueNumber: 42, title: "Add queue retry helper", labels: ["help wanted"], + assignees: [], commentsCount: 1, createdAt: "2026-07-01T00:00:00.000Z", updatedAt: "2026-07-02T00:00:00.000Z",