From 26f05945cd153d859ee09c43de5c2ba1d4fe2e9d Mon Sep 17 00:00:00 2001 From: shuban Date: Sun, 17 May 2026 19:50:04 +0530 Subject: [PATCH 01/12] Add recommendation feedback persistence support --- src/app/actions/profile.ts | 1 + src/app/actions/recommendations.ts | 8 +- src/lib/db/schema.ts | 8 ++ src/lib/pipeline/constants.ts | 57 +++++++++++++ src/lib/pipeline/recommend.ts | 85 +++++++++++++++++-- .../0010_recommendation_feedback.sql | 12 +++ 6 files changed, 163 insertions(+), 8 deletions(-) create mode 100644 src/lib/pipeline/constants.ts create mode 100644 supabase/migrations/0010_recommendation_feedback.sql diff --git a/src/app/actions/profile.ts b/src/app/actions/profile.ts index 7b8e19a..7c1b916 100644 --- a/src/app/actions/profile.ts +++ b/src/app/actions/profile.ts @@ -3,6 +3,7 @@ import { getServerSupabase } from '@/lib/supabase/server'; import { getServiceSupabase } from '@/lib/supabase/service'; import { inngest } from '@/inngest/client'; +import { rateLimit } from '@/lib/rate-limit'; import { ok, err, type Result } from '@/lib/result'; type BootstrapOutput = { diff --git a/src/app/actions/recommendations.ts b/src/app/actions/recommendations.ts index d62e90a..f9a0271 100644 --- a/src/app/actions/recommendations.ts +++ b/src/app/actions/recommendations.ts @@ -184,6 +184,7 @@ export async function linkPrToRec(recId: number, prUrl: string): Promise> { const sb = getServerSupabase(); if (!sb) return err('not_configured', 'auth not configured'); @@ -204,9 +205,14 @@ export async function skipRecommendation( if (!rateRes.ok) return err('rate_limited', 'slow down', true); // Atomic skip with the issue id so we know what tier to refill from. + // Persist the optional skip_reason alongside the status change (Issue #91). + const updatePayload: Record = { status: 'reassigned' }; + if (skipReason?.trim()) { + updatePayload.skip_reason = skipReason.trim().slice(0, 500); + } const { data, error: updateErr } = await service .from('recommendations') - .update({ status: 'reassigned' }) + .update(updatePayload) .eq('id', recId) .eq('user_id', user.id) .eq('status', 'open') diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index 7aab333..1913d1f 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -43,6 +43,11 @@ export const profiles = pgTable( level: integer('level').notNull().default(0), auditCompleted: boolean('audit_completed').notNull().default(false), timezone: text('timezone'), + // Contributor mute preferences (Issue #91). Arrays of repo full names + // and language strings the user has marked "not interested in". + // Fully reversible — cleared by passing an empty array. + mutedRepos: text('muted_repos').array().default([]).notNull(), + mutedLanguages: text('muted_languages').array().default([]).notNull(), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), }, @@ -145,6 +150,9 @@ export const recommendations = pgTable( difficulty: text('difficulty', { enum: ['E', 'M', 'H'] }).notNull(), xpReward: integer('xp_reward').notNull(), linkedPrUrl: text('linked_pr_url'), + // Optional free-text reason captured when a user skips a recommendation + // (Issue #91). Never required — omitting preserves existing skip behavior. + skipReason: text('skip_reason'), recommendedAt: timestamp('recommended_at', { withTimezone: true }).notNull().defaultNow(), expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(), claimedAt: timestamp('claimed_at', { withTimezone: true }), diff --git a/src/lib/pipeline/constants.ts b/src/lib/pipeline/constants.ts new file mode 100644 index 0000000..9fe13e8 --- /dev/null +++ b/src/lib/pipeline/constants.ts @@ -0,0 +1,57 @@ +/** + * Recommendation ranking constants. + * + * All scoring penalties applied during filterAndRank() are defined here. + * Adjust these to tune ranking quality without touching ranking logic. + * + * Penalty design principles: + * - Penalties are SUBTRACTIVE — they reduce a candidate's rank score. + * - They are DOWN-RANKING only: no candidate is hard-excluded by penalty alone. + * - Muted penalties are stronger than skip-history penalties. + * - Language skip penalties are softer than repo skip penalties to preserve diversity. + * - Stack predictably: a muted+skipped repo simply accumulates both penalties. + */ + +export const RECOMMENDATION_PENALTIES = { + /** + * Number of times a user must have skipped recs from the same repo + * before the repo-skip penalty kicks in. + */ + REPO_SKIP_THRESHOLD: 2, + + /** + * Score reduction applied to each issue from a repository the user + * has skipped >= REPO_SKIP_THRESHOLD times. + * Conservative: keeps the repo surfaceable but pushes it down the list. + */ + REPO_SKIP_PENALTY: 15, + + /** + * Number of skipped recs whose primary language matches a language + * before the language-skip penalty kicks in. + */ + LANGUAGE_SKIP_THRESHOLD: 3, + + /** + * Score reduction applied to issues whose repo_language has been + * skipped >= LANGUAGE_SKIP_THRESHOLD times. + * Softer than repo penalty to preserve cross-language diversity. + */ + LANGUAGE_SKIP_PENALTY: 10, + + /** + * Score reduction for repos the user has explicitly muted ("not interested"). + * Large enough to push muted repos near the bottom without disappearing them, + * so they can resurface if the candidate pool is thin. + */ + MUTED_REPO_PENALTY: 50, + + /** + * Score reduction for issues whose language the user has explicitly muted. + * Smaller than MUTED_REPO_PENALTY to allow some diversity bleed-through. + */ + MUTED_LANGUAGE_PENALTY: 30, +} as const; + +/** How many days of skip history to consider when computing skip penalties. */ +export const SKIP_HISTORY_WINDOW_DAYS = 30; diff --git a/src/lib/pipeline/recommend.ts b/src/lib/pipeline/recommend.ts index 2ec0891..840d8c3 100644 --- a/src/lib/pipeline/recommend.ts +++ b/src/lib/pipeline/recommend.ts @@ -1,9 +1,16 @@ import type { Difficulty } from './score'; +import { RECOMMENDATION_PENALTIES } from './constants'; /** * Recommendation pipeline — pure ranking + filtering logic. * The async fetch + persist orchestration lives in the server action * (src/app/actions/recommendations.ts). + * + * Penalty design (Issue #91): + * Penalties are SUBTRACTIVE and applied AFTER baseline ranking. + * No candidate is ever hard-excluded by a penalty — muted/skipped repos + * simply sink to the bottom so they can resurface when the pool is thin. + * See src/lib/pipeline/constants.ts for tunable values. */ export type ScoredIssue = { @@ -16,12 +23,30 @@ export type ScoredIssue = { repoHealthScore: number; freshnessHours: number; // hours since issue was opened/updated languageMatch: boolean; + /** Primary language of the issue's repo (used for language-based penalties). */ + repoLanguage: string | null; +}; + +/** + * Skip counts aggregated per repo and per language over the + * SKIP_HISTORY_WINDOW_DAYS window. Built in the Inngest build step + * so the ranking function stays pure and synchronous. + */ +export type SkipCounts = { + byRepo: Record; + byLanguage: Record; }; export type RecommendOptions = { level: number; excludeIssueIds: Set; allowFallback?: boolean; + /** Repos the user explicitly muted (full names). */ + mutedRepos?: readonly string[]; + /** Languages the user explicitly muted. */ + mutedLanguages?: readonly string[]; + /** Aggregated skip history for this user (last 30 days). */ + skipCounts?: SkipCounts; }; export type LevelMix = { E: number; M: number; H: number }; @@ -36,13 +61,59 @@ export function mixForLevel(level: number): LevelMix { return { E: 0, M: 1, H: 4 }; } -function rankScore(issue: ScoredIssue): number { - // higher = better. repo health weighted heaviest; language match is a tiebreaker. - return ( +/** + * Compute the rank score for a candidate issue. + * + * Scoring is two-phase: + * 1. **Baseline** — repo health, language match, freshness (unchanged). + * 2. **Penalty** — skip-history and mute penalties subtracted from baseline. + * + * Higher = better. Penalties can drive the score negative which is fine — + * it just means the candidate sinks below un-penalised alternatives. + */ +function rankScore(issue: ScoredIssue, opts: RecommendOptions): number { + // ── Phase 1: baseline (existing logic, untouched) ────────────── + const baseline = issue.repoHealthScore * 1 + (issue.languageMatch ? 20 : 0) + - Math.max(0, 30 - issue.freshnessHours / 24) - ); + Math.max(0, 30 - issue.freshnessHours / 24); + + // ── Phase 2: penalties (Issue #91) ───────────────────────────── + let penalty = 0; + + const { + REPO_SKIP_THRESHOLD, + REPO_SKIP_PENALTY, + LANGUAGE_SKIP_THRESHOLD, + LANGUAGE_SKIP_PENALTY, + MUTED_REPO_PENALTY, + MUTED_LANGUAGE_PENALTY, + } = RECOMMENDATION_PENALTIES; + + // Skip-history penalties (from aggregated counts). + if (opts.skipCounts) { + const repoSkips = opts.skipCounts.byRepo[issue.repoFullName] ?? 0; + if (repoSkips >= REPO_SKIP_THRESHOLD) { + penalty += REPO_SKIP_PENALTY; + } + + if (issue.repoLanguage) { + const langSkips = opts.skipCounts.byLanguage[issue.repoLanguage] ?? 0; + if (langSkips >= LANGUAGE_SKIP_THRESHOLD) { + penalty += LANGUAGE_SKIP_PENALTY; + } + } + } + + // Mute-preference penalties (explicit user preference). + if (opts.mutedRepos?.includes(issue.repoFullName)) { + penalty += MUTED_REPO_PENALTY; + } + if (issue.repoLanguage && opts.mutedLanguages?.includes(issue.repoLanguage)) { + penalty += MUTED_LANGUAGE_PENALTY; + } + + return baseline - penalty; } export function filterAndRank(pool: readonly ScoredIssue[], opts: RecommendOptions): ScoredIssue[] { @@ -58,7 +129,7 @@ export function filterAndRank(pool: readonly ScoredIssue[], opts: RecommendOptio if (want === 0) continue; const sorted = eligible .filter((i) => i.difficulty === tier) - .sort((a, b) => rankScore(b) - rankScore(a)); + .sort((a, b) => rankScore(b, opts) - rankScore(a, opts)); result.push(...sorted.slice(0, want)); } @@ -67,7 +138,7 @@ export function filterAndRank(pool: readonly ScoredIssue[], opts: RecommendOptio const seen = new Set(result.map((r) => r.id)); const extras = eligible .filter((i) => !seen.has(i.id)) - .sort((a, b) => rankScore(b) - rankScore(a)); + .sort((a, b) => rankScore(b, opts) - rankScore(a, opts)); const needed = totalDesired(mix) - result.length; result.push(...extras.slice(0, needed)); } diff --git a/supabase/migrations/0010_recommendation_feedback.sql b/supabase/migrations/0010_recommendation_feedback.sql new file mode 100644 index 0000000..c45eab7 --- /dev/null +++ b/supabase/migrations/0010_recommendation_feedback.sql @@ -0,0 +1,12 @@ +-- Issue #91: Recommendation quality feedback loop +-- Adds skip_reason to recommendations and mute preferences to profiles. + +-- 1. Optional skip reason on recommendations (free text, never required). +alter table recommendations + add column if not exists skip_reason text; + +-- 2. Contributor mute preferences on profiles. +-- Arrays of repo full names / language strings. Default empty array. +alter table profiles + add column if not exists muted_repos text[] not null default '{}', + add column if not exists muted_languages text[] not null default '{}'; From 378a6ce5b22b533373aafbaa012840ca815a472b Mon Sep 17 00:00:00 2001 From: shuban Date: Sun, 17 May 2026 19:52:36 +0530 Subject: [PATCH 02/12] Add recommendation skip history aggregation --- .../functions/recommendations-build.ts | 37 ++++++++++++++++++- src/lib/pipeline/recommend.ts | 29 +-------------- 2 files changed, 37 insertions(+), 29 deletions(-) diff --git a/src/inngest/functions/recommendations-build.ts b/src/inngest/functions/recommendations-build.ts index 2b04d80..699e61a 100644 --- a/src/inngest/functions/recommendations-build.ts +++ b/src/inngest/functions/recommendations-build.ts @@ -1,6 +1,7 @@ import { inngest } from '../client'; import { getServiceSupabase } from '@/lib/supabase/service'; -import { filterAndRank, type ScoredIssue } from '@/lib/pipeline/recommend'; +import { filterAndRank, type ScoredIssue, type SkipCounts } from '@/lib/pipeline/recommend'; +import { SKIP_HISTORY_WINDOW_DAYS } from '@/lib/pipeline/constants'; /** * For every active user, derive a fresh set of recommendation rows from the @@ -61,6 +62,38 @@ export const recommendationsBuild = inngest.createFunction( }; const userList = (users ?? []) as unknown as UserRow[]; + // Build bulk skip-history lookup map (Issue #91) + // Avoids N+1 queries by fetching all skips in the trailing window at once. + const cutoffDate = new Date( + Date.now() - SKIP_HISTORY_WINDOW_DAYS * 24 * 60 * 60 * 1000, + ).toISOString(); + const { data: skipsData } = await sb + .from('recommendations') + .select('user_id, issues!inner(repo_full_name, repo_language)') + .eq('status', 'reassigned') + .gte('recommended_at', cutoffDate); + + const skipHistoryMap: Record = {}; + for (const row of skipsData ?? []) { + const userId = row.user_id; + const issue = row.issues as unknown as { + repo_full_name: string; + repo_language: string | null; + }; + + if (!skipHistoryMap[userId]) { + skipHistoryMap[userId] = { byRepo: {}, byLanguage: {} }; + } + + const counts = skipHistoryMap[userId]; + counts.byRepo[issue.repo_full_name] = (counts.byRepo[issue.repo_full_name] ?? 0) + 1; + + if (issue.repo_language) { + counts.byLanguage[issue.repo_language] = + (counts.byLanguage[issue.repo_language] ?? 0) + 1; + } + } + let totalInserted = 0; for (const u of userList) { const level = u.profiles?.level ?? 0; @@ -88,11 +121,13 @@ export const recommendationsBuild = inngest.createFunction( .select('issue_id') .eq('user_id', u.user_id); const excludeIds = new Set((seen ?? []).map((r) => r.issue_id)); + const skipCounts = skipHistoryMap[u.user_id]; const picks = filterAndRank(candidates, { level, excludeIssueIds: excludeIds, allowFallback: true, + skipCounts, }); if (picks.length === 0) continue; diff --git a/src/lib/pipeline/recommend.ts b/src/lib/pipeline/recommend.ts index 840d8c3..e451f35 100644 --- a/src/lib/pipeline/recommend.ts +++ b/src/lib/pipeline/recommend.ts @@ -5,12 +5,6 @@ import { RECOMMENDATION_PENALTIES } from './constants'; * Recommendation pipeline — pure ranking + filtering logic. * The async fetch + persist orchestration lives in the server action * (src/app/actions/recommendations.ts). - * - * Penalty design (Issue #91): - * Penalties are SUBTRACTIVE and applied AFTER baseline ranking. - * No candidate is ever hard-excluded by a penalty — muted/skipped repos - * simply sink to the bottom so they can resurface when the pool is thin. - * See src/lib/pipeline/constants.ts for tunable values. */ export type ScoredIssue = { @@ -21,17 +15,11 @@ export type ScoredIssue = { difficulty: Difficulty; xpReward: number; repoHealthScore: number; - freshnessHours: number; // hours since issue was opened/updated + freshnessHours: number; languageMatch: boolean; - /** Primary language of the issue's repo (used for language-based penalties). */ repoLanguage: string | null; }; -/** - * Skip counts aggregated per repo and per language over the - * SKIP_HISTORY_WINDOW_DAYS window. Built in the Inngest build step - * so the ranking function stays pure and synchronous. - */ export type SkipCounts = { byRepo: Record; byLanguage: Record; @@ -41,11 +29,8 @@ export type RecommendOptions = { level: number; excludeIssueIds: Set; allowFallback?: boolean; - /** Repos the user explicitly muted (full names). */ mutedRepos?: readonly string[]; - /** Languages the user explicitly muted. */ mutedLanguages?: readonly string[]; - /** Aggregated skip history for this user (last 30 days). */ skipCounts?: SkipCounts; }; @@ -61,24 +46,12 @@ export function mixForLevel(level: number): LevelMix { return { E: 0, M: 1, H: 4 }; } -/** - * Compute the rank score for a candidate issue. - * - * Scoring is two-phase: - * 1. **Baseline** — repo health, language match, freshness (unchanged). - * 2. **Penalty** — skip-history and mute penalties subtracted from baseline. - * - * Higher = better. Penalties can drive the score negative which is fine — - * it just means the candidate sinks below un-penalised alternatives. - */ function rankScore(issue: ScoredIssue, opts: RecommendOptions): number { - // ── Phase 1: baseline (existing logic, untouched) ────────────── const baseline = issue.repoHealthScore * 1 + (issue.languageMatch ? 20 : 0) + Math.max(0, 30 - issue.freshnessHours / 24); - // ── Phase 2: penalties (Issue #91) ───────────────────────────── let penalty = 0; const { From d0e4edfda125e4d828eab6c67e06c89ff5005aca Mon Sep 17 00:00:00 2001 From: shuban Date: Sun, 17 May 2026 19:54:06 +0530 Subject: [PATCH 03/12] Add repository skip recommendation penalties --- src/lib/pipeline/recommend.ts | 31 ++++--------------------------- 1 file changed, 4 insertions(+), 27 deletions(-) diff --git a/src/lib/pipeline/recommend.ts b/src/lib/pipeline/recommend.ts index e451f35..3fd3e1a 100644 --- a/src/lib/pipeline/recommend.ts +++ b/src/lib/pipeline/recommend.ts @@ -54,36 +54,13 @@ function rankScore(issue: ScoredIssue, opts: RecommendOptions): number { let penalty = 0; - const { - REPO_SKIP_THRESHOLD, - REPO_SKIP_PENALTY, - LANGUAGE_SKIP_THRESHOLD, - LANGUAGE_SKIP_PENALTY, - MUTED_REPO_PENALTY, - MUTED_LANGUAGE_PENALTY, - } = RECOMMENDATION_PENALTIES; - - // Skip-history penalties (from aggregated counts). + // Apply soft penalty for frequently skipped repositories (Issue #91). + // Keeps the repo surfaceable but pushes it down the candidate list. if (opts.skipCounts) { const repoSkips = opts.skipCounts.byRepo[issue.repoFullName] ?? 0; - if (repoSkips >= REPO_SKIP_THRESHOLD) { - penalty += REPO_SKIP_PENALTY; + if (repoSkips >= RECOMMENDATION_PENALTIES.REPO_SKIP_THRESHOLD) { + penalty += RECOMMENDATION_PENALTIES.REPO_SKIP_PENALTY; } - - if (issue.repoLanguage) { - const langSkips = opts.skipCounts.byLanguage[issue.repoLanguage] ?? 0; - if (langSkips >= LANGUAGE_SKIP_THRESHOLD) { - penalty += LANGUAGE_SKIP_PENALTY; - } - } - } - - // Mute-preference penalties (explicit user preference). - if (opts.mutedRepos?.includes(issue.repoFullName)) { - penalty += MUTED_REPO_PENALTY; - } - if (issue.repoLanguage && opts.mutedLanguages?.includes(issue.repoLanguage)) { - penalty += MUTED_LANGUAGE_PENALTY; } return baseline - penalty; From c475194bc0ffa8940a77fbfb1cb5612f6db23fae Mon Sep 17 00:00:00 2001 From: shuban Date: Sun, 17 May 2026 19:56:01 +0530 Subject: [PATCH 04/12] Add language-based recommendation penalties --- src/lib/pipeline/recommend.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/lib/pipeline/recommend.ts b/src/lib/pipeline/recommend.ts index 3fd3e1a..082393c 100644 --- a/src/lib/pipeline/recommend.ts +++ b/src/lib/pipeline/recommend.ts @@ -61,6 +61,13 @@ function rankScore(issue: ScoredIssue, opts: RecommendOptions): number { if (repoSkips >= RECOMMENDATION_PENALTIES.REPO_SKIP_THRESHOLD) { penalty += RECOMMENDATION_PENALTIES.REPO_SKIP_PENALTY; } + + if (issue.repoLanguage) { + const langSkips = opts.skipCounts.byLanguage[issue.repoLanguage] ?? 0; + if (langSkips >= RECOMMENDATION_PENALTIES.LANGUAGE_SKIP_THRESHOLD) { + penalty += RECOMMENDATION_PENALTIES.LANGUAGE_SKIP_PENALTY; + } + } } return baseline - penalty; From 25ce81d1f5597ce9416a5eb678d6344ba65e82c8 Mon Sep 17 00:00:00 2001 From: shuban Date: Sun, 17 May 2026 19:57:46 +0530 Subject: [PATCH 05/12] Add muted recommendation preference persistence --- src/app/actions/profile.ts | 40 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/src/app/actions/profile.ts b/src/app/actions/profile.ts index 7c1b916..b491d69 100644 --- a/src/app/actions/profile.ts +++ b/src/app/actions/profile.ts @@ -105,3 +105,43 @@ export async function bootstrapProfile(): Promise> { auditQueued, }); } + +/** + * Updates or clears the user's mute preferences (Issue #91). + * Pass empty arrays to clear preferences. + */ +export async function updateMutePreferences( + mutedRepos: string[], + mutedLanguages: string[], +): Promise> { + const sb = getServerSupabase(); + if (!sb) return err('not_configured', 'auth not configured'); + + const { + data: { user }, + } = await sb.auth.getUser(); + if (!user) return err('not_authenticated', 'sign in first'); + + const rateRes = await rateLimit({ + namespace: 'profile:mute', + key: user.id, + limit: 10, + windowSec: 60, + }); + if (!rateRes.ok) return err('rate_limited', 'slow down', true); + + const service = getServiceSupabase(); + if (!service) return err('not_configured', 'service role not configured'); + + const { error: updateErr } = await service + .from('profiles') + .update({ + muted_repos: mutedRepos, + muted_languages: mutedLanguages, + }) + .eq('id', user.id); + + if (updateErr) return err('persist_failed', updateErr.message); + + return ok(undefined); +} From b5291548572b8760401863249e830cf5b8199b3a Mon Sep 17 00:00:00 2001 From: shuban Date: Sun, 17 May 2026 19:59:06 +0530 Subject: [PATCH 06/12] Add muted recommendation ranking penalties --- src/lib/pipeline/recommend.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/lib/pipeline/recommend.ts b/src/lib/pipeline/recommend.ts index 082393c..ca4b022 100644 --- a/src/lib/pipeline/recommend.ts +++ b/src/lib/pipeline/recommend.ts @@ -70,6 +70,14 @@ function rankScore(issue: ScoredIssue, opts: RecommendOptions): number { } } + // Mute-preference penalties (explicit user preference). + if (opts.mutedRepos?.includes(issue.repoFullName)) { + penalty += RECOMMENDATION_PENALTIES.MUTED_REPO_PENALTY; + } + if (issue.repoLanguage && opts.mutedLanguages?.includes(issue.repoLanguage)) { + penalty += RECOMMENDATION_PENALTIES.MUTED_LANGUAGE_PENALTY; + } + return baseline - penalty; } From 3a9b5f018a0ed4712fbd8beb77037fcfafaa5776 Mon Sep 17 00:00:00 2001 From: shuban Date: Sun, 17 May 2026 20:04:35 +0530 Subject: [PATCH 07/12] Fix ScoredIssue repoLanguage mapping --- src/inngest/functions/recommendations-build.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/inngest/functions/recommendations-build.ts b/src/inngest/functions/recommendations-build.ts index 699e61a..3b3e28e 100644 --- a/src/inngest/functions/recommendations-build.ts +++ b/src/inngest/functions/recommendations-build.ts @@ -102,6 +102,7 @@ export const recommendationsBuild = inngest.createFunction( // Build per-user candidates so languageMatch reflects this user's // primary_language. Pool is shared; only the language flag varies. const candidates: ScoredIssue[] = rawPool.map((i) => ({ + repoLanguage: i.repo_language, id: i.id, repoFullName: i.repo_full_name, number: i.github_issue_number, From b6e6fed3792cd52113f34dc95f62936406f6ef23 Mon Sep 17 00:00:00 2001 From: shuban Date: Sun, 17 May 2026 20:17:06 +0530 Subject: [PATCH 08/12] Clean up recommendation feedback comments --- src/app/actions/profile.ts | 2 +- src/app/actions/recommendations.ts | 2 +- src/inngest/functions/recommendations-build.ts | 3 +-- src/lib/db/schema.ts | 6 +++--- src/lib/pipeline/recommend.ts | 5 ++--- 5 files changed, 8 insertions(+), 10 deletions(-) diff --git a/src/app/actions/profile.ts b/src/app/actions/profile.ts index b491d69..c4e6ea3 100644 --- a/src/app/actions/profile.ts +++ b/src/app/actions/profile.ts @@ -107,7 +107,7 @@ export async function bootstrapProfile(): Promise> { } /** - * Updates or clears the user's mute preferences (Issue #91). + * Updates or clears the user's mute preferences. * Pass empty arrays to clear preferences. */ export async function updateMutePreferences( diff --git a/src/app/actions/recommendations.ts b/src/app/actions/recommendations.ts index f9a0271..da2f170 100644 --- a/src/app/actions/recommendations.ts +++ b/src/app/actions/recommendations.ts @@ -205,7 +205,7 @@ export async function skipRecommendation( if (!rateRes.ok) return err('rate_limited', 'slow down', true); // Atomic skip with the issue id so we know what tier to refill from. - // Persist the optional skip_reason alongside the status change (Issue #91). + // Persist the optional skip_reason alongside the status change. const updatePayload: Record = { status: 'reassigned' }; if (skipReason?.trim()) { updatePayload.skip_reason = skipReason.trim().slice(0, 500); diff --git a/src/inngest/functions/recommendations-build.ts b/src/inngest/functions/recommendations-build.ts index 3b3e28e..e31ac30 100644 --- a/src/inngest/functions/recommendations-build.ts +++ b/src/inngest/functions/recommendations-build.ts @@ -62,8 +62,7 @@ export const recommendationsBuild = inngest.createFunction( }; const userList = (users ?? []) as unknown as UserRow[]; - // Build bulk skip-history lookup map (Issue #91) - // Avoids N+1 queries by fetching all skips in the trailing window at once. + // Fetch skip history in bulk to avoid N+1 queries inside the user loop. const cutoffDate = new Date( Date.now() - SKIP_HISTORY_WINDOW_DAYS * 24 * 60 * 60 * 1000, ).toISOString(); diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index 1913d1f..de287e2 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -43,7 +43,7 @@ export const profiles = pgTable( level: integer('level').notNull().default(0), auditCompleted: boolean('audit_completed').notNull().default(false), timezone: text('timezone'), - // Contributor mute preferences (Issue #91). Arrays of repo full names + // Contributor mute preferences. Arrays of repo full names // and language strings the user has marked "not interested in". // Fully reversible — cleared by passing an empty array. mutedRepos: text('muted_repos').array().default([]).notNull(), @@ -150,8 +150,8 @@ export const recommendations = pgTable( difficulty: text('difficulty', { enum: ['E', 'M', 'H'] }).notNull(), xpReward: integer('xp_reward').notNull(), linkedPrUrl: text('linked_pr_url'), - // Optional free-text reason captured when a user skips a recommendation - // (Issue #91). Never required — omitting preserves existing skip behavior. + // Optional free-text reason captured when a user skips a recommendation. + // Never required — omitting preserves existing skip behavior. skipReason: text('skip_reason'), recommendedAt: timestamp('recommended_at', { withTimezone: true }).notNull().defaultNow(), expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(), diff --git a/src/lib/pipeline/recommend.ts b/src/lib/pipeline/recommend.ts index ca4b022..5419d65 100644 --- a/src/lib/pipeline/recommend.ts +++ b/src/lib/pipeline/recommend.ts @@ -54,8 +54,7 @@ function rankScore(issue: ScoredIssue, opts: RecommendOptions): number { let penalty = 0; - // Apply soft penalty for frequently skipped repositories (Issue #91). - // Keeps the repo surfaceable but pushes it down the candidate list. + // Apply soft penalties for frequently skipped repositories and languages. if (opts.skipCounts) { const repoSkips = opts.skipCounts.byRepo[issue.repoFullName] ?? 0; if (repoSkips >= RECOMMENDATION_PENALTIES.REPO_SKIP_THRESHOLD) { @@ -70,7 +69,7 @@ function rankScore(issue: ScoredIssue, opts: RecommendOptions): number { } } - // Mute-preference penalties (explicit user preference). + // Mute-preference penalties. if (opts.mutedRepos?.includes(issue.repoFullName)) { penalty += RECOMMENDATION_PENALTIES.MUTED_REPO_PENALTY; } From f30f6a66a6f982ab4963726675e3c8b49d058488 Mon Sep 17 00:00:00 2001 From: shuban Date: Sun, 17 May 2026 21:15:30 +0530 Subject: [PATCH 09/12] Fix recommendation test typecheck --- src/lib/pipeline/recommend.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/lib/pipeline/recommend.test.ts b/src/lib/pipeline/recommend.test.ts index fca51e2..8ca21a4 100644 --- a/src/lib/pipeline/recommend.test.ts +++ b/src/lib/pipeline/recommend.test.ts @@ -11,6 +11,7 @@ const issue = (over: Partial): ScoredIssue => ({ repoHealthScore: 60, freshnessHours: 24, languageMatch: false, + repoLanguage: null, ...over, }); From a49b3b4651de83e46586e6e8a9954dd9dd2bc891 Mon Sep 17 00:00:00 2001 From: shuban Date: Sun, 17 May 2026 22:45:19 +0530 Subject: [PATCH 10/12] Address PR review feedback --- src/app/actions/profile.ts | 19 ++---- src/lib/pipeline/recommend.test.ts | 59 +++++++++++++++++++ ...k.sql => 0012_recommendation_feedback.sql} | 0 3 files changed, 63 insertions(+), 15 deletions(-) rename supabase/migrations/{0010_recommendation_feedback.sql => 0012_recommendation_feedback.sql} (100%) diff --git a/src/app/actions/profile.ts b/src/app/actions/profile.ts index 4b53292..7488f0e 100644 --- a/src/app/actions/profile.ts +++ b/src/app/actions/profile.ts @@ -174,18 +174,9 @@ export async function updateMutePreferences( const profileUpdateSchema = z.object({ bio: z.string().max(280, 'Bio must be 280 characters or less').optional().nullable(), - skills: z - .array(z.string()) - .max(10, 'Maximum 10 skills allowed') - .optional() - .nullable(), + skills: z.array(z.string()).max(10, 'Maximum 10 skills allowed').optional().nullable(), - website_url: z - .string() - .url('Please enter a valid URL') - .optional() - .nullable() - .or(z.literal('')), + website_url: z.string().url('Please enter a valid URL').optional().nullable().or(z.literal('')), twitter_handle: z .string() @@ -200,9 +191,7 @@ export type ProfileUpdateData = z.infer; /** * Update user profile information (bio, skills, social links) */ -export async function updateProfile( - data: ProfileUpdateData, -): Promise> { +export async function updateProfile(data: ProfileUpdateData): Promise> { const sb = getServerSupabase(); if (!sb) { @@ -270,4 +259,4 @@ export async function updateProfile( } return ok({ message: 'Profile updated successfully!' }); -} \ No newline at end of file +} diff --git a/src/lib/pipeline/recommend.test.ts b/src/lib/pipeline/recommend.test.ts index 8ca21a4..234b902 100644 --- a/src/lib/pipeline/recommend.test.ts +++ b/src/lib/pipeline/recommend.test.ts @@ -125,4 +125,63 @@ describe('filterAndRank', () => { }); expect(result).toEqual([]); }); + + it('applies repository skip down-ranking', () => { + const issues = [ + issue({ id: 1, repoFullName: 'skipped/repo', difficulty: 'E', repoHealthScore: 80 }), + issue({ id: 2, repoFullName: 'fresh/repo', difficulty: 'E', repoHealthScore: 80 }), + ]; + const result = filterAndRank(issues, { + level: 0, + excludeIssueIds: new Set(), + skipCounts: { + byRepo: { 'skipped/repo': 2 }, + byLanguage: {}, + }, + }); + // id 2 (fresh) should rank above id 1 (skipped) due to penalty + expect(result.map((r) => r.id)).toEqual([2, 1]); + }); + + it('applies language skip down-ranking', () => { + const issues = [ + issue({ id: 1, repoLanguage: 'TypeScript', difficulty: 'E', repoHealthScore: 80 }), + issue({ id: 2, repoLanguage: 'Python', difficulty: 'E', repoHealthScore: 80 }), + ]; + const result = filterAndRank(issues, { + level: 0, + excludeIssueIds: new Set(), + skipCounts: { + byRepo: {}, + byLanguage: { TypeScript: 3 }, + }, + }); + expect(result.map((r) => r.id)).toEqual([2, 1]); + }); + + it('applies muted repository down-ranking', () => { + const issues = [ + issue({ id: 1, repoFullName: 'muted/repo', difficulty: 'E', repoHealthScore: 80 }), + issue({ id: 2, repoFullName: 'normal/repo', difficulty: 'E', repoHealthScore: 80 }), + ]; + const result = filterAndRank(issues, { + level: 0, + excludeIssueIds: new Set(), + mutedRepos: ['muted/repo'], + }); + expect(result.map((r) => r.id)).toEqual([2, 1]); + }); + + it('applies muted language down-ranking', () => { + const issues = [ + issue({ id: 1, repoLanguage: 'Java', difficulty: 'E', repoHealthScore: 80 }), + issue({ id: 2, repoLanguage: 'Rust', difficulty: 'E', repoHealthScore: 80 }), + ]; + const result = filterAndRank(issues, { + level: 0, + excludeIssueIds: new Set(), + mutedLanguages: ['Java'], + }); + expect(result.map((r) => r.id)).toEqual([2, 1]); + }); }); diff --git a/supabase/migrations/0010_recommendation_feedback.sql b/supabase/migrations/0012_recommendation_feedback.sql similarity index 100% rename from supabase/migrations/0010_recommendation_feedback.sql rename to supabase/migrations/0012_recommendation_feedback.sql From baf749885a88807b65e53212cccb853e71bf5992 Mon Sep 17 00:00:00 2001 From: shuban Date: Mon, 18 May 2026 19:05:42 +0530 Subject: [PATCH 11/12] chore: retrigger ci From b809bb8c9d0ec70c8221fc4753f130cfa80dc408 Mon Sep 17 00:00:00 2001 From: shuban Date: Tue, 19 May 2026 16:34:49 +0530 Subject: [PATCH 12/12] Fix recommendation build test mocks --- .../functions/recommendations-build.test.ts | 301 ++++++++++++++++++ 1 file changed, 301 insertions(+) create mode 100644 src/inngest/functions/recommendations-build.test.ts diff --git a/src/inngest/functions/recommendations-build.test.ts b/src/inngest/functions/recommendations-build.test.ts new file mode 100644 index 0000000..27ab868 --- /dev/null +++ b/src/inngest/functions/recommendations-build.test.ts @@ -0,0 +1,301 @@ +import { describe, it, expect, vi, beforeEach, beforeAll } from 'vitest'; + +/** + * Unit tests for recommendations-build Inngest function. + * + * The production function issues these Supabase queries in order: + * 1. from('issues').select(...).eq('state','open').order(...).limit() → candidate pool + * 2. from('github_installations').select(...).is(...).not(...) → active users + * 3. from('recommendations').select(...).eq('status','reassigned').gte() → skip history + * 4. per-user: from('recommendations').select('issue_id').eq('user_id') → seen ids + * 5. from('recommendations').upsert(...) → write picks + * + * The mock below must handle all five shapes through a single `from()` factory. + */ + +// --------------------------------------------------------------------------- +// Mock: inngest client +// +// createFunction() returns an opaque InngestFunction object in production. +// Here we capture the raw handler at mock-time so tests can invoke it directly +// via runHandler(), bypassing the Inngest runtime entirely. +// --------------------------------------------------------------------------- +type StepCtx = { step: { run: (name: string, fn: () => unknown) => unknown } }; +type RawHandler = (ctx: StepCtx) => unknown; + +let capturedHandler: RawHandler | null = null; + +vi.mock('../client', () => ({ + inngest: { + createFunction: (_meta: unknown, _trigger: unknown, handler: RawHandler) => { + capturedHandler = handler; + // Return a non-callable sentinel so the module exports something. + return { __isMockedInngestFn: true }; + }, + }, +})); + +/** Invoke the captured handler with a step.run() that calls its callback directly. */ +function runHandler(): Promise { + if (!capturedHandler) + throw new Error('Handler not captured — import recommendations-build first'); + return Promise.resolve( + capturedHandler({ + step: { + run: (_name: string, fn: () => unknown) => fn(), + }, + }), + ); +} + +// --------------------------------------------------------------------------- +// Mock: getServiceSupabase +// +// The builder is called multiple times on the same `from()` factory. +// We need to distinguish call sites by the table name and/or which terminal +// method is eventually invoked. We achieve this by tracking a simple call +// counter that resets in beforeEach. +// --------------------------------------------------------------------------- + +// Terminal mocks — reassigned in beforeEach so each test can override them. +const mockIssuesLimit = vi.fn(); +const mockUsersNot = vi.fn(); +const mockSkipHistoryGte = vi.fn(); // from('recommendations').eq('status','reassigned').gte() +const mockSeenEq = vi.fn(); // from('recommendations').eq('user_id', ...) (per-user) +const mockUpsert = vi.fn(); + +/** + * Tiny state machine: `from()` is called once per query; we use a counter + * so the nth call returns the nth builder shape. + * + * Call order inside build-all step: + * 0 → issues pool (.select.eq.order.limit) + * 1 → users (.select.is.not) + * 2 → skip history (.select.eq.gte) + * 3 → seen ids (.select.eq) ← per-user, may repeat + * 4+ → upsert (.upsert) ← per-user, may repeat + */ +let fromCallCount = 0; + +vi.mock('@/lib/supabase/service', () => ({ + getServiceSupabase: () => ({ + from: (_table: string) => { + const callIndex = fromCallCount++; + + if (callIndex === 0) { + // issues pool: .select().eq().order().limit() + return { + select: () => ({ + eq: () => ({ + order: () => ({ + limit: mockIssuesLimit, + }), + }), + }), + }; + } + + if (callIndex === 1) { + // github_installations: .select().is().not() + return { + select: () => ({ + is: () => ({ + not: mockUsersNot, + }), + }), + }; + } + + if (callIndex === 2) { + // skip history: .select().eq('status','reassigned').gte('recommended_at', ...) + return { + select: () => ({ + eq: () => ({ + gte: mockSkipHistoryGte, + }), + }), + }; + } + + // callIndex >= 3 alternates between seen-ids selects and upserts + // per-user seen ids: .select('issue_id').eq('user_id', ...) + // upsert: .upsert(rows, opts) + return { + select: () => ({ + eq: mockSeenEq, + }), + upsert: mockUpsert, + }; + }, + }), +})); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('recommendations-build', () => { + beforeAll(async () => { + await import('./recommendations-build'); + }); + + beforeEach(() => { + vi.clearAllMocks(); + fromCallCount = 0; + + // Default: empty pool → early-exit path (users: 0, inserted: 0) + mockIssuesLimit.mockResolvedValue({ data: [] }); + mockUsersNot.mockResolvedValue({ data: [] }); + mockSkipHistoryGte.mockResolvedValue({ data: [] }); + mockSeenEq.mockResolvedValue({ data: [] }); + mockUpsert.mockResolvedValue({ error: null }); + }); + + it('returns { users: 0, inserted: 0 } when the issue pool is empty', async () => { + mockIssuesLimit.mockResolvedValue({ data: [] }); + + const result = await runHandler(); + + expect(result).toEqual({ users: 0, inserted: 0 }); + }); + + it('returns { users: 0, inserted: 0 } when there are no active users', async () => { + mockIssuesLimit.mockResolvedValue({ + data: [ + { + id: 1, + repo_full_name: 'a/b', + github_issue_number: 10, + title: 'Fix bug', + difficulty: 'E', + xp_reward: 100, + repo_health_score: 80, + repo_language: 'TypeScript', + scored_at: new Date().toISOString(), + state: 'open', + }, + ], + }); + mockUsersNot.mockResolvedValue({ data: [] }); + + const result = await runHandler(); + + expect(result).toEqual({ users: 0, inserted: 0 }); + }); + + it('queries skip history with gte() and does not throw', async () => { + const issue = { + id: 1, + repo_full_name: 'a/b', + github_issue_number: 10, + title: 'Fix bug', + difficulty: 'E', + xp_reward: 100, + repo_health_score: 80, + repo_language: 'TypeScript', + scored_at: new Date().toISOString(), + state: 'open', + }; + + mockIssuesLimit.mockResolvedValue({ data: [issue] }); + mockUsersNot.mockResolvedValue({ + data: [ + { + user_id: 'user-1', + profiles: { level: 0, primary_language: 'TypeScript' }, + }, + ], + }); + // skip history returns empty — no penalty applied + mockSkipHistoryGte.mockResolvedValue({ data: [] }); + // user has no previously seen issues + mockSeenEq.mockResolvedValue({ data: [] }); + mockUpsert.mockResolvedValue({ error: null }); + + const result = (await runHandler()) as { users: number; inserted: number }; + + expect(mockSkipHistoryGte).toHaveBeenCalledOnce(); + expect(result.users).toBe(1); + expect(result.inserted).toBeGreaterThanOrEqual(0); + }); + + it('applies skip history penalty — skipped repo ranks lower', async () => { + const skippedIssue = { + id: 1, + repo_full_name: 'skipped/repo', + github_issue_number: 1, + title: 'Old issue', + difficulty: 'E', + xp_reward: 100, + repo_health_score: 80, + repo_language: 'TypeScript', + scored_at: new Date().toISOString(), + state: 'open', + }; + const freshIssue = { + id: 2, + repo_full_name: 'fresh/repo', + github_issue_number: 2, + title: 'Fresh issue', + difficulty: 'E', + xp_reward: 100, + repo_health_score: 80, + repo_language: 'TypeScript', + scored_at: new Date().toISOString(), + state: 'open', + }; + + mockIssuesLimit.mockResolvedValue({ data: [skippedIssue, freshIssue] }); + mockUsersNot.mockResolvedValue({ + data: [{ user_id: 'user-1', profiles: { level: 0, primary_language: null } }], + }); + // Two skip-history rows for 'skipped/repo' → triggers repo penalty + mockSkipHistoryGte.mockResolvedValue({ + data: [ + { + user_id: 'user-1', + issues: { repo_full_name: 'skipped/repo', repo_language: 'TypeScript' }, + }, + { + user_id: 'user-1', + issues: { repo_full_name: 'skipped/repo', repo_language: 'TypeScript' }, + }, + ], + }); + mockSeenEq.mockResolvedValue({ data: [] }); + mockUpsert.mockResolvedValue({ error: null }); + + // Should not throw — penalty logic runs without error + await expect(runHandler()).resolves.not.toThrow(); + + expect(mockSkipHistoryGte).toHaveBeenCalledOnce(); + }); + + it('does not insert recommendations when upsert errors', async () => { + const issue = { + id: 1, + repo_full_name: 'a/b', + github_issue_number: 1, + title: 'Bug', + difficulty: 'E', + xp_reward: 100, + repo_health_score: 80, + repo_language: null, + scored_at: new Date().toISOString(), + state: 'open', + }; + + mockIssuesLimit.mockResolvedValue({ data: [issue] }); + mockUsersNot.mockResolvedValue({ + data: [{ user_id: 'user-1', profiles: { level: 0, primary_language: null } }], + }); + mockSkipHistoryGte.mockResolvedValue({ data: [] }); + mockSeenEq.mockResolvedValue({ data: [] }); + mockUpsert.mockResolvedValue({ error: new Error('db error') }); + + const result = (await runHandler()) as { users: number; inserted: number }; + + expect(result.users).toBe(1); + expect(result.inserted).toBe(0); // error → not counted + }); +});