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
10 changes: 8 additions & 2 deletions src/signals/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1246,7 +1246,11 @@ export function buildContributorOpportunities(
const rankable = qualityByIssue
? availableIssues.filter((issue) => qualityByIssue.get(issue.number)?.status !== "do_not_use")
: availableIssues;
for (const issue of rankable.slice(0, 5)) {
// Score every eligible issue, then keep this repo's best 5 by score -- the cap must select the
// strongest-fit issues (mirroring the issue-quality report's score-descending order), not the
// arbitrary first 5 in DB order.
const repoOpportunities: ContributorOpportunity[] = [];
for (const issue of rankable) {
const quality = qualityByIssue?.get(issue.number);
const bounty = bountyByIssue.get(bountyIssueKey(repo.fullName, issue.number)) ?? null;
const bountyLifecycle = bounty ? classifyBountyLifecycle(bounty, issue) : null;
Expand Down Expand Up @@ -1277,7 +1281,7 @@ export function buildContributorOpportunities(
);
const baseFit = score >= 70 ? "good" : score >= 40 ? "caution" : "hold";
const downgradeToCaution = (bountyPenalty > 0 || quality?.status === "needs_proof") && baseFit === "good";
opportunities.push({
repoOpportunities.push({
repoFullName: repo.fullName,
issueNumber: issue.number,
title: issue.title,
Expand All @@ -1302,6 +1306,8 @@ export function buildContributorOpportunities(
],
});
}
repoOpportunities.sort((left, right) => right.score - left.score || (left.issueNumber ?? 0) - (right.issueNumber ?? 0));
opportunities.push(...repoOpportunities.slice(0, 5));
}

/* v8 ignore next -- Repo-name tie ordering is deterministic presentation fallback after scored opportunity ranking. */
Expand Down
22 changes: 22 additions & 0 deletions test/unit/issue-quality.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,28 @@ describe("buildContributorOpportunities x issue quality", () => {
expect(opportunities[0]?.reasons).toEqual(expect.arrayContaining(["Issue quality report rates this issue as ready."]));
});

it("surfaces the highest-quality issue even when it sits beyond the first 5 in DB order", () => {
const repo = issueDiscoveryRepo("owner/deep-ready");
// Six available issues in DB order; the only "ready" one is last (index 5), the rest are "hold".
const issues = [1, 2, 3, 4, 5, 6].map((n) => issue(repo.fullName, n, `Issue ${n}`, { body: "x".repeat(220), labels: ["bug"] }));
const quality: IssueQualityReport = {
repoFullName: repo.fullName,
generatedAt: now(),
lane: { repoFullName: repo.fullName, lane: "issue_discovery", issueDiscoveryShare: 1, directPrShare: 0, summary: "", contributorGuidance: "", maintainerGuidance: "" },
issues: [
...[1, 2, 3, 4, 5].map((n) => ({ number: n, title: `Issue ${n}`, status: "hold" as const, score: 30, reasons: [], warnings: [] })),
{ number: 6, title: "Issue 6", status: "ready" as const, score: 92, reasons: [], warnings: [] },
],
summary: "",
};
const opportunities = buildContributorOpportunities(sampleProfile(), [repo], issues, [], [], new Map([[repo.fullName, quality]]));
// The "ready" issue #6 (highest score) must be surfaced and ranked first, even though it is the
// 6th available issue -- the per-repo cap selects the best, not the arbitrary first 5.
expect(opportunities.map((o) => o.issueNumber)).toContain(6);
expect(opportunities[0]?.issueNumber).toBe(6);
expect(opportunities[0]?.reasons).toEqual(expect.arrayContaining(["Issue quality report rates this issue as ready."]));
});

it("downgrades needs_proof issues to caution and adds a warning", () => {
const repo = issueDiscoveryRepo("owner/caution");
const issues = [issue(repo.fullName, 1, "Vague candidate", { body: "x".repeat(220), labels: ["bug"] })];
Expand Down