diff --git a/packages/loopover-miner/lib/contribution-profile-filter.d.ts b/packages/loopover-miner/lib/contribution-profile-filter.d.ts index f5d63c7788..099d1489c2 100644 --- a/packages/loopover-miner/lib/contribution-profile-filter.d.ts +++ b/packages/loopover-miner/lib/contribution-profile-filter.d.ts @@ -4,16 +4,20 @@ export const ELIGIBILITY_EXCLUSION_REASONS: { readonly EXCLUSION_LABEL: "exclusion_label"; readonly MISSING_ELIGIBILITY_LABEL: "missing_eligibility_label"; readonly CONFLICTING_SIGNALS: "conflicting_signals"; + readonly EXCLUDED_ASSIGNEE: "excluded_assignee"; }; export type EligibilityExclusion = { candidate: T; reason: - "exclusion_label" | "missing_eligibility_label" | "conflicting_signals"; + | "exclusion_label" + | "missing_eligibility_label" + | "conflicting_signals" + | "excluded_assignee"; }; export function filterCandidatesByProfiles< - T extends { repoFullName: string; labels?: string[] }, + T extends { repoFullName: string; owner?: string; labels?: string[]; assignees?: string[] }, >( candidates: T[], profilesByRepo: Map, diff --git a/packages/loopover-miner/lib/contribution-profile-filter.js b/packages/loopover-miner/lib/contribution-profile-filter.js index 07a44e2dbd..463471a140 100644 --- a/packages/loopover-miner/lib/contribution-profile-filter.js +++ b/packages/loopover-miner/lib/contribution-profile-filter.js @@ -2,10 +2,16 @@ // list and a per-repo profile map, it partitions candidates into kept + excluded-with-reason. No fetching, no // side effects — discover-cli.js resolves the profiles and renders the result; this owns only the decision. // -// SAFE-DEFAULT POSTURE (the load-bearing requirement): filtering activates ONLY when a repo's profile has a -// trustworthy eligibility signal (eligibilityLabels.confidence === "explicit"). A repo with no profile, or a -// low-confidence/empty one — a repo whose conventions AMS simply couldn't read — has EVERY candidate kept, so a -// weak profile can never cause AMS to silently skip real, eligible work. +// SAFE-DEFAULT POSTURE (the load-bearing requirement) applies to the three LABEL-based rules only: filtering on +// them activates ONLY when a repo's profile has a trustworthy eligibility signal +// (eligibilityLabels.confidence === "explicit"). A repo with no profile, or a low-confidence/empty one — a repo +// whose conventions AMS simply couldn't read — has every candidate kept via those rules, so a weak profile can +// never cause AMS to silently skip real, eligible work. +// +// ASSIGNEE-EXCLUSION IS DIFFERENT (#7040): per the schema (ContributionAssigneeRuntimeCheck, +// contribution-profile.d.ts), it is deliberately NOT a profile field — it's a structural fact derivable from the +// issue's own assignees at query time, not something extraction infers with variable confidence. It therefore +// applies to EVERY candidate unconditionally, independent of the repo's ContributionProfile (or lack of one). /** Why a candidate was excluded. */ export const ELIGIBILITY_EXCLUSION_REASONS = Object.freeze({ @@ -15,8 +21,21 @@ export const ELIGIBILITY_EXCLUSION_REASONS = Object.freeze({ MISSING_ELIGIBILITY_LABEL: "missing_eligibility_label", /** The issue carries BOTH an eligibility and an exclusion label — conflicting signals; exclusion wins. */ CONFLICTING_SIGNALS: "conflicting_signals", + /** The issue is assigned to the repo's own owner login (#7040) — structural, not profile-derived. */ + EXCLUDED_ASSIGNEE: "excluded_assignee", }); +/** True when the candidate is assigned to its own repo's owner login (case-insensitive). Always-on: unlike the + * label rules below, this never depends on the profile's confidence — see the header comment. */ +function isAssignedToRepoOwner(candidate) { + const owner = typeof candidate?.owner === "string" ? candidate.owner.toLowerCase() : ""; + if (!owner) return false; + for (const login of candidate?.assignees ?? []) { + if (typeof login === "string" && login.toLowerCase() === owner) return true; + } + return false; +} + /** The actual repo label names a signal rule was derived from (its provenance details), lowercased for match. */ function labelNamesFromRule(rule) { const names = new Set(); @@ -40,7 +59,7 @@ function candidateHasAnyLabel(candidate, names) { /** * Partition candidates into kept + excluded against per-repo ContributionProfiles. * - * @param {Array<{ repoFullName: string, labels?: string[] }>} candidates the fanned-out discover candidates + * @param {Array<{ repoFullName: string, owner?: string, labels?: string[], assignees?: string[] }>} candidates the fanned-out discover candidates * @param {Map} profilesByRepo profile per repoFullName * @returns {{ kept: object[], excluded: Array<{ candidate: object, reason: string }> }} */ @@ -48,6 +67,14 @@ export function filterCandidatesByProfiles(candidates, profilesByRepo) { const kept = []; const excluded = []; for (const candidate of candidates) { + // Always-on, ahead of the label rules' safe-default gate (#7040) — see the header comment. + if (isAssignedToRepoOwner(candidate)) { + excluded.push({ + candidate, + reason: ELIGIBILITY_EXCLUSION_REASONS.EXCLUDED_ASSIGNEE, + }); + continue; + } const profile = profilesByRepo?.get(candidate.repoFullName); // Trust gate: only an EXPLICIT eligibility signal is trustworthy enough to filter on. Anything weaker // (absent/inferred/unknown, or no profile at all) keeps every candidate — the safe default. diff --git a/packages/loopover-miner/lib/opportunity-fanout.d.ts b/packages/loopover-miner/lib/opportunity-fanout.d.ts index 15fbedf95b..5f47a66a83 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 (#7040), 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..afd0ff7954 100644 --- a/packages/loopover-miner/lib/opportunity-fanout.js +++ b/packages/loopover-miner/lib/opportunity-fanout.js @@ -301,6 +301,17 @@ function labelNames(labels) { .filter((name) => name.length > 0); } +// Assignee logins (#7040): GitHub's issue-list/search payloads already carry `assignees` in the same response +// that supplies labels/comments/etc. -- no extra request needed. contribution-profile-filter.js's +// assignee-exclusion rule uses this to drop candidates assigned to a login the target repo considers off-limits +// (its own owner, by default). +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 +323,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/test/unit/miner-contribution-profile-filter.test.ts b/test/unit/miner-contribution-profile-filter.test.ts index 86e9cc919b..1d602b2132 100644 --- a/test/unit/miner-contribution-profile-filter.test.ts +++ b/test/unit/miner-contribution-profile-filter.test.ts @@ -9,12 +9,15 @@ import { type Candidate = { repoFullName: string; issueNumber: number; + owner?: string; labels?: string[]; + assignees?: string[]; }; const candidate = (issueNumber: number, labels: string[]): Candidate => ({ repoFullName: "acme/widgets", issueNumber, + owner: "acme", labels, }); @@ -199,4 +202,63 @@ describe("filterCandidatesByProfiles (#6798)", () => { ); expect(kept).toHaveLength(1); }); + + describe("assignee-exclusion (#7040)", () => { + it("excludes a candidate assigned to the repo's own owner login", () => { + const assigned: Candidate = { ...candidate(1, ["good first issue"]), assignees: ["acme"] }; + const { kept, excluded } = filterCandidatesByProfiles([assigned], profilesFor(trustworthyProfile())); + expect(kept).toEqual([]); + expect(excluded).toEqual([{ candidate: assigned, reason: ELIGIBILITY_EXCLUSION_REASONS.EXCLUDED_ASSIGNEE }]); + }); + + it("matches the owner login case-insensitively", () => { + const assigned: Candidate = { ...candidate(1, ["good first issue"]), assignees: ["ACME"] }; + const { excluded } = filterCandidatesByProfiles([assigned], profilesFor(trustworthyProfile())); + expect(excluded[0]?.reason).toBe(ELIGIBILITY_EXCLUSION_REASONS.EXCLUDED_ASSIGNEE); + }); + + it("does not exclude a candidate assigned to someone other than the repo owner", () => { + const assigned: Candidate = { ...candidate(1, ["good first issue"]), assignees: ["someone-else"] }; + const { kept, excluded } = filterCandidatesByProfiles([assigned], profilesFor(trustworthyProfile())); + expect(kept).toHaveLength(1); + expect(excluded).toEqual([]); + }); + + it("applies unconditionally: excludes an owner-assigned candidate even when the repo has NO profile at all", () => { + const assigned: Candidate = { ...candidate(1, ["bug"]), repoFullName: "other/repo", assignees: ["other"] }; + const withOwner: Candidate = { ...assigned, owner: "other" }; + const { kept, excluded } = filterCandidatesByProfiles([withOwner], profilesFor(trustworthyProfile())); + expect(kept).toEqual([]); + expect(excluded).toEqual([{ candidate: withOwner, reason: ELIGIBILITY_EXCLUSION_REASONS.EXCLUDED_ASSIGNEE }]); + }); + + it("applies unconditionally: excludes an owner-assigned candidate even when the profile's eligibility confidence is not explicit", () => { + const lowConfidence = trustworthyProfile({ + eligibilityLabels: { value: null, confidence: "absent", provenance: [] }, + }); + const assigned: Candidate = { ...candidate(1, ["bug"]), assignees: ["acme"] }; + const { kept, excluded } = filterCandidatesByProfiles([assigned], profilesFor(lowConfidence)); + expect(kept).toEqual([]); + expect(excluded).toEqual([{ candidate: assigned, reason: ELIGIBILITY_EXCLUSION_REASONS.EXCLUDED_ASSIGNEE }]); + }); + + it("tolerates a candidate with no owner or assignees fields (never excluded, never throws)", () => { + const { kept, excluded } = filterCandidatesByProfiles( + [{ repoFullName: "acme/widgets", issueNumber: 6, labels: ["good first issue"] }], + profilesFor(trustworthyProfile()), + ); + expect(kept).toHaveLength(1); + expect(excluded).toEqual([]); + }); + + it("ignores a non-string assignee entry without matching or throwing", () => { + const assigned: Candidate = { + ...candidate(1, ["good first issue"]), + assignees: [42 as unknown as string], + }; + const { kept, excluded } = filterCandidatesByProfiles([assigned], profilesFor(trustworthyProfile())); + expect(kept).toHaveLength(1); + expect(excluded).toEqual([]); + }); + }); }); diff --git a/test/unit/miner-discover-cli.test.ts b/test/unit/miner-discover-cli.test.ts index cbc92240e5..02537eede9 100644 --- a/test/unit/miner-discover-cli.test.ts +++ b/test/unit/miner-discover-cli.test.ts @@ -71,6 +71,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", @@ -1551,5 +1552,38 @@ describe("runDiscover onResult hook (#6522)", () => { }); expect(extract).not.toHaveBeenCalled(); }); + + describe("assignee-exclusion (#7040)", () => { + it("excludes a candidate assigned to the repo's own owner, unconditionally enqueuing only the rest", async () => { + const issues = [ + fanOutIssue({ issueNumber: 1, labels: ["help wanted"] }), // eligible, unassigned + fanOutIssue({ issueNumber: 2, labels: ["help wanted"], assignees: ["acme"] }), // owner-assigned + ]; + const { portfolioQueue, opts } = discoverWith(issues, new Map([["acme/widgets", trustworthyProfile]])); + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + const exitCode = await runDiscover(["acme/widgets", "--json"], opts); + expect(exitCode).toBe(0); + + const payload = JSON.parse(String(log.mock.calls[0]?.[0])); + expect(payload.ranked.map((e: { issueNumber: number }) => e.issueNumber)).toEqual([1]); + expect(payload.excluded).toEqual([ + { repoFullName: "acme/widgets", issueNumber: 2, reason: "excluded_assignee" }, + ]); + expect(portfolioQueue.listQueue("acme/widgets").map((e) => e.identifier)).toEqual(["issue:1"]); + }); + + it("applies even when the repo has no profile at all (not gated behind the label safe-default)", async () => { + const issues = [fanOutIssue({ issueNumber: 1, labels: ["bug"], assignees: ["acme"] })]; + const { opts } = discoverWith(issues, null); + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + await runDiscover(["acme/widgets", "--json"], opts); + + const payload = JSON.parse(String(log.mock.calls[0]?.[0])); + expect(payload.ranked).toEqual([]); + expect(payload.excluded).toEqual([ + { repoFullName: "acme/widgets", issueNumber: 1, reason: "excluded_assignee" }, + ]); + }); + }); }); }); 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..47772bdeef 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 (#7040)", 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",