Skip to content
Merged
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
49 changes: 49 additions & 0 deletions src/app/(app)/maintainer/export-csv-button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
'use client';

import { useState } from 'react';
import { exportPrQueueCsv } from '@/app/actions/maintainer';
import { type QueueFilters } from '@/lib/maintainer/queue';
import { isOk } from '@/lib/result';

export default function ExportCsvButton({
installationId,
filters,
}: {
installationId: number;
filters: Partial<QueueFilters>;
}) {
const [loading, setLoading] = useState(false);

async function handleExport() {
setLoading(true);
try {
const res = await exportPrQueueCsv(installationId, filters);
if (isOk(res)) {
const blob = new Blob([res.data], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `pr-queue-${installationId}.csv`;
a.click();
URL.revokeObjectURL(url);
} else {
alert(res.error.message || 'Failed to export CSV');
}
} catch (e) {
console.error(e);
alert('An unexpected error occurred');
} finally {
setLoading(false);
}
}

return (
<button
onClick={handleExport}
disabled={loading}
className="rounded-lg border border-zinc-700 px-3 py-1 text-zinc-300 hover:border-zinc-600 disabled:opacity-50"
>
{loading ? 'Exporting...' : 'Export CSV'}
</button>
);
}
16 changes: 10 additions & 6 deletions 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 ExportCsvButton from './export-csv-button';

export const dynamic = 'force-dynamic';

Expand Down Expand Up @@ -142,12 +143,15 @@ export default async function MaintainerPage({
href={withParam('verified', '', searchParams)}
active={!searchParams.verified}
/>
<Link
href={`/maintainer/issues?install=${activeInstallId}`}
className="ml-auto rounded-lg border border-zinc-700 px-3 py-1 text-zinc-300 hover:border-zinc-600"
>
Issue triage →
</Link>
<div className="ml-auto flex items-center gap-2">
<ExportCsvButton installationId={activeInstallId} filters={filters} />
<Link
href={`/maintainer/issues?install=${activeInstallId}`}
className="rounded-lg border border-zinc-700 px-3 py-1 text-zinc-300 hover:border-zinc-600"
>
Issue triage →
</Link>
</div>
<Link
href={`/maintainer/community?install=${activeInstallId}`}
className="rounded-lg border border-zinc-700 px-3 py-1 text-zinc-300 hover:border-zinc-600"
Expand Down
171 changes: 171 additions & 0 deletions src/app/actions/maintainer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -821,3 +821,174 @@ export async function getTopContributors(): Promise<Result<ContributorRow[]>> {
})),
);
}

export async function exportPrQueueCsv(
installationId: number,
filters?: Partial<QueueFilters>,
): Promise<Result<string>> {
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:csv',
key: user.id,
limit: 10,
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, installationId);
if (repos.length === 0) {
return ok('');
}

const validFilters = validateFilters(filters ?? {});

const scopedRepos =
validFilters.repos.length > 0 ? repos.filter((r) => validFilters.repos.includes(r)) : repos;
if (scopedRepos.length === 0) {
return ok('');
}

let q = service
.from('pull_requests')
.select(
'id, repo_full_name, number, title, url, state, draft, author_login, ' +
'author_user_id, mentor_verified, mentor_reviewer_id, github_updated_at',
)
.in('repo_full_name', scopedRepos);

if (validFilters.state.length > 0) q = q.in('state', validFilters.state);
if (validFilters.mentorVerified === 'yes') q = q.eq('mentor_verified', true);
else if (validFilters.mentorVerified === 'no') q = q.eq('mentor_verified', false);

type RawPr = {
id: number;
repo_full_name: string;
number: number;
title: string;
url: string;
state: 'open' | 'closed' | 'merged';
draft: boolean;
author_login: string;
author_user_id: string | null;
mentor_verified: boolean;
mentor_reviewer_id: string | null;
github_updated_at: string;
};

const { data: prs } = await q.order('github_updated_at', { ascending: false }).limit(1000);

const prRows = (prs ?? []) as unknown as RawPr[];

const authorIds = Array.from(
new Set(prRows.map((r) => r.author_user_id).filter((id): id is string => !!id)),
);
const mentorIds = Array.from(
new Set(prRows.map((r) => r.mentor_reviewer_id).filter((id): id is string => !!id)),
);

const profilesById = new Map<
string,
{ handle: string; level: number; xp: number; mergedPrs: number }
>();

const ids = Array.from(new Set([...authorIds, ...mentorIds]));
if (ids.length > 0) {
const { data: profileRows } = await service
.from('profiles')
.select('id, github_handle, level, xp')
.in('id', ids);
const merged = await service
.from('xp_events')
.select('user_id')
.in('user_id', ids)
.eq('source', 'recommended_merge');
const mergedCount = new Map<string, number>();
for (const row of merged.data ?? []) {
mergedCount.set(row.user_id, (mergedCount.get(row.user_id) ?? 0) + 1);
}
for (const p of profileRows ?? []) {
profilesById.set(p.id, {
handle: p.github_handle,
level: p.level ?? 0,
xp: p.xp ?? 0,
mergedPrs: mergedCount.get(p.id) ?? 0,
});
}
}

let rows: MaintainerPrRow[] = prRows.map((r) => {
const author = r.author_user_id ? (profilesById.get(r.author_user_id) ?? null) : null;
const mentor = r.mentor_reviewer_id ? (profilesById.get(r.mentor_reviewer_id) ?? null) : null;
return {
id: r.id,
repoFullName: r.repo_full_name,
number: r.number,
title: r.title,
url: r.url,
state: r.state as 'open' | 'closed' | 'merged',
draft: r.draft,
authorLogin: r.author_login,
authorLevel: author?.level ?? null,
authorXp: author?.xp ?? null,
authorMergedPrs: author?.mergedPrs ?? null,
mentorVerified: r.mentor_verified,
mentorReviewerHandle: mentor?.handle ?? null,
mentorReviewerLevel: mentor?.level ?? null,
githubUpdatedAt: r.github_updated_at,
};
});

if (validFilters.authorLevel.length > 0) {
rows = rows.filter((row) => validFilters.authorLevel.includes(row.authorLevel ?? 0));
}

rows.sort(comparePrRows);

const escapeCsv = (str: string) => `"${str.replace(/"/g, '""')}"`;

const header = [
'PR #',
'Title',
'Author',
'Author Level',
'Verified',
'Repo',
'Age (days)',
'URL',
];
const csvLines = [header.join(',')];

const now = Date.now();

for (const r of rows) {
const ageDays = Math.floor(
(now - new Date(r.githubUpdatedAt).getTime()) / (1000 * 60 * 60 * 24),
);
const line = [
r.number.toString(),
escapeCsv(r.title),
r.authorLogin,
r.authorLevel !== null ? r.authorLevel.toString() : '',
r.mentorVerified ? 'Yes' : 'No',
r.repoFullName,
ageDays.toString(),
r.url,
];
csvLines.push(line.join(','));
}

return ok(csvLines.join('\n'));
}
Loading