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
1 change: 1 addition & 0 deletions src/lib/aggregator/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,4 @@ export {

// Individual sources
export { createGitHubSource, GitHubBountySource, POPULAR_BOUNTY_REPOS } from './sources/github';
export { extractIssueHuntReward, ISSUEHUNT_REPOS, IssueHuntSource } from './sources/issuehunt';
53 changes: 53 additions & 0 deletions src/lib/aggregator/sources/issuehunt.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest';
import { extractIssueHuntReward, ISSUEHUNT_REPOS, IssueHuntSource } from './issuehunt';

describe('extractIssueHuntReward', () => {
it('extracts reward from /issuehunt comment', () => {
const text = 'Some issue body\n/issuehunt $250\nPlease solve this.';
const result = extractIssueHuntReward(text);
expect(result).toEqual({ amount: 250, currency: 'USD' });
});

it('extracts reward from 💵 emoji tag', () => {
const text = '💵 $500 bounty on Issuehunt';
const result = extractIssueHuntReward(text);
expect(result).toEqual({ amount: 500, currency: 'USD' });
});

it('returns null when no reward pattern is found', () => {
const text = 'Regular issue description without bounty info.';
const result = extractIssueHuntReward(text);
expect(result).toBeNull();
});
});

describe('IssueHuntSource', () => {
it('initializes with default configuration', () => {
const source = new IssueHuntSource();
expect(source.name).toBe('issuehunt');
expect(source.type).toBe('github_issuehunt');
});

it('normalizes raw bounty data correctly', () => {
const source = new IssueHuntSource();
const rawBounty = {
id: 'issuehunt-123',
title: 'Fix issuehunt bug',
description: 'Detailed description',
url: 'https://github.com/test/repo/issues/1',
reward_amount: 100,
reward_currency: 'USD',
created_at: '2026-08-01T00:00:00Z',
raw_data: {
number: 1,
repo: 'test/repo',
},
};

const normalized = source.normalize(rawBounty);
expect(normalized.id).toBe('issuehunt-123');
expect(normalized.reward).toBe(100);
expect(normalized.source).toBe('issuehunt');
expect(normalized.tags).toContain('issuehunt');
});
});
166 changes: 166 additions & 0 deletions src/lib/aggregator/sources/issuehunt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
/**
* IssueHunt Bounty Fetcher
*
* IssueHunt bounties are created on GitHub issues via IssueHunt integrations or `/issuehunt` comments.
* This source tracks known IssueHunt-active repositories and looks for IssueHunt-specific patterns in issues.
*
* IssueHunt patterns:
* - Comment: `/issuehunt $X` or `💵 Funded on Issuehunt`
* - Label: `issuehunt`, `💵 Funded on Issuehunt`, or `bounty`
* - Title/body: Contains reward info from IssueHunt bot
*/

import type { BountySource, NormalizedTask, RawBounty } from '../types';

const GITHUB_API_BASE = 'https://api.github.com';

export const ISSUEHUNT_REPOS = [
'sindresorhus/fkill',
'wulkano/Kap',
'sindresorhus/macos-wallpaper',
'BoostIO/Boostnote',
'CodeHarborHub/codeharborhub.github.io',
];

const ISSUEHUNT_LABELS = ['💵 Funded on Issuehunt', 'issuehunt', 'bounty'];

interface GitHubIssue {
id: number;
number: number;
title: string;
body: string | null;
html_url: string;
state: string;
labels: Array<{ name: string; color: string }>;
user: {
login: string;
id: number;
};
created_at: string;
updated_at: string;
}

interface GitHubSearchResponse {
total_count: number;
incomplete_results: boolean;
items: GitHubIssue[];
}

interface IssueHuntSourceConfig {
enabled: boolean;
repositories: string[];
token?: string;
}

export function extractIssueHuntReward(text: string | null): { amount: number; currency: string } | null {
if (!text) return null;

const patterns = [
/\/issuehunt\s+\$(\d+(?:,\d{3})*(?:\.\d+)?)/i,
/💵\s*\$(\d+(?:,\d{3})*(?:\.\d+)?)/i,
/##\s*💵\s*\$(\d+(?:,\d{3})*(?:\.\d+)?)\s*bounty/i,
/issuehunt[:\s]+\$(\d+(?:,\d{3})*(?:\.\d+)?)/i,
/reward[:\s]+\$(\d+(?:,\d{3})*(?:\.\d+)?)/i,
/\$(\d+(?:,\d{3})*)\s*funded on issuehunt/i,
];

for (const pattern of patterns) {
const match = text.match(pattern);
if (match) {
return {
amount: parseFloat(match[1].replace(/,/g, '')),
currency: 'USD',
};
}
}

return null;
}

export class IssueHuntSource implements BountySource {
name = 'issuehunt';
type = 'github_issuehunt' as const;
private config: IssueHuntSourceConfig;

constructor(config: Partial<IssueHuntSourceConfig> = {}) {
this.config = {
enabled: config.enabled ?? true,
repositories: config.repositories ?? ISSUEHUNT_REPOS,
token: config.token || process.env.GITHUB_TOKEN,
};
}

async fetchBounties(): Promise<RawBounty[]> {
if (!this.config.enabled) return [];

const bounties: RawBounty[] = [];
const headers: Record<string, string> = {
Accept: 'application/vnd.github.v3+json',
'User-Agent': 'ClawFreelance-Aggregator',
};

if (this.config.token) {
headers.Authorization = `Bearer ${this.config.token}`;
}

try {
const query = `is:issue is:open label:"💵 Funded on Issuehunt",issuehunt`;
const url = `${GITHUB_API_BASE}/search/issues?q=${encodeURIComponent(query)}&per_page=50`;

const response = await fetch(url, { headers });

if (response.ok) {
const data = (await response.json()) as GitHubSearchResponse;
for (const issue of data.items) {
const reward = extractIssueHuntReward(issue.body) || { amount: 50, currency: 'USD' };
bounties.push(this.formatGitHubIssue(issue, reward));
}
}
} catch (error) {
console.error('Error fetching IssueHunt bounties:', error);
}

return bounties;
}

normalize(raw: RawBounty): NormalizedTask {
const data = raw.raw_data as Partial<GitHubIssue> & { repo?: string };
return {
id: raw.id,
source: this.name,
sourceUrl: raw.url,
title: raw.title,
description: raw.description,
reward: raw.reward_amount,
currency: raw.reward_currency,
rewardType: 'fixed',
status: 'open',
createdAt: raw.created_at,
updatedAt: new Date().toISOString(),
tags: ['issuehunt', 'bounty', 'github'],
metadata: {
issueNumber: data.number,
repository: data.repo,
},
};
}

private formatGitHubIssue(issue: GitHubIssue, reward: { amount: number; currency: string }): RawBounty {
const repoMatch = issue.html_url.match(/github\.com\/([^/]+\/[^/]+)/);
const repo = repoMatch ? repoMatch[1] : 'unknown';

return {
id: `issuehunt-${issue.id}`,
title: issue.title,
description: issue.body || '',
url: issue.html_url,
reward_amount: reward.amount,
reward_currency: reward.currency,
created_at: issue.created_at,
raw_data: {
...issue,
repo,
},
};
}
}