From 7efa3f9038886565edaf008bb851909928fb4aa8 Mon Sep 17 00:00:00 2001 From: Ayush Date: Mon, 25 May 2026 13:38:37 +0530 Subject: [PATCH 1/3] feat: mentor verification flow for L2+ --- src/app/(app)/issues/my-work-section.tsx | 28 ++++++- src/app/(app)/issues/verify-button.tsx | 41 +++++++++++ src/app/(app)/maintainer/page.tsx | 10 ++- src/app/actions/mentor.ts | 93 ++++++++++++++++++++++++ 4 files changed, 170 insertions(+), 2 deletions(-) create mode 100644 src/app/(app)/issues/verify-button.tsx create mode 100644 src/app/actions/mentor.ts diff --git a/src/app/(app)/issues/my-work-section.tsx b/src/app/(app)/issues/my-work-section.tsx index ebfbf3d..5a2f0ed 100644 --- a/src/app/(app)/issues/my-work-section.tsx +++ b/src/app/(app)/issues/my-work-section.tsx @@ -3,6 +3,7 @@ import { useState, useTransition } from 'react'; import { ExternalLink, Pencil, X } from 'lucide-react'; import { linkPrToRec, unlinkPrFromRec, unclaimRecommendation } from '@/app/actions/recommendations'; +import { VerifyButton } from './verify-button'; export type LinkedRec = { id: number; @@ -11,6 +12,7 @@ export type LinkedRec = { xp_reward: number; issue_id: number; issue: { title: string; repo_full_name: string; url: string } | null; + pr: { id: number; author_user_id: string | null; mentor_verified: boolean; state: string } | null; }; const STATUS_CLS: Record = { @@ -20,7 +22,13 @@ const STATUS_CLS: Record = { reassigned: 'border-zinc-700 text-zinc-500', }; -export function MyWorkSection({ initialRecs }: { initialRecs: LinkedRec[] }) { +export function MyWorkSection({ + initialRecs, + currentUser, +}: { + initialRecs: LinkedRec[]; + currentUser: { id: string; level: number }; +}) { const [recs, setRecs] = useState(initialRecs); function onUnlink(id: number) { @@ -48,6 +56,7 @@ export function MyWorkSection({ initialRecs }: { initialRecs: LinkedRec[] }) { onUnlink(rec.id)} onUnclaim={() => onUnclaim(rec.id)} onRelink={(url) => onRelinkd(rec.id, url)} @@ -60,11 +69,13 @@ export function MyWorkSection({ initialRecs }: { initialRecs: LinkedRec[] }) { function WorkItem({ rec, + currentUser, onUnlink, onUnclaim, onRelink, }: { rec: LinkedRec; + currentUser: { id: string; level: number }; onUnlink: () => void; onUnclaim: () => void; onRelink: (url: string) => void; @@ -178,6 +189,21 @@ function WorkItem({ > EDIT + + {rec.pr && + !rec.pr.mentor_verified && + rec.pr.author_user_id !== currentUser.id && + currentUser.level >= 2 && + rec.pr.state === 'open' && ( +
+ +
+ )} + {rec.pr?.mentor_verified && ( + + Verified + + )} )} diff --git a/src/app/(app)/issues/verify-button.tsx b/src/app/(app)/issues/verify-button.tsx new file mode 100644 index 0000000..a21f931 --- /dev/null +++ b/src/app/(app)/issues/verify-button.tsx @@ -0,0 +1,41 @@ +'use client'; + +import { useState, useTransition } from 'react'; +import { verifyPrAction } from '@/app/actions/mentor'; +import { CheckCircle } from 'lucide-react'; + +export function VerifyButton({ prId, prUrl }: { prId?: number; prUrl?: string }) { + const [pending, startTransition] = useTransition(); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + + function handleVerify() { + setError(null); + setSuccess(null); + startTransition(async () => { + const res = await verifyPrAction({ prId, prUrl }); + if (res.ok) { + setSuccess(`Verified! +${res.data.xpAwarded} XP`); + } else { + setError(res.error.message); + } + }); + } + + return ( +
+ + {error && {error}} + {success && !pending && ( + {success} + )} +
+ ); +} diff --git a/src/app/(app)/maintainer/page.tsx b/src/app/(app)/maintainer/page.tsx index 4e5987c..7305519 100644 --- a/src/app/(app)/maintainer/page.tsx +++ b/src/app/(app)/maintainer/page.tsx @@ -17,6 +17,7 @@ import { import { isOk } from '@/lib/result'; import RefreshButton from './refresh-button'; import CiStatusBadge from './ci-status-badge'; +import { VerifyButton } from '../issues/verify-button'; export const dynamic = 'force-dynamic'; @@ -275,7 +276,7 @@ export default async function MaintainerPage({ {relativeTime(r.githubUpdatedAt)} - {r.mentorVerified && ( + {r.mentorVerified ? ( ✓ Mentor verified {r.mentorReviewerHandle && ( @@ -285,6 +286,13 @@ export default async function MaintainerPage({ )} + ) : ( + r.authorUserId !== user.id && + r.state === 'open' && ( +
+ +
+ ) )} ))} diff --git a/src/app/actions/mentor.ts b/src/app/actions/mentor.ts new file mode 100644 index 0000000..257a39b --- /dev/null +++ b/src/app/actions/mentor.ts @@ -0,0 +1,93 @@ +'use server'; + +import { getServerSupabase } from '@/lib/supabase/server'; +import { getServiceSupabase } from '@/lib/supabase/service'; +import { XP_SOURCE, XP_REWARDS, refIds } from '@/lib/xp/sources'; +import { insertXpEvent } from '@/lib/xp/events'; +import { Result, ok, err } from '@/lib/result'; +import { revalidatePath } from 'next/cache'; + +export async function verifyPrAction(opts: { + prId?: number; + prUrl?: 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 service = getServiceSupabase(); + if (!service) return err('not_configured', 'service missing'); + + // Verify user is L2+ + const { data: mentor } = await service + .from('profiles') + .select('id, level, github_handle') + .eq('id', user.id) + .single(); + + if (!mentor || mentor.level < 2) return err('not_authorised', 'Only L2+ users can verify PRs'); + + // Resolve PR + let prQuery = service + .from('pull_requests') + .select('id, author_user_id, repo_full_name, number, mentor_verified, author_login'); + if (opts.prId) { + prQuery = prQuery.eq('id', opts.prId); + } else if (opts.prUrl) { + prQuery = prQuery.eq('url', opts.prUrl); + } else { + return err('invalid_input', 'Must provide prId or prUrl'); + } + + const { data: pr } = await prQuery.maybeSingle(); + if (!pr) return err('not_found', 'PR not found'); + if (pr.mentor_verified) return err('already_verified', 'This PR is already verified'); + if (pr.author_user_id === user.id) + return err('cannot_verify_own', 'Mentors cannot verify their own PRs'); + + // Mark PR verified + const { error: updateErr } = await service + .from('pull_requests') + .update({ + mentor_verified: true, + mentor_reviewer_id: user.id, + mentor_review_at: new Date().toISOString(), + }) + .eq('id', pr.id); + + if (updateErr) return err('persist_failed', updateErr.message); + + // Award XP + const { data: mentee } = await service + .from('profiles') + .select('level') + .eq('id', pr.author_user_id) + .maybeSingle(); + + const menteeLevel = mentee?.level ?? 0; + const isMentor = mentor.level > menteeLevel; + + let xp = XP_REWARDS.HELP_REVIEW_BASE; + if (isMentor) xp += XP_REWARDS.HELP_REVIEW_MENTOR_BONUS; + + // Exclude speed bonus for manual verification + const inserted = await insertXpEvent({ + userId: user.id, + source: XP_SOURCE.HELP_REVIEW, + refType: 'review', + // Ensure refId is unique per PR and mentor + refId: refIds.helpReview(pr.id, mentor.github_handle), + repo: pr.repo_full_name, + xpDelta: xp, + metadata: { isMentor, menteeLevel, manual_verify: true }, + }); + + revalidatePath('/maintainer'); + revalidatePath('/issues'); + + return ok({ xpAwarded: inserted ? xp : 0 }); +} From 37c67c26919f831afe40501d05d715c63625538f Mon Sep 17 00:00:00 2001 From: Ayush Date: Mon, 25 May 2026 13:41:15 +0530 Subject: [PATCH 2/3] feat: mentor verification flow for L2+ --- src/app/(app)/issues/page.tsx | 42 ++++++++++++++++++++++++++++++----- src/lib/maintainer/queue.ts | 1 + 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/src/app/(app)/issues/page.tsx b/src/app/(app)/issues/page.tsx index c48fcdb..2621953 100644 --- a/src/app/(app)/issues/page.tsx +++ b/src/app/(app)/issues/page.tsx @@ -41,6 +41,16 @@ export default async function IssuesPage({ searchParams }: { searchParams: Searc const service = getServiceSupabase(); + let currentUserLevel = 0; + if (service) { + const { data: profile } = await service + .from('profiles') + .select('level') + .eq('id', user.id) + .single(); + currentUserLevel = profile?.level ?? 0; + } + // Step 1: fetch recs with linked PRs const linkedRecsRaw = service ? (( @@ -55,15 +65,31 @@ export default async function IssuesPage({ searchParams }: { searchParams: Searc // Step 2: fetch issue details separately (avoids FK detection issues) const issueMap = new Map(); + const prMap = new Map< + string, + { id: number; author_user_id: string | null; mentor_verified: boolean; state: string } + >(); + if (linkedRecsRaw.length > 0 && service) { const issueIds = linkedRecsRaw.map((r: any) => r.issue_id).filter(Boolean); - const { data: issuesData } = await service - .from('issues') - .select('id, title, repo_full_name, url') - .in('id', issueIds); + const prUrls = linkedRecsRaw.map((r: any) => r.linked_pr_url).filter(Boolean); + + const [{ data: issuesData }, { data: prsData }] = await Promise.all([ + service.from('issues').select('id, title, repo_full_name, url').in('id', issueIds), + prUrls.length > 0 + ? service + .from('pull_requests') + .select('id, url, author_user_id, mentor_verified, state') + .in('url', prUrls) + : { data: [] }, + ]); + for (const issue of issuesData ?? []) { issueMap.set(issue.id, issue); } + for (const pr of prsData ?? []) { + prMap.set(pr.url, pr); + } } const linkedRecs: LinkedRec[] = linkedRecsRaw.map((r: any) => ({ @@ -73,6 +99,7 @@ export default async function IssuesPage({ searchParams }: { searchParams: Searc xp_reward: r.xp_reward as number, issue_id: r.issue_id as number, issue: issueMap.get(r.issue_id) ?? null, + pr: prMap.get(r.linked_pr_url as string) ?? null, })); const [pageResult, repoResult] = await Promise.all([getIssuesPage(filters), getRepoOptions()]); @@ -93,7 +120,12 @@ export default async function IssuesPage({ searchParams }: { searchParams: Searc

Browse Issues

- {linkedRecs.length > 0 && } + {linkedRecs.length > 0 && ( + + )} diff --git a/src/lib/maintainer/queue.ts b/src/lib/maintainer/queue.ts index 66ef6f6..84a45a1 100644 --- a/src/lib/maintainer/queue.ts +++ b/src/lib/maintainer/queue.ts @@ -18,6 +18,7 @@ export type MaintainerPrRow = { state: PrState; draft: boolean; authorLogin: string; + authorUserId: string | null; authorLevel: number | null; // null = not on MergeShip authorXp: number | null; authorMergedPrs: number | null; From b9f8878ade31ff021f7784753ff75903e3ab2887 Mon Sep 17 00:00:00 2001 From: Ayush Date: Mon, 25 May 2026 15:22:52 +0530 Subject: [PATCH 3/3] feat: mentor verification flow for L2+ --- src/app/actions/maintainer.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app/actions/maintainer.ts b/src/app/actions/maintainer.ts index de0204c..bd1f085 100644 --- a/src/app/actions/maintainer.ts +++ b/src/app/actions/maintainer.ts @@ -212,6 +212,7 @@ export async function getMaintainerPrQueue(args: { state: r.state as 'open' | 'closed' | 'merged', draft: r.draft, authorLogin: r.author_login, + authorUserId: r.author_user_id, authorLevel: author?.level ?? null, authorXp: author?.xp ?? null, authorMergedPrs: author?.mergedPrs ?? null,