Skip to content
Open
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
2 changes: 2 additions & 0 deletions src/contracts/agent-contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
12 changes: 9 additions & 3 deletions src/infra/prompt-templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
{
Expand All @@ -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": ""
}
]
}
Expand All @@ -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"
}
]
}
Expand Down
10 changes: 10 additions & 0 deletions src/orchestration/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1667,13 +1667,17 @@ 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}`,
],
lines: [
`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})`,
Expand All @@ -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})`,
Expand Down
2 changes: 2 additions & 0 deletions src/services/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
'',
Expand Down
119 changes: 118 additions & 1 deletion src/services/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand All @@ -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;
Expand All @@ -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];

Expand Down Expand Up @@ -67,6 +86,11 @@ export interface RepositoryStarRange {
maxStars?: number;
}

export interface IssueClaimContext {
recentComments: GitHubIssueComment[];
claimAssessment: IssueClaimAssessment;
}

export interface RepositoryProbe {
repoFullName: string;
files: {
Expand Down Expand Up @@ -317,6 +341,58 @@ export class GitHubService {
};
}

async fetchIssueClaimContext(repoFullName: string, issueNumber: number): Promise<IssueClaimContext> {
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<RepositoryProbe> {
if (!this.octokit) {
throw new Error('GitHub service not initialized');
Expand Down Expand Up @@ -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<string | null> {
if (!this.octokit) {
throw new Error('GitHub service not initialized');
Expand Down
34 changes: 31 additions & 3 deletions src/services/issue-ranking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string[]> = {
typescript: ['ts', 'tsx'],
Expand Down Expand Up @@ -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));
}

Expand All @@ -104,10 +107,12 @@ export class IssueRankingService {
target: { repoFullName: string; issueNumber: number },
): Promise<RankedIssue[]> {
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),
);
}

Expand Down Expand Up @@ -151,6 +156,29 @@ export class IssueRankingService {
return matches;
}

private async enrichIssueClaimContexts(issues: GitHubIssue[]): Promise<GitHubIssue[]> {
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<string, number>();

Expand Down
33 changes: 32 additions & 1 deletion src/services/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ import type {
EnvironmentInfo,
GitHubIssue,
ImplementationDraft,
IssueClaimAssessment,
IssueClaimStatus,
LLMProvider,
LLMReasoningEffort,
MatchedIssue,
Expand Down Expand Up @@ -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');

Expand Down Expand Up @@ -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,
Expand All @@ -455,6 +468,24 @@ Repo Stars: ${i.repoStars}`,
};
}

private mergeClaimAssessment(
existing: IssueClaimAssessment | undefined,
llmStatus: IssueClaimStatus,
llmEvidence: string,
): IssueClaimAssessment {
const priority: Record<IssueClaimStatus, number> = { 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 {};
Expand Down
Loading
Loading