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",