diff --git a/src/app/(app)/maintainer/page.tsx b/src/app/(app)/maintainer/page.tsx index f890070d..ae62bf51 100644 --- a/src/app/(app)/maintainer/page.tsx +++ b/src/app/(app)/maintainer/page.tsx @@ -1,4 +1,5 @@ import Link from 'next/link'; +import { pingReviewers } from '@/app/actions/maintainer'; import { redirect } from 'next/navigation'; import { getServerSupabase } from '@/lib/supabase/server'; import { isUserMaintainer } from '@/lib/maintainer/detect'; @@ -8,6 +9,7 @@ import { getMaintainerAnalyticsTrends, getRepoHealthOverview, getStaleIssues, + getStalePrs, getFlaggedAccounts, getTopContributors, getInstallationSettings, @@ -19,6 +21,7 @@ import { type InstallationSettingsData, type RepoHealthRow, type StaleIssueRow, + type StalePrRow, type ContributorRow, type ReviewerLoadRow, type NoiseBreakdown, @@ -37,6 +40,7 @@ import { VerifyButton } from '../issues/verify-button'; import ExportCsvButton from './export-csv-button'; import QueueSettings from './queue-settings'; import { ResolveFlagButton } from './resolve-flag-button'; +import { StalePrBanner } from './stale-pr-banner'; import { getContributorFunnel } from '@/app/actions/maintainer/analytics'; import type { ContributorFunnelData } from '@/app/actions/maintainer/types'; import { ContributorFunnel } from './contributor-funnel'; @@ -146,6 +150,8 @@ export default async function MaintainerPage({ const reviewerLoads: ReviewerLoadRow[] = isOk(reviewerLoadsRes) ? reviewerLoadsRes.data : []; const maxLoad = reviewerLoads.length > 0 ? Math.max(...reviewerLoads.map((r) => r.prCount)) : 0; + const stalePrsRes = await getStalePrs({ installationId: activeInstallId }); + const stalePrs: StalePrRow[] = isOk(stalePrsRes) ? stalePrsRes.data : []; let noise: NoiseBreakdown = { valid: 0, spamAi: 0, other: 0, total: 0 }; if (settings.aiPrDetection) { const noiseRes = await getNoiseBreakdown({ installationId: activeInstallId }); @@ -175,6 +181,7 @@ export default async function MaintainerPage({ return (
+

Maintainer

diff --git a/src/app/(app)/maintainer/stale-pr-banner.tsx b/src/app/(app)/maintainer/stale-pr-banner.tsx new file mode 100644 index 00000000..d41bd7eb --- /dev/null +++ b/src/app/(app)/maintainer/stale-pr-banner.tsx @@ -0,0 +1,104 @@ +'use client'; + +import { useState, useTransition } from 'react'; +import { AlertCircle, X } from 'lucide-react'; +import { isOk, type Result } from '@/lib/result'; +import type { StalePrRow } from '@/app/actions/maintainer/analytics'; + +type PingAction = (prId: number) => Promise>; + +export function StalePrBanner({ + stalePrs, + onPing, +}: { + stalePrs: StalePrRow[]; + onPing: PingAction; +}) { + const [dismissed, setDismissed] = useState>(new Set()); + const [pinged, setPinged] = useState>(new Set()); + const [errors, setErrors] = useState>({}); + const [pending, startTransition] = useTransition(); + const [activePingId, setActivePingId] = useState(null); + + const visible = stalePrs.filter((pr) => !dismissed.has(pr.id)); + + if (visible.length === 0) return null; + + function dismiss(id: number) { + setDismissed((prev) => new Set(prev).add(id)); + } + + function handlePing(pr: StalePrRow) { + setActivePingId(pr.id); + setErrors((prev) => { + const next = { ...prev }; + delete next[pr.id]; + return next; + }); + startTransition(async () => { + const res = await onPing(pr.id); + if (isOk(res)) { + setPinged((prev) => new Set(prev).add(pr.id)); + } else { + setErrors((prev) => ({ ...prev, [pr.id]: res.error.message })); + } + setActivePingId(null); + }); + } + + return ( +
+ {visible.map((pr) => { + const hasPinged = pinged.has(pr.id); + const isThisPending = pending && activePingId === pr.id; + const error = errors[pr.id]; + + return ( +
+
+ +
+

+ + {pr.repoFullName} #{pr.number} + {' '} + has been stalled for{' '} + {pr.daysSinceUpdate} days{' '} + waiting on reviewer feedback. +

+ {error && ( +

{error}

+ )} +
+
+
+ {hasPinged ? ( + + Pinged ✓ + + ) : ( + + )} + +
+
+ ); + })} +
+ ); +} diff --git a/src/app/actions/maintainer/analytics.ts b/src/app/actions/maintainer/analytics.ts index 47d2a73b..69f94872 100644 --- a/src/app/actions/maintainer/analytics.ts +++ b/src/app/actions/maintainer/analytics.ts @@ -125,6 +125,74 @@ export async function getStaleIssues(args: { ); } +export type StalePrRow = { + id: number; + number: number; + title: string; + url: string; + repoFullName: string; + daysSinceUpdate: number; + authorLogin: string; +}; + +export async function getStalePrs({ + installationId, + thresholdDays = 14, +}: { + installationId: number; + thresholdDays?: number; +}): Promise> { + const authRes = await requireMaintainer({ + rateLimit: { namespace: 'maintainer', ...RATE_LIMIT_TIERS.STANDARD }, + requireService: true, + }); + if (!authRes.ok) return authRes; + const { user, service } = authRes.data; + + const repos = await listMaintainerRepos(user.id, installationId); + if (repos.length === 0) { + return ok([]); + } + + const thresholdDate = new Date(); + thresholdDate.setDate(thresholdDate.getDate() - thresholdDays); + + const { data, error } = await service + .from('pull_requests') + .select('id, number, title, url, repo_full_name, github_updated_at, author_login') + .in('repo_full_name', repos) + .eq('state', 'open') + .lt('github_updated_at', thresholdDate.toISOString()) + .order('github_updated_at', { ascending: true }) + .limit(5); + + if (error) return err('db_error', error.message); + + type RawStalePr = { + id: number; + number: number; + title: string; + url: string; + repo_full_name: string; + github_updated_at: string; + author_login: string | null; + }; + + const rows: StalePrRow[] = ((data ?? []) as RawStalePr[]).map((row) => ({ + id: row.id, + number: row.number, + title: row.title, + url: row.url, + repoFullName: row.repo_full_name, + daysSinceUpdate: Math.floor( + (Date.now() - new Date(row.github_updated_at).getTime()) / 86_400_000, + ), + authorLogin: row.author_login ?? 'unknown', + })); + + return ok(rows); +} + export async function getTopContributors(args: { installationId: number; }): Promise> { @@ -160,7 +228,7 @@ export async function getTopContributors(args: { .limit(5); return ok( - rows.map((row) => ({ + rows.map((row: { githubHandle: string | null; xp: unknown; level: number | null }) => ({ githubHandle: row.githubHandle ?? 'unknown', xp: row.xp ? Number(row.xp) : 0, level: row.level ?? 0, @@ -501,12 +569,19 @@ export async function getReviewerLoad(args: { .orderBy(desc(count(pullRequests.id))); return ok( - rows.map((row) => ({ - reviewerId: row.reviewerId as string, - githubHandle: row.githubHandle, - avatarUrl: row.avatarUrl, - prCount: row.prCount, - })), + rows.map( + (row: { + reviewerId: string | null; + githubHandle: string; + avatarUrl: string | null; + prCount: number; + }) => ({ + reviewerId: row.reviewerId as string, + githubHandle: row.githubHandle, + avatarUrl: row.avatarUrl, + prCount: row.prCount, + }), + ), ); } catch (error: any) { return err('query_failed', error.message || 'Drizzle query failed'); diff --git a/src/app/actions/maintainer/index.ts b/src/app/actions/maintainer/index.ts index e5ca2186..7afdcfc4 100644 --- a/src/app/actions/maintainer/index.ts +++ b/src/app/actions/maintainer/index.ts @@ -1,55 +1,307 @@ +'use server'; +import { requireMaintainerAuth } from '@/lib/action-auth'; +import { getServiceSupabase } from '@/lib/supabase/service'; +import { getInstallOctokit } from '@/lib/github/app'; +import { ok, err, type Result } from '@/lib/result'; +import * as settingsActions from './settings'; +import * as queueActions from './queue'; +import * as communityActions from './community'; +import * as analyticsActions from './analytics'; +import * as flaggedAccountsActions from './flagged-accounts'; +import * as contributorsActions from './contributors'; +import * as failedEventsActions from './failed-events'; +import * as xpPreviewActions from './xp-preview'; +import * as invitesActions from './invites'; + export type * from './types'; +export type { StalePrRow } from './analytics'; +export type { ContributorListRow, ContributorStats } from './contributors'; +export type { FailedWebhookEventRow } from './failed-events'; +export type { XpPreviewBreakdown } from './xp-preview'; +export type { InviteRow } from './invites'; + +export async function getMaintainerInstalls( + ...args: Parameters +): ReturnType { + return settingsActions.getMaintainerInstalls(...args); +} + +export async function getInstallationSettings( + ...args: Parameters +): ReturnType { + return settingsActions.getInstallationSettings(...args); +} + +export async function setMinContributorLevel( + ...args: Parameters +): ReturnType { + return settingsActions.setMinContributorLevel(...args); +} + +export async function setAutoAssignMentorChain( + ...args: Parameters +): ReturnType { + return settingsActions.setAutoAssignMentorChain(...args); +} + +export async function setAiPrDetection( + ...args: Parameters +): ReturnType { + return settingsActions.setAiPrDetection(...args); +} + +export async function getRepoPicker( + ...args: Parameters +): ReturnType { + return settingsActions.getRepoPicker(...args); +} + +export async function setRepoManaged( + ...args: Parameters +): ReturnType { + return settingsActions.setRepoManaged(...args); +} + +export async function getMaintainerPrQueue( + ...args: Parameters +): ReturnType { + return queueActions.getMaintainerPrQueue(...args); +} + +export async function getMaintainerIssueQueue( + ...args: Parameters +): ReturnType { + return queueActions.getMaintainerIssueQueue(...args); +} + +export async function refreshMaintainerBackfill( + ...args: Parameters +): ReturnType { + return queueActions.refreshMaintainerBackfill(...args); +} + +export async function getPrCiStatus( + ...args: Parameters +): ReturnType { + return queueActions.getPrCiStatus(...args); +} + +export async function closePullRequest( + ...args: Parameters +): ReturnType { + return queueActions.closePullRequest(...args); +} + +export async function getPrDiff( + ...args: Parameters +): ReturnType { + return queueActions.getPrDiff(...args); +} + +export async function getPrActivityTimeline( + ...args: Parameters +): ReturnType { + return queueActions.getPrActivityTimeline(...args); +} + +export async function getPrDetails( + ...args: Parameters +): ReturnType { + return queueActions.getPrDetails(...args); +} + +export async function getMaintainerPrById( + ...args: Parameters +): ReturnType { + return queueActions.getMaintainerPrById(...args); +} + +export async function requestChanges( + ...args: Parameters +): ReturnType { + return queueActions.requestChanges(...args); +} + +export async function mergePullRequest( + ...args: Parameters +): ReturnType { + return queueActions.mergePullRequest(...args); +} + +export async function getCommunityLinks( + ...args: Parameters +): ReturnType { + return communityActions.getCommunityLinks(...args); +} + +export async function upsertCommunityLink( + ...args: Parameters +): ReturnType { + return communityActions.upsertCommunityLink(...args); +} + +export async function deleteCommunityLink( + ...args: Parameters +): ReturnType { + return communityActions.deleteCommunityLink(...args); +} + +export async function getRepoHealthOverview( + ...args: Parameters +): ReturnType { + return analyticsActions.getRepoHealthOverview(...args); +} + +export async function getStaleIssues( + ...args: Parameters +): ReturnType { + return analyticsActions.getStaleIssues(...args); +} + +export async function getStalePrs( + ...args: Parameters +): ReturnType { + return analyticsActions.getStalePrs(...args); +} + +export async function getTopContributors( + ...args: Parameters +): ReturnType { + return analyticsActions.getTopContributors(...args); +} + +export async function getMaintainerAnalyticsTrends( + ...args: Parameters +): ReturnType { + return analyticsActions.getMaintainerAnalyticsTrends(...args); +} + +export async function exportPrQueueCsv( + ...args: Parameters +): ReturnType { + return analyticsActions.exportPrQueueCsv(...args); +} + +export async function getReviewerLoad( + ...args: Parameters +): ReturnType { + return analyticsActions.getReviewerLoad(...args); +} + +export async function getNoiseBreakdown( + ...args: Parameters +): ReturnType { + return analyticsActions.getNoiseBreakdown(...args); +} + +export async function getPromotionEligible( + ...args: Parameters +): ReturnType { + return analyticsActions.getPromotionEligible(...args); +} + +export async function getFlaggedAccounts( + ...args: Parameters +): ReturnType { + return flaggedAccountsActions.getFlaggedAccounts(...args); +} + +export async function resolveFlaggedAccount( + ...args: Parameters +): ReturnType { + return flaggedAccountsActions.resolveFlaggedAccount(...args); +} + +export async function getContributorsList( + ...args: Parameters +): ReturnType { + return contributorsActions.getContributorsList(...args); +} + +export async function removeContributorFromOrg( + ...args: Parameters +): ReturnType { + return contributorsActions.removeContributorFromOrg(...args); +} + +export async function getContributorStats( + ...args: Parameters +): ReturnType { + return contributorsActions.getContributorStats(...args); +} + +export async function exportContributorsCsv( + ...args: Parameters +): ReturnType { + return contributorsActions.exportContributorsCsv(...args); +} + +export async function getFailedWebhookEvents( + ...args: Parameters +): ReturnType { + return failedEventsActions.getFailedWebhookEvents(...args); +} + +export async function retryFailedWebhookEvent( + ...args: Parameters +): ReturnType { + return failedEventsActions.retryFailedWebhookEvent(...args); +} + +export async function previewMergeXp( + ...args: Parameters +): ReturnType { + return xpPreviewActions.previewMergeXp(...args); +} + +export async function listPendingInvites( + ...args: Parameters +): ReturnType { + return invitesActions.listPendingInvites(...args); +} + +export async function sendInvite( + ...args: Parameters +): ReturnType { + return invitesActions.sendInvite(...args); +} + +export async function resendInvite( + ...args: Parameters +): ReturnType { + return invitesActions.resendInvite(...args); +} + +export async function pingReviewers(prId: number): Promise> { + const auth = await requireMaintainerAuth(); + if (!auth.ok) return auth; + + const service = getServiceSupabase(); + if (!service) return err('not_configured', 'Service role not configured'); + + // Fetch the PR row to get installation and metadata + const { data: pr, error: prErr } = await service + .from('pull_requests') + .select('number, repo_full_name, installation_id, url') + .eq('id', prId) + .maybeSingle(); + + if (prErr || !pr) return err('not_found', 'PR not found'); + + const [owner, repo] = pr.repo_full_name.split('/'); + if (!owner || !repo) return err('invalid_data', 'Could not parse repo name'); -export { - getMaintainerInstalls, - getInstallationSettings, - setMinContributorLevel, - setAutoAssignMentorChain, - setAiPrDetection, - getRepoPicker, - setRepoManaged, -} from './settings'; - -export { - getMaintainerPrQueue, - getMaintainerIssueQueue, - refreshMaintainerBackfill, - getPrCiStatus, - closePullRequest, - getPrDiff, - getPrActivityTimeline, - getPrDetails, - getMaintainerPrById, - requestChanges, - mergePullRequest, -} from './queue'; - -export { getCommunityLinks, upsertCommunityLink, deleteCommunityLink } from './community'; -export { - getContributorsList, - exportContributorsCsv, - removeContributorFromOrg, - type ContributorListRow, - getContributorStats, - type ContributorStats, -} from './contributors'; -export { - getRepoHealthOverview, - getStaleIssues, - getTopContributors, - getMaintainerAnalyticsTrends, - exportPrQueueCsv, - getReviewerLoad, - getNoiseBreakdown, - getPromotionEligible, -} from './analytics'; - -export { getFlaggedAccounts, resolveFlaggedAccount } from './flagged-accounts'; -export * from './invites'; - -export { - getFailedWebhookEvents, - retryFailedWebhookEvent, - type FailedWebhookEventRow, -} from './failed-events'; -export { previewMergeXp, type XpPreviewBreakdown } from './xp-preview'; + try { + const octokit = await getInstallOctokit(pr.installation_id); + await octokit.rest.issues.createComment({ + owner, + repo, + issue_number: pr.number, + body: '👋 **Ping from MergeShip**: This PR has been waiting for reviewer feedback for over 14 days. Could reviewers please take a look when they get a chance?', + }); + return ok({ commented: true }); + } catch (e) { + const msg = e instanceof Error ? e.message : 'GitHub API error'; + return err('github_error', msg); + } +} diff --git a/src/lib/action-auth.ts b/src/lib/action-auth.ts index 96e478b5..3e6974a8 100644 --- a/src/lib/action-auth.ts +++ b/src/lib/action-auth.ts @@ -65,3 +65,8 @@ export async function requireMaintainer( return userRes; } + +// Compatibility helper used by existing maintainer server actions. +export async function requireMaintainerAuth(): Promise> { + return requireMaintainer(); +} diff --git a/supabase/snippets/Untitled query 835.sql b/supabase/snippets/Untitled query 835.sql new file mode 100644 index 00000000..6fff8a93 --- /dev/null +++ b/supabase/snippets/Untitled query 835.sql @@ -0,0 +1,13 @@ +INSERT INTO pull_requests (number, title, url, repo_full_name, state, github_updated_at, author_login, author_user_id, github_pr_id, github_created_at) +VALUES ( + 9998, + 'Test: stale PR for banner (Quark UI)', + 'https://github.com/demo/quark-ui/pull/9998', + 'demo/quark-ui', + 'open', + NOW() - INTERVAL '15 days', + 'testuser', + NULL, + 9998, + NOW() - INTERVAL '20 days' +); \ No newline at end of file diff --git a/supabase/snippets/Untitled query 938.sql b/supabase/snippets/Untitled query 938.sql new file mode 100644 index 00000000..df5a77c3 --- /dev/null +++ b/supabase/snippets/Untitled query 938.sql @@ -0,0 +1,13 @@ +INSERT INTO pull_requests (number, title, url, repo_full_name, state, github_updated_at, author_login, author_user_id, github_pr_id, github_created_at) +VALUES ( + 9999, + 'Test: stale PR for banner', + 'https://github.com/demo/sample-repo/pull/9999', + 'demo/sample-repo', + 'open', + NOW() - INTERVAL '15 days', + 'testuser', + NULL, + 9999, + NOW() - INTERVAL '20 days' +); \ No newline at end of file