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
28 changes: 27 additions & 1 deletion src/app/(app)/issues/my-work-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<string, string> = {
Expand All @@ -20,7 +22,13 @@ const STATUS_CLS: Record<string, string> = {
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) {
Expand Down Expand Up @@ -48,6 +56,7 @@ export function MyWorkSection({ initialRecs }: { initialRecs: LinkedRec[] }) {
<WorkItem
key={rec.id}
rec={rec}
currentUser={currentUser}
onUnlink={() => onUnlink(rec.id)}
onUnclaim={() => onUnclaim(rec.id)}
onRelink={(url) => onRelinkd(rec.id, url)}
Expand All @@ -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;
Expand Down Expand Up @@ -178,6 +189,21 @@ function WorkItem({
>
<Pencil className="h-3 w-3" /> EDIT
</button>

{rec.pr &&
!rec.pr.mentor_verified &&
rec.pr.author_user_id !== currentUser.id &&
currentUser.level >= 2 &&
rec.pr.state === 'open' && (
<div className="ml-2">
<VerifyButton prId={rec.pr.id} />
</div>
)}
{rec.pr?.mentor_verified && (
<span className="ml-2 rounded-full bg-emerald-900/40 px-2 py-0.5 text-[10px] uppercase tracking-widest text-emerald-400 ring-1 ring-emerald-700/40">
Verified
</span>
)}
</div>
)}

Expand Down
42 changes: 37 additions & 5 deletions src/app/(app)/issues/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
? ((
Expand All @@ -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<number, { title: string; repo_full_name: string; url: string }>();
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) => ({
Expand All @@ -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()]);
Expand All @@ -93,7 +120,12 @@ export default async function IssuesPage({ searchParams }: { searchParams: Searc
<h1 className="font-serif text-4xl text-white">Browse Issues</h1>
</header>

{linkedRecs.length > 0 && <MyWorkSection initialRecs={linkedRecs} />}
{linkedRecs.length > 0 && (
<MyWorkSection
initialRecs={linkedRecs}
currentUser={{ id: user.id, level: currentUserLevel }}
/>
)}

<IssuesList initialData={pageData} initialFilters={filters} repoOptions={repoOptions} />
</div>
Expand Down
41 changes: 41 additions & 0 deletions src/app/(app)/issues/verify-button.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(null);
const [success, setSuccess] = useState<string | null>(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 (
<div className="inline-flex items-center gap-2">
<button
onClick={handleVerify}
disabled={pending || success !== null}
className="flex items-center gap-1 rounded border border-emerald-700/50 bg-emerald-900/20 px-2.5 py-1 text-[10px] uppercase tracking-widest text-emerald-400 transition-colors hover:bg-emerald-900/40 disabled:opacity-50"
>
<CheckCircle className="h-3 w-3" />
{pending ? 'Verifying...' : success ? 'Verified' : 'Verify'}
</button>
{error && <span className="text-[10px] uppercase tracking-widest text-red-400">{error}</span>}
{success && !pending && (
<span className="text-[10px] uppercase tracking-widest text-emerald-400">{success}</span>
)}
</div>
);
}
10 changes: 9 additions & 1 deletion src/app/(app)/maintainer/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
import ExportCsvButton from './export-csv-button';

export const dynamic = 'force-dynamic';
Expand Down Expand Up @@ -279,7 +280,7 @@ export default async function MaintainerPage({
<span>{relativeTime(r.githubUpdatedAt)}</span>
</div>
</div>
{r.mentorVerified && (
{r.mentorVerified ? (
<span className="shrink-0 rounded-full bg-emerald-900/40 px-2.5 py-0.5 text-xs font-medium text-emerald-300 ring-1 ring-emerald-700/40">
✓ Mentor verified
{r.mentorReviewerHandle && (
Expand All @@ -289,6 +290,13 @@ export default async function MaintainerPage({
</span>
)}
</span>
) : (
r.authorUserId !== user.id &&
r.state === 'open' && (
<div className="shrink-0">
<VerifyButton prId={r.id} />
</div>
)
)}
</li>
))}
Expand Down
1 change: 1 addition & 0 deletions src/app/actions/maintainer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
93 changes: 93 additions & 0 deletions src/app/actions/mentor.ts
Original file line number Diff line number Diff line change
@@ -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<Result<{ xpAwarded: number }>> {
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 });
}
1 change: 1 addition & 0 deletions src/lib/maintainer/queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down