diff --git a/apps/dashboard/src/components/layouts/dashboard-error-screen.tsx b/apps/dashboard/src/components/layouts/dashboard-error-screen.tsx
index 760ff1b..a125e9a 100644
--- a/apps/dashboard/src/components/layouts/dashboard-error-screen.tsx
+++ b/apps/dashboard/src/components/layouts/dashboard-error-screen.tsx
@@ -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 }>;
@@ -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 (
diff --git a/apps/dashboard/src/components/layouts/dashboard-layout.tsx b/apps/dashboard/src/components/layouts/dashboard-layout.tsx
index 5cb05fe..33e9a80 100644
--- a/apps/dashboard/src/components/layouts/dashboard-layout.tsx
+++ b/apps/dashboard/src/components/layouts/dashboard-layout.tsx
@@ -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 {
@@ -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 +
diff --git a/apps/dashboard/src/lib/github-cache.ts b/apps/dashboard/src/lib/github-cache.ts
index de0e940..4e85fcc 100644
--- a/apps/dashboard/src/lib/github-cache.ts
+++ b/apps/dashboard/src/lib/github-cache.ts
@@ -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"]);
@@ -720,6 +732,24 @@ export async function getOrRevalidateGitHubResource({
return parseCachedPayload(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(existingEntry.payloadJson);
+ }
+
throw error;
}
diff --git a/apps/dashboard/src/lib/github.functions.ts b/apps/dashboard/src/lib/github.functions.ts
index 1ee394f..b37c7fb 100644
--- a/apps/dashboard/src/lib/github.functions.ts
+++ b/apps/dashboard/src/lib/github.functions.ts
@@ -1337,6 +1337,22 @@ async function executeGitHubGraphQL(
);
}
+/**
+ * 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({
fallback,
label,
@@ -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(
@@ -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),
),
@@ -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(
@@ -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),
),
diff --git a/apps/dashboard/src/lib/github.types.ts b/apps/dashboard/src/lib/github.types.ts
index a0fbe49..e31fe5c 100644
--- a/apps/dashboard/src/lib/github.types.ts
+++ b/apps/dashboard/src/lib/github.types.ts
@@ -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 = {
diff --git a/apps/dashboard/src/lib/warning-store.ts b/apps/dashboard/src/lib/warning-store.ts
index 18fdf62..69e13e6 100644
--- a/apps/dashboard/src/lib/warning-store.ts
+++ b/apps/dashboard/src/lib/warning-store.ts
@@ -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.
diff --git a/apps/dashboard/src/routes/_protected/issues.tsx b/apps/dashboard/src/routes/_protected/issues.tsx
index 7b15bfa..e6ad3a0 100644
--- a/apps/dashboard/src/routes/_protected/issues.tsx
+++ b/apps/dashboard/src/routes/_protected/issues.tsx
@@ -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";
@@ -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;
diff --git a/apps/dashboard/src/routes/_protected/pulls.tsx b/apps/dashboard/src/routes/_protected/pulls.tsx
index 91f631b..b047c13 100644
--- a/apps/dashboard/src/routes/_protected/pulls.tsx
+++ b/apps/dashboard/src/routes/_protected/pulls.tsx
@@ -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";
@@ -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;