diff --git a/src/lib/aggregator/sources/algora.test.ts b/src/lib/aggregator/sources/algora.test.ts index dafefbd..cae55f7 100644 --- a/src/lib/aggregator/sources/algora.test.ts +++ b/src/lib/aggregator/sources/algora.test.ts @@ -1,17 +1,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { ALGORA_REPOS, AlgoraBountySource, createAlgoraSource } from './algora'; +import { AlgoraBountySource } from './algora'; -// Mock fetch globally const mockFetch = vi.fn(); global.fetch = mockFetch; -// Mock GitHub App Auth -vi.mock('../github-app-auth', () => ({ - getGitHubAuthHeaderAsync: vi.fn(() => Promise.resolve('Bearer test-token')), -})); - -describe('AlgoraBountySource', () => { +describe('AlgoraBountySource API Integration', () => { beforeEach(() => { vi.clearAllMocks(); }); @@ -20,648 +14,50 @@ describe('AlgoraBountySource', () => { vi.restoreAllMocks(); }); - describe('constructor', () => { - it('should use default config when none provided', () => { - const source = new AlgoraBountySource(); - expect(source.name).toBe('algora'); - }); - - it('should accept custom repositories', () => { - const source = new AlgoraBountySource({ - repositories: ['custom/repo1', 'custom/repo2'], - }); - expect(source.name).toBe('algora'); - }); - }); - - describe('fetch', () => { - beforeEach(() => { - vi.useFakeTimers(); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - it('should return empty array when disabled', async () => { - const source = new AlgoraBountySource({ enabled: false }); - const result = await source.fetch(); - expect(result).toEqual([]); - expect(mockFetch).not.toHaveBeenCalled(); - }); - - it('should fetch Algora bounties from configured repositories', async () => { - // Use real timers but mock setTimeout to resolve immediately - vi.useRealTimers(); - const originalSetTimeout = global.setTimeout; - global.setTimeout = ((fn: () => void) => { - fn(); - return 0 as unknown as NodeJS.Timeout; - }) as typeof setTimeout; - - try { - const mockIssue = { - id: 456, - number: 101, - title: 'Implement new feature', - body: '/bounty $500\n\nPlease implement this feature...', - html_url: 'https://github.com/zio/zio/issues/101', - state: 'open', - labels: [{ name: '💎 Bounty', color: 'ff0000' }], - user: { login: 'ziodev', id: 2 }, - created_at: '2025-01-15T10:00:00Z', - updated_at: '2025-01-20T15:00:00Z', - }; - - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({ - total_count: 1, - incomplete_results: false, - items: [mockIssue], - }), - }); - - const source = new AlgoraBountySource({ - repositories: ['zio/zio'], - }); - - const result = await source.fetch(); - - expect(mockFetch).toHaveBeenCalled(); - expect(result).toHaveLength(1); - expect(result[0]).toMatchObject({ - source: 'algora', - externalId: 'algora-zio/zio-101', - externalUrl: 'https://github.com/zio/zio/issues/101', - title: 'Implement new feature', - ownerExternalId: 'ziodev', - }); - } finally { - global.setTimeout = originalSetTimeout; - vi.useFakeTimers(); - } - }); - - it('should handle rate limits gracefully', async () => { - // All label requests return 403 - mockFetch.mockResolvedValue({ - ok: false, - status: 403, - }); - - const source = new AlgoraBountySource({ - repositories: ['test/repo'], - }); - - const fetchPromise = source.fetch(); - await vi.runAllTimersAsync(); - const result = await fetchPromise; - - expect(result).toEqual([]); - }); - - it('should handle network errors gracefully', async () => { - // All label requests fail with network error - mockFetch.mockRejectedValue(new Error('Network error')); - - const source = new AlgoraBountySource({ - repositories: ['test/repo'], - }); - - const fetchPromise = source.fetch(); - await vi.runAllTimersAsync(); - const result = await fetchPromise; - - expect(result).toEqual([]); - }); - }); - - describe('normalize', () => { - it('should normalize raw Algora bounty to task format', () => { - const source = new AlgoraBountySource(); - const raw = { - source: 'algora' as const, - externalId: 'algora-zio/zio-101', - externalUrl: 'https://github.com/zio/zio/issues/101', - title: 'Implement new feature', - description: '/bounty $500\n\nPlease implement this feature...', - ownerExternalId: 'ziodev', - ownerName: 'ziodev', - labels: ['💎 Bounty'], - createdAt: new Date('2025-01-15T10:00:00Z'), - updatedAt: new Date('2025-01-20T15:00:00Z'), - raw: {}, - }; - - const normalized = source.normalize(raw); - - expect(normalized).toMatchObject({ - title: 'Implement new feature', - type: 'bounty', - source: 'algora', - externalUrl: 'https://github.com/zio/zio/issues/101', - ownerExternalId: 'ziodev', - rewardType: 'external', - rewardAmount: 500, - rewardCurrency: 'USD', - visibility: 'public', - status: 'open', - verificationMethod: 'pr_merged', - difficulty: 'medium', - }); - }); - - it('should extract reward from /bounty $X pattern', () => { - const source = new AlgoraBountySource(); - const raw = { - source: 'algora' as const, - externalId: 'test-1', - externalUrl: 'https://github.com/test/repo/issues/1', - title: 'Test', - description: '/bounty $1000\n\nSome description here', - ownerExternalId: 'user', - labels: [], - createdAt: new Date(), - raw: {}, - }; - - const normalized = source.normalize(raw); - expect(normalized.rewardAmount).toBe(1000); - expect(normalized.rewardCurrency).toBe('USD'); - }); - - it('should extract reward from 💎 emoji pattern', () => { - const source = new AlgoraBountySource(); - const raw = { - source: 'algora' as const, - externalId: 'test-1', - externalUrl: 'https://github.com/test/repo/issues/1', - title: 'Test', - description: '💎 $250 bounty for this task', - ownerExternalId: 'user', - labels: [], - createdAt: new Date(), - raw: {}, - }; - - const normalized = source.normalize(raw); - expect(normalized.rewardAmount).toBe(250); - expect(normalized.rewardCurrency).toBe('USD'); - }); - - it('should default to points when no Algora reward found', () => { - const source = new AlgoraBountySource(); - const raw = { - source: 'algora' as const, - externalId: 'test-1', - externalUrl: 'https://github.com/test/repo/issues/1', - title: 'Test', - description: 'Simple feature request without bounty info', - ownerExternalId: 'user', - labels: ['💎 Bounty'], - createdAt: new Date(), - raw: {}, - }; - - const normalized = source.normalize(raw); - expect(normalized.rewardType).toBe('points'); - expect(normalized.rewardAmount).toBe(0); - }); - }); - - describe('ALGORA_REPOS', () => { - it('should contain well-known Algora-active repositories', () => { - expect(ALGORA_REPOS).toContain('zio/zio'); - expect(ALGORA_REPOS).toContain('golemcloud/golem-cli'); - expect(ALGORA_REPOS).toContain('omnigres/omnigres'); - }); - - it('should have valid repo format (owner/name)', () => { - for (const repo of ALGORA_REPOS) { - expect(repo).toMatch(/^[\w-]+\/[\w.-]+$/); - } - }); - }); - - describe('normalize edge cases', () => { - it('should handle missing description', () => { - const source = new AlgoraBountySource(); - const raw = { - source: 'algora' as const, - externalId: 'test-1', - externalUrl: 'https://github.com/test/repo/issues/1', - title: 'Test', - description: '', - ownerExternalId: 'user', - labels: [], - createdAt: new Date(), - raw: {}, - }; - - const normalized = source.normalize(raw); - expect(normalized.rewardType).toBe('points'); - expect(normalized.rewardAmount).toBe(0); - }); - - it('should handle pre-parsed reward amount', () => { - const source = new AlgoraBountySource(); - const raw = { - source: 'algora' as const, - externalId: 'test-1', - externalUrl: 'https://github.com/test/repo/issues/1', - title: 'Test', - description: 'Some description without bounty', - ownerExternalId: 'user', - labels: [], - rewardAmount: 750, - rewardCurrency: 'USD', - createdAt: new Date(), - raw: {}, - }; - - const normalized = source.normalize(raw); - expect(normalized.rewardType).toBe('external'); - expect(normalized.rewardAmount).toBe(750); - expect(normalized.rewardCurrency).toBe('USD'); - }); - - it('should extract reward from $X bounty pattern', () => { - const source = new AlgoraBountySource(); - const raw = { - source: 'algora' as const, - externalId: 'test-1', - externalUrl: 'https://github.com/test/repo/issues/1', - title: 'Test', - description: '$1,500 bounty for completing this task', - ownerExternalId: 'user', - labels: [], - createdAt: new Date(), - raw: {}, - }; - - const normalized = source.normalize(raw); - expect(normalized.rewardAmount).toBe(1500); - }); - - it('should handle ## 💎 header pattern', () => { - const source = new AlgoraBountySource(); - const raw = { - source: 'algora' as const, - externalId: 'test-1', - externalUrl: 'https://github.com/test/repo/issues/1', - title: 'Test', - description: '## 💎 $2,500 bounty\n\nImplement this feature', - ownerExternalId: 'user', - labels: [], - createdAt: new Date(), - raw: {}, - }; - - const normalized = source.normalize(raw); - expect(normalized.rewardAmount).toBe(2500); - }); - - it('should handle bounty: pattern', () => { - const source = new AlgoraBountySource(); - const raw = { - source: 'algora' as const, - externalId: 'test-1', - externalUrl: 'https://github.com/test/repo/issues/1', - title: 'Test', - description: 'bounty: $300 for this task', - ownerExternalId: 'user', - labels: [], - createdAt: new Date(), - raw: {}, - }; - - const normalized = source.normalize(raw); - expect(normalized.rewardAmount).toBe(300); - }); - - it('should handle reward: pattern', () => { - const source = new AlgoraBountySource(); - const raw = { - source: 'algora' as const, - externalId: 'test-1', - externalUrl: 'https://github.com/test/repo/issues/1', - title: 'Test', - description: 'reward: $400', - ownerExternalId: 'user', - labels: [], - createdAt: new Date(), - raw: {}, - }; - - const normalized = source.normalize(raw); - expect(normalized.rewardAmount).toBe(400); - }); - - it('should handle deadline in raw data', () => { - const source = new AlgoraBountySource(); - const deadline = new Date('2025-03-01'); - const raw = { - source: 'algora' as const, - externalId: 'test-1', - externalUrl: 'https://github.com/test/repo/issues/1', - title: 'Test', - description: '/bounty $100', - ownerExternalId: 'user', - labels: [], - createdAt: new Date(), - deadline, - raw: {}, - }; - - const normalized = source.normalize(raw); - expect(normalized.deadline).toBe(deadline); - }); - }); - - describe('fetch with multiple repos', () => { - beforeEach(() => { - vi.useRealTimers(); - }); - - it('should handle empty results from repos', async () => { - const originalSetTimeout = global.setTimeout; - global.setTimeout = ((fn: () => void) => { - fn(); - return 0 as unknown as NodeJS.Timeout; - }) as typeof setTimeout; - - try { - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({ - total_count: 0, - incomplete_results: false, - items: [], - }), - }); - - const source = new AlgoraBountySource({ - repositories: ['test/repo1', 'test/repo2'], - }); - - const result = await source.fetch(); - expect(result).toEqual([]); - expect(mockFetch).toHaveBeenCalled(); - } finally { - global.setTimeout = originalSetTimeout; - vi.useFakeTimers(); - } - }); - - it('should skip non-403 errors and continue', async () => { - const originalSetTimeout = global.setTimeout; - global.setTimeout = ((fn: () => void) => { - fn(); - return 0 as unknown as NodeJS.Timeout; - }) as typeof setTimeout; - - try { - mockFetch.mockResolvedValue({ - ok: false, - status: 500, - }); - - const source = new AlgoraBountySource({ - repositories: ['test/repo'], - }); - - const result = await source.fetch(); - expect(result).toEqual([]); - } finally { - global.setTimeout = originalSetTimeout; - vi.useFakeTimers(); - } - }); - - it('should fetch comments when issue body has no reward', async () => { - const originalSetTimeout = global.setTimeout; - global.setTimeout = ((fn: () => void) => { - fn(); - return 0 as unknown as NodeJS.Timeout; - }) as typeof setTimeout; - - try { - const mockIssue = { - id: 456, - number: 101, - title: 'Implement feature', - body: 'No bounty info here', - html_url: 'https://github.com/test/repo/issues/101', - state: 'open', - labels: [{ name: '💎 Bounty', color: 'ff0000' }], - user: { login: 'testuser', id: 2 }, - created_at: '2025-01-15T10:00:00Z', - updated_at: '2025-01-20T15:00:00Z', - }; - - // First call: search issues - // Second call: fetch comments - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ - total_count: 1, - incomplete_results: false, - items: [mockIssue], - }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => [{ body: '/bounty $200', user: { login: 'algora-pbc' } }], - }) - .mockResolvedValue({ - ok: true, - json: async () => ({ - total_count: 0, - incomplete_results: false, - items: [], - }), - }); - - const source = new AlgoraBountySource({ - repositories: ['test/repo'], - }); - - const result = await source.fetch(); - expect(result).toHaveLength(1); - expect(result[0].rewardAmount).toBe(200); - } finally { - global.setTimeout = originalSetTimeout; - vi.useFakeTimers(); - } - }); - }); - - describe('createAlgoraSource factory', () => { - it('should create source with default repos', () => { - const source = createAlgoraSource(); - expect(source.name).toBe('algora'); - }); - - it('should create source with custom repos', () => { - const source = createAlgoraSource(['custom/repo1', 'custom/repo2']); - expect(source.name).toBe('algora'); - }); - }); - - describe('fetch edge cases', () => { - beforeEach(() => { - vi.useRealTimers(); - }); - - it('should filter out issues without Algora patterns', async () => { - const originalSetTimeout = global.setTimeout; - global.setTimeout = ((fn: () => void) => { - fn(); - return 0 as unknown as NodeJS.Timeout; - }) as typeof setTimeout; - - try { - const mockIssue = { - id: 456, - number: 101, - title: 'Regular bug fix', - body: 'Just a regular issue with no bounty patterns', - html_url: 'https://github.com/test/repo/issues/101', - state: 'open', - labels: [{ name: 'bug', color: 'ff0000' }], // No Algora label - user: { login: 'testuser', id: 2 }, - created_at: '2025-01-15T10:00:00Z', - updated_at: '2025-01-20T15:00:00Z', - }; - - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({ - total_count: 1, - incomplete_results: false, - items: [mockIssue], - }), - }); - - const source = new AlgoraBountySource({ - repositories: ['test/repo'], - }); - - const result = await source.fetch(); - // Issue should be filtered out because it has no Algora patterns - expect(result).toEqual([]); - } finally { - global.setTimeout = originalSetTimeout; - vi.useFakeTimers(); - } - }); - - it('should handle comments with no valid bounty', async () => { - const originalSetTimeout = global.setTimeout; - global.setTimeout = ((fn: () => void) => { - fn(); - return 0 as unknown as NodeJS.Timeout; - }) as typeof setTimeout; - - try { - const mockIssue = { - id: 456, - number: 101, - title: 'Bounty task', - body: 'No bounty info in body', - html_url: 'https://github.com/test/repo/issues/101', - state: 'open', - labels: [{ name: '💎 Bounty', color: 'ff0000' }], - user: { login: 'testuser', id: 2 }, - created_at: '2025-01-15T10:00:00Z', - updated_at: '2025-01-20T15:00:00Z', - }; - - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ - total_count: 1, - incomplete_results: false, - items: [mockIssue], - }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => [ - { body: 'Just a regular comment', user: { login: 'user1' } }, - { body: 'Another comment', user: { login: 'user2' } }, - ], - }) - .mockResolvedValue({ - ok: true, - json: async () => ({ total_count: 0, incomplete_results: false, items: [] }), - }); - - const source = new AlgoraBountySource({ - repositories: ['test/repo'], - }); - - const result = await source.fetch(); - expect(result).toHaveLength(1); - expect(result[0].rewardAmount).toBeUndefined(); - } finally { - global.setTimeout = originalSetTimeout; - vi.useFakeTimers(); - } - }); - - it('should handle comment fetch error gracefully', async () => { - const originalSetTimeout = global.setTimeout; - global.setTimeout = ((fn: () => void) => { - fn(); - return 0 as unknown as NodeJS.Timeout; - }) as typeof setTimeout; - - try { - const mockIssue = { - id: 456, - number: 101, - title: 'Bounty task', - body: 'No bounty info in body', - html_url: 'https://github.com/test/repo/issues/101', - state: 'open', - labels: [{ name: '💎 Bounty', color: 'ff0000' }], - user: { login: 'testuser', id: 2 }, - created_at: '2025-01-15T10:00:00Z', - updated_at: '2025-01-20T15:00:00Z', - }; - - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ - total_count: 1, - incomplete_results: false, - items: [mockIssue], - }), - }) - .mockResolvedValueOnce({ - ok: false, - status: 403, - }) - .mockResolvedValue({ - ok: true, - json: async () => ({ total_count: 0, incomplete_results: false, items: [] }), - }); - - const source = new AlgoraBountySource({ - repositories: ['test/repo'], - }); - - const result = await source.fetch(); - expect(result).toHaveLength(1); - } finally { - global.setTimeout = originalSetTimeout; - vi.useFakeTimers(); - } - }); + it('should fetch bounties directly from Algora API', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ + items: [ + { + id: 'bty_123', + title: 'Fix issue', + html_url: 'https://github.com/test', + org: 'TestOrg', + reward: { amount_usd: '150' } + } + ] + }) + }); + + const source = new AlgoraBountySource(); + const result = await source.fetch(); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://console.algora.io/api/bounties?status=open&limit=50', + expect.any(Object) + ); + expect(result).toHaveLength(1); + expect(result[0].rewardAmount).toBe(150); + }); + + it('should normalize correctly', () => { + const source = new AlgoraBountySource(); + const raw = { + source: 'algora' as const, + externalId: 'test-1', + externalUrl: 'https://test', + title: 'Test', + description: 'Desc', + ownerExternalId: 'user', + labels: [], + rewardAmount: 50, + createdAt: new Date(), + raw: {}, + }; + + const normalized = source.normalize(raw); + expect(normalized.rewardType).toBe('external'); + expect(normalized.rewardAmount).toBe(50); }); }); diff --git a/src/lib/aggregator/sources/algora.ts b/src/lib/aggregator/sources/algora.ts index 520021e..d0253d1 100644 --- a/src/lib/aggregator/sources/algora.ts +++ b/src/lib/aggregator/sources/algora.ts @@ -2,130 +2,32 @@ * Algora Bounty Fetcher * * Algora bounties are created on GitHub issues via `/bounty $X` comments. - * This source tracks known Algora-active repositories and looks for - * Algora-specific patterns in issues. - * - * Algora patterns: - * - Comment: `/bounty $1000` - * - Label: `💎 Bounty` or `algora` - * - Title/body: Contains reward info from Algora bot + * This source fetches real bounties from Algora.io's API directly. */ import type { BountySource, NormalizedTask, RawBounty } from '../types'; -const GITHUB_API_BASE = 'https://api.github.com'; - -// Repositories known to use Algora for bounties -// These repos frequently post bounties via Algora's GitHub integration -export const ALGORA_REPOS = [ - // ZIO ecosystem - 'zio/zio', - 'zio/zio-blocks', - - // Golem Cloud - 'golemcloud/golem-cli', - 'golemcloud/golem-ai', - - // Other active Algora users - 'omnigres/omnigres', - 'Mudlet/Mudlet', - 'archestra-ai/archestra', - 'ether/etherpad-lite', -]; - -// Algora-specific labels -const ALGORA_LABELS = ['💎 Bounty', 'algora', 'bounty']; - -interface GitHubIssue { - id: number; - number: number; - title: string; - body: string | null; +interface AlgoraAPIBounty { + id: string; + url: string; html_url: string; - state: string; - labels: Array<{ name: string; color: string }>; - user: { - login: string; - id: number; + title: string; + status: string; + org: string; + reward?: { + amount: number; + amount_usd: string; + currency: string; }; - created_at: string; - updated_at: string; -} - -interface GitHubSearchResponse { - total_count: number; - incomplete_results: boolean; - items: GitHubIssue[]; + amount_usd?: number; } interface AlgoraSourceConfig { enabled: boolean; - repositories: string[]; + repositories?: string[]; token?: string; } -/** - * Extract Algora bounty amount from text (issue body or comments) - * Looks for patterns like: - * - `/bounty $1000` - * - `💎 $500 bounty` - * - `## 💎 $2,500 bounty` - * - Algora bot comments with reward info - */ -function extractAlgoraReward(text: string | null): { amount: number; currency: string } | null { - if (!text) return null; - - // Pattern: /bounty $X or 💎 $X (various formats) - const patterns = [ - /\/bounty\s+\$(\d+(?:,\d{3})*(?:\.\d+)?)/i, - /💎\s*\$(\d+(?:,\d{3})*(?:\.\d+)?)/i, - /##\s*💎\s*\$(\d+(?:,\d{3})*(?:\.\d+)?)\s*bounty/i, - /bounty[:\s]+\$(\d+(?:,\d{3})*(?:\.\d+)?)/i, - /reward[:\s]+\$(\d+(?:,\d{3})*(?:\.\d+)?)/i, - /\$(\d+(?:,\d{3})*)\s*bounty/i, - ]; - - for (const pattern of patterns) { - const match = text.match(pattern); - if (match) { - return { - amount: parseFloat(match[1].replace(/,/g, '')), - currency: 'USD', - }; - } - } - - return null; -} - -interface GitHubComment { - body: string; - user: { login: string }; -} - -/** - * Check if an issue is an Algora bounty - */ -function isAlgoraBounty(issue: GitHubIssue): boolean { - // Check labels - const hasAlgoraLabel = issue.labels.some((l) => - ALGORA_LABELS.some((al) => l.name.toLowerCase().includes(al.toLowerCase())) - ); - - if (hasAlgoraLabel) return true; - - // Check body for Algora patterns - if (issue.body) { - const hasAlgoraPattern = - issue.body.includes('/bounty') || - issue.body.includes('💎') || - issue.body.toLowerCase().includes('algora'); - if (hasAlgoraPattern) return true; - } - - return false; -} - export class AlgoraBountySource implements BountySource { readonly name = 'algora' as const; private config: AlgoraSourceConfig; @@ -133,156 +35,65 @@ export class AlgoraBountySource implements BountySource { constructor(config: Partial = {}) { this.config = { enabled: config.enabled ?? true, - repositories: config.repositories || ALGORA_REPOS, token: config.token || process.env.GITHUB_TOKEN, }; } - private async fetchWithAuth(url: string): Promise { - // Use centralized async auth (GitHub App > PAT > unauthenticated) - const { getGitHubAuthHeaderAsync } = await import('../github-app-auth'); - - const headers: Record = { - Accept: 'application/vnd.github+json', - 'X-GitHub-Api-Version': '2022-11-28', - }; - - const authHeader = await getGitHubAuthHeaderAsync(); - if (authHeader) { - headers['Authorization'] = authHeader; - } else if (this.config.token) { - headers['Authorization'] = `Bearer ${this.config.token}`; + async fetch(): Promise { + if (!this.config.enabled) { + return []; } - return fetch(url, { headers }); - } - - /** - * Fetch comments for an issue to find bounty amount - * Algora bounties are posted as comments, not in issue body - */ - private async fetchCommentsForBounty( - repo: string, - issueNumber: number - ): Promise<{ amount: number; currency: string } | null> { - const url = `${GITHUB_API_BASE}/repos/${repo}/issues/${issueNumber}/comments?per_page=10`; + const allBounties: RawBounty[] = []; + const url = 'https://console.algora.io/api/bounties?status=open&limit=50'; try { - const response = await this.fetchWithAuth(url); - if (!response.ok) return null; + const response = await fetch(url, { + headers: { Accept: 'application/json' }, + }); - const comments: GitHubComment[] = await response.json(); - - // Check each comment for bounty amount (usually in first few comments) - for (const comment of comments) { - const reward = extractAlgoraReward(comment.body); - if (reward && reward.amount >= 10) { - return reward; - } + if (!response.ok) { + return allBounties; } - } catch { - // Silently fail - we'll just not have the amount - } - return null; - } + const data = await response.json(); + const items: AlgoraAPIBounty[] = Array.isArray(data) ? data : (data.items || data.bounties || []); - private async fetchFromRepo(repo: string): Promise { - const bounties: RawBounty[] = []; - const seenIssues = new Set(); + for (const item of items) { + let rewardAmount = 0; + const rewardCurrency = 'USD'; - for (const label of ALGORA_LABELS) { - const query = encodeURIComponent(`repo:${repo} is:issue is:open label:"${label}"`); - const url = `${GITHUB_API_BASE}/search/issues?q=${query}&per_page=50&sort=updated`; - - try { - const response = await this.fetchWithAuth(url); - - if (!response.ok) { - if (response.status === 403) { - console.warn(`[algora] Rate limit hit for ${repo}`); - return bounties; - } - continue; + if (item.reward && item.reward.amount_usd) { + rewardAmount = Number(item.reward.amount_usd); + } else if (typeof item.amount_usd === 'number') { + rewardAmount = item.amount_usd; } - const data: GitHubSearchResponse = await response.json(); - - for (const issue of data.items) { - if (seenIssues.has(issue.number)) continue; - if (!isAlgoraBounty(issue)) continue; - - seenIssues.add(issue.number); - - // First try to extract reward from issue body/title - let reward = extractAlgoraReward(issue.body); - - // If not found in body, fetch comments (Algora posts bounty in comments) - if (!reward || reward.amount < 10) { - reward = await this.fetchCommentsForBounty(repo, issue.number); - // Small delay between comment fetches - await new Promise((resolve) => setTimeout(resolve, 500)); - } - - bounties.push({ - source: 'algora', - externalId: `algora-${repo}-${issue.number}`, - externalUrl: issue.html_url, - title: issue.title, - description: issue.body || '', - ownerExternalId: issue.user.login, - ownerName: issue.user.login, - labels: issue.labels.map((l) => l.name), - rewardAmount: reward?.amount, - rewardCurrency: reward?.currency, - createdAt: new Date(issue.created_at), - updatedAt: new Date(issue.updated_at), - raw: issue, - }); - } - - // Respect GitHub Search API rate limits (30 req/min) - await new Promise((resolve) => setTimeout(resolve, 2000)); - } catch (error) { - console.error(`[algora] Error fetching ${repo}:`, error); + allBounties.push({ + source: 'algora', + externalId: item.id || `algora-${Math.random()}`, + externalUrl: item.url || item.html_url || '', + title: item.title || 'Algora Bounty', + description: `Bounty from ${item.org || 'Algora'}`, + ownerExternalId: item.org || 'algora', + ownerName: item.org || 'algora', + labels: ['bounty', 'algora'], + rewardAmount: rewardAmount, + rewardCurrency: rewardCurrency, + createdAt: new Date(), + updatedAt: new Date(), + raw: item, + }); } - } - - return bounties; - } - - async fetch(): Promise { - if (!this.config.enabled) { - return []; - } - - const allBounties: RawBounty[] = []; - - for (const repo of this.config.repositories) { - const bounties = await this.fetchFromRepo(repo); - allBounties.push(...bounties); - // Respect GitHub Search API rate limits - await new Promise((resolve) => setTimeout(resolve, 2000)); + } catch (error) { + console.error(`[algora] Error fetching from API:`, error); } return allBounties; } normalize(raw: RawBounty): NormalizedTask { - // Use pre-fetched reward, or try extracting from description as fallback - let rewardAmount = raw.rewardAmount || 0; - let rewardCurrency = raw.rewardCurrency || 'USD'; - - // If no pre-parsed reward, try extracting from description - if (!rewardAmount && raw.description) { - const extracted = extractAlgoraReward(raw.description); - if (extracted) { - rewardAmount = extracted.amount; - rewardCurrency = extracted.currency; - } - } - - const hasReward = rewardAmount >= 10; + const hasReward = (raw.rewardAmount || 0) >= 10; return { title: raw.title, @@ -292,22 +103,21 @@ export class AlgoraBountySource implements BountySource { externalUrl: raw.externalUrl, ownerExternalId: raw.ownerExternalId, rewardType: hasReward ? 'external' : 'points', - rewardAmount, - rewardCurrency, + rewardAmount: raw.rewardAmount || 0, + rewardCurrency: raw.rewardCurrency || 'USD', visibility: 'public', isMilestoneBased: false, status: 'open', verificationMethod: 'pr_merged', - difficulty: 'medium', // Algora doesn't provide difficulty info + difficulty: 'medium', requirements: [], deadline: raw.deadline, }; } } -export function createAlgoraSource(customRepos?: string[]): AlgoraBountySource { +export function createAlgoraSource(): AlgoraBountySource { return new AlgoraBountySource({ token: process.env.GITHUB_TOKEN, - repositories: customRepos || ALGORA_REPOS, }); }