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
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
51 changes: 51 additions & 0 deletions src/app/actions/issues.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -57,6 +58,56 @@ export async function getRepoOptions(): Promise<Result<RepoOption[]>> {

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<string, number>();
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<RepoOption> => {
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<string>();
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;
Expand Down
4 changes: 3 additions & 1 deletion src/app/actions/recommendations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })),
};
});

Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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
Expand Down
28 changes: 24 additions & 4 deletions src/app/actions/recommendations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Comment on lines +289 to +293

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

profiles has no user_id column, it's id (every other lookup in the codebase uses .eq('id', user.id)). This silently fails and userLevel defaults to 0 always - error isn't checked. Should be .eq('id', user.id).


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}`);
Expand All @@ -300,25 +309,36 @@ async function pickReplacement(args: {
service: NonNullable<ReturnType<typeof getServiceSupabase>>;
userId: string;
preferDifficulty: 'E' | 'M' | 'H';
level: number;
}): Promise<RecCard | null> {
const { service, userId, preferDifficulty } = args;
const { service, userId, preferDifficulty, level } = args;

const { data: seen } = await service
.from('recommendations')
.select('issue_id')
.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<string, string>]) {
const allowedDifficulties = new Set<string>();
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')
.eq('state', 'open')
.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;
Expand Down
4 changes: 2 additions & 2 deletions src/app/actions/search.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
2 changes: 1 addition & 1 deletion src/lib/maintainer/community.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down
13 changes: 12 additions & 1 deletion src/lib/pipeline/recommend.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([]);
});
Expand Down
7 changes: 7 additions & 0 deletions src/lib/pipeline/recommend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();
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'];

Expand Down
4 changes: 2 additions & 2 deletions src/lib/supabase/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down