Skip to content
Merged
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
122 changes: 71 additions & 51 deletions apps/dashboard/src/components/layouts/dashboard-bottombar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,58 +12,78 @@ export function DashboardBottomBar() {
return (
<div className="empty:hidden flex flex-row flex-wrap items-start gap-1 px-2 pb-2">
<ExtensionInstallPrompt />
{warnings.map((warning) => (
<div
key={warning.id}
className={cn(
"flex w-fit items-center gap-2 rounded-lg bg-yellow-500 px-3 py-2 text-xs text-yellow-950 dark:bg-yellow-500 dark:text-yellow-950",
)}
>
<AlertCircleIcon size={14} strokeWidth={2} className="shrink-0" />
<span className="min-w-0 flex-1">{warning.message}</span>
{warning.action
? (() => {
const action = warning.action;
{warnings.map((warning) => {
const isError = warning.severity === "error";

return action.kind === "link" ? (
<a
href={action.href}
target="_blank"
rel="noopener noreferrer"
className="shrink-0 rounded-md bg-yellow-950/10 px-2 py-0.5 font-medium transition-colors hover:bg-yellow-950/20"
>
{action.label}
</a>
) : (
<button
type="button"
onClick={() => {
openGitHubAccessPrompt({
source: "warning",
owner: action.owner,
repo: action.repo,
fallbackHref: action.href,
});
void setShowOrgSetup(true);
}}
className="shrink-0 rounded-md bg-yellow-950/10 px-2 py-0.5 font-medium transition-colors hover:bg-yellow-950/20"
>
{action.label}
</button>
);
})()
: null}
{warning.dismissible && (
<button
type="button"
onClick={() => removeWarning(warning.id)}
className="flex size-5 shrink-0 items-center justify-center rounded transition-colors hover:bg-yellow-600/20"
>
<XIcon size={12} strokeWidth={2} />
</button>
)}
</div>
))}
return (
<div
key={warning.id}
className={cn(
"flex w-fit items-center gap-2 rounded-lg px-3 py-2 text-xs",
isError
? "bg-red-500 text-white dark:bg-red-500 dark:text-white"
: "bg-yellow-500 text-yellow-950 dark:bg-yellow-500 dark:text-yellow-950",
)}
>
<AlertCircleIcon size={14} strokeWidth={2} className="shrink-0" />
<span className="min-w-0 flex-1">{warning.message}</span>
{warning.action
? (() => {
const action = warning.action;

return action.kind === "link" ? (
<a
href={action.href}
target="_blank"
rel="noopener noreferrer"
className={cn(
"shrink-0 rounded-md px-2 py-0.5 font-medium transition-colors",
isError
? "bg-white/15 hover:bg-white/25"
: "bg-yellow-950/10 hover:bg-yellow-950/20",
)}
>
{action.label}
</a>
) : (
<button
type="button"
onClick={() => {
openGitHubAccessPrompt({
source: "warning",
owner: action.owner,
repo: action.repo,
fallbackHref: action.href,
});
void setShowOrgSetup(true);
}}
className={cn(
"shrink-0 rounded-md px-2 py-0.5 font-medium transition-colors",
isError
? "bg-white/15 hover:bg-white/25"
: "bg-yellow-950/10 hover:bg-yellow-950/20",
)}
>
{action.label}
</button>
);
})()
: null}
{warning.dismissible && (
<button
type="button"
onClick={() => removeWarning(warning.id)}
className={cn(
"flex size-5 shrink-0 items-center justify-center rounded transition-colors",
isError ? "hover:bg-white/15" : "hover:bg-yellow-600/20",
)}
>
<XIcon size={12} strokeWidth={2} />
</button>
)}
</div>
);
})}
</div>
);
}
10 changes: 9 additions & 1 deletion apps/dashboard/src/components/layouts/dashboard-layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ import {
} from "#/lib/github.query";
import { useHasMounted } from "#/lib/use-has-mounted";
import { useMediaQuery } from "#/lib/use-media-query";
import { surfaceForbiddenOrgWarnings } from "#/lib/warning-store";
import {
surfaceForbiddenOrgWarnings,
surfaceTimeoutWarning,
} from "#/lib/warning-store";
import { DashboardBottomBar } from "./dashboard-bottombar";
import { DashboardMobileNav } from "./dashboard-mobile-nav";
import {
Expand Down Expand Up @@ -57,6 +60,11 @@ export function DashboardLayout() {
useEffect(() => {
surfaceForbiddenOrgWarnings(issuesQuery.data?.forbiddenOrgs);
}, [issuesQuery.data?.forbiddenOrgs]);
useEffect(() => {
surfaceTimeoutWarning(
pullsQuery.data?.timedOut || issuesQuery.data?.timedOut,
);
}, [pullsQuery.data?.timedOut, issuesQuery.data?.timedOut]);

const pullCount =
hasMounted && pullsQuery.data
Expand Down
8 changes: 0 additions & 8 deletions apps/dashboard/src/lib/github-request-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { getRequest } from "@tanstack/react-start/server";
import type { Octokit as OctokitType } from "octokit";
import { debug } from "./debug";

const GITHUB_READ_RETRY_COUNT = 1;
export const GITHUB_REQUEST_TIMEOUT_MS = 12_000;

type GitHubRequestOptions = Parameters<
Expand All @@ -29,10 +28,6 @@ type GitHubRequestPolicyOptions = {
tokenLabel?: string;
};

function isSafeGitHubRetryMethod(method: string | undefined) {
return method === "GET" || method === "HEAD" || method === "OPTIONS";
}

function createGitHubRequestTimeoutSignal(
requestSignal: AbortSignal | undefined,
) {
Expand Down Expand Up @@ -116,9 +111,6 @@ export function configureGitHubRequestPolicies(
octokit.hook.before("request", (options: GitHubRequestOptions) => {
const requestOptions = options.request ?? {};
options.request = requestOptions;
requestOptions.retries = isSafeGitHubRetryMethod(options.method)
? GITHUB_READ_RETRY_COUNT
: 0;
requestOptions.signal ??= createGitHubRequestTimeoutSignal(
getIncomingRequestSignal(),
);
Expand Down
41 changes: 41 additions & 0 deletions apps/dashboard/src/lib/github.functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1286,6 +1286,31 @@ class GitHubOperationTimeoutError extends Error {
}
}

/**
* Module-level tracker for recent GitHub API timeouts.
* Automatically recorded by `withGitHubOperationTimeout` so that
* callers that swallow the error (fallback-to-REST, return []) still
* contribute to the global "GitHub is timing out" signal.
*/
const TIMEOUT_TRACKER_WINDOW_MS = 60_000;
let recentTimeoutTimestamps: number[] = [];

function recordGitHubTimeout() {
const now = Date.now();
recentTimeoutTimestamps.push(now);
recentTimeoutTimestamps = recentTimeoutTimestamps.filter(
(t) => now - t < TIMEOUT_TRACKER_WINDOW_MS,
);
}

function hasRecentGitHubTimeouts(): boolean {
const now = Date.now();
recentTimeoutTimestamps = recentTimeoutTimestamps.filter(
(t) => now - t < TIMEOUT_TRACKER_WINDOW_MS,
);
return recentTimeoutTimestamps.length > 0;
}

function getRemainingSearchTimeoutMs(deadlineAt: number, maxTimeoutMs: number) {
return Math.max(0, Math.min(maxTimeoutMs, deadlineAt - Date.now()));
}
Expand All @@ -1296,6 +1321,7 @@ async function withGitHubOperationTimeout<T>(
task: (signal: AbortSignal) => Promise<T>,
) {
if (timeoutMs <= 0) {
recordGitHubTimeout();
throw new GitHubOperationTimeoutError(label, timeoutMs);
}

Expand All @@ -1305,6 +1331,7 @@ async function withGitHubOperationTimeout<T>(
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutId = setTimeout(() => {
controller.abort();
recordGitHubTimeout();
reject(new GitHubOperationTimeoutError(label, timeoutMs));
}, timeoutMs);
});
Expand Down Expand Up @@ -3968,6 +3995,7 @@ async function getMyPullsResult({
const results: MyPullsResult[] = [];
const rateLimits: GitHubGraphQLRateLimit[] = [];
const forbiddenOrgs: string[] = [];
let timedOut = false;

for (const source of sources) {
const sourceTimeoutMs = getRemainingSearchTimeoutMs(
Expand Down Expand Up @@ -4105,6 +4133,9 @@ async function getMyPullsResult({
source.label,
error,
);
if (error instanceof GitHubOperationTimeoutError) {
timedOut = true;
}
const org = extractForbiddenOrg(error);
if (org) forbiddenOrgs.push(org);
}
Expand All @@ -4114,6 +4145,9 @@ async function getMyPullsResult({
if (forbiddenOrgs.length > 0) {
data.forbiddenOrgs = [...new Set(forbiddenOrgs)];
}
if (timedOut || hasRecentGitHubTimeouts()) {
data.timedOut = true;
}

return {
kind: "success",
Expand Down Expand Up @@ -4148,6 +4182,7 @@ async function getMyIssuesResult({
const results: MyIssuesResult[] = [];
const rateLimits: GitHubGraphQLRateLimit[] = [];
const forbiddenOrgs: string[] = [];
let timedOut = false;

for (const source of sources) {
const sourceTimeoutMs = getRemainingSearchTimeoutMs(
Expand Down Expand Up @@ -4260,6 +4295,9 @@ async function getMyIssuesResult({
source.label,
error,
);
if (error instanceof GitHubOperationTimeoutError) {
timedOut = true;
}
const org = extractForbiddenOrg(error);
if (org) forbiddenOrgs.push(org);
}
Expand All @@ -4269,6 +4307,9 @@ async function getMyIssuesResult({
if (forbiddenOrgs.length > 0) {
data.forbiddenOrgs = [...new Set(forbiddenOrgs)];
}
if (timedOut || hasRecentGitHubTimeouts()) {
data.timedOut = true;
}

return {
kind: "success",
Expand Down
Loading
Loading