Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> = {
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<string, ContributionProfile>,
Expand Down
37 changes: 32 additions & 5 deletions packages/loopover-miner/lib/contribution-profile-filter.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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();
Expand All @@ -40,14 +59,22 @@ 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<string, import("./contribution-profile.js").ContributionProfile>} profilesByRepo profile per repoFullName
* @returns {{ kept: object[], excluded: Array<{ candidate: object, reason: string }> }}
*/
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.
Expand Down
2 changes: 2 additions & 0 deletions packages/loopover-miner/lib/opportunity-fanout.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
12 changes: 12 additions & 0 deletions packages/loopover-miner/lib/opportunity-fanout.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand Down
62 changes: 62 additions & 0 deletions test/unit/miner-contribution-profile-filter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});

Expand Down Expand Up @@ -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([]);
});
});
});
34 changes: 34 additions & 0 deletions test/unit/miner-discover-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ function fanOutIssue(overrides: Record<string, unknown> = {}) {
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",
Expand Down Expand Up @@ -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" },
]);
});
});
});
});
1 change: 1 addition & 0 deletions test/unit/miner-extension-content.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ function rawIssue(overrides: Record<string, unknown> = {}) {
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",
Expand Down
25 changes: 25 additions & 0 deletions test/unit/miner-opportunity-fanout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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) => {
Expand Down
1 change: 1 addition & 0 deletions test/unit/miner-opportunity-ranker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ function rawIssue(overrides: Record<string, unknown> = {}) {
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",
Expand Down