Skip to content
Closed
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
60 changes: 43 additions & 17 deletions 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 { MentorVerifyButton } from '../maintainer/mentor-verify-button';

export type LinkedRec = {
id: number;
Expand All @@ -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<string, string> = {
Expand All @@ -20,7 +24,15 @@ const STATUS_CLS: Record<string, string> = {
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) {
Expand Down Expand Up @@ -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}
/>
))}
</div>
Expand All @@ -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);
Expand Down Expand Up @@ -185,22 +203,30 @@ function WorkItem({
<div className="mb-2 text-[10px] uppercase tracking-widest text-red-400">{error}</div>
)}

<div className="flex items-center gap-4">
<button
onClick={handleUnlink}
disabled={pending}
className="text-[10px] uppercase tracking-widest text-zinc-600 transition-colors hover:text-zinc-400 disabled:opacity-40"
>
<X className="mr-1 inline h-3 w-3" />
UNLINK PR
</button>
<button
onClick={handleUnclaim}
disabled={pending}
className="text-[10px] uppercase tracking-widest text-zinc-600 transition-colors hover:text-red-400 disabled:opacity-40"
>
UNCLAIM ISSUE
</button>
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex items-center gap-4">
<button
onClick={handleUnlink}
disabled={pending}
className="text-[10px] uppercase tracking-widest text-zinc-600 transition-colors hover:text-zinc-400 disabled:opacity-40"
>
<X className="mr-1 inline h-3 w-3" />
UNLINK PR
</button>
<button
onClick={handleUnclaim}
disabled={pending}
className="text-[10px] uppercase tracking-widest text-zinc-600 transition-colors hover:text-red-400 disabled:opacity-40"
>
UNCLAIM ISSUE
</button>
</div>
{canVerify &&
rec.pullRequestId !== null &&
!rec.mentorVerified &&
rec.pullRequestAuthorUserId !== currentUserId && (
<MentorVerifyButton prId={rec.pullRequestId} />
)}
</div>
</div>
);
Expand Down
53 changes: 44 additions & 9 deletions src/app/(app)/issues/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()]);

Expand All @@ -93,7 +122,13 @@ 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}
currentUserId={user.id}
canVerify={canVerify}
/>
)}

<IssuesList initialData={pageData} initialFilters={filters} repoOptions={repoOptions} />
</div>
Expand Down
48 changes: 48 additions & 0 deletions src/app/(app)/maintainer/mentor-verify-button.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(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 (
<div className="flex shrink-0 flex-col items-end gap-1">
{verified ? (
<span className="inline-flex items-center gap-1.5 rounded-full bg-emerald-900/40 px-2.5 py-1 text-xs font-medium text-emerald-300 ring-1 ring-emerald-700/40">
<CheckCircle2 className="h-3.5 w-3.5" />
Verified
</span>
) : (
<button
type="button"
onClick={onVerify}
disabled={pending}
className="inline-flex items-center gap-1.5 rounded-full border border-emerald-800/70 bg-emerald-950/30 px-2.5 py-1 text-xs font-medium text-emerald-300 transition-colors hover:border-emerald-600 hover:text-emerald-200 disabled:cursor-not-allowed disabled:opacity-50"
>
<CheckCircle2 className="h-3.5 w-3.5" />
{pending ? 'Verifying...' : 'Verify'}
</button>
)}
{error && <span className="max-w-40 text-right text-[10px] text-red-400">{error}</span>}
</div>
);
}
13 changes: 12 additions & 1 deletion src/app/(app)/maintainer/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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 (
<div className="min-h-screen bg-zinc-950 px-6 py-12 text-white">
Expand Down Expand Up @@ -185,7 +192,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 @@ -195,6 +202,10 @@ export default async function MaintainerPage({
</span>
)}
</span>
) : (
canVerify &&
r.state === 'open' &&
r.authorUserId !== user.id && <MentorVerifyButton prId={r.id} />
)}
</li>
))}
Expand Down
95 changes: 95 additions & 0 deletions src/app/actions/maintainer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -363,6 +367,97 @@ export async function refreshMaintainerBackfill(
return ok({ ok: true });
}

export async function verifyMentorPr(prId: number): Promise<Result<{ xpAwarded: number }>> {
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 = {
Expand Down
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
Loading