diff --git a/src/app/(app)/issues/my-work-section.tsx b/src/app/(app)/issues/my-work-section.tsx index ebfbf3d..4f56e80 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 { MentorVerifyButton } from '../maintainer/mentor-verify-button'; export type LinkedRec = { id: number; @@ -11,6 +12,9 @@ export type LinkedRec = { xp_reward: number; issue_id: number; issue: { title: string; repo_full_name: string; url: string } | null; + pullRequestId: number | null; + pullRequestAuthorUserId: string | null; + mentorVerified: boolean; }; const STATUS_CLS: Record = { @@ -20,7 +24,15 @@ const STATUS_CLS: Record = { reassigned: 'border-zinc-700 text-zinc-500', }; -export function MyWorkSection({ initialRecs }: { initialRecs: LinkedRec[] }) { +export function MyWorkSection({ + initialRecs, + currentUserId, + canVerify, +}: { + initialRecs: LinkedRec[]; + currentUserId: string; + canVerify: boolean; +}) { const [recs, setRecs] = useState(initialRecs); function onUnlink(id: number) { @@ -51,6 +63,8 @@ export function MyWorkSection({ initialRecs }: { initialRecs: LinkedRec[] }) { onUnlink={() => onUnlink(rec.id)} onUnclaim={() => onUnclaim(rec.id)} onRelink={(url) => onRelinkd(rec.id, url)} + currentUserId={currentUserId} + canVerify={canVerify} /> ))} @@ -63,11 +77,15 @@ function WorkItem({ onUnlink, onUnclaim, onRelink, + currentUserId, + canVerify, }: { rec: LinkedRec; onUnlink: () => void; onUnclaim: () => void; onRelink: (url: string) => void; + currentUserId: string; + canVerify: boolean; }) { const [editing, setEditing] = useState(false); const [editUrl, setEditUrl] = useState(rec.linked_pr_url); @@ -185,22 +203,30 @@ function WorkItem({
{error}
)} -
- - +
+
+ + +
+ {canVerify && + rec.pullRequestId !== null && + !rec.mentorVerified && + rec.pullRequestAuthorUserId !== currentUserId && ( + + )}
); diff --git a/src/app/(app)/issues/page.tsx b/src/app/(app)/issues/page.tsx index c48fcdb..f119f8c 100644 --- a/src/app/(app)/issues/page.tsx +++ b/src/app/(app)/issues/page.tsx @@ -40,6 +40,10 @@ export default async function IssuesPage({ searchParams }: { searchParams: Searc }; const service = getServiceSupabase(); + const { data: profile } = service + ? await service.from('profiles').select('level').eq('id', user.id).maybeSingle() + : { data: null }; + const canVerify = (profile?.level ?? 0) >= 2; // Step 1: fetch recs with linked PRs const linkedRecsRaw = service @@ -66,14 +70,39 @@ export default async function IssuesPage({ searchParams }: { searchParams: Searc } } - const linkedRecs: LinkedRec[] = linkedRecsRaw.map((r: any) => ({ - id: r.id, - linked_pr_url: r.linked_pr_url as string, - status: r.status as string, - xp_reward: r.xp_reward as number, - issue_id: r.issue_id as number, - issue: issueMap.get(r.issue_id) ?? null, - })); + const prMap = new Map< + string, + { id: number; author_user_id: string | null; mentor_verified: boolean } + >(); + if (linkedRecsRaw.length > 0 && service) { + const prUrls = linkedRecsRaw.map((r: any) => r.linked_pr_url).filter(Boolean); + const { data: prsData } = await service + .from('pull_requests') + .select('id, url, author_user_id, mentor_verified') + .in('url', prUrls); + for (const pr of prsData ?? []) { + prMap.set(pr.url, { + id: pr.id, + author_user_id: pr.author_user_id, + mentor_verified: pr.mentor_verified, + }); + } + } + + const linkedRecs: LinkedRec[] = linkedRecsRaw.map((r: any) => { + const pr = prMap.get(r.linked_pr_url); + return { + id: r.id, + linked_pr_url: r.linked_pr_url as string, + status: r.status as string, + xp_reward: r.xp_reward as number, + issue_id: r.issue_id as number, + issue: issueMap.get(r.issue_id) ?? null, + pullRequestId: pr?.id ?? null, + pullRequestAuthorUserId: pr?.author_user_id ?? null, + mentorVerified: pr?.mentor_verified ?? false, + }; + }); const [pageResult, repoResult] = await Promise.all([getIssuesPage(filters), getRepoOptions()]); @@ -93,7 +122,13 @@ export default async function IssuesPage({ searchParams }: { searchParams: Searc

Browse Issues

- {linkedRecs.length > 0 && } + {linkedRecs.length > 0 && ( + + )} diff --git a/src/app/(app)/maintainer/mentor-verify-button.tsx b/src/app/(app)/maintainer/mentor-verify-button.tsx new file mode 100644 index 0000000..b14f1be --- /dev/null +++ b/src/app/(app)/maintainer/mentor-verify-button.tsx @@ -0,0 +1,48 @@ +'use client'; + +import { useState, useTransition } from 'react'; +import { useRouter } from 'next/navigation'; +import { CheckCircle2 } from 'lucide-react'; +import { verifyMentorPr } from '@/app/actions/maintainer'; + +export function MentorVerifyButton({ prId }: { prId: number }) { + const router = useRouter(); + const [pending, startTransition] = useTransition(); + const [error, setError] = useState(null); + const [verified, setVerified] = useState(false); + + function onVerify() { + setError(null); + startTransition(async () => { + const res = await verifyMentorPr(prId); + if (res.ok) { + setVerified(true); + router.refresh(); + } else { + setError(res.error.message); + } + }); + } + + return ( +
+ {verified ? ( + + + Verified + + ) : ( + + )} + {error && {error}} +
+ ); +} diff --git a/src/app/(app)/maintainer/page.tsx b/src/app/(app)/maintainer/page.tsx index 4325a1a..dcb9b5c 100644 --- a/src/app/(app)/maintainer/page.tsx +++ b/src/app/(app)/maintainer/page.tsx @@ -10,6 +10,7 @@ import { } from '@/app/actions/maintainer'; import { isOk } from '@/lib/result'; import RefreshButton from './refresh-button'; +import { MentorVerifyButton } from './mentor-verify-button'; export const dynamic = 'force-dynamic'; @@ -67,6 +68,12 @@ export default async function MaintainerPage({ filters, }); const rows: MaintainerPrRow[] = isOk(queueRes) ? queueRes.data.rows : []; + const { data: profile } = await sb + .from('profiles') + .select('level') + .eq('id', user.id) + .maybeSingle(); + const canVerify = (profile?.level ?? 0) >= 2; return (
@@ -185,7 +192,7 @@ export default async function MaintainerPage({ {relativeTime(r.githubUpdatedAt)}
- {r.mentorVerified && ( + {r.mentorVerified ? ( ✓ Mentor verified {r.mentorReviewerHandle && ( @@ -195,6 +202,10 @@ export default async function MaintainerPage({ )} + ) : ( + canVerify && + r.state === 'open' && + r.authorUserId !== user.id && )} ))} diff --git a/src/app/actions/maintainer.ts b/src/app/actions/maintainer.ts index e1cda3b..0834c54 100644 --- a/src/app/actions/maintainer.ts +++ b/src/app/actions/maintainer.ts @@ -22,6 +22,9 @@ import { type CommunityKind, } from '@/lib/maintainer/community'; import { inngest } from '@/inngest/client'; +import { insertXpEvent } from '@/lib/xp/events'; +import { applyCap } from '@/lib/xp/caps'; +import { XP_REWARDS, XP_SOURCE, refIds } from '@/lib/xp/sources'; import { classifyTriage, type IssueTriageBucket } from '@/lib/maintainer/issue-triage'; @@ -190,6 +193,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, @@ -363,6 +367,97 @@ export async function refreshMaintainerBackfill( return ok({ ok: true }); } +export async function verifyMentorPr(prId: number): Promise> { + const sb = getServerSupabase(); + if (!sb) return err('not_configured', 'auth not configured'); + const service = getServiceSupabase(); + if (!service) return err('not_configured', 'service role missing'); + + const { + data: { user }, + } = await sb.auth.getUser(); + if (!user) return err('not_authenticated', 'sign in first'); + + const limited = await rateLimit({ + namespace: 'mentor:verify', + key: user.id, + limit: 20, + windowSec: 60, + }); + if (!limited.ok) return err('rate_limited', 'slow down', true); + + const { data: reviewer } = await service + .from('profiles') + .select('id, github_handle, level') + .eq('id', user.id) + .maybeSingle(); + if (!reviewer) return err('not_found', 'profile not found'); + if (!reviewer.github_handle) return err('profile_incomplete', 'GitHub handle missing'); + if ((reviewer.level ?? 0) < 2) { + return err('not_authorised', 'L2+ mentors can verify PRs'); + } + + const { data: pr } = await service + .from('pull_requests') + .select('id, repo_full_name, number, state, author_user_id, mentor_verified') + .eq('id', prId) + .maybeSingle(); + if (!pr) return err('not_found', 'PR not found'); + if (pr.state !== 'open') return err('invalid_state', 'only open PRs can be verified'); + if (pr.mentor_verified) return err('already_verified', 'PR is already mentor verified'); + if (!pr.author_user_id) return err('author_missing', 'PR author is not linked to a profile'); + if (pr.author_user_id === reviewer.id) { + return err('self_verify_blocked', 'you cannot verify your own PR'); + } + + const dayStartIso = new Date(new Date().toISOString().slice(0, 10) + 'T00:00:00Z').toISOString(); + const { count: todaysReviewCount } = await service + .from('xp_events') + .select('id', { count: 'exact', head: true }) + .eq('user_id', reviewer.id) + .eq('source', XP_SOURCE.REVIEW) + .gte('created_at', dayStartIso); + + const cap = applyCap('review', todaysReviewCount ?? 0, XP_REWARDS.REVIEW); + if (!cap.allowed) return err('daily_review_cap_reached', 'daily review XP cap reached'); + + const now = new Date().toISOString(); + const { data: updated, error: updateError } = await service + .from('pull_requests') + .update({ + mentor_verified: true, + mentor_reviewer_id: reviewer.id, + mentor_review_at: now, + }) + .eq('id', pr.id) + .eq('mentor_verified', false) + .select('id') + .maybeSingle(); + if (updateError) return err('db_error', updateError.message); + if (!updated) return err('already_verified', 'PR is already mentor verified'); + + const inserted = await insertXpEvent({ + userId: reviewer.id, + source: XP_SOURCE.REVIEW, + refType: 'mentor_verify', + refId: refIds.review(pr.repo_full_name, pr.number, reviewer.github_handle), + repo: pr.repo_full_name, + xpDelta: cap.xpDelta, + metadata: { + manualMentorVerify: true, + prId: pr.id, + authorUserId: pr.author_user_id, + }, + }); + + await inngest.send({ + name: 'mentor/post-comment', + data: { prId: pr.id, reviewerId: reviewer.id }, + }); + + return ok({ xpAwarded: inserted ? cap.xpDelta : 0 }); +} + // ---------------- community links ---------------- export type CommunityLink = { diff --git a/src/lib/maintainer/queue.ts b/src/lib/maintainer/queue.ts index 0e43594..a0b8ef4 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;