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
79 changes: 79 additions & 0 deletions src/app/(app)/maintainer/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@ import { getServerSupabase } from '@/lib/supabase/server';
import { isUserMaintainer } from '@/lib/maintainer/detect';
import {
getMaintainerInstalls,
getMaintainerFlags,
getMaintainerPrQueue,
getRepoHealthOverview,
getStaleIssues,
getTopContributors,
type MaintainerInstall,
type MaintainerFlagRow,
type MaintainerPrRow,
type RepoHealthRow,
type StaleIssueRow,
Expand Down Expand Up @@ -82,6 +84,9 @@ export default async function MaintainerPage({
const contributorsRes = await getTopContributors();
const topContributors: ContributorRow[] = isOk(contributorsRes) ? contributorsRes.data : [];

const flagsRes = await getMaintainerFlags(8);
const flags: MaintainerFlagRow[] = isOk(flagsRes) ? flagsRes.data : [];

return (
<div className="min-h-screen bg-zinc-950 px-6 py-12 text-white">
<div className="mx-auto max-w-5xl">
Expand Down Expand Up @@ -229,6 +234,8 @@ export default async function MaintainerPage({
</section>
</div>

<FlaggedAccountsPanel flags={flags} />

{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 @@ -289,6 +296,55 @@ export default async function MaintainerPage({
);
}

function FlaggedAccountsPanel({ flags }: { flags: MaintainerFlagRow[] }) {
return (
<section className="mb-6 rounded-2xl border border-zinc-800 bg-zinc-900 p-5">
<div className="mb-3 flex items-baseline justify-between gap-3">
<div>
<h2 className="font-display text-lg font-semibold">XP audit flags</h2>
<p className="mt-1 text-xs text-zinc-500">
Detection only. Review the evidence before taking any action.
</p>
</div>
<span className="rounded-full bg-zinc-800 px-2.5 py-0.5 text-xs text-zinc-400">
{flags.length} open
</span>
</div>

{flags.length === 0 ? (
<div className="rounded-xl border border-zinc-800 bg-zinc-950 px-4 py-3 text-sm text-zinc-500">
No suspicious XP patterns flagged.
</div>
) : (
<ul className="space-y-2">
{flags.map((flag) => (
<li
key={flag.id}
className="rounded-xl border border-zinc-800 bg-zinc-950 px-4 py-3 text-sm"
>
<div className="flex flex-wrap items-center gap-2">
<span
className={`rounded-full px-2 py-0.5 text-xs ${severityColor(flag.severity)}`}
>
{flag.severity}
</span>
<span className="font-medium text-zinc-200">{reasonLabel(flag.reason)}</span>
<span className="text-xs text-zinc-500">
{flag.githubHandle ? `@${flag.githubHandle}` : (flag.userId ?? 'unknown user')}
</span>
<span className="ml-auto text-xs text-zinc-600">
{new Date(flag.createdAt).toLocaleString()}
</span>
</div>
<p className="mt-2 text-xs text-zinc-500">{formatFlagEvidence(flag.evidence)}</p>
</li>
))}
</ul>
)}
</section>
);
}

function FilterPill({ label, href, active }: { label: string; href: string; active: boolean }) {
return (
<Link
Expand Down Expand Up @@ -329,6 +385,29 @@ function stateColor(state: 'open' | 'closed' | 'merged'): string {
return 'bg-zinc-800 text-zinc-400';
}

function severityColor(severity: 'medium' | 'high'): string {
if (severity === 'high') return 'bg-red-950 text-red-300 ring-1 ring-red-800';
return 'bg-amber-950 text-amber-300 ring-1 ring-amber-800';
}

function reasonLabel(reason: MaintainerFlagRow['reason']): string {
const labels: Record<MaintainerFlagRow['reason'], string> = {
daily_event_burst: 'Daily XP event burst',
hourly_merge_burst: 'Hourly merge burst',
review_pair_burst: 'Repeated reviewer pair',
};
return labels[reason];
}

function formatFlagEvidence(evidence: Record<string, unknown>): string {
const parts: string[] = [];
for (const key of ['eventCount', 'mergeCount', 'reviewCount', 'totalXp', 'day', 'week']) {
const value = evidence[key];
if (value !== null && value !== undefined) parts.push(`${key}=${String(value)}`);
}
return parts.length > 0 ? parts.join(' · ') : 'Evidence payload captured for review.';
}

function relativeTime(iso: string): string {
const ms = Date.now() - new Date(iso).getTime();
const min = Math.floor(ms / 60000);
Expand Down
63 changes: 63 additions & 0 deletions src/app/actions/maintainer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,17 @@ export type ContributorRow = {
level: number;
};

export type MaintainerFlagRow = {
id: number;
userId: string | null;
githubHandle: string | null;
reason: 'daily_event_burst' | 'hourly_merge_burst' | 'review_pair_burst';
severity: 'medium' | 'high';
status: 'open' | 'resolved' | 'dismissed';
evidence: Record<string, unknown>;
createdAt: string;
};

const ISSUE_BUCKETS = new Set<IssueTriageBucket>([
'needs-triage',
'in-progress',
Expand Down Expand Up @@ -354,6 +365,58 @@ export async function getMaintainerIssueQueue(args: {
return ok({ rows: pageRows, hasMore: filtered.length > PAGE_SIZE });
}

export async function getMaintainerFlags(limit = 10): Promise<Result<MaintainerFlagRow[]>> {
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');
}

type RawFlag = {
id: number;
user_id: string | null;
reason: MaintainerFlagRow['reason'];
severity: MaintainerFlagRow['severity'];
status: MaintainerFlagRow['status'];
evidence: Record<string, unknown>;
created_at: string;
profiles: { github_handle?: string | null } | { github_handle?: string | null }[] | null;
};

const { data, error } = await service
.from('flagged_accounts')
.select('id, user_id, reason, severity, status, evidence, created_at, profiles(github_handle)')
.eq('status', 'open')
.order('created_at', { ascending: false })
.limit(Math.max(1, Math.min(50, limit)));

if (error) return err('db_error', error.message);

const rows = ((data ?? []) as RawFlag[]).map((row) => {
const profile = Array.isArray(row.profiles) ? row.profiles[0] : row.profiles;
return {
id: row.id,
userId: row.user_id,
githubHandle: profile?.github_handle ?? null,
reason: row.reason,
severity: row.severity,
status: row.status,
evidence: row.evidence ?? {},
createdAt: row.created_at,
};
});

return ok(rows);
}

export async function refreshMaintainerBackfill(
installationId: number,
): Promise<Result<{ ok: true }>> {
Expand Down
2 changes: 2 additions & 0 deletions src/app/api/inngest/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { githubStatsSync } from '@/inngest/functions/github-stats-sync';
import { mentorPostComment } from '@/inngest/functions/mentor-post-comment';
import { processIssueEvent } from '@/inngest/functions/process-issue-event';
import { processIssueCommentEvent } from '@/inngest/functions/process-issue-comment-event';
import { suspiciousXpAudit } from '@/inngest/functions/suspicious-xp-audit';

export const { GET, POST, PUT } = serve({
client: inngest,
Expand All @@ -44,5 +45,6 @@ export const { GET, POST, PUT } = serve({
mentorPostComment,
processIssueEvent,
processIssueCommentEvent,
suspiciousXpAudit,
],
});
151 changes: 151 additions & 0 deletions src/inngest/functions/suspicious-xp-audit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { inngest } from '../client';
import { getServiceSupabase } from '@/lib/supabase/service';
import {
detectSuspiciousXpPatterns,
type ReviewAuditEvent,
type XpAuditEvent,
} from '@/lib/xp/suspicious-flags';

const LOOKBACK_DAYS = 8;
const AUDIT_PAGE_SIZE = 1000;

type SupabasePage<T> = {
data: T[] | null;
error: { message: string } | null;
};

type XpAuditRow = {
id: number;
user_id: string;
source: string;
ref_id: string;
repo: string | null;
xp_delta: number;
created_at: string;
};

type ReviewAuditRow = {
id: number;
pr_id: number;
reviewer_user_id: string | null;
state: string;
submitted_at: string;
pull_requests:
| {
author_user_id: string | null;
repo_full_name: string | null;
number: number | null;
}
| {
author_user_id: string | null;
repo_full_name: string | null;
number: number | null;
}[]
| null;
};

async function fetchAllAuditRows<T>(
buildQuery: (from: number, to: number) => PromiseLike<SupabasePage<T>>,
): Promise<T[]> {
const rows: T[] = [];

for (let from = 0; ; from += AUDIT_PAGE_SIZE) {
const to = from + AUDIT_PAGE_SIZE - 1;
const { data, error } = await buildQuery(from, to);
if (error) throw new Error(error.message);

const page = data ?? [];
rows.push(...page);

if (page.length < AUDIT_PAGE_SIZE) {
return rows;
}
}
}

export const suspiciousXpAudit = inngest.createFunction(
{ id: 'suspicious-xp-audit' },
{ cron: '30 2 * * *' },
async ({ step }) => {
return await step.run('detect-and-upsert-flags', async () => {
const sb = getServiceSupabase();
if (!sb) throw new Error('service role missing');

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

const [xpRows, reviewRows] = await Promise.all([
fetchAllAuditRows<XpAuditRow>(
(from, to) =>
sb
.from('xp_events')
.select('id, user_id, source, ref_id, repo, xp_delta, created_at')
.gte('created_at', since)
.order('created_at', { ascending: true })
.order('id', { ascending: true })
.range(from, to) as unknown as PromiseLike<SupabasePage<XpAuditRow>>,
),
fetchAllAuditRows<ReviewAuditRow>(
(from, to) =>
sb
.from('pull_request_reviews')
.select(
'id, pr_id, reviewer_user_id, state, submitted_at, ' +
'pull_requests!inner(author_user_id, repo_full_name, number)',
)
.gte('submitted_at', since)
.order('submitted_at', { ascending: true })
.order('id', { ascending: true })
.range(from, to) as unknown as PromiseLike<SupabasePage<ReviewAuditRow>>,
),
]);

const xpEvents: XpAuditEvent[] = xpRows.map((row) => ({
id: row.id,
userId: row.user_id,
source: row.source,
refId: row.ref_id,
repo: row.repo,
xpDelta: row.xp_delta,
createdAt: row.created_at,
}));

const reviewEvents: ReviewAuditEvent[] = reviewRows.map((row) => {
const pr = Array.isArray(row.pull_requests) ? row.pull_requests[0] : row.pull_requests;
return {
id: row.id,
prId: row.pr_id,
reviewerUserId: row.reviewer_user_id,
authorUserId: pr?.author_user_id ?? null,
state: row.state,
submittedAt: row.submitted_at,
repoFullName: pr?.repo_full_name ?? null,
prNumber: pr?.number ?? null,
};
});

const flags = detectSuspiciousXpPatterns({ xpEvents, reviewEvents });
if (flags.length === 0) {
return { scannedXpEvents: xpEvents.length, scannedReviews: reviewEvents.length, flags: 0 };
}

const { error } = await sb.from('flagged_accounts').upsert(
flags.map((flag) => ({
user_id: flag.userId,
reason: flag.reason,
severity: flag.severity,
status: 'open',
dedupe_key: flag.dedupeKey,
evidence: flag.evidence,
})),
{ onConflict: 'dedupe_key', ignoreDuplicates: true },
);
if (error) throw new Error(error.message);

return {
scannedXpEvents: xpEvents.length,
scannedReviews: reviewEvents.length,
flags: flags.length,
};
});
},
);
26 changes: 26 additions & 0 deletions src/lib/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,32 @@ export const activityLog = pgTable(
}),
);

export const flaggedAccounts = pgTable(
'flagged_accounts',
{
id: bigserial('id', { mode: 'number' }).primaryKey(),
userId: uuid('user_id').references(() => profiles.id, { onDelete: 'set null' }),
reason: text('reason', {
enum: ['daily_event_burst', 'hourly_merge_burst', 'review_pair_burst'],
}).notNull(),
severity: text('severity', { enum: ['medium', 'high'] })
.notNull()
.default('medium'),
status: text('status', { enum: ['open', 'resolved', 'dismissed'] })
.notNull()
.default('open'),
dedupeKey: text('dedupe_key').notNull(),
evidence: jsonb('evidence').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
resolvedAt: timestamp('resolved_at', { withTimezone: true }),
},
(t) => ({
dedupeKeyUnique: uniqueIndex('flagged_accounts_dedupe_key_unique').on(t.dedupeKey),
statusCreatedIdx: index('flagged_accounts_status_created_idx').on(t.status, t.createdAt),
userCreatedIdx: index('flagged_accounts_user_created_idx').on(t.userId, t.createdAt),
}),
);

// ========================================================================
// Maintainer-side tables (migration 0005)
// ========================================================================
Expand Down
Loading
Loading