From 7662e7e9386960df8419c5426feab34bf044dd8d Mon Sep 17 00:00:00 2001 From: Rhea Date: Mon, 6 Jul 2026 20:57:42 +0530 Subject: [PATCH 1/3] feat: add stalled PR detection and reviewer ping banner --- src/app/(app)/maintainer/page.tsx | 8 + src/app/(app)/maintainer/stale-pr-banner.tsx | 104 +++++++++ src/app/actions/maintainer/analytics.ts | 89 +++++++- src/app/actions/maintainer/index.ts | 212 ++++++++++++++++--- src/lib/action-auth.ts | 5 + supabase/snippets/Untitled query 835.sql | 13 ++ supabase/snippets/Untitled query 938.sql | 13 ++ 7 files changed, 408 insertions(+), 36 deletions(-) create mode 100644 src/app/(app)/maintainer/stale-pr-banner.tsx create mode 100644 supabase/snippets/Untitled query 835.sql create mode 100644 supabase/snippets/Untitled query 938.sql diff --git a/src/app/(app)/maintainer/page.tsx b/src/app/(app)/maintainer/page.tsx index e0d5d2cf..a786e203 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, @@ -16,6 +18,7 @@ import { type InstallationSettingsData, type RepoHealthRow, type StaleIssueRow, + type StalePrRow, type ContributorRow, type ReviewerLoadRow, } from '@/app/actions/maintainer'; @@ -30,6 +33,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'; export const dynamic = 'force-dynamic'; @@ -119,9 +123,13 @@ 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 : []; + 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 63c86878..ed79ada7 100644 --- a/src/app/actions/maintainer/analytics.ts +++ b/src/app/actions/maintainer/analytics.ts @@ -121,6 +121,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> { @@ -156,7 +224,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, @@ -409,12 +477,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 1656a1a9..00f6f037 100644 --- a/src/app/actions/maintainer/index.ts +++ b/src/app/actions/maintainer/index.ts @@ -1,31 +1,185 @@ +'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'; + export type * from './types'; +export type { StalePrRow } from './analytics'; + +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 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 getFlaggedAccounts( + ...args: Parameters +): ReturnType { + return flaggedAccountsActions.getFlaggedAccounts(...args); +} + +export async function resolveFlaggedAccount( + ...args: Parameters +): ReturnType { + return flaggedAccountsActions.resolveFlaggedAccount(...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, -} from './queue'; - -export { getCommunityLinks, upsertCommunityLink, deleteCommunityLink } from './community'; - -export { - getRepoHealthOverview, - getStaleIssues, - getTopContributors, - getMaintainerAnalyticsTrends, - exportPrQueueCsv, - getReviewerLoad, -} from './analytics'; - -export { getFlaggedAccounts, resolveFlaggedAccount } from './flagged-accounts'; + 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 From f33d9310db4cdbbc027d744bfb9a97484c778e23 Mon Sep 17 00:00:00 2001 From: Rhea Date: Wed, 8 Jul 2026 16:09:04 +0530 Subject: [PATCH 2/3] feat: add stalled PR detection and reviewer ping banner --- src/app/actions/maintainer/index.ts | 125 ++++++++++++++++++----- supabase/snippets/Untitled query 835.sql | 13 --- supabase/snippets/Untitled query 938.sql | 13 --- 3 files changed, 97 insertions(+), 54 deletions(-) delete mode 100644 supabase/snippets/Untitled query 835.sql delete mode 100644 supabase/snippets/Untitled query 938.sql diff --git a/src/app/actions/maintainer/index.ts b/src/app/actions/maintainer/index.ts index bc038729..ec5303e1 100644 --- a/src/app/actions/maintainer/index.ts +++ b/src/app/actions/maintainer/index.ts @@ -1,4 +1,5 @@ 'use server'; + import { requireMaintainerAuth } from '@/lib/action-auth'; import { getServiceSupabase } from '@/lib/supabase/service'; import { getInstallOctokit } from '@/lib/github/app'; @@ -8,9 +9,15 @@ 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'; 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 async function getMaintainerInstalls( ...args: Parameters @@ -78,6 +85,48 @@ export async function getPrCiStatus( 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 { @@ -138,6 +187,18 @@ export async function getReviewerLoad( 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 { @@ -150,6 +211,42 @@ export async function resolveFlaggedAccount( 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 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 pingReviewers(prId: number): Promise> { const auth = await requireMaintainerAuth(); if (!auth.ok) return auth; @@ -157,7 +254,6 @@ export async function pingReviewers(prId: number): Promise Date: Sun, 12 Jul 2026 18:42:32 +0530 Subject: [PATCH 3/3] Fix to CI 1.0 --- src/app/actions/maintainer/index.ts | 2 +- supabase/snippets/Untitled query 835.sql | 13 +++++++++++++ supabase/snippets/Untitled query 938.sql | 13 +++++++++++++ 3 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 supabase/snippets/Untitled query 835.sql create mode 100644 supabase/snippets/Untitled query 938.sql diff --git a/src/app/actions/maintainer/index.ts b/src/app/actions/maintainer/index.ts index ec5303e1..f1d22119 100644 --- a/src/app/actions/maintainer/index.ts +++ b/src/app/actions/maintainer/index.ts @@ -1,5 +1,4 @@ 'use server'; - import { requireMaintainerAuth } from '@/lib/action-auth'; import { getServiceSupabase } from '@/lib/supabase/service'; import { getInstallOctokit } from '@/lib/github/app'; @@ -254,6 +253,7 @@ export async function pingReviewers(prId: number): Promise