feat(web): graceful GitHub API rate limit handling in UI

When the GitHub plugin hits rate limits, the dashboard now:

- Shows a single amber banner: "GitHub API rate limited — PR data
  (CI status, review state, sizes) may be stale. Will retry on
  next refresh."
- Hides CI badge, review decision, and size pill on PR cards
  (they'd show wrong values: +0 -0 XS, CI failing)
- Shows a subtle "⚠ PR data rate limited" note on affected cards
  instead of misleading alert badges
- Skips CI/review/conflict-based attention zone classification
  for rate-limited PRs (prevents sessions moving to Review due
  to phantom "CI failing" from the fallback value)
- Doesn't cache partial rate-limited data so next refresh retries
  live API calls as soon as the rate limit window resets

What still works when rate limited:
- Session ID, title, branch, PR number/link
- Session activity status (working/spawning/etc.)
- Merge button if mergeability was already cached
- Restore/terminate/send actions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Prateek 2026-02-19 13:30:40 +05:30
parent 8249679dac
commit 1ad71940c9
4 changed files with 60 additions and 11 deletions

View File

@ -7,6 +7,7 @@ import {
type DashboardPR,
type AttentionLevel,
getAttentionLevel,
isPRRateLimited,
} from "@/lib/types";
import { CI_STATUS } from "@composio/ao-core/types";
import { AttentionZone } from "./AttentionZone";
@ -85,6 +86,11 @@ export function Dashboard({ sessions, stats, orchestratorId, projectName }: Dash
const hasKanbanSessions = KANBAN_LEVELS.some((l) => grouped[l].length > 0);
const anyRateLimited = useMemo(
() => sessions.some((s) => s.pr && isPRRateLimited(s.pr)),
[sessions],
);
return (
<div className="px-8 py-7">
<DynamicFavicon sessions={sessions} projectName={projectName} />
@ -109,6 +115,19 @@ export function Dashboard({ sessions, stats, orchestratorId, projectName }: Dash
)}
</div>
{/* Rate limit notice */}
{anyRateLimited && (
<div className="mb-6 flex items-center gap-2.5 rounded border border-[rgba(245,158,11,0.25)] bg-[rgba(245,158,11,0.05)] px-3.5 py-2.5 text-[11px] text-[var(--color-status-attention)]">
<svg className="h-3.5 w-3.5 shrink-0" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path d="M12 9v4m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
</svg>
<span>
GitHub API rate limited PR data (CI status, review state, sizes) may be stale.
{" "}Will retry automatically on next refresh.
</span>
</div>
)}
{/* Kanban columns for active zones */}
{hasKanbanSessions && (
<div className="mb-8 flex gap-4 overflow-x-auto pb-2">

View File

@ -1,6 +1,7 @@
"use client";
import type { DashboardPR } from "@/lib/types";
import { isPRRateLimited } from "@/lib/types";
import { CIBadge } from "./CIBadge";
function getSizeLabel(additions: number, deletions: number): string {
@ -14,6 +15,7 @@ interface PRStatusProps {
export function PRStatus({ pr }: PRStatusProps) {
const sizeLabel = getSizeLabel(pr.additions, pr.deletions);
const rateLimited = isPRRateLimited(pr);
return (
<div className="flex flex-wrap items-center gap-1.5">
@ -28,10 +30,12 @@ export function PRStatus({ pr }: PRStatusProps) {
#{pr.number}
</a>
{/* Size */}
<span className="inline-flex items-center rounded-full bg-[rgba(125,133,144,0.08)] px-2 py-0.5 text-[10px] font-semibold text-[var(--color-text-muted)]">
+{pr.additions} -{pr.deletions} {sizeLabel}
</span>
{/* Size — hide when rate limited (would show +0 -0 XS) */}
{!rateLimited && (
<span className="inline-flex items-center rounded-full bg-[rgba(125,133,144,0.08)] px-2 py-0.5 text-[10px] font-semibold text-[var(--color-text-muted)]">
+{pr.additions} -{pr.deletions} {sizeLabel}
</span>
)}
{/* Merged badge */}
{pr.state === "merged" && (
@ -47,11 +51,13 @@ export function PRStatus({ pr }: PRStatusProps) {
</span>
)}
{/* CI status (only for open PRs) */}
{pr.state === "open" && !pr.isDraft && <CIBadge status={pr.ciStatus} checks={pr.ciChecks} />}
{/* CI status — only when we have real data */}
{pr.state === "open" && !pr.isDraft && !rateLimited && (
<CIBadge status={pr.ciStatus} checks={pr.ciChecks} />
)}
{/* Review decision (only for open PRs) */}
{pr.state === "open" && pr.reviewDecision === "approved" && (
{/* Review decision (only for open PRs with real data) */}
{pr.state === "open" && pr.reviewDecision === "approved" && !rateLimited && (
<span className="inline-flex items-center rounded-full bg-[rgba(63,185,80,0.1)] px-2 py-0.5 text-[10px] font-semibold text-[var(--color-accent-green)]">
approved
</span>

View File

@ -5,6 +5,7 @@ import {
type DashboardSession,
type AttentionLevel,
getAttentionLevel,
isPRRateLimited,
TERMINAL_STATUSES,
TERMINAL_ACTIVITIES,
} from "@/lib/types";
@ -72,6 +73,7 @@ export function SessionCard({ session, onSend, onKill, onMerge, onRestore }: Ses
timerRef.current = setTimeout(() => setSendingAction(null), 2000);
};
const rateLimited = pr ? isPRRateLimited(pr) : false;
const alerts = getAlerts(session);
const isReadyToMerge = pr?.mergeability.mergeable && pr.state === "open";
const isTerminal =
@ -144,8 +146,20 @@ export function SessionCard({ session, onSend, onKill, onMerge, onRestore }: Ses
{pr && <PRStatus pr={pr} />}
</div>
{/* Rate limited indicator */}
{rateLimited && pr?.state === "open" && (
<div className="px-4 pb-3">
<span className="inline-flex items-center gap-1 text-[10px] text-[var(--color-text-muted)]">
<svg className="h-3 w-3 text-[var(--color-status-attention)]" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path d="M12 9v4m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
</svg>
PR data rate limited
</span>
</div>
)}
{/* Merge button or alert tags */}
{(alerts.length > 0 || isReadyToMerge) && (
{!rateLimited && (alerts.length > 0 || isReadyToMerge) && (
<div className="px-4 pb-3.5 pt-0.5">
{isReadyToMerge && pr ? (
<button
@ -304,6 +318,7 @@ interface Alert {
function getAlerts(session: DashboardSession): Alert[] {
const pr = session.pr;
if (!pr || pr.state !== "open") return [];
if (isPRRateLimited(pr)) return [];
const alerts: Alert[] = [];

View File

@ -149,6 +149,15 @@ export interface SSEActivityEvent {
timestamp: string;
}
/**
* Returns true when this PR's enrichment data couldn't be fetched due to
* API rate limiting. When true, CI status / review decision / mergeability
* may be stale defaults don't make decisions based on them.
*/
export function isPRRateLimited(pr: DashboardPR): boolean {
return pr.mergeability.blockers.includes("API rate limited or unavailable");
}
/** Determines which attention zone a session belongs to */
export function getAttentionLevel(session: DashboardSession): AttentionLevel {
// ── Done: terminal states ─────────────────────────────────────────
@ -200,7 +209,7 @@ export function getAttentionLevel(session: DashboardSession): AttentionLevel {
if (session.status === "ci_failed" || session.status === "changes_requested") {
return "review";
}
if (session.pr) {
if (session.pr && !isPRRateLimited(session.pr)) {
const pr = session.pr;
if (pr.ciStatus === CI_STATUS.FAILING) return "review";
if (pr.reviewDecision === "changes_requested") return "review";
@ -211,7 +220,7 @@ export function getAttentionLevel(session: DashboardSession): AttentionLevel {
if (session.status === "review_pending") {
return "pending";
}
if (session.pr) {
if (session.pr && !isPRRateLimited(session.pr)) {
const pr = session.pr;
if (!pr.isDraft && pr.unresolvedThreads > 0) return "pending";
if (!pr.isDraft && (pr.reviewDecision === "pending" || pr.reviewDecision === "none")) {