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