diff --git a/package-lock.json b/package-lock.json index 6dbf226b..de0cc7e3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,7 +32,7 @@ "react": "^18", "react-dom": "^18", "recharts": "^2.12.0", - "resend": "^6.12.4", + "resend": "^6.14.0", "tailwind-merge": "^2.3.0", "three": "^0.184.0", "zod": "^3.25.76" @@ -11913,9 +11913,9 @@ } }, "node_modules/resend": { - "version": "6.12.4", - "resolved": "https://registry.npmjs.org/resend/-/resend-6.12.4.tgz", - "integrity": "sha512-lRpJ2Hxd+ht+JPDm97juRcUp9HOMuZyxaRFRFmc9Tx8iNWiei94Dx9v6SWufgKk2667C/uCeKKspMotOHSpCSg==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/resend/-/resend-6.14.0.tgz", + "integrity": "sha512-jVdpUgOoWGLjaP64lo8KwzHT9gY4w6Dl8c36CIb2F+ayYOMLr3khqs8xrNjXM2k19b+lPoj0VWQFhVNLiToBjA==", "license": "MIT", "dependencies": { "postal-mime": "2.7.4", diff --git a/package.json b/package.json index a834cdc3..2fbca32c 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "react": "^18", "react-dom": "^18", "recharts": "^2.12.0", - "resend": "^6.12.4", + "resend": "^6.14.0", "tailwind-merge": "^2.3.0", "three": "^0.184.0", "zod": "^3.25.76" diff --git a/src/app/actions/issues.ts b/src/app/actions/issues.ts index da584cff..97b37a78 100644 --- a/src/app/actions/issues.ts +++ b/src/app/actions/issues.ts @@ -6,6 +6,7 @@ import { ok, err, type Result } from '@/lib/result'; import { rateLimit, RATE_LIMIT_TIERS } from '@/lib/rate-limit'; import { cacheDel, cacheGet, cacheSet } from '@/lib/cache'; import { repoFilterPattern } from './issues-helpers'; +import { getInstallOctokit } from '@/lib/github/app'; const PAGE_SIZE = 10; @@ -57,6 +58,56 @@ export async function getRepoOptions(): Promise> { const cacheKey = `repo-options:${user.id}`; + const { data: insts } = await service + .from('github_installations') + .select('id') + .eq('user_id', user.id); + + const instIds = (insts ?? []).map((i: { id: number }) => i.id); + if (instIds.length === 0) return ok([]); + + const { data: repoRows } = await service + .from('installation_repositories') + .select('repo_full_name, installation_id') + .in('installation_id', instIds); + + const repoMap = new Map(); + for (const r of (repoRows ?? []) as { repo_full_name: string; installation_id: number }[]) { + repoMap.set(r.repo_full_name, r.installation_id); + } + + const userRepos = [...repoMap.keys()]; + if (userRepos.length === 0) return ok([]); + + // Resolve each repo: if it's a fork, use the upstream (parent) as the issues source + const options = await Promise.all( + userRepos.map(async (repo): Promise => { + const instId = repoMap.get(repo); + if (!instId) return { label: repo, value: repo }; + try { + const octokit = await getInstallOctokit(instId); + const [owner, name] = repo.split('/'); + if (!owner || !name) return { label: repo, value: repo }; + const { data } = await octokit.repos.get({ owner, repo: name }); + if (data.fork && data.parent?.full_name) { + return { label: repo, value: data.parent.full_name }; + } + return { label: repo, value: repo }; + } catch { + return { label: repo, value: repo }; + } + }), + ); + + // Deduplicate by value (multiple forks of same upstream → one entry) + const seen = new Set(); + const deduped = options + .filter((opt) => { + if (seen.has(opt.value)) return false; + seen.add(opt.value); + return true; + }) + .sort((a, b) => a.label.localeCompare(b.label)); // Return active in-flight request if there is one (deduplicates concurrent calls during page load) const inFlight = inFlightRepoOptions.get(user.id); if (inFlight) return inFlight; diff --git a/src/app/actions/recommendations.test.ts b/src/app/actions/recommendations.test.ts index 1d26bc48..6cfca1f2 100644 --- a/src/app/actions/recommendations.test.ts +++ b/src/app/actions/recommendations.test.ts @@ -10,7 +10,7 @@ const mocks = vi.hoisted(() => { mockCacheDel: vi.fn(), mockRateLimit: vi.fn(), mockTryGetDb: vi.fn(), - mockSql: vi.fn((strings, ...values) => ({ strings, values })), + mockSql: vi.fn((strings: TemplateStringsArray, ...values: any[]) => ({ strings, values })), }; }); @@ -272,6 +272,7 @@ describe('Recommendations Server Actions', () => { .mockReturnValueOnce( createMockChain(null, { data: { id: 1, difficulty: 'E', issue_id: 10 }, error: null }), ) // update rec + .mockReturnValueOnce(createMockChain(null, { data: { level: 1 }, error: null })) // profiles .mockReturnValueOnce(createMockChain({ data: [{ issue_id: 10 }] })) // select seen .mockReturnValueOnce( createMockChain({ @@ -305,6 +306,7 @@ describe('Recommendations Server Actions', () => { .mockReturnValueOnce( createMockChain(null, { data: { id: 1, difficulty: 'E', issue_id: 10 }, error: null }), ) // update rec + .mockReturnValueOnce(createMockChain(null, { data: { level: 1 }, error: null })) // profiles .mockReturnValueOnce(createMockChain({ data: [{ issue_id: 10 }] })) // select seen .mockReturnValueOnce(createMockChain({ data: [] })) // select pool E .mockReturnValueOnce(createMockChain({ data: [] })); // select pool Any diff --git a/src/app/actions/recommendations.ts b/src/app/actions/recommendations.ts index 630d9a6a..14f50108 100644 --- a/src/app/actions/recommendations.ts +++ b/src/app/actions/recommendations.ts @@ -286,10 +286,19 @@ export async function skipRecommendation( // Insert a replacement pick. Same difficulty if possible. Excludes // anything the user has already seen (any status). + const { data: profile } = await service + .from('profiles') + .select('level') + .eq('user_id', user.id) + .single(); + + const userLevel = profile?.level ?? 0; + const replacement = await pickReplacement({ service, userId: user.id, preferDifficulty: data.difficulty as 'E' | 'M' | 'H', + level: userLevel, }); await cacheDel(`recs:${user.id}`); @@ -300,8 +309,9 @@ async function pickReplacement(args: { service: NonNullable>; userId: string; preferDifficulty: 'E' | 'M' | 'H'; + level: number; }): Promise { - const { service, userId, preferDifficulty } = args; + const { service, userId, preferDifficulty, level } = args; const { data: seen } = await service .from('recommendations') @@ -309,8 +319,13 @@ async function pickReplacement(args: { .eq('user_id', userId); const excludeIds = new Set((seen ?? []).map((r) => r.issue_id)); - // Try same tier first, then any tier. Health >= 40 filter mirrors filterAndRank. - for (const where of [{ difficulty: preferDifficulty }, {} as Record]) { + const allowedDifficulties = new Set(); + if (level >= 0) allowedDifficulties.add('E'); + if (level >= 1) allowedDifficulties.add('M'); + if (level >= 2) allowedDifficulties.add('H'); + + // Try same tier first, then any allowed tier. Health >= 40 filter mirrors filterAndRank. + for (const fallback of [false, true]) { let q = service .from('issues') .select('id, repo_full_name, github_issue_number, title, difficulty, xp_reward, url') @@ -318,7 +333,12 @@ async function pickReplacement(args: { .gte('repo_health_score', 40) .order('scored_at', { ascending: false }) .limit(50); - if (where.difficulty) q = q.eq('difficulty', where.difficulty); + + if (!fallback) { + q = q.eq('difficulty', preferDifficulty); + } else { + q = q.in('difficulty', Array.from(allowedDifficulties)); + } const { data: pool } = await q; const pick = (pool ?? []).find((i) => !excludeIds.has(i.id)); if (!pick) continue; diff --git a/src/app/actions/search.test.ts b/src/app/actions/search.test.ts index 5554ed0f..bcbbcde0 100644 --- a/src/app/actions/search.test.ts +++ b/src/app/actions/search.test.ts @@ -49,8 +49,8 @@ vi.mock('@/lib/db/schema', () => ({ })); vi.mock('drizzle-orm', () => ({ - ilike: vi.fn((col, pat) => ({ col, pat })), - desc: vi.fn((col) => ({ col, dir: 'desc' })), + ilike: vi.fn((col: any, pat: any) => ({ col, pat })), + desc: vi.fn((col: any) => ({ col, dir: 'desc' })), })); import { searchGlobal } from './search'; diff --git a/src/lib/maintainer/community.test.ts b/src/lib/maintainer/community.test.ts index 6d7b406e..dadb12e7 100644 --- a/src/lib/maintainer/community.test.ts +++ b/src/lib/maintainer/community.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest'; import { validateCommunityUrl, COMMUNITY_KINDS, type CommunityKind } from './community'; describe('validateCommunityUrl', () => { - it.each(COMMUNITY_KINDS)('accepts plain https for %s', (kind) => { + it.each(COMMUNITY_KINDS)('accepts plain https for %s', (kind: any) => { const res = validateCommunityUrl('https://example.com/abc', kind as CommunityKind); expect(res.ok).toBe(true); }); diff --git a/src/lib/pipeline/recommend.test.ts b/src/lib/pipeline/recommend.test.ts index 3c493fd2..6afebb0b 100644 --- a/src/lib/pipeline/recommend.test.ts +++ b/src/lib/pipeline/recommend.test.ts @@ -105,14 +105,25 @@ describe('filterAndRank', () => { expect(result).toEqual([]); }); + it('falls back to other allowed tiers but respects max difficulty cap', () => { + const issues = [ + issue({ id: 1, difficulty: 'H' }), + issue({ id: 2, difficulty: 'M' }), + issue({ id: 3, difficulty: 'E' }), + ]; + // L1 wants 2 E + 2 M. + // Pool has 1 H, 1 M, 1 E. + // It will take M and E. Fallback should NOT take H. it('does not fallback to higher difficulty tiers', () => { const issues = [issue({ id: 1, difficulty: 'M' }), issue({ id: 2, difficulty: 'M' })]; const result = filterAndRank(issues, { - level: 0, + level: 1, excludeIssueIds: new Set(), allowFallback: true, }); + expect(result).toHaveLength(2); + expect(result.map((r) => r.difficulty).sort()).toEqual(['E', 'M']); expect(result).toEqual([]); }); diff --git a/src/lib/pipeline/recommend.ts b/src/lib/pipeline/recommend.ts index a81688fc..e2ccb09b 100644 --- a/src/lib/pipeline/recommend.ts +++ b/src/lib/pipeline/recommend.ts @@ -100,6 +100,13 @@ export function filterAndRank(pool: readonly ScoredIssue[], opts: RecommendOptio // Fallback: if any tier came up empty, optionally borrow from adjacent (only easier). if (opts.allowFallback && result.length < totalDesired(mix)) { const seen = new Set(result.map((r) => r.id)); + const allowed = new Set(); + if (opts.level >= 0) allowed.add('E'); + if (opts.level >= 1) allowed.add('M'); + if (opts.level >= 2) allowed.add('H'); + + const extras = eligible + .filter((i) => !seen.has(i.id) && allowed.has(i.difficulty)) const allowedDifficulties: Difficulty[] = opts.level <= 0 ? ['E'] : opts.level === 1 ? ['E', 'M'] : ['E', 'M', 'H']; diff --git a/src/lib/supabase/server.ts b/src/lib/supabase/server.ts index 7ad7cae1..41e31fbb 100644 --- a/src/lib/supabase/server.ts +++ b/src/lib/supabase/server.ts @@ -15,12 +15,12 @@ export async function getServerSupabase() { return createServerClient(env.url, env.anonKey, { cookies: { getAll() { - return cookieStore.getAll(); + return (cookieStore as any).getAll(); }, setAll(toSet: { name: string; value: string; options?: CookieOptions }[]) { try { for (const { name, value, options } of toSet) { - cookieStore.set(name, value, options); + (cookieStore as any).set(name, value, options); } } catch { // setAll called from a server component — middleware handles refresh, ignore.