From 8fc7cea0c96eec83da2691eb61a12cbcc7d369b3 Mon Sep 17 00:00:00 2001 From: sujini kudipudi Date: Wed, 17 Jun 2026 23:34:56 +0530 Subject: [PATCH 1/4] fix: resolve forked repos to upstream correctly in issues filter --- src/app/actions/issues.ts | 33 +++++++++++++++------------------ 1 file changed, 15 insertions(+), 18 deletions(-) diff --git a/src/app/actions/issues.ts b/src/app/actions/issues.ts index 490b41a7..97ef5408 100644 --- a/src/app/actions/issues.ts +++ b/src/app/actions/issues.ts @@ -5,6 +5,8 @@ import { getServiceSupabase } from '@/lib/supabase/service'; import { ok, err, type Result } from '@/lib/result'; import { rateLimit } from '@/lib/rate-limit'; import { cacheDel } from '@/lib/cache'; +import { repoFilterPattern } from './issues-helpers'; +import { getInstallOctokit } from '@/lib/github/app'; const PAGE_SIZE = 10; @@ -65,32 +67,27 @@ export async function getRepoOptions(): Promise> { const { data: repoRows } = await service .from('installation_repositories') - .select('repo_full_name') + .select('repo_full_name, installation_id') .in('installation_id', instIds); - const userRepos = [ - ...new Set((repoRows ?? []).map((r: { repo_full_name: string }) => r.repo_full_name)), - ]; - if (userRepos.length === 0) return ok([]); + 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); + } - // Get provider token so we can call GitHub API to detect forks - const sessionRes = await sb.auth.getSession(); - const token = sessionRes.data.session?.provider_token; + 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 => { - if (!token) return { label: repo, value: repo }; + const instId = repoMap.get(repo); + if (!instId) return { label: repo, value: repo }; try { - const res = await fetch(`https://api.github.com/repos/${repo}`, { - headers: { - Authorization: `Bearer ${token}`, - Accept: 'application/vnd.github+json', - 'X-GitHub-Api-Version': '2022-11-28', - }, - }); - if (!res.ok) return { label: repo, value: repo }; - const data = (await res.json()) as { fork?: boolean; parent?: { full_name: string } }; + 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 }; } From ae8c25ee539ef6f2e720d597ad1c462a829774fe Mon Sep 17 00:00:00 2001 From: sujini kudipudi Date: Thu, 18 Jun 2026 00:32:21 +0530 Subject: [PATCH 2/4] fix: enforce maximum difficulty cap in recommendation fallbacks --- src/app/actions/recommendations.ts | 28 ++++++++++++++++++++++++---- src/lib/pipeline/recommend.test.ts | 15 +++++++++++---- src/lib/pipeline/recommend.ts | 7 ++++++- 3 files changed, 41 insertions(+), 9 deletions(-) diff --git a/src/app/actions/recommendations.ts b/src/app/actions/recommendations.ts index cd8253f6..75d3bf6e 100644 --- a/src/app/actions/recommendations.ts +++ b/src/app/actions/recommendations.ts @@ -228,10 +228,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}`); @@ -242,8 +251,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') @@ -251,8 +261,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') @@ -260,7 +275,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/lib/pipeline/recommend.test.ts b/src/lib/pipeline/recommend.test.ts index fca51e2c..dc0635c8 100644 --- a/src/lib/pipeline/recommend.test.ts +++ b/src/lib/pipeline/recommend.test.ts @@ -104,15 +104,22 @@ describe('filterAndRank', () => { expect(result).toEqual([]); }); - it('falls back to easier tier when target tier is empty', () => { - const issues = [issue({ id: 1, difficulty: 'M' }), issue({ id: 2, difficulty: 'M' })]; - // L0 wants 3 E but pool has none. Soft fallback: take M's so user has something. + 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. 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']); }); it('without fallback returns empty when tier is missing', () => { diff --git a/src/lib/pipeline/recommend.ts b/src/lib/pipeline/recommend.ts index 2ec0891e..90838362 100644 --- a/src/lib/pipeline/recommend.ts +++ b/src/lib/pipeline/recommend.ts @@ -65,8 +65,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)) + .filter((i) => !seen.has(i.id) && allowed.has(i.difficulty)) .sort((a, b) => rankScore(b) - rankScore(a)); const needed = totalDesired(mix) - result.length; result.push(...extras.slice(0, needed)); From 2296bfdfd647ec2a42bbd6f765189d3c774d14dc Mon Sep 17 00:00:00 2001 From: sujini kudipudi Date: Thu, 18 Jun 2026 18:54:57 +0530 Subject: [PATCH 3/4] chore: fix CI typescript compilation errors --- src/app/actions/issues.ts | 1 - src/lib/supabase/server.ts | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/app/actions/issues.ts b/src/app/actions/issues.ts index d79d6f2d..1dcb4e0a 100644 --- a/src/app/actions/issues.ts +++ b/src/app/actions/issues.ts @@ -5,7 +5,6 @@ import { getServiceSupabase } from '@/lib/supabase/service'; import { ok, err, type Result } from '@/lib/result'; import { rateLimit } from '@/lib/rate-limit'; import { cacheDel } from '@/lib/cache'; -import { repoFilterPattern } from './issues-helpers'; import { getInstallOctokit } from '@/lib/github/app'; const PAGE_SIZE = 10; 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. From 1c92126c7146d6e625e0a35040e885d90a488369 Mon Sep 17 00:00:00 2001 From: sujini kudipudi Date: Fri, 19 Jun 2026 20:15:40 +0530 Subject: [PATCH 4/4] chore: fix test mocks, formatting, and typing issues --- package-lock.json | 8 ++++---- package.json | 2 +- src/app/actions/issues.ts | 1 + src/app/actions/recommendations.test.ts | 4 +++- src/app/actions/search.test.ts | 4 ++-- src/lib/maintainer/community.test.ts | 2 +- src/lib/pipeline/recommend.ts | 2 -- 7 files changed, 12 insertions(+), 11 deletions(-) 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 1dcb4e0a..d79d6f2d 100644 --- a/src/app/actions/issues.ts +++ b/src/app/actions/issues.ts @@ -5,6 +5,7 @@ import { getServiceSupabase } from '@/lib/supabase/service'; import { ok, err, type Result } from '@/lib/result'; import { rateLimit } from '@/lib/rate-limit'; import { cacheDel } from '@/lib/cache'; +import { repoFilterPattern } from './issues-helpers'; import { getInstallOctokit } from '@/lib/github/app'; const PAGE_SIZE = 10; diff --git a/src/app/actions/recommendations.test.ts b/src/app/actions/recommendations.test.ts index 7982f5e5..7c766b5c 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 })), }; }); @@ -268,6 +268,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({ @@ -301,6 +302,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/search.test.ts b/src/app/actions/search.test.ts index 62df0ad6..550fd205 100644 --- a/src/app/actions/search.test.ts +++ b/src/app/actions/search.test.ts @@ -45,8 +45,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.ts b/src/lib/pipeline/recommend.ts index 7abb5897..50de8f32 100644 --- a/src/lib/pipeline/recommend.ts +++ b/src/lib/pipeline/recommend.ts @@ -107,8 +107,6 @@ export function filterAndRank(pool: readonly ScoredIssue[], opts: RecommendOptio const extras = eligible .filter((i) => !seen.has(i.id) && allowed.has(i.difficulty)) - .sort((a, b) => rankScore(b) - rankScore(a)); - .filter((i) => !seen.has(i.id)) .sort((a, b) => rankScore(b, opts) - rankScore(a, opts)); const needed = totalDesired(mix) - result.length; result.push(...extras.slice(0, needed));