diff --git a/maps/feature-map/chat-channels.md b/maps/feature-map/chat-channels.md index 5288ba7ff47..9c01ca4f938 100644 --- a/maps/feature-map/chat-channels.md +++ b/maps/feature-map/chat-channels.md @@ -103,6 +103,15 @@ must remain stopped while transient disconnections can still recover. The PostgreSQL suite uses controlled provider failures for these cases; live provider credential repair remains a separate acceptance check. +GitHub connections in attention or revoked state show a repair notice above the +settings. **Reconnect GitHub → Reconnect saved App** checks the same App with +stored credentials and repairs delivery. **Replace App credentials** is the +fallback, with the saved App ID, a link to its GitHub settings, and PEM upload or +paste instructions. The production management Storybook's **04 Recovery** group +covers these views, pending/failure/success, mobile, and light theme with simulated +provider responses. Verify actual credential validity and restored delivery +separately against GitHub; a Storybook success does not establish live recovery. + For GitHub, submit a disposable PR and verify the summary heading uses the bot's display name and the check remains **Paperclip Review**. For two separate bots on one PR, verify their App-scoped checks and comments remain independent. diff --git a/tests/e2e/chat-adapters-ui-providers.spec.ts b/tests/e2e/chat-adapters-ui-providers.spec.ts index a7795120a94..399a966d1bd 100644 --- a/tests/e2e/chat-adapters-ui-providers.spec.ts +++ b/tests/e2e/chat-adapters-ui-providers.spec.ts @@ -54,12 +54,25 @@ async function exerciseGitHubReviewSetup(page: Page, mock: ChatMock, seed: Seed, // Existing-App recovery stays available and preserves entered credentials after failure. await page.getByRole("button", { name: "I already have an App" }).click(); await page.getByLabel("App ID", { exact: true }).fill("123456"); - await page.getByLabel("Private key", { exact: true }).fill(GITHUB_PRIVATE_KEY_PASTE_FIXTURE); + await page.getByLabel("Private key", { exact: true }).evaluate((element, privateKey) => { + const clipboardData = new DataTransfer(); + clipboardData.setData("text/plain", privateKey); + element.dispatchEvent(new ClipboardEvent("paste", { bubbles: true, cancelable: true, clipboardData })); + }, GITHUB_PRIVATE_KEY_PASTE_FIXTURE); await page.getByLabel("Webhook secret", { exact: true }).fill("github-webhook-secret"); + const rejectedAppResponse = page.waitForResponse((response) => + response.url().endsWith("/api/chat-endpoints/endpoint-github/github/app") && response.request().method() === "POST", + ); await page.getByRole("button", { name: "Connect existing App", exact: true }).click(); + expect((await rejectedAppResponse).request().postDataJSON().privateKey).toBe(GITHUB_PRIVATE_KEY_PASTE_FIXTURE); await expect(page.getByRole("alert")).toContainText("GitHub rejected the supplied App credentials."); - await expect(page.getByLabel("Private key", { exact: true })).toHaveValue(GITHUB_PRIVATE_KEY_PASTE_FIXTURE); + await expect(page.getByLabel("Private key", { exact: true })).toHaveValue(""); + await expect(page.getByText("Private key loaded. Paste or upload another key to replace it.", { exact: true })).toBeVisible(); + const retriedAppResponse = page.waitForResponse((response) => + response.url().endsWith("/api/chat-endpoints/endpoint-github/github/app") && response.request().method() === "POST", + ); await page.getByRole("button", { name: "Connect existing App", exact: true }).click(); + expect((await retriedAppResponse).request().postDataJSON().privateKey).toBe(GITHUB_PRIVATE_KEY_PASTE_FIXTURE); await expect(page.getByRole("heading", { name: "Connect your account", exact: true })).toBeVisible(); await page.getByLabel("Existing GitHub connection").selectOption("personal-github"); await expect(page.getByRole("button", { name: "Check my account" })).toBeVisible(); @@ -92,18 +105,19 @@ async function exerciseGitHubReviewSetup(page: Page, mock: ChatMock, seed: Seed, await expect.poll(() => mock.replayedDelivery).toBe(true); mock.setStatus("attention"); await page.reload(); - await page.getByText("Connection health and controls", { exact: true }).click(); - await page.getByRole("button", { name: "Reconnect", exact: true }).click(); - await expect(page.getByRole("heading", { name: "Connect GitHub", exact: true })).toBeVisible(); - await expect(page.getByText(/Leave these blank to reuse/)).toBeVisible(); + const recoveryNotice = page.getByRole("region", { name: "GitHub connection needs repair" }); + await expect(recoveryNotice).toBeVisible(); + await recoveryNotice.getByRole("link", { name: "Reconnect GitHub", exact: true }).click(); + await expect(page.getByRole("heading", { name: "Reconnect GitHub", exact: true })).toBeVisible(); + await expect(page.getByText(/using its saved credentials/)).toBeVisible(); await expect(page.getByRole("button", { name: "Choose an agent", exact: true })).toHaveCount(0); - await expect(page.getByLabel("Private key", { exact: true })).toHaveValue(""); + await expect(page.getByLabel("Private key", { exact: true })).toHaveCount(0); const reconnectResponse = page.waitForResponse((response) => response.url().endsWith("/api/chat-endpoints/endpoint-github/setup") && response.request().method() === "POST" && response.request().postDataJSON().action === "reconnect", ); - await page.getByRole("button", { name: "Reconnect App", exact: true }).click(); + await page.getByRole("button", { name: "Reconnect saved App", exact: true }).click(); const reconnected = await reconnectResponse; expect(reconnected.request().postDataJSON()).toEqual({ action: "reconnect" }); expect(await reconnected.json()).toMatchObject({ assignedAgentId: seed.agentId, assignedAgentName: "Maya" }); diff --git a/ui/src/pages/apps/chat/ChatEndpoint.clipboard.test.tsx b/ui/src/pages/apps/chat/ChatEndpoint.clipboard.test.tsx index adf87af5089..8b30ba2da62 100644 --- a/ui/src/pages/apps/chat/ChatEndpoint.clipboard.test.tsx +++ b/ui/src/pages/apps/chat/ChatEndpoint.clipboard.test.tsx @@ -662,8 +662,12 @@ describe("chat setup and identity-link clipboard actions", () => { it("keeps manual existing-App recovery write-only without generating or copying credentials", async () => { await render("github"); await click("I already have an App"); - expect(container.querySelector('input[type="password"]')).not.toBeNull(); - expect(container.querySelector("textarea")?.value).toBe(""); + const privateKey = container.querySelector("#github-private-key"); + const webhookSecret = container.querySelector("#github-webhook-secret"); + expect(privateKey?.type).toBe("password"); + expect(privateKey?.value).toBe(""); + expect(webhookSecret?.type).toBe("password"); + expect(webhookSecret?.value).toBe(""); expect(container.textContent).not.toContain("Copy webhook secret"); expect(container.textContent).not.toContain("Generate webhook secret"); expect(mocks.generateSetupSecret).not.toHaveBeenCalled(); diff --git a/ui/src/pages/apps/chat/ChatEndpointDetail.tsx b/ui/src/pages/apps/chat/ChatEndpointDetail.tsx index f9753854f67..cd58f45fa9e 100644 --- a/ui/src/pages/apps/chat/ChatEndpointDetail.tsx +++ b/ui/src/pages/apps/chat/ChatEndpointDetail.tsx @@ -15,6 +15,7 @@ import { ChatCommunicationInstructions } from "./ChatCommunicationInstructions"; import { ChatExecutionDefaults } from "./ChatExecutionDefaults"; import { agentsApi } from "@/api/agents"; import { ChatConversationList } from "./ChatConversationList"; +import { GitHubConnectionRecoveryNotice } from "./GitHubConnectionRecovery"; import { GitHubBotManagement, GitHubReviews } from "./GitHubBotManagement"; import { GitHubBotMention, GitHubAppIdentityActions } from "./GitHubAppIdentity"; import { GitHubConnectionComplete, type GitHubConnectionCompleteLocationState } from "./GitHubConnectionComplete"; @@ -372,6 +373,7 @@ export function ChatEndpointDetail() { {endpoint.status !== "active" && } + {endpoint.provider === "github" && } {activeTab === "settings" && endpoint.provider === "speko" && endpoint.status === "active" &&
} {endpoint.provider === "slack" && } {endpoint.provider === "github" &&