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
134 changes: 134 additions & 0 deletions src/app/(app)/maintainer/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@ import { redirect } from 'next/navigation';
import { getServerSupabase } from '@/lib/supabase/server';
import { isUserMaintainer } from '@/lib/maintainer/detect';
import {
getMaintainerAnalytics,
getMaintainerInstalls,
getMaintainerPrQueue,
type MaintainerInstall,
type MaintainerPrRow,
} from '@/app/actions/maintainer';
import type { MaintainerAnalytics } from '@/lib/maintainer/analytics';
import { isOk } from '@/lib/result';
import RefreshButton from './refresh-button';

Expand Down Expand Up @@ -67,6 +69,8 @@ export default async function MaintainerPage({
filters,
});
const rows: MaintainerPrRow[] = isOk(queueRes) ? queueRes.data.rows : [];
const analyticsRes = await getMaintainerAnalytics({ installationId: activeInstallId });
const analytics: MaintainerAnalytics | null = isOk(analyticsRes) ? analyticsRes.data : null;

return (
<div className="min-h-screen bg-zinc-950 px-6 py-12 text-white">
Expand Down Expand Up @@ -145,6 +149,8 @@ export default async function MaintainerPage({
{activeInstall.accountLogin} ({activeInstall.permissionLevel.replace('_', ' ')})
</p>

{analytics && <AnalyticsPanel analytics={analytics} installationId={activeInstallId} />}

{rows.length === 0 ? (
<div className="rounded-2xl border border-zinc-800 bg-zinc-900 p-8 text-zinc-400">
No PRs match your filters. Try widening state or running a refresh.
Expand Down Expand Up @@ -205,6 +211,134 @@ export default async function MaintainerPage({
);
}

function AnalyticsPanel({
analytics,
installationId,
}: {
analytics: MaintainerAnalytics;
installationId: number;
}) {
const maxMerged = Math.max(1, ...analytics.weekly.map((point) => point.mergedPrs));
const maxXp = Math.max(1, ...analytics.weekly.map((point) => point.xpDistributed));

return (
<section className="mb-8">
<div className="mb-3 flex items-end justify-between gap-4">
<div>
<h2 className="font-display text-xl font-semibold">Analytics trends</h2>
<p className="mt-1 text-xs text-zinc-500">
Cached for 30 minutes · generated {relativeTime(analytics.generatedAt)}
</p>
</div>
<Link
href={`/maintainer/issues?install=${installationId}`}
className="hidden rounded-lg border border-zinc-700 px-3 py-1 text-xs text-zinc-300 hover:border-zinc-600 sm:inline-flex"
>
Review issue health →
</Link>
</div>

<div className="mb-4 grid gap-3 md:grid-cols-4">
<MetricCard label="Merged PRs / 12w" value={analytics.totals.mergedPrs12w} />
<MetricCard label="Avg merge rate" value={`${analytics.totals.mergeRatePerWeek}/wk`} />
<MetricCard
label="XP distributed"
value={analytics.totals.xpDistributed12w.toLocaleString()}
/>
<MetricCard label="Active contributors" value={analytics.totals.activeContributors12w} />
</div>

<div className="grid gap-4 lg:grid-cols-2">
<div className="rounded-2xl border border-zinc-800 bg-zinc-900 p-4">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-sm font-semibold text-zinc-100">Weekly merge rate</h3>
<span className="text-xs text-zinc-500">last 12 weeks</span>
</div>
<div className="flex h-40 items-end gap-2">
{analytics.weekly.map((point) => (
<div key={point.start} className="flex min-w-0 flex-1 flex-col items-center gap-2">
<div
className="w-full rounded-t bg-emerald-400/80"
style={{ height: `${Math.max(4, (point.mergedPrs / maxMerged) * 100)}%` }}
title={`${point.label}: ${point.mergedPrs} merged PRs`}
/>
<span className="max-w-full truncate text-[10px] text-zinc-600">{point.label}</span>
</div>
))}
</div>
</div>

<div className="rounded-2xl border border-zinc-800 bg-zinc-900 p-4">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-sm font-semibold text-zinc-100">XP distributed per week</h3>
<span className="text-xs text-zinc-500">completed recommendations</span>
</div>
<div className="flex h-40 items-end gap-2">
{analytics.weekly.map((point) => (
<div key={point.start} className="flex min-w-0 flex-1 flex-col items-center gap-2">
<div
className="w-full rounded-t bg-sky-400/80"
style={{ height: `${Math.max(4, (point.xpDistributed / maxXp) * 100)}%` }}
title={`${point.label}: ${point.xpDistributed} XP`}
/>
<span className="max-w-full truncate text-[10px] text-zinc-600">{point.label}</span>
</div>
))}
</div>
</div>
</div>

<div className="mt-4 rounded-2xl border border-zinc-800 bg-zinc-900 p-4">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-sm font-semibold text-zinc-100">Contributor level distribution</h3>
<span className="text-xs text-zinc-500">monthly cohort view</span>
</div>
<div className="space-y-3">
{analytics.levelDistribution.map((point) => (
<StackedLevelRow key={point.monthStart} point={point} />
))}
</div>
</div>
</section>
);
}

function MetricCard({ label, value }: { label: string; value: number | string }) {
return (
<div className="rounded-2xl border border-zinc-800 bg-zinc-900 p-4">
<div className="text-[10px] uppercase tracking-widest text-zinc-500">{label}</div>
<div className="mt-2 font-display text-2xl font-bold text-white">{value}</div>
</div>
);
}

function StackedLevelRow({ point }: { point: MaintainerAnalytics['levelDistribution'][number] }) {
const total = point.l0 + point.l1 + point.l2 + point.l3Plus;
const segments = [
{ label: 'L0', value: point.l0, color: 'bg-zinc-600' },
{ label: 'L1', value: point.l1, color: 'bg-emerald-500' },
{ label: 'L2', value: point.l2, color: 'bg-sky-500' },
{ label: 'L3+', value: point.l3Plus, color: 'bg-purple-500' },
];

return (
<div className="grid grid-cols-[3rem_1fr_5rem] items-center gap-3 text-xs">
<span className="text-zinc-500">{point.label}</span>
<div className="flex h-3 overflow-hidden rounded-full bg-zinc-800">
{segments.map((segment) => (
<span
key={segment.label}
className={segment.color}
style={{ width: `${total > 0 ? (segment.value / total) * 100 : 0}%` }}
title={`${segment.label}: ${segment.value}`}
/>
))}
</div>
<span className="text-right text-zinc-500">{total} users</span>
</div>
);
}

function FilterPill({ label, href, active }: { label: string; href: string; active: boolean }) {
return (
<Link
Expand Down
119 changes: 119 additions & 0 deletions src/app/actions/maintainer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,20 @@ import { getServerSupabase } from '@/lib/supabase/server';
import { getServiceSupabase } from '@/lib/supabase/service';
import { ok, err, type Result } from '@/lib/result';
import { rateLimit } from '@/lib/rate-limit';
import { cacheGet, cacheSet } from '@/lib/cache';
import {
isUserMaintainer,
listMaintainerInstalls,
listMaintainerRepos,
type MaintainerInstall,
} from '@/lib/maintainer/detect';
import {
buildMaintainerAnalytics,
type MaintainerAnalytics,
type PrAnalyticsRow,
type ProfileAnalyticsRow,
type RecommendationAnalyticsRow,
} from '@/lib/maintainer/analytics';
import {
comparePrRows,
validateFilters,
Expand Down Expand Up @@ -51,6 +59,7 @@ const ISSUE_BUCKETS = new Set<IssueTriageBucket>([
]);

const PAGE_SIZE = 25;
const ANALYTICS_TTL_S = 30 * 60;

export async function getMaintainerInstalls(): Promise<Result<MaintainerInstall[]>> {
const sb = getServerSupabase();
Expand Down Expand Up @@ -334,6 +343,116 @@ export async function getMaintainerIssueQueue(args: {
return ok({ rows: pageRows, hasMore: filtered.length > PAGE_SIZE });
}

export async function getMaintainerAnalytics(args: {
installationId: number;
}): Promise<Result<MaintainerAnalytics>> {
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: 'maint:analytics',
key: user.id,
limit: 30,
windowSec: 60,
});
if (!limited.ok) return err('rate_limited', 'slow down', true);

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(buildMaintainerAnalytics({ prs: [], recommendations: [], profiles: [] }));
}

const cacheKey = `maint:analytics:${user.id}:${args.installationId}`;
const cached = await cacheGet<MaintainerAnalytics>(cacheKey);
if (cached) return ok(cached);

const since = new Date(Date.now() - 12 * 7 * 24 * 60 * 60 * 1000).toISOString();

type RawPrAnalyticsRow = {
id: number;
state: 'open' | 'closed' | 'merged';
author_user_id: string | null;
github_created_at: string;
github_updated_at: string;
merged_at: string | null;
};

const { data: prRows } = await service
.from('pull_requests')
.select('id, state, author_user_id, github_created_at, github_updated_at, merged_at')
.in('repo_full_name', repos)
.or(`github_updated_at.gte.${since},merged_at.gte.${since}`);

type RawRecommendationRow = {
xp_reward: number;
completed_at: string | null;
issues?: { repo_full_name?: string | null } | { repo_full_name?: string | null }[] | null;
};

const { data: recRows } = await service
.from('recommendations')
.select('xp_reward, completed_at, issues!inner(repo_full_name)')
.eq('status', 'completed')
.gte('completed_at', since)
.in('issues.repo_full_name', repos);

const authorIds = Array.from(
new Set(
((prRows ?? []) as RawPrAnalyticsRow[])
.map((row) => row.author_user_id)
.filter((id): id is string => !!id),
),
);

type RawProfileRow = {
id: string;
level: number | null;
created_at: string;
};

const { data: profileRows } =
authorIds.length > 0
? await service.from('profiles').select('id, level, created_at').in('id', authorIds)
: { data: [] };

const prs: PrAnalyticsRow[] = ((prRows ?? []) as RawPrAnalyticsRow[]).map((row) => ({
id: row.id,
state: row.state,
authorUserId: row.author_user_id,
githubCreatedAt: row.github_created_at,
githubUpdatedAt: row.github_updated_at,
mergedAt: row.merged_at,
}));

const recommendations: RecommendationAnalyticsRow[] = (
(recRows ?? []) as RawRecommendationRow[]
).map((row) => ({
xpReward: row.xp_reward ?? 0,
completedAt: row.completed_at,
}));

const profiles: ProfileAnalyticsRow[] = ((profileRows ?? []) as RawProfileRow[]).map((row) => ({
id: row.id,
level: row.level,
createdAt: row.created_at,
}));

const analytics = buildMaintainerAnalytics({ prs, recommendations, profiles });
await cacheSet(cacheKey, analytics, ANALYTICS_TTL_S);
return ok(analytics);
}

export async function refreshMaintainerBackfill(
installationId: number,
): Promise<Result<{ ok: true }>> {
Expand Down
Loading
Loading