diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx index 9862725e..3a52cfd9 100644 --- a/src/app/(app)/layout.tsx +++ b/src/app/(app)/layout.tsx @@ -18,22 +18,26 @@ export default async function AppLayout({ children }: { children: React.ReactNod let handle: string | null = null; let level = 0; + let isMaintainer = false; const service = getServiceSupabase(); if (service) { const { data: profile } = await service .from('profiles') - .select('github_handle, level') + .select('github_handle, level, role') .eq('id', user.id) .maybeSingle(); handle = profile?.github_handle ?? null; level = profile?.level ?? 0; + const roleIsMaintainer = profile?.role === 'maintainer' || profile?.role === 'both'; + if (roleIsMaintainer) isMaintainer = true; } - let isMaintainer = false; - try { - isMaintainer = await isUserMaintainer(user.id); - } catch { - // never break the layout + if (!isMaintainer) { + try { + isMaintainer = await isUserMaintainer(user.id); + } catch { + // never break the layout + } } return ( diff --git a/src/app/(app)/maintainer/issues/page.tsx b/src/app/(app)/maintainer/issues/page.tsx deleted file mode 100644 index 956b7d65..00000000 --- a/src/app/(app)/maintainer/issues/page.tsx +++ /dev/null @@ -1,257 +0,0 @@ -import Link from 'next/link'; -import { redirect } from 'next/navigation'; -import { getServerSupabase } from '@/lib/supabase/server'; -import { isUserMaintainer } from '@/lib/maintainer/detect'; -import { - getMaintainerInstalls, - getMaintainerIssueQueue, - type MaintainerInstall, - type MaintainerIssueRow, -} from '@/app/actions/maintainer'; -import { isOk } from '@/lib/result'; -import type { IssueTriageBucket } from '@/lib/maintainer/issue-triage'; - -export const dynamic = 'force-dynamic'; - -const ALL_BUCKETS: IssueTriageBucket[] = ['needs-triage', 'in-progress', 'stale', 'closed']; - -const BUCKET_LABEL: Record = { - 'needs-triage': 'Needs triage', - 'in-progress': 'In progress', - stale: 'Stale', - closed: 'Closed', -}; - -const BUCKET_COLOR: Record = { - 'needs-triage': 'bg-amber-900/40 text-amber-300 ring-1 ring-amber-700/40', - 'in-progress': 'bg-emerald-900/40 text-emerald-300 ring-1 ring-emerald-700/40', - stale: 'bg-rose-900/40 text-rose-300 ring-1 ring-rose-700/40', - closed: 'bg-zinc-800 text-zinc-400 ring-1 ring-zinc-700/40', -}; - -export default async function MaintainerIssuesPage({ - searchParams, -}: { - searchParams: { install?: string; bucket?: string }; -}) { - const sb = getServerSupabase(); - if (!sb) return ; - const { - data: { user }, - } = await sb.auth.getUser(); - if (!user) redirect('/'); - - if (!(await isUserMaintainer(user.id))) { - redirect('/dashboard'); - } - - const installsRes = await getMaintainerInstalls(); - const installs: MaintainerInstall[] = isOk(installsRes) ? installsRes.data : []; - if (installs.length === 0) { - return ; - } - - const activeInstallId = - searchParams.install && installs.find((i) => i.installationId === Number(searchParams.install)) - ? Number(searchParams.install) - : installs[0]!.installationId; - - const activeInstall = installs.find((i) => i.installationId === activeInstallId)!; - - const requestedBuckets = (searchParams.bucket ?? '') - .split(',') - .filter((b): b is IssueTriageBucket => ALL_BUCKETS.includes(b as IssueTriageBucket)); - const buckets: IssueTriageBucket[] = - requestedBuckets.length > 0 ? requestedBuckets : ['needs-triage', 'in-progress', 'stale']; - - const queueRes = await getMaintainerIssueQueue({ - installationId: activeInstallId, - buckets, - }); - const rows: MaintainerIssueRow[] = isOk(queueRes) ? queueRes.data.rows : []; - - return ( -
-
-
-

Issue triage

- - ← PR queue - -
- - {installs.length > 1 && ( - - )} - -
- {ALL_BUCKETS.map((b) => ( - - ))} -
- -

- {activeInstall.accountLogin} ({activeInstall.permissionLevel.replace('_', ' ')}) -

- - {rows.length === 0 ? ( -
- No issues match. Try widening the filter — or the install hasn't seen any issue - webhooks yet. -
- ) : ( -
    - {rows.map((r) => ( -
  • -
    -
    - - {r.title} - - - {r.repoFullName} · #{r.number} - - - {BUCKET_LABEL[r.triage]} - -
    -
    - {r.authorLogin && opened by @{r.authorLogin}} - {r.assigneeLogin && ( - <> - · - assigned to @{r.assigneeLogin} - - )} - {r.commentsCount > 0 && ( - <> - · - - {r.commentsCount} comment{r.commentsCount === 1 ? '' : 's'} - - - )} - {r.lastEventAt && ( - <> - · - {relativeTime(r.lastEventAt)} - - )} -
    - {r.labels.length > 0 && ( -
    - {r.labels.slice(0, 6).map((l) => ( - - {l} - - ))} -
    - )} -
    -
  • - ))} -
- )} -
-
- ); -} - -function BucketPill({ - label, - bucket, - active, - current, - installId, -}: { - label: string; - bucket: IssueTriageBucket; - active: boolean; - current: IssueTriageBucket[]; - installId: number; -}) { - // Toggle: if currently active, remove from filter; otherwise add. - const next = active ? current.filter((b) => b !== bucket) : [...current, bucket]; - const params = new URLSearchParams(); - params.set('install', String(installId)); - if (next.length > 0) params.set('bucket', next.join(',')); - return ( - - {label} - - ); -} - -function relativeTime(iso: string): string { - const ms = Date.now() - new Date(iso).getTime(); - const min = Math.floor(ms / 60000); - if (min < 1) return 'just now'; - if (min < 60) return `${min}m ago`; - const h = Math.floor(min / 60); - if (h < 24) return `${h}h ago`; - const d = Math.floor(h / 24); - if (d < 30) return `${d}d ago`; - return new Date(iso).toLocaleDateString(); -} - -function NoInstalls() { - return ( -
-
-

No installs

-

Install the MergeShip App to see issues here.

-
-
- ); -} - -function NotConfigured() { - return ( -
-

Auth not configured.

-
- ); -} diff --git a/src/app/(app)/maintainer/page.tsx b/src/app/(app)/maintainer/page.tsx deleted file mode 100644 index 4325a1ac..00000000 --- a/src/app/(app)/maintainer/page.tsx +++ /dev/null @@ -1,292 +0,0 @@ -import Link from 'next/link'; -import { redirect } from 'next/navigation'; -import { getServerSupabase } from '@/lib/supabase/server'; -import { isUserMaintainer } from '@/lib/maintainer/detect'; -import { - getMaintainerInstalls, - getMaintainerPrQueue, - type MaintainerInstall, - type MaintainerPrRow, -} from '@/app/actions/maintainer'; -import { isOk } from '@/lib/result'; -import RefreshButton from './refresh-button'; - -export const dynamic = 'force-dynamic'; - -const TIER_LABEL: Record<'open' | 'closed' | 'merged', string> = { - open: 'Open', - closed: 'Closed', - merged: 'Merged', -}; - -export default async function MaintainerPage({ - searchParams, -}: { - searchParams: { install?: string; state?: string; verified?: string }; -}) { - const sb = getServerSupabase(); - if (!sb) { - return ; - } - const { - data: { user }, - } = await sb.auth.getUser(); - if (!user) redirect('/'); - - if (!(await isUserMaintainer(user.id))) { - redirect('/dashboard'); - } - - const installsRes = await getMaintainerInstalls(); - const installs: MaintainerInstall[] = isOk(installsRes) ? installsRes.data : []; - if (installs.length === 0) { - return ; - } - - const activeInstallId = - searchParams.install && installs.find((i) => i.installationId === Number(searchParams.install)) - ? Number(searchParams.install) - : installs[0]!.installationId; - - const activeInstall = installs.find((i) => i.installationId === activeInstallId)!; - - const filters: { state?: ('open' | 'closed' | 'merged')[]; mentorVerified?: 'yes' | 'no' } = {}; - if (searchParams.state) { - const parts = searchParams.state - .split(',') - .filter((s) => ['open', 'closed', 'merged'].includes(s)) as ('open' | 'closed' | 'merged')[]; - if (parts.length > 0) filters.state = parts; - } - if (searchParams.verified === 'yes' || searchParams.verified === 'no') { - filters.mentorVerified = searchParams.verified; - } - if (!filters.state) filters.state = ['open']; // default - - const queueRes = await getMaintainerPrQueue({ - installationId: activeInstallId, - filters, - }); - const rows: MaintainerPrRow[] = isOk(queueRes) ? queueRes.data.rows : []; - - return ( -
-
-
-

Maintainer

- -
- - {installs.length > 1 && ( - - )} - -
- - - - | - - - - - Issue triage → - - - Community links → - -
- -

- {activeInstall.accountLogin} ({activeInstall.permissionLevel.replace('_', ' ')}) -

- - {rows.length === 0 ? ( -
- No PRs match your filters. Try widening state or running a refresh. -
- ) : ( -
    - {rows.map((r) => ( -
  • -
    -
    - - {r.title} - - - {r.repoFullName} · #{r.number} - - {r.draft && ( - - Draft - - )} - - {TIER_LABEL[r.state]} - -
    -
    - @{r.authorLogin} - - · - {relativeTime(r.githubUpdatedAt)} -
    -
    - {r.mentorVerified && ( - - ✓ Mentor verified - {r.mentorReviewerHandle && ( - - by @{r.mentorReviewerHandle} - {r.mentorReviewerLevel !== null && ` (L${r.mentorReviewerLevel})`} - - )} - - )} -
  • - ))} -
- )} -
-
- ); -} - -function FilterPill({ label, href, active }: { label: string; href: string; active: boolean }) { - return ( - - {label} - - ); -} - -function AuthorBadge({ - level, - xp, - merged, -}: { - level: number | null; - xp: number | null; - merged: number | null; -}) { - if (level === null) { - return not on MergeShip; - } - return ( - - L{level} - {xp !== null && {xp.toLocaleString()} XP} - {merged !== null && merged > 0 && · {merged} merged} - - ); -} - -function stateColor(state: 'open' | 'closed' | 'merged'): string { - if (state === 'open') return 'bg-emerald-900/40 text-emerald-300'; - if (state === 'merged') return 'bg-purple-900/40 text-purple-300'; - return 'bg-zinc-800 text-zinc-400'; -} - -function relativeTime(iso: string): string { - const ms = Date.now() - new Date(iso).getTime(); - const min = Math.floor(ms / 60000); - if (min < 1) return 'just now'; - if (min < 60) return `${min}m ago`; - const h = Math.floor(min / 60); - if (h < 24) return `${h}h ago`; - const d = Math.floor(h / 24); - if (d < 30) return `${d}d ago`; - return new Date(iso).toLocaleDateString(); -} - -function withParam( - key: string, - value: string, - current: Record, -): string { - const params = new URLSearchParams(); - for (const [k, v] of Object.entries(current)) { - if (v && k !== key) params.set(k, v); - } - if (value) params.set(key, value); - return `/maintainer?${params.toString()}`; -} - -function NoInstalls() { - return ( -
-
-

No installs

-

- Install the MergeShip App on a repo your organisation owns to see PRs here. -

-
-
- ); -} - -function NotConfigured() { - return ( -
-

Auth not configured.

-
- ); -} diff --git a/src/app/(app)/maintainer/refresh-button.tsx b/src/app/(app)/maintainer/refresh-button.tsx deleted file mode 100644 index 54e98850..00000000 --- a/src/app/(app)/maintainer/refresh-button.tsx +++ /dev/null @@ -1,30 +0,0 @@ -'use client'; - -import { useState, useTransition } from 'react'; -import { refreshMaintainerBackfill } from '@/app/actions/maintainer'; - -export default function RefreshButton({ installationId }: { installationId: number }) { - const [pending, startTransition] = useTransition(); - const [feedback, setFeedback] = useState(null); - - function onClick() { - setFeedback(null); - startTransition(async () => { - const res = await refreshMaintainerBackfill(installationId); - setFeedback(res.ok ? 'Refresh queued — may take a minute.' : res.error.message); - }); - } - - return ( -
- {feedback && {feedback}} - -
- ); -} diff --git a/src/app/(maintainer)/maintainer/analytics/page.tsx b/src/app/(maintainer)/maintainer/analytics/page.tsx new file mode 100644 index 00000000..1ce38b91 --- /dev/null +++ b/src/app/(maintainer)/maintainer/analytics/page.tsx @@ -0,0 +1,126 @@ +import { redirect } from 'next/navigation'; +import { getServerSupabase } from '@/lib/supabase/server'; +import { getServiceSupabase } from '@/lib/supabase/service'; +import { isUserMaintainer, listMaintainerRepos } from '@/lib/maintainer/detect'; +import { getMaintainerInstalls } from '@/app/actions/maintainer'; +import { isOk } from '@/lib/result'; + +export const dynamic = 'force-dynamic'; + +export default async function AnalyticsPage({ + searchParams, +}: { + searchParams: { install?: string }; +}) { + const sb = getServerSupabase(); + if (!sb) redirect('/'); + const { + data: { user }, + } = await sb.auth.getUser(); + if (!user) redirect('/'); + if (!(await isUserMaintainer(user.id))) redirect('/dashboard'); + + const service = getServiceSupabase(); + const installsRes = await getMaintainerInstalls(); + if (!isOk(installsRes) || installsRes.data.length === 0) { + return ( +
+ No installations found. +
+ ); + } + + const installs = installsRes.data; + const installationId = + searchParams.install && installs.find((i) => i.installationId === Number(searchParams.install)) + ? Number(searchParams.install) + : installs[0]!.installationId; + + let totalXp = 0; + let mergedThisMonth = 0; + let activeContributors = 0; + + if (service) { + const startOfMonth = new Date(); + startOfMonth.setDate(1); + startOfMonth.setHours(0, 0, 0, 0); + const since = startOfMonth.toISOString(); + + const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(); + const repos = await listMaintainerRepos(user.id, installationId); + + const [xpRes, mergedRes, activeRes] = await Promise.all([ + service.from('xp_events').select('amount').gte('created_at', since), + repos.length > 0 + ? service + .from('pull_requests') + .select('id', { count: 'exact', head: true }) + .eq('state', 'merged') + .gte('merged_at', since) + .in('repo_full_name', repos) + : Promise.resolve({ count: 0, data: [] }), + repos.length > 0 + ? service + .from('pull_requests') + .select('author_user_id') + .gte('github_created_at', thirtyDaysAgo) + .in('repo_full_name', repos) + .not('author_user_id', 'is', null) + : Promise.resolve({ data: [] }), + ]); + + totalXp = ((xpRes.data ?? []) as { amount: number }[]).reduce( + (sum, r) => sum + (r.amount ?? 0), + 0, + ); + mergedThisMonth = (mergedRes as { count: number | null }).count ?? 0; + + const uniqueActiveIds = new Set( + ((activeRes.data ?? []) as { author_user_id: string | null }[]) + .map((r) => r.author_user_id) + .filter(Boolean), + ); + activeContributors = uniqueActiveIds.size; + } + + return ( +
+
+
+

Analytics

+

Coming Soon

+
+
+ + {/* Placeholder stat cards with real data */} +
+
+
+ XP Distributed This Month +
+
{totalXp.toLocaleString()}
+
+
+
+ PRs Merged This Month +
+
{mergedThisMonth}
+
+
+
+ Active Contributors (30d) +
+
{activeContributors}
+
+
+ +
+
Coming Soon
+

+ Full analytics dashboard — charts, trends, contributor growth, XP velocity — is in + development. +

+
+
+ ); +} diff --git a/src/app/(app)/maintainer/community/editor.tsx b/src/app/(maintainer)/maintainer/communications/editor.tsx similarity index 69% rename from src/app/(app)/maintainer/community/editor.tsx rename to src/app/(maintainer)/maintainer/communications/editor.tsx index 668de71b..cd391b5b 100644 --- a/src/app/(app)/maintainer/community/editor.tsx +++ b/src/app/(maintainer)/maintainer/communications/editor.tsx @@ -66,13 +66,15 @@ export default function CommunityEditor({ return (
-
-

Add or update

+
+

+ Add or update +

setLabel(e.target.value)} - className="rounded-lg border border-zinc-700 bg-zinc-950 px-2 py-1.5 text-sm" + className="rounded border border-[#30363d] bg-[#0d1117] px-2 py-1.5 text-sm text-white placeholder-zinc-600 focus:border-[#00d26a] focus:outline-none" />
{error && ( -

+

{error}

)} -

One link per kind. Save again to update.

+

One link per kind. Save again to update.

-
    +
      {links.length === 0 ? ( -
    • No links yet.
    • +
    • No links yet.
    • ) : ( links.map((l) => (
    • - {l.kind} + + {l.kind} + {l.label ?? l.url} diff --git a/src/app/(app)/maintainer/community/page.tsx b/src/app/(maintainer)/maintainer/communications/page.tsx similarity index 73% rename from src/app/(app)/maintainer/community/page.tsx rename to src/app/(maintainer)/maintainer/communications/page.tsx index 2fde3b18..6ece46cb 100644 --- a/src/app/(app)/maintainer/community/page.tsx +++ b/src/app/(maintainer)/maintainer/communications/page.tsx @@ -13,13 +13,13 @@ import CommunityEditor from './editor'; export const dynamic = 'force-dynamic'; -export default async function CommunityPage({ +export default async function CommunicationsPage({ searchParams, }: { searchParams: { install?: string }; }) { const sb = getServerSupabase(); - if (!sb) return null; + if (!sb) redirect('/'); const { data: { user }, } = await sb.auth.getUser(); @@ -40,14 +40,16 @@ export default async function CommunityPage({ const install = installs.find((i) => i.installationId === installId)!; return ( -
      +
      -

      Community links

      -

      - Discord, Slack, forums, anywhere your community lives. Contributors who work in{' '} - {install.accountLogin} repos see these on their - dashboard. -

      +
      +

      Communications

      +

      + Discord, Slack, forums — anywhere your community lives. Contributors who work in{' '} + {install.accountLogin} repos see these on their + dashboard. +

      +
      + No GitHub App installations found. +
      + ); + } + + const installs = installsRes.data; + const installationId = + searchParams.install && installs.find((i) => i.installationId === Number(searchParams.install)) + ? Number(searchParams.install) + : installs[0]!.installationId; + + const page = Math.max(0, Number(searchParams.page ?? 0)); + const contribRes = await getMaintainerContributors({ installationId, page }); + const rows: ContributorRow[] = isOk(contribRes) ? contribRes.data.rows : []; + const hasMore = isOk(contribRes) ? contribRes.data.hasMore : false; + + return ( +
      +
      +
      +

      Contributors

      +

      + Contributor Management +

      +
      +
      + +
      + {rows.length === 0 ? ( +
      No contributors found.
      + ) : ( + + + + + + + + + + + + {rows.map((row) => ( + + + + + + + + ))} + +
      ContributorLevelXPMerged PRsJoined
      +
      +
      + {row.handle.substring(0, 2).toUpperCase()} +
      + @{row.handle} +
      +
      + + L{row.level} + + {row.xp.toLocaleString()}{row.mergedPrs} + {new Date(row.joinedAt).toLocaleDateString()} +
      + )} +
      + + {(page > 0 || hasMore) && ( +
      + {page > 0 ? ( + + ← Previous + + ) : ( +
      + )} + Page {page + 1} + {hasMore ? ( + + Next → + + ) : ( +
      + )} +
      + )} +
      + ); +} diff --git a/src/app/(maintainer)/maintainer/issues/page.tsx b/src/app/(maintainer)/maintainer/issues/page.tsx new file mode 100644 index 00000000..4c869513 --- /dev/null +++ b/src/app/(maintainer)/maintainer/issues/page.tsx @@ -0,0 +1,191 @@ +import Link from 'next/link'; +import { redirect } from 'next/navigation'; +import { getServerSupabase } from '@/lib/supabase/server'; +import { isUserMaintainer } from '@/lib/maintainer/detect'; +import { + getMaintainerInstalls, + getMaintainerIssueQueue, + type MaintainerInstall, + type MaintainerIssueRow, +} from '@/app/actions/maintainer'; +import { isOk } from '@/lib/result'; +import type { IssueTriageBucket } from '@/lib/maintainer/issue-triage'; + +export const dynamic = 'force-dynamic'; + +const ALL_BUCKETS: IssueTriageBucket[] = ['needs-triage', 'in-progress', 'stale', 'closed']; + +const BUCKET_LABEL: Record = { + 'needs-triage': 'Needs Triage', + 'in-progress': 'In Progress', + stale: 'Stale', + closed: 'Closed', +}; + +const BUCKET_COLOR: Record = { + 'needs-triage': 'bg-amber-900/40 text-amber-300', + 'in-progress': 'bg-[#00d26a]/10 text-[#00d26a]', + stale: 'bg-red-900/40 text-red-300', + closed: 'bg-zinc-800 text-zinc-400', +}; + +function relativeTime(iso: string): string { + const ms = Date.now() - new Date(iso).getTime(); + const min = Math.floor(ms / 60000); + if (min < 1) return 'just now'; + if (min < 60) return `${min}m ago`; + const h = Math.floor(min / 60); + if (h < 24) return `${h}h ago`; + const d = Math.floor(h / 24); + if (d < 30) return `${d}d ago`; + return new Date(iso).toLocaleDateString(); +} + +export default async function MaintainerIssuesPage({ + searchParams, +}: { + searchParams: { install?: string; bucket?: string }; +}) { + const sb = getServerSupabase(); + if (!sb) redirect('/'); + const { + data: { user }, + } = await sb.auth.getUser(); + if (!user) redirect('/'); + if (!(await isUserMaintainer(user.id))) redirect('/dashboard'); + + const installsRes = await getMaintainerInstalls(); + const installs: MaintainerInstall[] = isOk(installsRes) ? installsRes.data : []; + if (installs.length === 0) { + return ( +
      + No GitHub App installations found. +
      + ); + } + + const installationId = + searchParams.install && installs.find((i) => i.installationId === Number(searchParams.install)) + ? Number(searchParams.install) + : installs[0]!.installationId; + + const requestedBuckets = (searchParams.bucket ?? '') + .split(',') + .filter((b): b is IssueTriageBucket => ALL_BUCKETS.includes(b as IssueTriageBucket)); + const buckets: IssueTriageBucket[] = + requestedBuckets.length > 0 ? requestedBuckets : ['needs-triage', 'in-progress', 'stale']; + + const queueRes = await getMaintainerIssueQueue({ installationId, buckets }); + const rows: MaintainerIssueRow[] = isOk(queueRes) ? queueRes.data.rows : []; + + return ( +
      +
      +
      +

      Issues

      +

      + Issue Triage +

      +
      +
      + + {/* Bucket filters */} +
      + {ALL_BUCKETS.map((b) => { + const active = buckets.includes(b); + const next = active ? buckets.filter((x) => x !== b) : [...buckets, b]; + const params = new URLSearchParams(); + params.set('install', String(installationId)); + if (next.length > 0) params.set('bucket', next.join(',')); + return ( + + {BUCKET_LABEL[b]} + + ); + })} +
      + + {/* Issue list */} +
      + {rows.length === 0 ? ( +
      + No issues match your filters. +
      + ) : ( +
        + {rows.map((r) => ( +
      • +
        +
        + + {r.title} + + + {r.repoFullName} · #{r.number} + + + {BUCKET_LABEL[r.triage]} + +
        +
        + {r.authorLogin && opened by @{r.authorLogin}} + {r.assigneeLogin && ( + <> + · + assigned to @{r.assigneeLogin} + + )} + {r.commentsCount > 0 && ( + <> + · + + {r.commentsCount} comment{r.commentsCount === 1 ? '' : 's'} + + + )} + {r.lastEventAt && ( + <> + · + {relativeTime(r.lastEventAt)} + + )} +
        + {r.labels.length > 0 && ( +
        + {r.labels.slice(0, 6).map((l) => ( + + {l} + + ))} +
        + )} +
        +
      • + ))} +
      + )} +
      +
      + ); +} diff --git a/src/app/(maintainer)/maintainer/layout.tsx b/src/app/(maintainer)/maintainer/layout.tsx new file mode 100644 index 00000000..8e9b15eb --- /dev/null +++ b/src/app/(maintainer)/maintainer/layout.tsx @@ -0,0 +1,64 @@ +import Link from 'next/link'; +import { redirect } from 'next/navigation'; +import { getServerSupabase } from '@/lib/supabase/server'; +import { getServiceSupabase } from '@/lib/supabase/service'; +import { isUserMaintainer } from '@/lib/maintainer/detect'; +import { getMaintainerInstalls } from '@/app/actions/maintainer'; +import { isOk } from '@/lib/result'; +import { MaintainerNav } from './nav-items'; + +export default async function MaintainerLayout({ children }: { children: React.ReactNode }) { + const sb = getServerSupabase(); + if (!sb) redirect('/'); + const { + data: { user }, + } = await sb.auth.getUser(); + if (!user) redirect('/'); + if (!(await isUserMaintainer(user.id))) redirect('/dashboard'); + + const service = getServiceSupabase(); + let handle = ''; + let installLogin = ''; + if (service) { + const { data: profile } = await service + .from('profiles') + .select('github_handle') + .eq('id', user.id) + .maybeSingle(); + handle = profile?.github_handle ?? ''; + } + const installsRes = await getMaintainerInstalls(); + if (isOk(installsRes) && installsRes.data.length > 0) { + installLogin = installsRes.data[0]!.accountLogin; + } + + return ( +
      + +
      {children}
      +
      + ); +} diff --git a/src/app/(maintainer)/maintainer/mentors/page.tsx b/src/app/(maintainer)/maintainer/mentors/page.tsx new file mode 100644 index 00000000..b51de127 --- /dev/null +++ b/src/app/(maintainer)/maintainer/mentors/page.tsx @@ -0,0 +1,107 @@ +import { redirect } from 'next/navigation'; +import { getServerSupabase } from '@/lib/supabase/server'; +import { isUserMaintainer } from '@/lib/maintainer/detect'; +import { + getMaintainerInstalls, + getMaintainerMentors, + type MentorRow, +} from '@/app/actions/maintainer'; +import { isOk } from '@/lib/result'; + +export const dynamic = 'force-dynamic'; + +function levelBadge(level: number): string { + if (level === 2) return 'bg-cyan-900/40 text-cyan-300'; + return 'bg-purple-900/40 text-purple-300'; +} + +export default async function MentorsPage({ + searchParams, +}: { + searchParams: { install?: string }; +}) { + const sb = getServerSupabase(); + if (!sb) redirect('/'); + const { + data: { user }, + } = await sb.auth.getUser(); + if (!user) redirect('/'); + if (!(await isUserMaintainer(user.id))) redirect('/dashboard'); + + const installsRes = await getMaintainerInstalls(); + if (!isOk(installsRes) || installsRes.data.length === 0) { + return ( +
      + No GitHub App installations found. +
      + ); + } + + const installs = installsRes.data; + const installationId = + searchParams.install && installs.find((i) => i.installationId === Number(searchParams.install)) + ? Number(searchParams.install) + : installs[0]!.installationId; + + const mentorsRes = await getMaintainerMentors(installationId); + const rows: MentorRow[] = isOk(mentorsRes) ? mentorsRes.data : []; + + return ( +
      +
      +

      Mentors

      +

      + Level 2+ Contributors +

      +
      + +
      + {rows.length === 0 ? ( +
      + No mentors found (level 2+ contributors). +
      + ) : ( + + + + + + + + + + + {rows.map((row) => ( + + + + + + + ))} + +
      MentorLevelXPHelp Resolved
      +
      +
      + {row.handle.substring(0, 2).toUpperCase()} +
      + @{row.handle} +
      +
      + + L{row.level} + + {row.xp.toLocaleString()} + {row.helpResolved > 0 ? ( + {row.helpResolved} + ) : ( + 0 + )} +
      + )} +
      +
      + ); +} diff --git a/src/app/(maintainer)/maintainer/nav-items.tsx b/src/app/(maintainer)/maintainer/nav-items.tsx new file mode 100644 index 00000000..45b6b66d --- /dev/null +++ b/src/app/(maintainer)/maintainer/nav-items.tsx @@ -0,0 +1,51 @@ +'use client'; + +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; +import { + LayoutDashboard, + GitPullRequest, + AlertCircle, + Users, + GraduationCap, + MessageSquare, + BarChart3, + Settings, +} from 'lucide-react'; + +const NAV = [ + { name: 'Dashboard', href: '/maintainer', icon: LayoutDashboard, exact: true }, + { name: 'PR Queue', href: '/maintainer/pr-queue', icon: GitPullRequest }, + { name: 'Issues', href: '/maintainer/issues', icon: AlertCircle }, + { name: 'Contributors', href: '/maintainer/contributors', icon: Users }, + { name: 'Mentors', href: '/maintainer/mentors', icon: GraduationCap }, + { name: 'Communications', href: '/maintainer/communications', icon: MessageSquare }, + { name: 'Analytics', href: '/maintainer/analytics', icon: BarChart3 }, + { name: 'Settings', href: '/maintainer/settings', icon: Settings }, +]; + +export function MaintainerNav() { + const pathname = usePathname(); + return ( + <> + {NAV.map((item) => { + const Icon = item.icon; + const isActive = item.exact ? pathname === item.href : pathname.startsWith(item.href); + return ( + + + {item.name} + + ); + })} + + ); +} diff --git a/src/app/(maintainer)/maintainer/page.tsx b/src/app/(maintainer)/maintainer/page.tsx new file mode 100644 index 00000000..f11dc7c5 --- /dev/null +++ b/src/app/(maintainer)/maintainer/page.tsx @@ -0,0 +1,337 @@ +import Link from 'next/link'; +import { GitMerge, GitPullRequest, UserPlus, Flame } from 'lucide-react'; +import { redirect } from 'next/navigation'; +import { getServerSupabase } from '@/lib/supabase/server'; +import { isUserMaintainer } from '@/lib/maintainer/detect'; +import { + getMaintainerInstalls, + getMaintainerPrQueue, + getMaintainerDashboardStats, + getMaintainerRecentActivity, + getMaintainerHelpRequests, + getMaintainerLevelDistribution, + type MaintainerPrRow, + type ActivityItem, + type HelpRequestRow, + type DashboardStats, + type LevelDistribution, +} from '@/app/actions/maintainer'; +import { isOk } from '@/lib/result'; + +export const dynamic = 'force-dynamic'; + +function relativeTime(iso: string): string { + const ms = Date.now() - new Date(iso).getTime(); + const min = Math.floor(ms / 60000); + if (min < 1) return 'just now'; + if (min < 60) return `${min}m ago`; + const h = Math.floor(min / 60); + if (h < 24) return `${h}h ago`; + const d = Math.floor(h / 24); + if (d < 30) return `${d}d ago`; + return new Date(iso).toLocaleDateString(); +} + +function levelBadge(level: number | null): string { + if (level === null) return 'bg-zinc-800 text-zinc-400'; + if (level === 0) return 'bg-zinc-800 text-zinc-400'; + if (level === 1) return 'bg-blue-900/40 text-blue-300'; + if (level === 2) return 'bg-cyan-900/40 text-cyan-300'; + return 'bg-purple-900/40 text-purple-300'; +} + +function levelLabel(level: number | null): string { + if (level === null) return '?'; + return `L${level}`; +} + +function VerificationBadge({ row }: { row: MaintainerPrRow }) { + if (row.mentorVerified) { + return ( + + L{row.mentorReviewerLevel ?? '?'} Verified + + ); + } + return ( + + Unverified + + ); +} + +function ActivityIcon({ type }: { type: ActivityItem['type'] }) { + if (type === 'pr_merged') return ; + if (type === 'contributor_joined') + return ; + return ; +} + +export default async function MaintainerDashboardPage({ + searchParams, +}: { + searchParams: { install?: string }; +}) { + const sb = getServerSupabase(); + if (!sb) redirect('/'); + const { + data: { user }, + } = await sb.auth.getUser(); + if (!user) redirect('/'); + if (!(await isUserMaintainer(user.id))) redirect('/dashboard'); + + const installsRes = await getMaintainerInstalls(); + if (!isOk(installsRes) || installsRes.data.length === 0) { + return ( +
      + No GitHub App installations found. +
      + ); + } + + const installs = installsRes.data; + const installationId = + searchParams.install && installs.find((i) => i.installationId === Number(searchParams.install)) + ? Number(searchParams.install) + : installs[0]!.installationId; + + const [statsRes, queueRes, activityRes, helpRes, distRes] = await Promise.all([ + getMaintainerDashboardStats(installationId), + getMaintainerPrQueue({ installationId, filters: { state: ['open'] } }), + getMaintainerRecentActivity(installationId), + getMaintainerHelpRequests(), + getMaintainerLevelDistribution(), + ]); + + const stats: DashboardStats = isOk(statsRes) + ? statsRes.data + : { openPrs: 0, mentorVerified: 0, aiFlagged: 0, openIssues: 0, stalePrs: 0 }; + const prRows: MaintainerPrRow[] = isOk(queueRes) ? queueRes.data.rows.slice(0, 5) : []; + const activity: ActivityItem[] = isOk(activityRes) ? activityRes.data : []; + const helpRequests: HelpRequestRow[] = isOk(helpRes) ? helpRes.data : []; + const dist: LevelDistribution = isOk(distRes) ? distRes.data : { l0: 0, l1: 0, l2: 0, l3plus: 0 }; + + const totalContribs = dist.l0 + dist.l1 + dist.l2 + dist.l3plus; + const pct = (n: number) => (totalContribs === 0 ? 0 : Math.round((n / totalContribs) * 100)); + + return ( +
      + {/* Header */} +
      +
      +

      Dashboard

      +

      + Overview & Key Metrics +

      +
      +
      + + NEW ISSUE + + + MERGE PR + +
      +
      + + {/* Stats row */} +
      + + + + + +
      + + {/* Two-column grid */} +
      + {/* PR Queue */} +
      +
      +

      + PR Queue +

      + + VIEW ALL → + +
      + {prRows.length === 0 ? ( +

      No open PRs.

      + ) : ( + + + + + + + + + + + + + {prRows.map((row) => ( + + + + + + + + + ))} + +
      PR #TitleContributorLevelVerificationAge
      #{row.number} + + {row.title} + + @{row.authorLogin} + + {levelLabel(row.authorLevel)} + + + + {relativeTime(row.githubUpdatedAt)}
      + )} +
      + + {/* Right column */} +
      + {/* Recent Activity */} +
      +

      + Recent Activity +

      + {activity.length === 0 ? ( +

      No recent activity.

      + ) : ( +
        + {activity.slice(0, 6).map((item, i) => ( +
      • + +
        +
        {item.text}
        +
        {item.subtext}
        +
        + + {relativeTime(item.timestamp)} + +
      • + ))} +
      + )} +
      + + {/* Meeting Requests */} +
      +

      + Meeting Requests +

      + {helpRequests.length === 0 ? ( +

      No pending requests.

      + ) : ( +
        + {helpRequests.map((req) => ( +
      • +
        +
        @{req.handle}
        +
        + {req.message.slice(0, 40)} + {req.message.length > 40 ? '…' : ''} +
        +
        + +
      • + ))} +
      + )} +
      +
      +
      + + {/* Contributor Levels Distribution */} +
      +

      + Contributor Levels Distribution +

      +
      + {pct(dist.l0) > 0 && ( +
      + )} + {pct(dist.l1) > 0 && ( +
      + )} + {pct(dist.l2) > 0 && ( +
      + )} + {pct(dist.l3plus) > 0 && ( +
      + )} + {totalContribs === 0 &&
      } +
      +
      + + + L0: {dist.l0} + + + + L1: {dist.l1} + + + + L2: {dist.l2} + + + + L3+: {dist.l3plus} + +
      +
      +
      + ); +} + +function StatCard({ label, value, color }: { label: string; value: number; color: string }) { + return ( +
      +
      {label}
      +
      {value}
      +
      + ); +} diff --git a/src/app/(maintainer)/maintainer/pr-queue/page.tsx b/src/app/(maintainer)/maintainer/pr-queue/page.tsx new file mode 100644 index 00000000..58e2410e --- /dev/null +++ b/src/app/(maintainer)/maintainer/pr-queue/page.tsx @@ -0,0 +1,264 @@ +import Link from 'next/link'; +import { redirect } from 'next/navigation'; +import { getServerSupabase } from '@/lib/supabase/server'; +import { isUserMaintainer } from '@/lib/maintainer/detect'; +import { + getMaintainerInstalls, + getMaintainerPrQueue, + type MaintainerPrRow, +} from '@/app/actions/maintainer'; +import { isOk } from '@/lib/result'; + +export const dynamic = 'force-dynamic'; + +function relativeTime(iso: string): string { + const ms = Date.now() - new Date(iso).getTime(); + const min = Math.floor(ms / 60000); + if (min < 1) return 'just now'; + if (min < 60) return `${min}m ago`; + const h = Math.floor(min / 60); + if (h < 24) return `${h}h ago`; + const d = Math.floor(h / 24); + if (d < 30) return `${d}d ago`; + return new Date(iso).toLocaleDateString(); +} + +function levelBadge(level: number | null): string { + if (level === null || level === 0) return 'bg-zinc-800 text-zinc-400'; + if (level === 1) return 'bg-blue-900/40 text-blue-300'; + if (level === 2) return 'bg-cyan-900/40 text-cyan-300'; + return 'bg-purple-900/40 text-purple-300'; +} + +function FilterLink({ label, href, active }: { label: string; href: string; active: boolean }) { + return ( + + {label} + + ); +} + +function VerificationBadge({ row }: { row: MaintainerPrRow }) { + if (row.mentorVerified) { + return ( + + L{row.mentorReviewerLevel ?? '?'} Verified + + ); + } + return ( + + Unverified + + ); +} + +function buildHref( + current: Record, + updates: Record, +): string { + const params = new URLSearchParams(); + for (const [k, v] of Object.entries({ ...current, ...updates })) { + if (v) params.set(k, v); + } + return `/maintainer/pr-queue?${params.toString()}`; +} + +export default async function PrQueuePage({ + searchParams, +}: { + searchParams: { install?: string; state?: string; verified?: string; page?: string }; +}) { + const sb = getServerSupabase(); + if (!sb) redirect('/'); + const { + data: { user }, + } = await sb.auth.getUser(); + if (!user) redirect('/'); + if (!(await isUserMaintainer(user.id))) redirect('/dashboard'); + + const installsRes = await getMaintainerInstalls(); + if (!isOk(installsRes) || installsRes.data.length === 0) { + return ( +
      + No GitHub App installations found. +
      + ); + } + + const installs = installsRes.data; + const installationId = + searchParams.install && installs.find((i) => i.installationId === Number(searchParams.install)) + ? Number(searchParams.install) + : installs[0]!.installationId; + + const page = Math.max(0, Number(searchParams.page ?? 0)); + + const filters: { state?: ('open' | 'closed' | 'merged')[]; mentorVerified?: 'yes' | 'no' } = {}; + if (searchParams.state) { + const parts = searchParams.state + .split(',') + .filter((s) => ['open', 'closed', 'merged'].includes(s)) as ('open' | 'closed' | 'merged')[]; + if (parts.length > 0) filters.state = parts; + } + if (searchParams.verified === 'yes' || searchParams.verified === 'no') { + filters.mentorVerified = searchParams.verified; + } + if (!filters.state) filters.state = ['open']; + + const queueRes = await getMaintainerPrQueue({ installationId, filters, page }); + const rows: MaintainerPrRow[] = isOk(queueRes) ? queueRes.data.rows : []; + const hasMore = isOk(queueRes) ? queueRes.data.hasMore : false; + + const sp = searchParams as Record; + + return ( +
      +
      +
      +

      PR Queue

      +

      + Pull Request Management +

      +
      +
      + + {/* Filters */} +
      + + + + | + + + +
      + + {/* Table */} +
      + {rows.length === 0 ? ( +
      + No PRs match your filters. +
      + ) : ( + + + + + + + + + + + + + + {rows.map((row) => ( + + + + + + + + + + ))} + +
      PR #TitleContributorLevelVerificationAgeRepo
      + + #{row.number} + + + + {row.title} + + {row.draft && ( + + DRAFT + + )} + @{row.authorLogin} + + L{row.authorLevel ?? '?'} + + + + {relativeTime(row.githubUpdatedAt)}{row.repoFullName}
      + )} +
      + + {/* Pagination */} + {(page > 0 || hasMore) && ( +
      + {page > 0 ? ( + + ← Previous + + ) : ( +
      + )} + Page {page + 1} + {hasMore ? ( + + Next → + + ) : ( +
      + )} +
      + )} +
      + ); +} diff --git a/src/app/(maintainer)/maintainer/settings/page.tsx b/src/app/(maintainer)/maintainer/settings/page.tsx new file mode 100644 index 00000000..162d16e8 --- /dev/null +++ b/src/app/(maintainer)/maintainer/settings/page.tsx @@ -0,0 +1,84 @@ +import Link from 'next/link'; +import { redirect } from 'next/navigation'; +import { getServerSupabase } from '@/lib/supabase/server'; +import { isUserMaintainer } from '@/lib/maintainer/detect'; +import { getMaintainerInstalls } from '@/app/actions/maintainer'; +import { isOk } from '@/lib/result'; + +export const dynamic = 'force-dynamic'; + +export default async function SettingsPage() { + const sb = getServerSupabase(); + if (!sb) redirect('/'); + const { + data: { user }, + } = await sb.auth.getUser(); + if (!user) redirect('/'); + if (!(await isUserMaintainer(user.id))) redirect('/dashboard'); + + const installsRes = await getMaintainerInstalls(); + const installs = isOk(installsRes) ? installsRes.data : []; + + return ( +
      +
      +

      Settings

      +

      + Console Configuration +

      +
      + + {/* GitHub App section */} +
      +

      + GitHub App Installations +

      + {installs.length === 0 ? ( +

      No installations found.

      + ) : ( +
        + {installs.map((install) => ( +
      • +
        +
        +
        {install.accountLogin}
        +
        + + Type: {install.accountType} + + + Permission:{' '} + + {install.permissionLevel.replace('_', ' ')} + + + + ID: {install.installationId} + +
        +
        + + ACTIVE + +
        +
      • + ))} +
      + )} +
      + + {/* Back to contributor view */} +
      +

      + Navigation +

      + + ← Back to contributor view + +
      +
      + ); +} diff --git a/src/app/actions/maintainer.ts b/src/app/actions/maintainer.ts index e1cda3b6..4705cd5b 100644 --- a/src/app/actions/maintainer.ts +++ b/src/app/actions/maintainer.ts @@ -497,3 +497,428 @@ export async function deleteCommunityLink(linkId: 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'); + + if (!(await isUserMaintainer(user.id))) { + return err('not_authorised', 'not a maintainer'); + } + + const repos = await listMaintainerRepos(user.id, installationId); + if (repos.length === 0) { + return ok({ openPrs: 0, mentorVerified: 0, aiFlagged: 0, openIssues: 0, stalePrs: 0 }); + } + + const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(); + + const [openPrsRes, mentorVerifiedRes, stalePrsRes, openIssuesRes] = await Promise.all([ + service + .from('pull_requests') + .select('id', { count: 'exact', head: true }) + .eq('state', 'open') + .in('repo_full_name', repos), + service + .from('pull_requests') + .select('id', { count: 'exact', head: true }) + .eq('state', 'open') + .eq('mentor_verified', true) + .in('repo_full_name', repos), + service + .from('pull_requests') + .select('id', { count: 'exact', head: true }) + .eq('state', 'open') + .lt('github_updated_at', sevenDaysAgo) + .in('repo_full_name', repos), + service.from('issues').select('id', { count: 'exact', head: true }).eq('state', 'open'), + ]); + + return ok({ + openPrs: openPrsRes.count ?? 0, + mentorVerified: mentorVerifiedRes.count ?? 0, + aiFlagged: 0, + openIssues: openIssuesRes.count ?? 0, + stalePrs: stalePrsRes.count ?? 0, + }); +} + +export async function getMaintainerRecentActivity( + installationId: 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'); + + if (!(await isUserMaintainer(user.id))) { + return err('not_authorised', 'not a maintainer'); + } + + const repos = await listMaintainerRepos(user.id, installationId); + const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(); + + const [mergedRes, profilesRes, openedRes] = await Promise.all([ + repos.length > 0 + ? service + .from('pull_requests') + .select('number, title, author_login, merged_at') + .eq('state', 'merged') + .in('repo_full_name', repos) + .gte('merged_at', since) + .order('merged_at', { ascending: false }) + .limit(5) + : Promise.resolve({ data: [] }), + service + .from('profiles') + .select('github_handle, created_at') + .gte('created_at', since) + .order('created_at', { ascending: false }) + .limit(5), + repos.length > 0 + ? service + .from('pull_requests') + .select('number, title, author_login, github_created_at') + .eq('state', 'open') + .in('repo_full_name', repos) + .gte('github_created_at', since) + .order('github_created_at', { ascending: false }) + .limit(5) + : Promise.resolve({ data: [] }), + ]); + + const items: ActivityItem[] = []; + + for (const r of (mergedRes.data ?? []) as { + number: number; + title: string; + author_login: string; + merged_at: string; + }[]) { + items.push({ + type: 'pr_merged', + text: `#${r.number} merged`, + subtext: r.title, + timestamp: r.merged_at, + }); + } + + for (const r of (profilesRes.data ?? []) as { github_handle: string; created_at: string }[]) { + items.push({ + type: 'contributor_joined', + text: `@${r.github_handle} joined`, + subtext: 'New contributor', + timestamp: r.created_at, + }); + } + + for (const r of (openedRes.data ?? []) as { + number: number; + title: string; + author_login: string; + github_created_at: string; + }[]) { + items.push({ + type: 'pr_opened', + text: `#${r.number} opened`, + subtext: r.title, + timestamp: r.github_created_at, + }); + } + + items.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); + + return ok(items.slice(0, 10)); +} + +export async function getMaintainerHelpRequests(): 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'); + + if (!(await isUserMaintainer(user.id))) { + return err('not_authorised', 'not a maintainer'); + } + + const { data: requests } = await service + .from('help_requests') + .select('id, pr_url, reason, user_id, created_at') + .in('status', ['open', 'escalated']) + .order('created_at', { ascending: false }) + .limit(5); + + const rows = (requests ?? []) as { + id: number; + pr_url: string | null; + reason: string | null; + user_id: string; + created_at: string; + }[]; + + const userIds = Array.from(new Set(rows.map((r) => r.user_id))); + const handleMap = new Map(); + + if (userIds.length > 0) { + const { data: profiles } = await service + .from('profiles') + .select('id, github_handle') + .in('id', userIds); + for (const p of profiles ?? []) { + handleMap.set(p.id, p.github_handle); + } + } + + return ok( + rows.map((r) => ({ + id: r.id, + handle: handleMap.get(r.user_id) ?? 'unknown', + message: r.reason ?? r.pr_url ?? 'Help requested', + prUrl: r.pr_url, + createdAt: r.created_at, + })), + ); +} + +export async function getMaintainerLevelDistribution(): 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'); + + if (!(await isUserMaintainer(user.id))) { + return err('not_authorised', 'not a maintainer'); + } + + const { data } = await service.from('profiles').select('level').limit(1000); + + const rows = (data ?? []) as { level: number | null }[]; + let l0 = 0, + l1 = 0, + l2 = 0, + l3plus = 0; + for (const r of rows) { + const lvl = r.level ?? 0; + if (lvl === 0) l0++; + else if (lvl === 1) l1++; + else if (lvl === 2) l2++; + else l3plus++; + } + + return ok({ l0, l1, l2, l3plus }); +} + +export async function getMaintainerContributors(args: { + installationId: number; + page?: 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'); + + if (!(await isUserMaintainer(user.id))) { + return err('not_authorised', 'not a maintainer'); + } + + const repos = await listMaintainerRepos(user.id, args.installationId); + if (repos.length === 0) return ok({ rows: [], hasMore: false }); + + const page = Math.max(0, args.page ?? 0); + const limit = 25; + + const { data: prAuthorRows } = await service + .from('pull_requests') + .select('author_user_id') + .in('repo_full_name', repos) + .not('author_user_id', 'is', null); + + const authorIds = Array.from( + new Set( + (prAuthorRows ?? []) + .map((r: { author_user_id: string | null }) => r.author_user_id) + .filter((id): id is string => !!id), + ), + ); + + if (authorIds.length === 0) return ok({ rows: [], hasMore: false }); + + const { data: profiles } = await service + .from('profiles') + .select('id, github_handle, level, xp, created_at') + .in('id', authorIds) + .order('xp', { ascending: false }) + .range(page * limit, (page + 1) * limit); + + const profileRows = (profiles ?? []) as { + id: string; + github_handle: string; + level: number | null; + xp: number | null; + created_at: string; + }[]; + + const pageIds = profileRows.map((p) => p.id); + const mergedCountMap = new Map(); + + if (pageIds.length > 0) { + const { data: mergedPrs } = await service + .from('pull_requests') + .select('author_user_id') + .eq('state', 'merged') + .in('author_user_id', pageIds) + .in('repo_full_name', repos); + for (const r of (mergedPrs ?? []) as { author_user_id: string }[]) { + mergedCountMap.set(r.author_user_id, (mergedCountMap.get(r.author_user_id) ?? 0) + 1); + } + } + + const rows: ContributorRow[] = profileRows.map((p) => ({ + id: p.id, + handle: p.github_handle, + level: p.level ?? 0, + xp: p.xp ?? 0, + mergedPrs: mergedCountMap.get(p.id) ?? 0, + joinedAt: p.created_at, + })); + + return ok({ rows, hasMore: rows.length === limit }); +} + +export async function getMaintainerMentors(installationId: 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'); + + if (!(await isUserMaintainer(user.id))) { + return err('not_authorised', 'not a maintainer'); + } + + // installationId is used for auth context (already checked above via isUserMaintainer) + void installationId; + + const { data: mentorProfiles } = await service + .from('profiles') + .select('id, github_handle, level, xp') + .gte('level', 2) + .order('level', { ascending: false }) + .order('xp', { ascending: false }) + .limit(20); + + const profiles = (mentorProfiles ?? []) as { + id: string; + github_handle: string; + level: number; + xp: number; + }[]; + + const mentorIds = profiles.map((p) => p.id); + const resolvedMap = new Map(); + + if (mentorIds.length > 0) { + const { data: resolved } = await service + .from('help_requests') + .select('resolved_by') + .in('resolved_by', mentorIds) + .not('resolved_by', 'is', null); + for (const r of (resolved ?? []) as { resolved_by: string }[]) { + resolvedMap.set(r.resolved_by, (resolvedMap.get(r.resolved_by) ?? 0) + 1); + } + } + + const rows: MentorRow[] = profiles.map((p) => ({ + id: p.id, + handle: p.github_handle, + level: p.level, + xp: p.xp, + helpResolved: resolvedMap.get(p.id) ?? 0, + })); + + return ok(rows); +}