diff --git a/src/contracts/agent-contracts.ts b/src/contracts/agent-contracts.ts index f8b083f..32e7bd4 100644 --- a/src/contracts/agent-contracts.ts +++ b/src/contracts/agent-contracts.ts @@ -26,6 +26,8 @@ export const IssueMatchSchema = z.object({ coreDemand: nonEmptyTrimmedString, techRequirements: z.array(trimmedString).default([]).transform(dedupeStrings), estimatedWorkload: nonEmptyTrimmedString, + claimStatus: z.enum(['none', 'possible', 'likely', 'claimed']).default('none'), + claimEvidence: trimmedString.default(''), }); export const IssueMatchListSchema = z.object({ diff --git a/src/infra/prompt-templates.ts b/src/infra/prompt-templates.ts index 25e6ea2..571a690 100644 --- a/src/infra/prompt-templates.ts +++ b/src/infra/prompt-templates.ts @@ -10,7 +10,9 @@ Requirements: 5. Only include issues with score >= 60 6. Use the exact issue reference shown in the input for every matched issue 7. Do not invent issues or references that are not in the input -8. Return one valid JSON object only. No markdown. No commentary. +8. Inspect recent issue comments for evidence that someone has claimed or started the work +9. claimStatus must be none, possible, likely, or claimed; cite one concise comment-based reason in claimEvidence +10. Return one valid JSON object only. No markdown. No commentary. Output schema: { @@ -24,7 +26,9 @@ Output schema: "score": 84, "coreDemand": "one sentence", "techRequirements": ["typescript", "react"], - "estimatedWorkload": "1-2 hours" + "estimatedWorkload": "1-2 hours", + "claimStatus": "none", + "claimEvidence": "" } ] } @@ -48,7 +52,9 @@ Required schema: "score": 84, "coreDemand": "one sentence", "techRequirements": ["typescript", "react"], - "estimatedWorkload": "1-2 hours" + "estimatedWorkload": "1-2 hours", + "claimStatus": "none" | "possible" | "likely" | "claimed", + "claimEvidence": "one concise reason or empty string" } ] } diff --git a/src/orchestration/agent.ts b/src/orchestration/agent.ts index b62f7c9..48ce101 100644 --- a/src/orchestration/agent.ts +++ b/src/orchestration/agent.ts @@ -1667,6 +1667,9 @@ export class AgentOrchestrator { `overall ${issue.opportunity.overallScore}`, `match ${issue.matchScore}`, `opportunity ${issue.opportunity.score}`, + ...(issue.claimAssessment?.status && issue.claimAssessment.status !== 'none' + ? [`claim ${issue.claimAssessment.status}`] + : []), ...(hint ? [`feasibility ${hint.level}`] : []), `stars ${issue.repoStars}`, ], @@ -1674,6 +1677,7 @@ export class AgentOrchestrator { `Labels: ${issue.labels.join(', ') || 'none'}`, `Tech: ${issue.analysis.techRequirements.join(', ') || 'n/a'}`, `Workload: ${issue.analysis.estimatedWorkload || 'n/a'}`, + ...(issue.claimAssessment?.evidence[0] ? [`Claim evidence: ${issue.claimAssessment.evidence[0]}`] : []), ...(hint ? [ `Feasibility: ${hint.level} (${hint.issueScope}, ${hint.scoreAdjustment >= 0 ? '+' : ''}${hint.scoreAdjustment})`, @@ -1699,6 +1703,12 @@ export class AgentOrchestrator { lines: [ `Repository: ${issue.repoFullName}`, `Summary: ${issue.opportunity.summary}`, + ...(issue.claimAssessment + ? [ + `Claim risk: ${issue.claimAssessment.status}`, + ...(issue.claimAssessment.evidence[0] ? [`Claim evidence: ${issue.claimAssessment.evidence[0]}`] : []), + ] + : []), ...(issue.scoutFeasibility ? [ `Scout feasibility: ${issue.scoutFeasibility.level} (${issue.scoutFeasibility.issueScope}, adjusted ${issue.scoutFeasibility.adjustedOverallScore})`, diff --git a/src/services/content.ts b/src/services/content.ts index 81d74a5..bd34f28 100644 --- a/src/services/content.ts +++ b/src/services/content.ts @@ -258,6 +258,8 @@ export class ContentService { `- Technical Match: ${issue.matchScore}/100`, `- Opportunity Score: ${issue.opportunity.score}/100`, `- Summary: ${issue.opportunity.summary}`, + `- Claim Risk: ${issue.claimAssessment?.status ?? 'not checked'}`, + ...(issue.claimAssessment?.evidence.map((evidence) => `- Claim Evidence: ${evidence}`) ?? []), '', '## Breakdown', '', diff --git a/src/services/github.ts b/src/services/github.ts index 926c5b3..db25bef 100644 --- a/src/services/github.ts +++ b/src/services/github.ts @@ -4,7 +4,7 @@ import { existsSync, readFileSync, writeFileSync } from 'fs'; import { join } from 'path'; import { ensureDirectory, getOpenMetaStateDir, parseGitHubRepoFullName } from '../infra/index.js'; import { logger } from '../infra/logger.js'; -import type { GitHubIssue } from '../types/index.js'; +import type { GitHubIssue, GitHubIssueComment, IssueClaimAssessment, IssueClaimStatus } from '../types/index.js'; const FILTER_LABEL_GROUPS = [ ['good first issue', 'good-first-issue'], @@ -19,6 +19,10 @@ const ACTION_BLOCKING_LABELS = [ 'question', 'discussion', 'wontfix', + 'claimed', + 'assigned', + 'in progress', + 'work in progress', ] as const; const SEARCH_RESULTS_PER_PAGE = 30; const SEARCH_CACHE_TTL_MS = 10 * 60 * 1000; @@ -28,6 +32,21 @@ const SEARCH_PAGE_PACING_DELAY_MS = 3_000; const RATE_LIMIT_RETRY_FALLBACK_DELAY_MS = 10_000; const MAX_ISSUES_PER_REPO = 3; export const DEFAULT_MIN_REPO_STARS = 50; +const MAX_RECENT_ISSUE_COMMENTS = 5; +const CLAIM_LOOKBACK_DAYS = 180; + +const SELF_CLAIM_PATTERNS = [ + /\bi(?:'d| would) like to (?:work on|take|handle|pick up) this\b/i, + /\bcan i (?:work on|take|handle|pick up) this\b/i, + /\bplease assign (?:this |the )?(?:issue )?to me\b/i, + /\bi(?:'ll| will) (?:work on|take|handle|pick up) this\b/i, + /\bi(?:'m| am) (?:currently )?working on this\b/i, +]; +const MAINTAINER_CLAIM_PATTERNS = [ + /\bassign(?:ed|ing)? (?:this |the )?(?:issue )?to @?[a-z0-9-]+\b/i, + /@[a-z0-9-]+[^\n]{0,80}\b(?:go ahead|you can (?:work on|take|handle) this)\b/i, +]; +const MAINTAINER_ASSOCIATIONS = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']); type SearchIssueItem = RestEndpointMethodTypes['search']['issuesAndPullRequests']['response']['data']['items'][number]; @@ -67,6 +86,11 @@ export interface RepositoryStarRange { maxStars?: number; } +export interface IssueClaimContext { + recentComments: GitHubIssueComment[]; + claimAssessment: IssueClaimAssessment; +} + export interface RepositoryProbe { repoFullName: string; files: { @@ -317,6 +341,58 @@ export class GitHubService { }; } + async fetchIssueClaimContext(repoFullName: string, issueNumber: number): Promise { + if (!this.octokit) { + throw new Error('GitHub service not initialized'); + } + + const normalizedRepo = parseGitHubRepoFullName(repoFullName); + const [owner, repo] = normalizedRepo.split('/'); + if (!owner || !repo) { + throw new Error(`Invalid GitHub repository reference: ${repoFullName}`); + } + + const checkedAt = new Date().toISOString(); + try { + const response = await this.octokit.rest.issues.listComments({ + owner, + repo, + issue_number: issueNumber, + per_page: 30, + sort: 'created', + direction: 'desc', + }); + const comments = response.data.flatMap((comment): GitHubIssueComment[] => { + const body = comment.body?.trim() ?? ''; + const author = comment.user?.login ?? ''; + if (!body || !author || comment.user?.type === 'Bot' || author.endsWith('[bot]')) { + return []; + } + return [ + { + author, + authorAssociation: comment.author_association ?? 'NONE', + body, + createdAt: comment.created_at, + htmlUrl: comment.html_url, + }, + ]; + }); + const activeComments = comments.filter((comment) => this.isWithinClaimLookback(comment.createdAt, checkedAt)); + + return { + recentComments: activeComments.slice(0, MAX_RECENT_ISSUE_COMMENTS), + claimAssessment: this.assessClaimSignals(activeComments, checkedAt), + }; + } catch (error) { + logger.debug(`Unable to load issue comments for ${normalizedRepo}#${issueNumber}`, error); + return { + recentComments: [], + claimAssessment: { status: 'none', evidence: [], checkedAt }, + }; + } + } + async fetchRepositoryProbe(repoFullName: string): Promise { if (!this.octokit) { throw new Error('GitHub service not initialized'); @@ -400,6 +476,47 @@ export class GitHubService { ); } + private assessClaimSignals(comments: GitHubIssueComment[], checkedAt: string): IssueClaimAssessment { + let status: IssueClaimStatus = 'none'; + const evidence: string[] = []; + const checkedAtMs = new Date(checkedAt).getTime(); + + for (const comment of comments) { + const createdAtMs = new Date(comment.createdAt).getTime(); + const ageDays = Number.isFinite(createdAtMs) ? (checkedAtMs - createdAtMs) / (24 * 60 * 60 * 1000) : 0; + + const maintainerClaim = + MAINTAINER_ASSOCIATIONS.has(comment.authorAssociation.toUpperCase()) && + MAINTAINER_CLAIM_PATTERNS.some((pattern) => pattern.test(comment.body)); + const selfClaim = SELF_CLAIM_PATTERNS.some((pattern) => pattern.test(comment.body)); + if (!maintainerClaim && !selfClaim) { + continue; + } + + const candidateStatus: IssueClaimStatus = maintainerClaim ? 'claimed' : ageDays <= 60 ? 'likely' : 'possible'; + if (this.claimStatusPriority(candidateStatus) > this.claimStatusPriority(status)) { + status = candidateStatus; + } + evidence.push(`${comment.author}: ${comment.body.replace(/\s+/g, ' ').slice(0, 180)}`); + } + + return { status, evidence: evidence.slice(0, 3), checkedAt }; + } + + private claimStatusPriority(status: IssueClaimStatus): number { + return { none: 0, possible: 1, likely: 2, claimed: 3 }[status]; + } + + private isWithinClaimLookback(createdAt: string, checkedAt: string): boolean { + const createdAtMs = new Date(createdAt).getTime(); + const checkedAtMs = new Date(checkedAt).getTime(); + if (!Number.isFinite(createdAtMs) || !Number.isFinite(checkedAtMs)) { + return true; + } + const ageDays = (checkedAtMs - createdAtMs) / (24 * 60 * 60 * 1000); + return ageDays <= CLAIM_LOOKBACK_DAYS; + } + private async fetchRepoTextFile(owner: string, repo: string, path: string): Promise { if (!this.octokit) { throw new Error('GitHub service not initialized'); diff --git a/src/services/issue-ranking.ts b/src/services/issue-ranking.ts index 7c88d24..a9af08a 100644 --- a/src/services/issue-ranking.ts +++ b/src/services/issue-ranking.ts @@ -10,6 +10,8 @@ import { proofOfWorkService } from './proof-of-work.js'; const ISSUE_SCORING_BATCH_SIZE = 20; const MAX_ISSUES_FOR_LLM_SCORING = 80; const MAX_ISSUES_FOR_FEASIBILITY_HINTS = 30; +const MAX_ISSUES_FOR_CLAIM_CHECKS = 15; +const CLAIM_CHECK_BATCH_SIZE = 5; const PROFILE_TERM_ALIASES: Record = { typescript: ['ts', 'tsx'], @@ -95,7 +97,8 @@ export class IssueRankingService { maxStars: options.maxStars, }); const rankedCandidates = this.rankIssuesForProfile(issues, config.userProfile); - const matched = await this.scoreIssuesInBatches(config.userProfile, rankedCandidates); + const claimAwareCandidates = await this.enrichIssueClaimContexts(rankedCandidates); + const matched = await this.scoreIssuesInBatches(config.userProfile, claimAwareCandidates); return this.applyScoutFeasibilityHints(opportunityService.rankIssues(matched, config.scoring)); } @@ -104,10 +107,12 @@ export class IssueRankingService { target: { repoFullName: string; issueNumber: number }, ): Promise { const issue = await githubService.fetchIssue(target.repoFullName, target.issueNumber); - const [matched] = await this.scoreIssuesInBatches(config.userProfile, [issue]); + const [claimAwareIssue] = await this.enrichIssueClaimContexts([issue]); + const targetIssue = claimAwareIssue ?? issue; + const [matched] = await this.scoreIssuesInBatches(config.userProfile, [targetIssue]); if (!matched) { return this.applyScoutFeasibilityHints( - opportunityService.rankIssues(this.buildLocalIssueMatches([issue], config.userProfile), config.scoring), + opportunityService.rankIssues(this.buildLocalIssueMatches([targetIssue], config.userProfile), config.scoring), ); } @@ -151,6 +156,29 @@ export class IssueRankingService { return matches; } + private async enrichIssueClaimContexts(issues: GitHubIssue[]): Promise { + const candidates = issues.slice(0, MAX_ISSUES_FOR_CLAIM_CHECKS); + const enriched: GitHubIssue[] = []; + + for (let start = 0; start < candidates.length; start += CLAIM_CHECK_BATCH_SIZE) { + const batch = candidates.slice(start, start + CLAIM_CHECK_BATCH_SIZE); + const results = await Promise.all( + batch.map(async (issue) => { + try { + const context = await githubService.fetchIssueClaimContext(issue.repoFullName, issue.number); + return { ...issue, ...context }; + } catch (error) { + logger.debug(`Unable to enrich claim context for ${issue.repoFullName}#${issue.number}`, error); + return issue; + } + }), + ); + enriched.push(...results); + } + + return [...enriched, ...issues.slice(MAX_ISSUES_FOR_CLAIM_CHECKS)]; + } + rankIssuesForProfile(issues: GitHubIssue[], userProfile: AppConfig['userProfile']): GitHubIssue[] { const repoOrder = new Map(); diff --git a/src/services/llm.ts b/src/services/llm.ts index d1bde11..7cb5771 100644 --- a/src/services/llm.ts +++ b/src/services/llm.ts @@ -35,6 +35,8 @@ import type { EnvironmentInfo, GitHubIssue, ImplementationDraft, + IssueClaimAssessment, + IssueClaimStatus, LLMProvider, LLMReasoningEffort, MatchedIssue, @@ -153,7 +155,17 @@ Title: ${i.title} Body: ${i.body.slice(0, 500)} Labels: ${i.labels.join(', ')} Repo Description: ${i.repoDescription} -Repo Stars: ${i.repoStars}`, +Repo Stars: ${i.repoStars} +Rule-based Claim Signal: ${i.claimAssessment?.status ?? 'not_checked'} +Recent Comments: +${ + i.recentComments + ?.map( + (comment) => + `- ${comment.author} (${comment.authorAssociation}, ${comment.createdAt}): ${comment.body.replace(/\s+/g, ' ').slice(0, 400)}`, + ) + .join('\n') || '- No recent comments loaded.' +}`, ) .join('\n\n---\n\n'); @@ -443,6 +455,7 @@ Repo Stars: ${i.repoStars}`, { ...issue, matchScore: match.score, + claimAssessment: this.mergeClaimAssessment(issue.claimAssessment, match.claimStatus, match.claimEvidence), analysis: { coreDemand: match.coreDemand, techRequirements: match.techRequirements, @@ -455,6 +468,24 @@ Repo Stars: ${i.repoStars}`, }; } + private mergeClaimAssessment( + existing: IssueClaimAssessment | undefined, + llmStatus: IssueClaimStatus, + llmEvidence: string, + ): IssueClaimAssessment { + const priority: Record = { none: 0, possible: 1, likely: 2, claimed: 3 }; + const evidencedLlmStatus = llmEvidence.trim() ? llmStatus : 'none'; + const status = + existing && priority[existing.status] >= priority[evidencedLlmStatus] ? existing.status : evidencedLlmStatus; + const evidence = [...(existing?.evidence ?? []), ...(llmEvidence.trim() ? [`LLM: ${llmEvidence.trim()}`] : [])]; + + return { + status, + evidence: [...new Set(evidence)].slice(0, 4), + checkedAt: existing?.checkedAt ?? new Date().toISOString(), + }; + } + private getReasoningRequestParams(): { reasoning_effort?: LLMReasoningEffort } { if (!this.reasoningEffort || !this.supportsReasoningEffort()) { return {}; diff --git a/src/services/opportunity.ts b/src/services/opportunity.ts index be05581..7de6839 100644 --- a/src/services/opportunity.ts +++ b/src/services/opportunity.ts @@ -20,6 +20,12 @@ const LARGE_SCOPE_PATTERNS = [ /\barchitecture\b/i, /\bbreaking change\b/i, ]; +const CLAIM_RISK_PENALTIES = { + none: 0, + possible: 12, + likely: 25, + claimed: 40, +} as const; function clampScore(value: number): number { return Math.max(0, Math.min(100, Math.round(value))); @@ -86,7 +92,7 @@ function computeImpactScore(issue: MatchedIssue): number { return clampScore(20 + Math.log10(issue.repoStars + 10) * 28); } -function summarizeOpportunity(opportunity: OpportunityAnalysis): string { +function summarizeOpportunity(opportunity: OpportunityAnalysis, issue: MatchedIssue): string { const strongest = Object.entries(opportunity.breakdown).sort((left, right) => right[1] - left[1])[0]; const weakest = Object.entries(opportunity.breakdown).sort((left, right) => left[1] - right[1])[0]; @@ -95,7 +101,9 @@ function summarizeOpportunity(opportunity: OpportunityAnalysis): string { return 'Opportunity score is based on repository fit and issue freshness.'; } - return `Strongest signal: ${strongest[0]} (${strongest[1]}). Main risk: ${weakest[0]} (${weakest[1]}).`; + const claimRisk = issue.claimAssessment?.status; + const claimSummary = claimRisk && claimRisk !== 'none' ? ` Claim risk: ${claimRisk}.` : ''; + return `Strongest signal: ${strongest[0]} (${strongest[1]}). Main risk: ${weakest[0]} (${weakest[1]}).${claimSummary}`; } function normalizeLabel(label: string): string { @@ -139,7 +147,9 @@ function computeRiskPenalty(issue: MatchedIssue): number { penalty += 16; } - return Math.min(45, penalty); + penalty += CLAIM_RISK_PENALTIES[issue.claimAssessment?.status ?? 'none']; + + return Math.min(60, penalty); } export class OpportunityService { @@ -177,7 +187,7 @@ export class OpportunityService { }, }; - opportunity.summary = summarizeOpportunity(opportunity); + opportunity.summary = summarizeOpportunity(opportunity, issue); return { ...issue, diff --git a/src/types/github.types.ts b/src/types/github.types.ts index 9864627..38fc94d 100644 --- a/src/types/github.types.ts +++ b/src/types/github.types.ts @@ -1,3 +1,19 @@ +export type IssueClaimStatus = 'none' | 'possible' | 'likely' | 'claimed'; + +export interface GitHubIssueComment { + author: string; + authorAssociation: string; + body: string; + createdAt: string; + htmlUrl: string; +} + +export interface IssueClaimAssessment { + status: IssueClaimStatus; + evidence: string[]; + checkedAt: string; +} + export interface GitHubIssue { id: number; number: number; @@ -11,6 +27,8 @@ export interface GitHubIssue { labels: string[]; createdAt: string; updatedAt: string; + recentComments?: GitHubIssueComment[]; + claimAssessment?: IssueClaimAssessment; } export interface MatchedIssue extends GitHubIssue { diff --git a/test/github.test.ts b/test/github.test.ts index ea3748a..257b9bd 100644 --- a/test/github.test.ts +++ b/test/github.test.ts @@ -11,6 +11,14 @@ interface GitHubServiceInternals { rest: { issues: { get: (params: { owner: string; repo: string; issue_number: number }) => Promise<{ data: unknown }>; + listComments: (params: { + owner: string; + repo: string; + issue_number: number; + per_page: number; + sort: 'created'; + direction: 'desc'; + }) => Promise<{ data: unknown[] }>; }; repos: { get: (params: { @@ -277,6 +285,59 @@ describe('GitHubService internals', () => { await expect(service.fetchIssue('acme/demo', 44)).rejects.toThrow('cannot be handled automatically'); }); + test('extracts recent human claim signals from issue comments', async () => { + const service = new GitHubService(); + const internals = service as unknown as GitHubServiceInternals; + const recent = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(); + const stale = new Date(Date.now() - 250 * 24 * 60 * 60 * 1000).toISOString(); + + internals.octokit = { + rest: { + issues: { + listComments: async () => ({ + data: [ + { + body: 'Assigned this issue to @alice. Please go ahead.', + user: { login: 'maintainer', type: 'User' }, + author_association: 'MEMBER', + created_at: recent, + html_url: 'https://github.com/acme/demo/issues/18#issuecomment-1', + }, + { + body: "I'd like to work on this.", + user: { login: 'alice', type: 'User' }, + author_association: 'CONTRIBUTOR', + created_at: recent, + html_url: 'https://github.com/acme/demo/issues/18#issuecomment-2', + }, + { + body: "I'll work on this.", + user: { login: 'old-contributor', type: 'User' }, + author_association: 'CONTRIBUTOR', + created_at: stale, + html_url: 'https://github.com/acme/demo/issues/18#issuecomment-3', + }, + { + body: "I'd like to work on this.", + user: { login: 'triage-bot[bot]', type: 'Bot' }, + author_association: 'NONE', + created_at: recent, + html_url: 'https://github.com/acme/demo/issues/18#issuecomment-4', + }, + ], + }), + }, + }, + } as unknown as GitHubServiceInternals['octokit']; + + const context = await service.fetchIssueClaimContext('acme/demo', 18); + + expect(context.claimAssessment.status).toBe('claimed'); + expect(context.claimAssessment.evidence).toHaveLength(2); + expect(context.recentComments.map((comment) => comment.author)).not.toContain('triage-bot[bot]'); + expect(context.recentComments.map((comment) => comment.author)).not.toContain('old-contributor'); + }); + test('classifies search failures by rate limit and validation errors', () => { const service = new GitHubService() as unknown as GitHubServiceInternals; diff --git a/test/issue-ranking.test.ts b/test/issue-ranking.test.ts index b04188c..be0c9f1 100644 --- a/test/issue-ranking.test.ts +++ b/test/issue-ranking.test.ts @@ -171,6 +171,7 @@ describe('IssueRankingService', () => { test('builds a ranked target issue without batch discovery', async () => { const originalFetchIssue = githubService.fetchIssue; + const originalFetchIssueClaimContext = githubService.fetchIssueClaimContext; const originalFetchRepositoryProbe = githubService.fetchRepositoryProbe; const originalScoreIssues = llmService.scoreIssues; const rankingServiceState = issueRankingService as unknown as { @@ -180,6 +181,7 @@ describe('IssueRankingService', () => { const originalCachedEnvironment = rankingServiceState.cachedEnvironment; const originalDetectionPromise = rankingServiceState.detectionPromise; const observedFetches: unknown[] = []; + const observedClaimChecks: unknown[] = []; try { rankingServiceState.cachedEnvironment = testEnvironment; @@ -195,6 +197,25 @@ describe('IssueRankingService', () => { labels: [], }); }; + githubService.fetchIssueClaimContext = async (repoFullName, issueNumber) => { + observedClaimChecks.push({ repoFullName, issueNumber }); + return { + recentComments: [ + { + author: 'alice', + authorAssociation: 'CONTRIBUTOR', + body: "I'd like to work on this.", + createdAt: new Date().toISOString(), + htmlUrl: 'https://github.com/Wei-Shaw/sub2api/issues/3014#issuecomment-1', + }, + ], + claimAssessment: { + status: 'likely', + evidence: ["alice: I'd like to work on this."], + checkedAt: new Date().toISOString(), + }, + }; + }; githubService.fetchRepositoryProbe = async (repoFullName) => ({ repoFullName, files: { @@ -279,14 +300,22 @@ describe('IssueRankingService', () => { issueNumber: 3014, }, ]); + expect(observedClaimChecks).toEqual([ + { + repoFullName: 'Wei-Shaw/sub2api', + issueNumber: 3014, + }, + ]); expect(ranked?.repoFullName).toBe('Wei-Shaw/sub2api'); expect(ranked?.number).toBe(3014); expect(ranked?.matchScore).toBe(77); + expect(ranked?.claimAssessment?.status).toBe('likely'); expect(ranked?.opportunity.overallScore).toBeGreaterThan(0); } finally { rankingServiceState.cachedEnvironment = originalCachedEnvironment; rankingServiceState.detectionPromise = originalDetectionPromise; githubService.fetchIssue = originalFetchIssue; + githubService.fetchIssueClaimContext = originalFetchIssueClaimContext; githubService.fetchRepositoryProbe = originalFetchRepositoryProbe; llmService.scoreIssues = originalScoreIssues; } diff --git a/test/llm.test.ts b/test/llm.test.ts index ea42ef4..c1e0172 100644 --- a/test/llm.test.ts +++ b/test/llm.test.ts @@ -840,14 +840,18 @@ describe('LLMService issue scoring response parsing', () => { "score": 100, "coreDemand": "Add accessible labels", "techRequirements": ["react", "typescript", "accessibility"], - "estimatedWorkload": "1-2 hours" + "estimatedWorkload": "1-2 hours", + "claimStatus": "claimed", + "claimEvidence": "" }, { "issueReference": "acme/web#7", "score": 61, "coreDemand": "Improve documentation clarity", "techRequirements": ["markdown", "docs"], - "estimatedWorkload": "30 minutes" + "estimatedWorkload": "30 minutes", + "claimStatus": "likely", + "claimEvidence": "alice said she is working on this" }, { "issueReference": "acme/ignored#11", @@ -867,9 +871,12 @@ describe('LLMService issue scoring response parsing', () => { expect(parsed.data).toHaveLength(2); expect(parsed.data[0]?.repoFullName).toBe('acme/demo'); expect(parsed.data[0]?.matchScore).toBe(100); + expect(parsed.data[0]?.claimAssessment?.status).toBe('none'); expect(parsed.data[0]?.analysis.techRequirements).toEqual(['react', 'typescript', 'accessibility']); expect(parsed.data[1]?.repoFullName).toBe('acme/web'); expect(parsed.data[1]?.analysis.estimatedWorkload).toBe('30 minutes'); + expect(parsed.data[1]?.claimAssessment?.status).toBe('likely'); + expect(parsed.data[1]?.claimAssessment?.evidence[0]).toContain('alice said she is working on this'); }); }); diff --git a/test/opportunity.test.ts b/test/opportunity.test.ts index 35d3e20..30eb90d 100644 --- a/test/opportunity.test.ts +++ b/test/opportunity.test.ts @@ -67,4 +67,28 @@ describe('opportunityService', () => { ranked[0]?.opportunity.breakdown.onboardingClarity ?? 0, ); }); + + test('penalizes issues whose conversation indicates someone already claimed the work', () => { + const available = createMatchedIssue({ + number: 20, + updatedAt: new Date().toISOString(), + repoStars: 200, + matchScore: 84, + }); + const claimed = createMatchedIssue({ + ...available, + number: 21, + claimAssessment: { + status: 'claimed', + evidence: ['maintainer: Assigned this issue to @alice.'], + checkedAt: new Date().toISOString(), + }, + }); + + const ranked = opportunityService.rankIssues([claimed, available]); + + expect(ranked[0]?.number).toBe(20); + expect(ranked[1]?.opportunity.summary).toContain('Claim risk: claimed'); + expect(ranked[0]?.opportunity.overallScore).toBeGreaterThan(ranked[1]?.opportunity.overallScore ?? 0); + }); });