diff --git a/apps/gittensory-ui/public/openapi.json b/apps/gittensory-ui/public/openapi.json index 6becd7e0a1..cd4f0f0706 100644 --- a/apps/gittensory-ui/public/openapi.json +++ b/apps/gittensory-ui/public/openapi.json @@ -1445,6 +1445,20 @@ "items": { "type": "string" } + }, + "multiplierTier": { + "type": "string", + "enum": [ + "maintainer_created", + "community" + ] + }, + "availability": { + "type": "string", + "enum": [ + "ready", + "maintainer_wip" + ] } }, "required": [ @@ -1453,6 +1467,8 @@ "fit", "score", "lane", + "multiplierTier", + "availability", "reasons", "warnings" ] diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index de425d36c1..9801122099 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -358,6 +358,8 @@ export const ContributorOpportunitySchema = z fit: z.enum(["good", "caution", "hold"]), score: z.number(), lane: z.enum(["direct_pr", "issue_discovery", "split", "inactive", "unknown"]), + multiplierTier: z.enum(["maintainer_created", "community"]), + availability: z.enum(["ready", "maintainer_wip"]), reasons: z.array(z.string()), warnings: z.array(z.string()), }) diff --git a/src/signals/engine.ts b/src/signals/engine.ts index 62d0368564..26d94e6498 100644 --- a/src/signals/engine.ts +++ b/src/signals/engine.ts @@ -181,10 +181,39 @@ export type ContributorOpportunity = { fit: "good" | "caution" | "hold"; score: number; lane: ParticipationLane; + /** Reward-multiplier tier of the issue. Maintainer-CREATED issues typically carry the biggest Gittensor + * multiplier, so they rank highest when grabbable — surfacing them is the core of issue-watch (#699). */ + multiplierTier: "maintainer_created" | "community"; + /** Whether the issue is a real outside-contributor target. `maintainer_wip` = maintainer-authored AND + * labelled in-progress/internal → downgraded, not steered to outsiders (the #186 reconciliation). */ + availability: "ready" | "maintainer_wip"; reasons: string[]; warnings: string[]; }; +// Labels that signal a maintainer's OWN in-progress / internal work — NOT an open outside-contributor +// target. Combined with a maintainer author association, these downgrade an issue (#186) even though +// maintainer-CREATED open issues are otherwise the highest-multiplier targets (#699). +const MAINTAINER_WIP_LABELS = new Set([ + "wip", + "work in progress", + "work-in-progress", + "in progress", + "in-progress", + "blocked", + "on hold", + "on-hold", + "draft", + "do not work", + "do-not-work", + "internal", +]); + +/** True iff a maintainer-authored issue is labelled as the maintainer's own in-progress/internal work. */ +function isMaintainerWipIssue(issue: IssueRecord): boolean { + return isMaintainerAssociation(issue.authorAssociation) && issue.labels.some((label) => MAINTAINER_WIP_LABELS.has(label.toLowerCase().trim())); +} + export type ContributorFit = { login: string; generatedAt: string; @@ -1302,6 +1331,14 @@ export function buildContributorOpportunities( : quality?.status === "hold" ? -15 : 0; + const maintainerAuthored = isMaintainerAssociation(issue.authorAssociation); + const maintainerWip = isMaintainerWipIssue(issue); + const multiplierTier: ContributorOpportunity["multiplierTier"] = maintainerAuthored ? "maintainer_created" : "community"; + const availability: ContributorOpportunity["availability"] = maintainerWip ? "maintainer_wip" : "ready"; + // Maintainer-CREATED grabbable issues carry the biggest Gittensor multiplier → rank them up (#699). + // A maintainer's own WIP/internal issue is heavily downgraded so outsiders aren't steered to it (#186). + const multiplierBoost = maintainerAuthored && !maintainerWip ? 12 : 0; + const maintainerWipPenalty = maintainerWip ? 45 : 0; const score = clamp( 50 + (touchedRepos.has(repo.fullName) ? 20 : 0) + @@ -1311,11 +1348,13 @@ export function buildContributorOpportunities( queuePenalty - bountyPenalty - (lane.lane === "inactive" || lane.lane === "unknown" ? 35 : 0) + - qualityAdjustment, + qualityAdjustment + + multiplierBoost - + maintainerWipPenalty, 0, 100, ); - const baseFit = score >= 70 ? "good" : score >= 40 ? "caution" : "hold"; + const baseFit = maintainerWip ? "hold" : score >= 70 ? "good" : score >= 40 ? "caution" : "hold"; const downgradeToCaution = (bountyPenalty > 0 || quality?.status === "needs_proof") && baseFit === "good"; repoOpportunities.push({ repoFullName: repo.fullName, @@ -1324,14 +1363,19 @@ export function buildContributorOpportunities( fit: downgradeToCaution ? "caution" : baseFit, score, lane: lane.lane, + multiplierTier, + availability, reasons: [ lane.summary, + ...(maintainerAuthored && !maintainerWip ? ["Maintainer-created issue — typically the highest contribution multiplier on Gittensor."] : []), ...(touchedRepos.has(repo.fullName) ? ["Contributor has prior activity in this registered repo."] : []), ...(labelFit > 0 ? [`Issue labels overlap contributor history: ${issue.labels.filter((label) => labelHistory.has(label)).join(", ")}.`] : []), ...(bountyLifecycle === "active" ? ["An active bounty is attached as contribution context (not guaranteed payout)."] : []), ...(quality?.status === "ready" ? ["Issue quality report rates this issue as ready."] : []), ], warnings: [ + ...(maintainerAuthored && !maintainerWip ? ["Maintainer-authored; confirm it is open for outside contribution before starting."] : []), + ...(maintainerWip ? ["Maintainer-authored and labelled in-progress/internal; not a recommended outside-contributor target without confirmation."] : []), ...(repoPullRequests.length >= 8 ? ["This repo has a busy open PR queue."] : []), ...(lane.lane === "issue_discovery" ? ["This repo is not a direct-PR-first lane."] : []), ...(lane.lane === "unknown" || lane.lane === "inactive" ? ["Gittensory cannot recommend this as a strong contribution target right now."] : []), @@ -2749,6 +2793,10 @@ export function buildIssueQualityReport( const bountyContext = bounty ? buildBountyOpportunityContext(bounty, issue, linkedPrs, linkedMergedPrs) : undefined; const linkedWorkCount = linkedPrs.length + linkedMergedPrs.length + issue.linkedPrs.length; const linkage = buildIssueLinkageRecord(issue, lifecycleEntry, linkedPrs, linkedMergedPrs); + // #186: maintainer-authored issues must not silently read as "ready" for outside contributors — + // always warn to confirm intent, and downgrade ones labelled as the maintainer's own in-progress work. + const maintainerAuthored = isMaintainerAssociation(issue.authorAssociation); + const maintainerWip = isMaintainerWipIssue(issue); const reasons = [ ...(bodyLength >= 200 ? ["Issue has enough body detail to evaluate."] : []), ...(issue.labels.length > 0 ? [`Labels: ${issue.labels.join(", ")}.`] : []), @@ -2769,6 +2817,8 @@ export function buildIssueQualityReport( ...(bountyLifecycle === "historical" ? ["Historical bounty context is attached; this is not an active opportunity without upstream confirmation."] : []), ...(bountyLifecycle === "stale" ? ["Bounty context for this issue looks stale; confirm it is still active before acting."] : []), ...(bountyLifecycle === "ambiguous" ? ["Bounty state for this issue is ambiguous; verify it before acting."] : []), + ...(maintainerAuthored && !maintainerWip ? ["Maintainer-authored; confirm it is open for outside contribution before starting."] : []), + ...(maintainerWip ? ["Maintainer-authored and labelled in-progress/internal; not a recommended outside-contributor target without confirmation."] : []), ]; const score = clamp(100 - warnings.length * 18 + reasons.length * 5 - (age > 180 ? 15 : 0), 0, 100); const bountyBlocks = bountyLifecycle === "completed" || bountyLifecycle === "cancelled" || bountyLifecycle === "historical"; @@ -2776,7 +2826,7 @@ export function buildIssueQualityReport( const status: IssueQualityReport["issues"][number]["status"] = linkedWorkCount > 0 || issueCollisions.some((cluster) => cluster.risk === "high") || bountyBlocks || ["duplicate", "invalid", "solved", "valid_solved"].includes(lifecycle) ? "do_not_use" - : warnings.some((warning) => /thin|stale|direct-PR/i.test(warning)) || bountyCaution || lifecycle === "stale" + : maintainerWip || warnings.some((warning) => /thin|stale|direct-PR/i.test(warning)) || bountyCaution || lifecycle === "stale" ? "needs_proof" : score < 45 ? "hold" diff --git a/test/unit/decision-pack.test.ts b/test/unit/decision-pack.test.ts index a2e946f9bc..00ae971ff1 100644 --- a/test/unit/decision-pack.test.ts +++ b/test/unit/decision-pack.test.ts @@ -850,6 +850,8 @@ describe("decision-pack service", () => { fit: "good", score: 82, lane: "split", + multiplierTier: "community", + availability: "ready", reasons: ["Active bounty context is available."], warnings: [], }, diff --git a/test/unit/issue-quality.test.ts b/test/unit/issue-quality.test.ts index 4e4d348724..74cdcf7f4e 100644 --- a/test/unit/issue-quality.test.ts +++ b/test/unit/issue-quality.test.ts @@ -219,6 +219,34 @@ describe("issue quality reports", () => { expect(report.issues[0]?.warnings).toEqual(expect.arrayContaining([expect.stringMatching(/direct-PR first/i)])); }); + it("warns to confirm scope on a maintainer-authored issue but still lets a clean one read ready (#186)", () => { + const repo = issueDiscoveryRepo("owner/maintainer-open"); + const report = buildIssueQualityReport( + repo, + [issue(repo.fullName, 30, "Maintainer-created: add reconnect backoff", { body: "x".repeat(220), labels: ["feature"], authorAssociation: "OWNER", updatedAt: now() })], + [], + repo.fullName, + ); + expect(report.issues[0]).toMatchObject({ + status: "ready", + warnings: expect.arrayContaining(["Maintainer-authored; confirm it is open for outside contribution before starting."]), + }); + }); + + it("never reads a maintainer-authored WIP/internal issue as ready (#186)", () => { + const repo = issueDiscoveryRepo("owner/maintainer-wip"); + const report = buildIssueQualityReport( + repo, + [issue(repo.fullName, 31, "Maintainer-created: internal refactor", { body: "x".repeat(220), labels: ["feature", "internal"], authorAssociation: "OWNER", updatedAt: now() })], + [], + repo.fullName, + ); + expect(report.issues[0]).toMatchObject({ + status: "needs_proof", + warnings: expect.arrayContaining(["Maintainer-authored and labelled in-progress/internal; not a recommended outside-contributor target without confirmation."]), + }); + }); + it("respects a worker-budget cap of 100 issues per repo", () => { const repo = issueDiscoveryRepo("owner/big"); const issues = Array.from({ length: 150 }, (_, index) => issue(repo.fullName, index + 1, `bulk ${index}`, { body: "x".repeat(220) })); diff --git a/test/unit/signals.test.ts b/test/unit/signals.test.ts index 4622a462e0..da8960d5fd 100644 --- a/test/unit/signals.test.ts +++ b/test/unit/signals.test.ts @@ -145,6 +145,66 @@ describe("world-class backend signals", () => { const opportunities = buildContributorOpportunities(profile, [repo], issues, pullRequests); expect(profile.trustSignals.level).toBe("new"); expect(opportunities[0]?.repoFullName).toBe(repo.fullName); + for (const opportunity of opportunities) { + expect(opportunity.multiplierTier).toBe("community"); + expect(opportunity.availability).toBe("ready"); + } + }); + + it("ranks grabbable maintainer-created issues above community issues and downgrades maintainer WIP (#699/#186)", () => { + const profile = buildContributorProfile("scout", { login: "scout", topLanguages: ["TypeScript"], source: "github" }, [], []); + const maintainerOpen: IssueRecord = { + repoFullName: repo.fullName, + number: 40, + title: "Maintainer-created: implement reconnect backoff", + state: "open", + authorLogin: "entrius", + authorAssociation: "OWNER", + labels: ["feature"], + linkedPrs: [], + }; + const maintainerWip: IssueRecord = { + repoFullName: repo.fullName, + number: 41, + title: "Maintainer-created: internal refactor", + state: "open", + authorLogin: "entrius", + authorAssociation: "OWNER", + labels: ["feature", "WIP"], + linkedPrs: [], + }; + const community: IssueRecord = { + repoFullName: repo.fullName, + number: 42, + title: "Community-reported: same feature label", + state: "open", + authorLogin: "outsider", + authorAssociation: "NONE", + labels: ["feature"], + linkedPrs: [], + }; + + const opportunities = buildContributorOpportunities(profile, [repo], [maintainerOpen, maintainerWip, community], []); + const byNumber = new Map(opportunities.map((opportunity) => [opportunity.issueNumber, opportunity])); + + const open = byNumber.get(40)!; + expect(open.multiplierTier).toBe("maintainer_created"); + expect(open.availability).toBe("ready"); + expect(open.reasons).toContain("Maintainer-created issue — typically the highest contribution multiplier on Gittensor."); + expect(open.warnings).toContain("Maintainer-authored; confirm it is open for outside contribution before starting."); + + const wip = byNumber.get(41)!; + expect(wip.multiplierTier).toBe("maintainer_created"); + expect(wip.availability).toBe("maintainer_wip"); + expect(wip.fit).toBe("hold"); + expect(wip.warnings).toContain("Maintainer-authored and labelled in-progress/internal; not a recommended outside-contributor target without confirmation."); + + const open42 = byNumber.get(42)!; + expect(open42.multiplierTier).toBe("community"); + + // Same labels/lane: the grabbable maintainer-created issue outscores the community one and the WIP one. + expect(open.score).toBeGreaterThan(open42.score); + expect(open.score).toBeGreaterThan(wip.score); }); it("profiles contributors from cached repo stats when sampled PR rows miss their history", () => {