mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 19:35:13 +01:00
fix(github): stop counting suspended installations as repository access (#17343)
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - Agents get GitHub access through a managed GitHub App connection.
**Refresh access** reads the installations and repositories that the
user token can reach.
> - GitHub keeps listing a suspended App installation and all of its
repositories for the user token. But the token then loses write access
and cannot read private repositories.
> - `loadGitHubGrantMetadata` did not read `suspended_at`. It counted a
suspended installation as live access ("all repos") and set the
connection to healthy.
> - So the board showed "connected, all repositories" while agents got a
403 on `git push` and a 404 on private repositories. The suspend webhook
marks the connection unhealthy, but the next refresh or reconnect
reverted that.
> - This pull request skips suspended installations. When all
installations are suspended, Refresh access fails with a clear error and
a link to the installation settings page, where an owner can click
**Unsuspend**.
> - The benefit is that the board shows the real access state, and
operators get a direct path to fix it.
## Linked Issues or Issue Description
**What happened?**
A GitHub App installation on an organization was suspended. The
Paperclip GitHub connection still showed "connected" with access to all
repositories after **Refresh access**. Agents that used the managed
token got `Permission to <org>/<repo>.git denied` (403) on push,
`Resource not accessible by integration` on branch creation, and 404 on
private repositories.
**Expected behavior**
Refresh access must not count a suspended installation as repository
access. When every installation is suspended, Refresh access must fail
with a message that says the installation is suspended, and the page
must link to the installation settings page.
**Steps to reproduce**
1. Connect GitHub to Paperclip through the GitHub App, with an
installation on an organization.
2. On GitHub, suspend that installation (Organization settings → GitHub
Apps → Configure → Suspend).
3. In Paperclip, click **Refresh access** on the GitHub connection.
4. See that the connection stays healthy and reports all repositories,
while agent pushes fail with 403.
**Paperclip version or commit**
`master` at a708a1333.
**Additional context**
`GET /user/installations` returns the suspended installation with
`suspended_at` set and `repository_selection: all`, and `GET
/user/installations/{id}/repositories` still lists its repositories.
Unsuspending the installation restored push access and private reads for
the same token.
## What Changed
- `server/src/services/tool-access/github-access-discovery.ts`:
`loadGitHubGrantMetadata` skips installations that have `suspended_at`
set. It does not add their IDs, owners, repository selection, or
repositories. When there is no usable installation and at least one is
suspended, it throws `github_installation_required` with a "suspended"
message. `managementUrl` points at the suspended installation's settings
page (or the generic installations page when more than one is
suspended).
- `server/src/services/tool-access/catalog/health.ts`:
`checkConnectionHealth` (the Refresh access path) keeps
`installationUrl` and `managementUrl` when it rebuilds a
`github_installation_required` error. Before, the links were dropped.
- `server/src/services/tool-access/catalog/failures.ts`:
`github_installation_required` keeps its own code and returns 422.
Before, it was folded into a generic `paperclip_error` with HTTP 502.
- `ui/src/pages/apps/app-detail/AccessRetryBanner.tsx` (new): the
attention banner with the saved health message and **Retry access**.
After a failed Refresh access with `github_installation_required`, it
shows an **Open installation settings on GitHub** link (https
`github.com` URLs only).
- `ui/src/pages/apps/AppDetail.tsx`: uses `AccessRetryBanner`, refetches
the connection when a refresh fails so the banner shows the saved
message, and keys the page on `connectionId` so a recovery link never
carries over to another connection. The file stays under 1,000 lines.
- Tests:
- `server/src/__tests__/github-grant-metadata.test.ts`: a mix of
suspended and active installations, and an account where all
installations are suspended.
- `server/src/__tests__/github-health-recovery.test.ts`: drives
`checkConnectionHealth` with a suspended-installation GitHub response
and checks the 422, code, and recovery links.
- `ui/src/pages/apps/AppDetail.test.tsx`: a failed Retry access renders
the installation settings link, and navigating to another cached
connection does not show it.
## Verification
- Red/green: with the source changes reverted and the tests kept, the
new tests fail (suspended installation counted as `all`; recovery link
missing from the health-check error; link not rendered; link leaking to
another connection). With the changes, they pass.
- `server`: `github-grant-metadata`, `github-health-recovery`,
`tool-access-service`, `github-operation-credentials`,
`tool-access-oauth-protocol.integration`, `tool-connection-removal`,
`tool-connection-visibility`, and the other `github-*` / `tool-access-*`
suites pass locally (630 tests).
- `ui`: `AppDetail.test.tsx` passes (78 tests). `ui` `tsc --noEmit`
shows no errors in changed files.
- Not run locally: the server `tsc`. The sandbox has a 4 GB memory
limit, and the server typecheck is killed there even on clean `master`.
CI ran the full typecheck and build, and both pass.
- CI: all gates green. One unrelated `workspace-recovery.test.ts` poll
timeout in a server shard passed on re-run.
- Live check: after the suspended installation was unsuspended, a
managed-token `git push --dry-run` succeeded and a private repository
returned 200.
## Risks
- Low risk. The change only affects installations that GitHub marks as
suspended. Active installations behave as before.
- A connection that has only suspended installations now fails Refresh
access with `github_installation_required` (422) and does not report
fake access. This is the intended behavior.
- The recovery link only renders for https `github.com` URLs.
- Keying the app page on `connectionId` remounts it when moving between
connections, which resets per-page state (for example an open rename
field). That reset is intended.
## Model Used
- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, used through the
Claude Code agent SDK with tool use (shell, file edit, code execution).
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: devinfoley <139239+devinfoley@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
8 files changed
+284
-14
No files matched your search
@@ -82,6 +82,60 @@ describe("GitHub grant metadata", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("does not count a suspended installation as repository access", async () => {
|
||||
// GitHub still lists a suspended installation and its repositories for the
|
||||
// user token, but the token cannot write to them or read private ones.
|
||||
const request = vi.fn<typeof fetch>(async (input) => {
|
||||
const url = new URL(String(input));
|
||||
if (url.pathname === "/user") return json({ id: 42, login: "octocat" });
|
||||
if (url.pathname === "/user/installations") {
|
||||
return json({ installations: [
|
||||
{ id: 101, repository_selection: "all", suspended_at: "2026-10-22T01:33:41Z", html_url: "https://github.com/organizations/paperclipai/settings/installations/101", account: { login: "paperclipai" } },
|
||||
{ id: 102, repository_selection: "selected", suspended_at: null, account: { login: "octocat" } },
|
||||
] });
|
||||
}
|
||||
if (url.pathname === "/user/installations/101/repositories") {
|
||||
return json({ repositories: [{ id: 1, full_name: "paperclipai/a" }] });
|
||||
}
|
||||
if (url.pathname === "/user/installations/102/repositories") {
|
||||
return json({ repositories: [{ id: 2, full_name: "octocat/b" }] });
|
||||
}
|
||||
throw new Error(`Unexpected GitHub path: ${url.pathname}`);
|
||||
});
|
||||
await expect(loadGitHubGrantMetadata("ghu_secret", request)).resolves.toMatchObject({
|
||||
installationCount: 1,
|
||||
repositoryCount: 1,
|
||||
repositorySelection: "selected",
|
||||
installationIds: ["102"],
|
||||
installationOwnerLogins: ["octocat"],
|
||||
repositories: [{ id: "2", fullName: "octocat/b", installationId: "102" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("points an all-suspended account at the suspended installation to restore access", async () => {
|
||||
const request = vi.fn<typeof fetch>(async (input) => {
|
||||
const url = new URL(String(input));
|
||||
if (url.pathname === "/user") return json({ id: 42, login: "octocat" });
|
||||
if (url.pathname === "/user/installations") {
|
||||
return json({ installations: [
|
||||
{ id: 101, app_slug: "paperclip-staging", repository_selection: "all", suspended_at: "2026-10-22T01:33:41Z", html_url: "https://github.com/organizations/paperclipai/settings/installations/101" },
|
||||
] });
|
||||
}
|
||||
if (url.pathname === "/user/installations/101/repositories") {
|
||||
return json({ repositories: [{ id: 1, full_name: "paperclipai/a" }] });
|
||||
}
|
||||
throw new Error(`Unexpected GitHub path: ${url.pathname}`);
|
||||
});
|
||||
await expect(loadGitHubGrantMetadata("ghu_secret", request)).rejects.toMatchObject({
|
||||
message: expect.stringContaining("suspended"),
|
||||
details: {
|
||||
code: "github_installation_required",
|
||||
installationUrl: "https://github.com/apps/paperclip-staging/installations/new",
|
||||
managementUrl: "https://github.com/organizations/paperclipai/settings/installations/101",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("does not report a partial repository list when a later page fails", async () => {
|
||||
const request = vi.fn<typeof fetch>()
|
||||
.mockResolvedValueOnce(json({ id: 42, login: "octocat" }))
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { loadGitHubGrantMetadata } from "../services/tool-access.js";
|
||||
import { createConnectionHealth } from "../services/tool-access/catalog/health.js";
|
||||
|
||||
vi.mock("../services/tool-access/connections/projection.js", () => ({
|
||||
toConnection: (row: { id: string; healthStatus: string; healthMessage: string | null }) => ({
|
||||
id: row.id,
|
||||
healthStatus: row.healthStatus,
|
||||
healthMessage: row.healthMessage,
|
||||
}),
|
||||
}));
|
||||
|
||||
const SUSPENDED_INSTALLATION_URL = "https://github.com/organizations/paperclipai/settings/installations/101";
|
||||
|
||||
function json(value: unknown): Response {
|
||||
return new Response(JSON.stringify(value), { headers: { "content-type": "application/json" } });
|
||||
}
|
||||
|
||||
// GitHub's view of an account whose only installation is suspended.
|
||||
const suspendedGitHub = vi.fn<typeof fetch>(async (input) => {
|
||||
const url = new URL(String(input));
|
||||
if (url.pathname === "/user") return json({ id: 42, login: "octocat" });
|
||||
if (url.pathname === "/user/installations") {
|
||||
return json({ installations: [
|
||||
{ id: 101, app_slug: "paperclip-staging", repository_selection: "all", suspended_at: "2026-10-22T01:33:41Z", html_url: SUSPENDED_INSTALLATION_URL },
|
||||
] });
|
||||
}
|
||||
if (url.pathname === "/user/installations/101/repositories") {
|
||||
return json({ repositories: [{ id: 1, full_name: "paperclipai/a" }] });
|
||||
}
|
||||
throw new Error(`Unexpected GitHub path: ${url.pathname}`);
|
||||
});
|
||||
|
||||
function githubConnectionHealth() {
|
||||
const connection = {
|
||||
id: "conn-1",
|
||||
companyId: "company-1",
|
||||
connectionPurpose: "tools",
|
||||
transport: "mcp_remote",
|
||||
credentialPolicy: "shared",
|
||||
config: { sourceTemplateKey: "github", oauth: { connectorProfile: "github.code" } },
|
||||
healthStatus: "ok",
|
||||
healthMessage: null,
|
||||
};
|
||||
const db = {
|
||||
select: () => ({ from: () => ({ where: async () => [{ id: "grant-1", kind: "organization", status: "active" }] }) }),
|
||||
update: () => ({
|
||||
set: (values: Record<string, unknown>) => ({
|
||||
where: () => ({ returning: async () => [{ ...connection, ...values }] }),
|
||||
}),
|
||||
}),
|
||||
} as unknown as Db;
|
||||
return createConnectionHealth(db, {
|
||||
now: () => new Date(),
|
||||
audit: vi.fn(async () => undefined),
|
||||
getConnectionRow: async () => connection as never,
|
||||
discovery: {} as never,
|
||||
setupPolicy: { assertConnectorConnectionSetupEnabled: async () => undefined } as never,
|
||||
resolveCredentialHeaders: vi.fn() as never,
|
||||
refreshManagedGitHubGrantAccess: (async () => {
|
||||
await loadGitHubGrantMetadata("ghu_secret", suspendedGitHub);
|
||||
}) as never,
|
||||
ensureRuntimeSlot: vi.fn() as never,
|
||||
stdioTemplateId: vi.fn() as never,
|
||||
});
|
||||
}
|
||||
|
||||
describe("GitHub Refresh access recovery", () => {
|
||||
it("keeps the suspended installation's settings link on the health-check failure", async () => {
|
||||
const { checkConnectionHealth } = githubConnectionHealth();
|
||||
|
||||
await expect(checkConnectionHealth("conn-1", { actorType: "user", actorId: "user-1" } as never)).rejects.toMatchObject({
|
||||
status: 422,
|
||||
message: expect.stringContaining("suspended"),
|
||||
details: {
|
||||
code: "github_installation_required",
|
||||
managementUrl: SUSPENDED_INSTALLATION_URL,
|
||||
installationUrl: "https://github.com/apps/paperclip-staging/installations/new",
|
||||
connection: { id: "conn-1", healthStatus: "error", healthMessage: expect.stringContaining("suspended") },
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -29,6 +29,7 @@ export function healthFailureHttpStatus(failure: {
|
||||
if (failure.code === "oauth_insufficient_scope") return 422;
|
||||
if (failure.code === "slack_mcp_access_disabled") return 422;
|
||||
if (failure.code === "user_authorization_required") return 422;
|
||||
if (failure.code === "github_installation_required") return 422;
|
||||
if (failure.code === "composio_broker_retired") return 422;
|
||||
if (failure.code === "tool_connection_transport_unsupported") return 422;
|
||||
if (failure.code === "cognee_access_unverified" || failure.code === "memory_api_key_rejected") return 422;
|
||||
@@ -59,7 +60,7 @@ export function sanitizeHttpFailure(error: unknown): {
|
||||
if (code === "slack_mcp_access_disabled" || code === "google_workspace_services_unavailable") {
|
||||
return { status: "error", message: error.message, code };
|
||||
}
|
||||
if (code === "user_authorization_required") {
|
||||
if (code === "user_authorization_required" || code === "github_installation_required") {
|
||||
return { status: "error", message: error.message, code };
|
||||
}
|
||||
if (code === "tool_connection_transport_unsupported") {
|
||||
|
||||
@@ -205,12 +205,22 @@ export function createConnectionHealth(db: Db, { now, audit, getConnectionRow, d
|
||||
actor,
|
||||
details: { status: failure.status, transport: connection.transport },
|
||||
});
|
||||
// Keep GitHub's recovery links so Refresh access can point at the exact
|
||||
// installation page, such as the one with the Unsuspend button.
|
||||
const githubRecovery =
|
||||
failure.code === "github_installation_required" && error instanceof HttpError
|
||||
? {
|
||||
installationUrl: asRecord(error.details).installationUrl,
|
||||
managementUrl: asRecord(error.details).managementUrl,
|
||||
}
|
||||
: {};
|
||||
throw retainMcpConnectionFailure(error, new HttpError(healthFailureHttpStatus(failure), failure.message, {
|
||||
code: failure.code,
|
||||
connection: toConnection(updated),
|
||||
runtimeSlot,
|
||||
setupUrl: connectionSetupUrl(connection),
|
||||
reconnectUrl: connectionReconnectUrl(connection),
|
||||
...githubRecovery,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,6 +158,8 @@ export async function loadGitHubGrantMetadata(
|
||||
const owners = new Set<string>();
|
||||
const selections = new Set<"all" | "selected">();
|
||||
const managementUrls = new Set<string>();
|
||||
const suspendedManagementUrls = new Set<string>();
|
||||
let suspendedCount = 0;
|
||||
const repositories = new Map<
|
||||
string,
|
||||
{ id: string; fullName: string; installationId: string; ownerType?: "personal" | "organization"; private?: boolean }
|
||||
@@ -165,7 +167,6 @@ export async function loadGitHubGrantMetadata(
|
||||
for (const installation of installations) {
|
||||
const installationId = githubId(installation.id);
|
||||
if (!installationId) continue;
|
||||
installationIds.push(installationId);
|
||||
// Older grants predate the broker's appSlug field. GitHub's installation
|
||||
// response identifies this token's app without choosing an environment.
|
||||
if (
|
||||
@@ -175,6 +176,16 @@ export async function loadGitHubGrantMetadata(
|
||||
) {
|
||||
resolvedAppSlug = installation.app_slug;
|
||||
}
|
||||
// GitHub keeps listing a suspended installation and its repositories, but
|
||||
// user tokens lose write access and private-repository reads until an
|
||||
// owner unsuspends it, so it grants no usable access.
|
||||
if (installation.suspended_at) {
|
||||
const suspendedUrl = githubInstallationManagementUrl(installation.html_url);
|
||||
if (suspendedUrl) suspendedManagementUrls.add(suspendedUrl);
|
||||
suspendedCount += 1;
|
||||
continue;
|
||||
}
|
||||
installationIds.push(installationId);
|
||||
if (
|
||||
installation.repository_selection === "all" ||
|
||||
installation.repository_selection === "selected"
|
||||
@@ -222,6 +233,19 @@ export async function loadGitHubGrantMetadata(
|
||||
const installationUrl = resolvedAppSlug
|
||||
? `https://github.com/apps/${resolvedAppSlug}/installations/new`
|
||||
: "https://github.com/settings/installations";
|
||||
if (installationIds.length === 0 && suspendedCount > 0) {
|
||||
throw unprocessable(
|
||||
"The Paperclip GitHub installation is suspended. Unsuspend it on GitHub before refreshing access.",
|
||||
{
|
||||
code: "github_installation_required",
|
||||
installationUrl,
|
||||
managementUrl:
|
||||
suspendedManagementUrls.size === 1
|
||||
? suspendedManagementUrls.values().next().value!
|
||||
: "https://github.com/settings/installations",
|
||||
},
|
||||
);
|
||||
}
|
||||
throw unprocessable(
|
||||
"GitHub access is required. Install Paperclip and grant at least one repository before refreshing access.",
|
||||
{
|
||||
|
||||
@@ -7,6 +7,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { getAppStoreDefinition } from "@paperclipai/shared";
|
||||
import type { AggregatorAppSnapshot, AggregatorAppsResponse } from "@paperclipai/shared/aggregator-apps";
|
||||
import { ApiError } from "@/api/client";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { rememberSkillSourceReturn, skillSourceReturnPath } from "@/lib/skill-source-connect-return";
|
||||
import { AppDetail } from "./AppDetail";
|
||||
@@ -1429,6 +1430,52 @@ describe("AppDetail", () => {
|
||||
expect(container.textContent).not.toContain("Reconnect required");
|
||||
});
|
||||
|
||||
it("links a failed GitHub refresh to the suspended installation's settings", async () => {
|
||||
mockParams.tab = "permissions";
|
||||
const message = "The Paperclip GitHub installation is suspended. Unsuspend it on GitHub before refreshing access.";
|
||||
getConnectionMock.mockResolvedValue(connection({
|
||||
authKind: "oauth", healthStatus: "error", requiresReauthorization: false, healthMessage: message,
|
||||
}));
|
||||
checkConnectionHealthMock.mockRejectedValue(new ApiError(message, 422, {
|
||||
error: message,
|
||||
code: "github_installation_required",
|
||||
details: {
|
||||
code: "github_installation_required",
|
||||
managementUrl: "https://github.com/organizations/paperclipai/settings/installations/101",
|
||||
},
|
||||
}));
|
||||
const client = await renderAppDetail();
|
||||
await act(async () => {
|
||||
Array.from(container.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.trim() === "Retry access")
|
||||
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(checkConnectionHealthMock).toHaveBeenCalledWith("conn-1");
|
||||
expect(container.querySelector('a[href="https://github.com/organizations/paperclipai/settings/installations/101"]')?.textContent)
|
||||
.toBe("Open installation settings on GitHub");
|
||||
|
||||
// An already-cached connection that also needs attention must not inherit that link.
|
||||
const other = connection({
|
||||
id: "conn-2", authKind: "oauth", healthStatus: "error", requiresReauthorization: false, healthMessage: message,
|
||||
});
|
||||
getConnectionMock.mockResolvedValue(other);
|
||||
client.setQueryData(queryKeys.tools.connection("conn-2"), other);
|
||||
mockParams.connectionId = "conn-2";
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={client}>
|
||||
<AppDetail />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Retry access");
|
||||
expect(container.querySelector('a[href="https://github.com/organizations/paperclipai/settings/installations/101"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("shows terminal OAuth failures as reconnect-required sign-in", async () => {
|
||||
mockParams.tab = "permissions";
|
||||
getConnectionMock.mockResolvedValue(connection({
|
||||
|
||||
@@ -62,6 +62,7 @@ import { isAppAggregator } from "@paperclipai/shared/aggregator-apps";
|
||||
import { actionPermissionMutation } from "./app-detail/action-permissions";
|
||||
import { RailwayAccessPanel } from "./app-detail/RailwayAccessPanel";
|
||||
import { ReviewPanel } from "./app-detail/ReviewPanel";
|
||||
import { AccessRetryBanner } from "./app-detail/AccessRetryBanner";
|
||||
import {
|
||||
ReconnectCard,
|
||||
DangerZone,
|
||||
@@ -82,7 +83,8 @@ export function AppDetail(props: { renderActions?: (connection: ToolConnection)
|
||||
if (connection.isPending) return <p role="status">Loading connection…</p>;
|
||||
if (connection.error) return <p role="alert">{connection.error.message}</p>;
|
||||
const pluginKey = connection.data && aiConnectionRouterPluginKey(connection.data);
|
||||
return pluginKey ? <AiConnectionPoolConnector pluginKey={pluginKey} connection={connection.data} /> : <StandardAppDetail {...props} />;
|
||||
// Keyed so page state, such as a failed refresh's recovery link, never carries over to another connection.
|
||||
return pluginKey ? <AiConnectionPoolConnector pluginKey={pluginKey} connection={connection.data} /> : <StandardAppDetail key={connectionId} {...props} />;
|
||||
}
|
||||
|
||||
function StandardAppDetail({ renderActions, renderAgentSettings, renderConnectionSettings, onReconnect }: {
|
||||
@@ -469,11 +471,17 @@ function StandardAppDetail({ renderActions, renderAgentSettings, renderConnectio
|
||||
tone: "success",
|
||||
});
|
||||
},
|
||||
onError: (error) => pushToast({
|
||||
title: "Couldn't refresh GitHub access",
|
||||
body: error instanceof Error ? error.message : "Please try again.",
|
||||
tone: "error",
|
||||
}),
|
||||
onError: (error) => {
|
||||
// The failed check is saved on the connection; refetch so the banner
|
||||
// shows it next to any GitHub recovery link.
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connection(connectionId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.apps.attention(selectedCompanyId!) });
|
||||
pushToast({
|
||||
title: "Couldn't refresh GitHub access",
|
||||
body: error instanceof Error ? error.message : "Please try again.",
|
||||
tone: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const apply = (mutate: {
|
||||
@@ -592,12 +600,12 @@ function StandardAppDetail({ renderActions, renderAgentSettings, renderConnectio
|
||||
/>
|
||||
|
||||
{status.tone === "attention" && connection.requiresReauthorization === false && (
|
||||
<div role="status">
|
||||
<p>{connection.healthMessage || "GitHub access could not be checked. Try again."}</p>
|
||||
<Button variant="outline" disabled={refreshGitHubAccess.isPending} onClick={() => refreshGitHubAccess.mutate()}>
|
||||
Retry access
|
||||
</Button>
|
||||
</div>
|
||||
<AccessRetryBanner
|
||||
message={connection.healthMessage}
|
||||
error={refreshGitHubAccess.error}
|
||||
pending={refreshGitHubAccess.isPending}
|
||||
onRetry={() => refreshGitHubAccess.mutate()}
|
||||
/>
|
||||
)}
|
||||
{needsReconnect && (
|
||||
<ReconnectCard
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { ApiError } from "@/api/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
/** GitHub page that resolves a failed Refresh access, such as Unsuspend. */
|
||||
function githubAccessRecoveryUrl(error: unknown): string | null {
|
||||
if (!(error instanceof ApiError) || !error.body || typeof error.body !== "object") return null;
|
||||
const details = (error.body as { details?: { code?: unknown; managementUrl?: unknown } }).details;
|
||||
if (details?.code !== "github_installation_required" || typeof details.managementUrl !== "string") return null;
|
||||
try {
|
||||
const url = new URL(details.managementUrl);
|
||||
return url.protocol === "https:" && url.hostname.toLowerCase() === "github.com" ? url.toString() : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A connection that needs attention without a new login: its saved health
|
||||
* message, a link to the GitHub page that fixes the last failed refresh, and a
|
||||
* retry.
|
||||
*/
|
||||
export function AccessRetryBanner({ message, error, pending, onRetry }: {
|
||||
message: string | null | undefined;
|
||||
error: unknown;
|
||||
pending: boolean;
|
||||
onRetry: () => void;
|
||||
}) {
|
||||
const recoveryUrl = githubAccessRecoveryUrl(error);
|
||||
return (
|
||||
<div role="status">
|
||||
<p>{message || "GitHub access could not be checked. Try again."}</p>
|
||||
{recoveryUrl ? (
|
||||
<Button asChild variant="outline">
|
||||
<a href={recoveryUrl} target="_blank" rel="noreferrer">Open installation settings on GitHub</a>
|
||||
</Button>
|
||||
) : null}
|
||||
<Button variant="outline" disabled={pending} onClick={onRetry}>
|
||||
Retry access
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user