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
13 changes: 12 additions & 1 deletion apps/dashboard/src/components/layouts/dashboard-error-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,10 @@ import {
Link,
useRouter,
} from "@tanstack/react-router";
import type { ComponentType } from "react";
import { type ComponentType, useEffect } from "react";
import { useShowOrgSetupQueryState } from "#/lib/github-access-dialog-query";
import { openGitHubAccessPrompt } from "#/lib/github-access-modal-store";
import { surfaceForbiddenOrgWarnings } from "#/lib/warning-store";

type ErrorInfo = {
icon: ComponentType<{ size?: number; strokeWidth?: number }>;
Expand Down Expand Up @@ -127,6 +128,16 @@ export function DashboardErrorScreen({ error, reset }: ErrorComponentProps) {
const isNotFound = action === "go-home";
const detail = isNotFound ? null : cleanErrorMessage(error.message);

useEffect(() => {
if (action !== "configure-access") return;
const msg = error.message;
const orgMatch = msg.match(/the `([^`]+)` organization/);
const orgs = orgMatch ? [orgMatch[1]] : null;
if (orgs) {
surfaceForbiddenOrgWarnings(orgs);
}
}, [action, error.message]);

return (
<div className="flex h-full items-center justify-center px-6 py-16">
<div className="mx-auto flex w-full max-w-md flex-col items-center gap-6 text-center">
Expand Down
10 changes: 9 additions & 1 deletion apps/dashboard/src/components/layouts/dashboard-layout.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { useQuery } from "@tanstack/react-query";
import { getRouteApi, Outlet } from "@tanstack/react-router";
import { motion } from "motion/react";
import { lazy, Suspense } from "react";
import { lazy, Suspense, useEffect } from "react";
import {
githubMyIssuesQueryOptions,
githubMyPullsQueryOptions,
} 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 { DashboardBottomBar } from "./dashboard-bottombar";
import { DashboardMobileNav } from "./dashboard-mobile-nav";
import {
Expand Down Expand Up @@ -50,6 +51,13 @@ export function DashboardLayout() {
...githubMyIssuesQueryOptions(scope),
enabled: hasMounted,
});
useEffect(() => {
surfaceForbiddenOrgWarnings(pullsQuery.data?.forbiddenOrgs);
}, [pullsQuery.data?.forbiddenOrgs]);
useEffect(() => {
surfaceForbiddenOrgWarnings(issuesQuery.data?.forbiddenOrgs);
}, [issuesQuery.data?.forbiddenOrgs]);

const pullCount =
hasMounted && pullsQuery.data
? pullsQuery.data.reviewRequested.length +
Expand Down
30 changes: 30 additions & 0 deletions apps/dashboard/src/lib/github-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,18 @@ function isGitHubRateLimitError(error: unknown) {
return retryAfter !== null || remaining === 0 || statusCode === 429;
}

/** 30 s — short so the data refreshes once the user configures access. */
const GITHUB_STALE_IF_FORBIDDEN_MS = 30_000;

function isGitHubForbiddenError(error: unknown) {
const msg = error instanceof Error ? error.message : String(error ?? "");
return (
msg.includes("OAuth App access restrictions") ||
msg.includes("FORBIDDEN") ||
msg.includes("Resource not accessible by integration")
);
}

function getRateLimitedStaleFreshUntil(currentTime: number, error: unknown) {
const headers = normalizeUnknownHeaders(getErrorResponseHeaders(error));
const retryAfterSeconds = parseNullableInt(headers["retry-after"]);
Expand Down Expand Up @@ -720,6 +732,24 @@ export async function getOrRevalidateGitHubResource<TData>({
return parseCachedPayload<TData>(existingEntry.payloadJson);
}

if (existingEntry && isGitHubForbiddenError(error)) {
const staleEntry = {
...existingEntry,
freshUntil: currentTime + GITHUB_STALE_IF_FORBIDDEN_MS,
statusCode: getErrorStatusCode(error) ?? existingEntry.statusCode,
};

await persistGitHubCacheEntry({
entry: staleEntry,
legacyStore: await getResolvedStore(),
payloadStore: resolvedPayloadStore,
payloadStorageKey,
payloadRetentionSeconds,
});

return parseCachedPayload<TData>(existingEntry.payloadJson);
}

throw error;
}

Expand Down
36 changes: 34 additions & 2 deletions apps/dashboard/src/lib/github.functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1337,6 +1337,22 @@ async function executeGitHubGraphQL<TResponse>(
);
}

/**
* Extract an organization name from a GitHub FORBIDDEN/OAuth App access
* restriction error. Returns `null` when the error is unrelated.
*/
function extractForbiddenOrg(error: unknown): string | null {
const message = error instanceof Error ? error.message : String(error ?? "");
if (
!message.includes("OAuth App access restrictions") &&
!message.includes("FORBIDDEN")
) {
return null;
}
const match = message.match(/the `([^`]+)` organization/);
return match?.[1] ?? null;
}

async function safeCommandPaletteSearch<T>({
fallback,
label,
Expand Down Expand Up @@ -3964,6 +3980,7 @@ async function getMyPullsResult({
const sources = await getMySearchSources(context, username, deadlineAt);
const results: MyPullsResult[] = [];
const rateLimits: GitHubGraphQLRateLimit[] = [];
const forbiddenOrgs: string[] = [];

for (const source of sources) {
const sourceTimeoutMs = getRemainingSearchTimeoutMs(
Expand Down Expand Up @@ -4101,12 +4118,19 @@ async function getMyPullsResult({
source.label,
error,
);
const org = extractForbiddenOrg(error);
if (org) forbiddenOrgs.push(org);
}
}

const data = mergeMyPullsResults(results);
if (forbiddenOrgs.length > 0) {
data.forbiddenOrgs = [...new Set(forbiddenOrgs)];
}

return {
kind: "success",
data: mergeMyPullsResults(results),
data,
metadata: createGraphQLResponseMetadata(
mergeGraphQLRateLimits(rateLimits),
),
Expand Down Expand Up @@ -4135,6 +4159,7 @@ async function getMyIssuesResult({
const sources = await getMySearchSources(context, username, deadlineAt);
const results: MyIssuesResult[] = [];
const rateLimits: GitHubGraphQLRateLimit[] = [];
const forbiddenOrgs: string[] = [];

for (const source of sources) {
const sourceTimeoutMs = getRemainingSearchTimeoutMs(
Expand Down Expand Up @@ -4247,12 +4272,19 @@ async function getMyIssuesResult({
source.label,
error,
);
const org = extractForbiddenOrg(error);
if (org) forbiddenOrgs.push(org);
}
}

const data = mergeMyIssuesResults(results);
if (forbiddenOrgs.length > 0) {
data.forbiddenOrgs = [...new Set(forbiddenOrgs)];
}

return {
kind: "success",
data: mergeMyIssuesResults(results),
data,
metadata: createGraphQLResponseMetadata(
mergeGraphQLRateLimits(rateLimits),
),
Expand Down
2 changes: 2 additions & 0 deletions apps/dashboard/src/lib/github.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,12 +113,14 @@ export type MyPullsResult = {
authored: PullSummary[];
mentioned: PullSummary[];
involved: PullSummary[];
forbiddenOrgs?: string[];
};

export type MyIssuesResult = {
assigned: IssueSummary[];
authored: IssueSummary[];
mentioned: IssueSummary[];
forbiddenOrgs?: string[];
};

export type CommandPaletteSearchResult = {
Expand Down
21 changes: 21 additions & 0 deletions apps/dashboard/src/lib/warning-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,27 @@ export function useWarnings() {
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
}

/**
* Surface warnings for organizations that returned FORBIDDEN due to
* OAuth App access restrictions during search queries.
*/
export function surfaceForbiddenOrgWarnings(orgs: string[] | undefined) {
if (!orgs || orgs.length === 0) return;

for (const org of orgs) {
addWarning({
id: `forbidden-org:${org}`,
message: `The ${org} organization has restricted third-party access. Configure access to include its repositories.`,
dismissible: true,
action: {
kind: "github-access",
label: "Configure access",
owner: org,
},
});
}
}

/**
* Check a MutationResult for permission errors and surface a warning.
* Call this client-side after a mutation returns.
Expand Down
4 changes: 2 additions & 2 deletions apps/dashboard/src/routes/_protected/issues.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import {
import { IssueRow } from "#/components/issues/issue-row";
import { DashboardContentLoading } from "#/components/layouts/dashboard-content-loading";
import { githubMyIssuesQueryOptions } from "#/lib/github.query";
import type { IssueSummary, MyIssuesResult } from "#/lib/github.types";
import type { IssueSummary } from "#/lib/github.types";
import { buildSeo, formatPageTitle } from "#/lib/seo";
import { useHasMounted } from "#/lib/use-has-mounted";

Expand Down Expand Up @@ -149,7 +149,7 @@ type IssueGroupData = {
id: string;
title: string;
icon: ComponentType<{ size?: number; strokeWidth?: number }>;
issues: MyIssuesResult[keyof MyIssuesResult];
issues: IssueSummary[];
};

const ISSUE_GROUP_STICKY_TOP = -32;
Expand Down
4 changes: 2 additions & 2 deletions apps/dashboard/src/routes/_protected/pulls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import {
import { DashboardContentLoading } from "#/components/layouts/dashboard-content-loading";
import { PullRequestRow } from "#/components/pulls/pull-request-row";
import { githubMyPullsQueryOptions } from "#/lib/github.query";
import type { MyPullsResult, PullSummary } from "#/lib/github.types";
import type { PullSummary } from "#/lib/github.types";
import { buildSeo, formatPageTitle } from "#/lib/seo";
import { useHasMounted } from "#/lib/use-has-mounted";

Expand Down Expand Up @@ -180,7 +180,7 @@ type PullGroupData = {
id: string;
title: string;
icon: ComponentType<{ size?: number; strokeWidth?: number }>;
pulls: MyPullsResult[keyof MyPullsResult];
pulls: PullSummary[];
};

const PULL_GROUP_STICKY_TOP = -32;
Expand Down
Loading