diff --git a/docs/PROJECT_MEMORY.md b/docs/PROJECT_MEMORY.md index 5187d4ba..3b3ff8d9 100644 --- a/docs/PROJECT_MEMORY.md +++ b/docs/PROJECT_MEMORY.md @@ -1,5 +1,14 @@ # Prayu Project Memory +> 2026-10-09 recovery and connection workflow checkpoint (#292): Continuity +> Fork/Resume and checkpoint Fork open the returned Run in V2 without inheriting +> a source Thread binding. Late completion refreshes record and Thread lists +> without replacing a later navigation. GitHub connection editing uses the selected +> generation; confirmed disconnect deletes the connection's current local credential, +> preserving settings and historical evidence. Approval navigation retains the exact +> PR, snapshot and connection binding. Verification is recorded in the issue/PR; this slice +> does not add execution authority or certify the remaining frontend rebuild. + > 2026-10-09 Go security baseline (#289): Go 1.26.9 is the minimum compiler; > CI and release builders read the exact version from go.mod. Local desktop > builders require that same patched compiler. The x/net diff --git a/web/src/components/context-continuity-panel.tsx b/web/src/components/context-continuity-panel.tsx index c45abb5f..effe1698 100644 --- a/web/src/components/context-continuity-panel.tsx +++ b/web/src/components/context-continuity-panel.tsx @@ -23,7 +23,7 @@ import type { } from "../api/types"; import { formatDate, shortID } from "../lib/format"; import { useLocale } from "../lib/locale"; -import { useConnectionStore } from "../state/connection"; +import { v2QueryKeys } from "../v2/query-keys"; import { ErrorState, LoadingState, StatusBadge } from "./common"; type MemoryScope = "project" | "user"; @@ -47,15 +47,15 @@ const emptyMemoryDraft: MemoryDraft = { redactSensitive: false, }; -export function ContextContinuityPanel({ client, runID, sessionID, workspaceID }: { +export function ContextContinuityPanel({ client, runID, sessionID, workspaceID, onOpenRun }: { client: APIClient; runID: string; sessionID: string; workspaceID: string; + onOpenRun?: (runID: string) => void; }) { const { t } = useLocale(); const queryClient = useQueryClient(); - const selectRun = useConnectionStore((state) => state.selectRun); const [memoryScope, setMemoryScope] = useState(workspaceID ? "project" : "user"); const [memoryDraft, setMemoryDraft] = useState(emptyMemoryDraft); const [editingMemoryID, setEditingMemoryID] = useState(""); @@ -177,13 +177,21 @@ export function ContextContinuityPanel({ client, runID, sessionID, workspaceID } `/continuity-nodes/${encodeURIComponent(node.id)}/${kind}`, { goal: branchGoal || undefined }, `web-continuity-${kind}-${globalThis.crypto.randomUUID()}`), - onSuccess: (result) => { + onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["runs"] }); void queryClient.invalidateQueries({ queryKey: ["sessions"] }); - selectRun(result.run.id); + void queryClient.invalidateQueries({ queryKey: v2QueryKeys.inspectorRecords }); + void queryClient.invalidateQueries({ queryKey: v2QueryKeys.threads("active") }); + void queryClient.invalidateQueries({ queryKey: v2QueryKeys.workspaces }); }, }); + const branchFrom = (node: SessionTreeNodeView, kind: BranchKind) => { + // Observer callbacks stop on unmount; background completion still refreshes + // the records above, but must not take navigation away from another page. + branch.mutate({ node, kind }, { onSuccess: (result) => onOpenRun?.(result.run.id) }); + }; + const exportMemories = useMutation({ mutationFn: () => client.get("/memories/export", { scope: memoryScope, @@ -395,11 +403,11 @@ export function ContextContinuityPanel({ client, runID, sessionID, workspaceID } {!node.derived &&
} diff --git a/web/src/components/github-review-panel.test.tsx b/web/src/components/github-review-panel.test.tsx index 095fe0e6..1ed03634 100644 --- a/web/src/components/github-review-panel.test.tsx +++ b/web/src/components/github-review-panel.test.tsx @@ -1,8 +1,8 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { render, screen, waitFor } from "@testing-library/react"; +import { act, render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import type { APIClient } from "../api/client"; -import type { GitHubReviewWriteReviewResultView } from "../api/types"; +import { APIRequestError, type APIClient } from "../api/client"; +import type { GitHubReviewConnectionView, GitHubReviewWriteReviewResultView } from "../api/types"; import { standardCodeDeliveryFixture } from "../test/standard-code-delivery"; import { GitHubReviewPanel } from "./github-review-panel"; @@ -10,7 +10,7 @@ const oid = "1".repeat(40); const digest = "a".repeat(64); const now = "2026-08-21T10:00:00Z"; -function connection() { +function connection(): GitHubReviewConnectionView { return { protocol_version: "github-review-connection.v1", id: "connection-1", repository: { host: "github.com", owner: "acme", name: "widget", @@ -23,21 +23,21 @@ function connection() { }; } -function projection() { +function projection(selected = connection()) { return { - protocol_version: "github-review-api.v1", run_id: "run-1", connection: connection(), + protocol_version: "github-review-api.v1", run_id: "run-1", connection: selected, credential: { protocol_version: "github-review-provider.v1", - credential: connection().credential, store_kind: "test", store_available: true, + credential: selected.credential, store_kind: "test", store_available: true, configured: true, refreshable: true }, snapshots: [{ protocol_version: "github-review-snapshot.v1", id: "snapshot-1", - identity: { repository: connection().repository, number: 118, node_id: "PR_node", + identity: { repository: selected.repository, number: 118, node_id: "PR_node", state: "open", merged: false, draft: false, base_ref: "main", base_sha: oid, head_ref: "feature", head_sha: "2".repeat(40), merge_base_sha: oid, updated_at: now }, capability: { protocol_version: "github-review-capability.v1", generation: digest, api_host: "api.github.com", api_version: "2026-03-10", account_login: "octocat", - installation_id: 1, repository: connection().repository, - credential: connection().credential, permissions: { pull_requests: "write" }, + installation_id: 1, repository: selected.repository, + credential: selected.credential, permissions: { pull_requests: "write" }, read: true, reply: true, resolve: true, review: true, request_reviewer: true, push: false, logs: false, captured_at: now }, title: { text: "Review provider", truncated: false, original_bytes: 15 }, @@ -49,24 +49,65 @@ function projection() { }; } +function credentialView(selected = connection(), configured = true) { + return { protocol_version: "github-review-api.v1", connection: selected, + credential: { ...projection(selected).credential, configured } }; +} + +function reviewedWrite(selected = connection(), runID = "run-1"): GitHubReviewWriteReviewResultView { + const preview = { protocol_version: "github-review-write.v1", operation: "submit_review", + approval_fingerprint: digest, identity: projection(selected).snapshots[0].identity, + credential: selected.credential, capability_generation: digest }; + return { protocol_version: "github-review-api.v1", preview, + operation: { id: "write-1", run_id: runID, connection_id: selected.id, preview }, + approval: { ID: "approval-1" }, replayed: false } as GitHubReviewWriteReviewResultView; +} + +function mockClient(selected = [connection()], overrides: Partial = {}): APIClient { + return { hasGitHubReviewControl: true, + githubReviewConnections: vi.fn().mockImplementation(async () => selected.map((item) => credentialView(item))), + githubReviewCredential: vi.fn().mockImplementation(async (id: string) => credentialView(selected.find((item) => item.id === id))), + githubReviewProjection: vi.fn().mockImplementation(async (_runID: string, id: string, number: number) => { + const value = projection(selected.find((item) => item.id === id)); + return { ...value, snapshots: value.snapshots.filter((item) => !number || item.identity.number === number) }; + }), + reviewGitHubWrite: vi.fn().mockImplementation(async (runID: string, body: { connection_id: string }) => + reviewedWrite(selected.find((item) => item.id === body.connection_id), runID)), + ...overrides } as unknown as APIClient; +} + +async function createPreview(user: ReturnType) { + await waitFor(() => expect(screen.getByRole("button", { name: "Create exact preview" })).toBeEnabled()); + await user.click(screen.getByRole("button", { name: "Create exact preview" })); + await waitFor(() => expect(screen.getByRole("button", { name: "Execute approved write" })).toBeEnabled()); +} + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + describe("GitHubReviewPanel", () => { - it("retains an exact approved write across repository-panel remounts", async () => { + it("retains the original exact preview across approval-panel unmount and remount", async () => { const user = userEvent.setup(); const onOpenDelivery = vi.fn(); - const retained = { protocol_version: "github-review-api.v1", - preview: { protocol_version: "github-review-write.v1", operation: "submit_review", - approval_fingerprint: digest }, operation: { id: "write-1" }, - approval: { ID: "approval-1" }, replayed: false }; + const onRetainedReviewChange = vi.fn(); const executeGitHubWrite = vi.fn().mockResolvedValue({ protocol_version: "github-review-api.v1", operation: { id: "write-1" }, receipt: { status: "succeeded" }, replayed: false }); - renderPanel({ hasGitHubReviewControl: true, - githubReviewConnections: vi.fn().mockResolvedValue([{ connection: connection(), - credential: projection().credential }]), - githubReviewProjection: vi.fn().mockResolvedValue(projection()), - executeGitHubWrite } as unknown as APIClient, - retained as unknown as GitHubReviewWriteReviewResultView, onOpenDelivery); + const client = mockClient([connection()], { executeGitHubWrite }); + const first = renderPanel(client, undefined, onOpenDelivery, onRetainedReviewChange); + await createPreview(user); + const retained = onRetainedReviewChange.mock.calls.at(-1)![0] as GitHubReviewWriteReviewResultView; + await user.click(screen.getByRole("button", { name: "Open approvals" })); + first.unmount(); + renderPanel(client, retained, onOpenDelivery); expect(await screen.findByText(digest)).toBeInTheDocument(); + expect(screen.getByLabelText("PR number")).toHaveValue(118); expect(screen.getByText("Delivery truth")).toBeInTheDocument(); expect(screen.getByText("f".repeat(64))).toBeInTheDocument(); await user.click(screen.getByRole("button", { name: "Open delivery" })); @@ -75,17 +116,428 @@ describe("GitHubReviewPanel", () => { await waitFor(() => expect(executeGitHubWrite).toHaveBeenCalledWith( "run-1", "write-1", "approval-1")); }); + + it("edits the selected connection with its generation and preserves existing settings", async () => { + const user = userEvent.setup(); + const selected = { ...connection(), generation: 7, + repository: { ...connection().repository, private: true }, + network: { ...connection().network, allowed_log_hosts: ["logs.acme.example"] } }; + const configureGitHubReview = vi.fn().mockImplementation(async (body) => ({ + protocol_version: "github-review-api.v1", replayed: false, + connection: { ...selected, credential: body.credential, generation: body.expected_generation + 1, + network: { ...selected.network, write_enabled: body.write_enabled } }, + })); + renderPanel(mockClient([selected], { configureGitHubReview })); + + expect(await screen.findByDisplayValue("acme/widget")).toHaveAttribute("readonly"); + expect(screen.getByLabelText("Credential reference")).toHaveValue("prayu-github-app"); + expect(screen.getByLabelText("GitHub App Client ID")).toHaveValue("Iv1.client"); + await user.click(screen.getByRole("checkbox", { name: "Allow per-call approved write-back" })); + await user.click(screen.getByRole("button", { name: "Update connection" })); + await waitFor(() => expect(configureGitHubReview).toHaveBeenCalledWith({ + connection_id: selected.id, expected_generation: 7, repository: selected.repository, + credential: selected.credential, client_id: selected.client_id, + allowed_log_hosts: ["logs.acme.example"], write_enabled: false, enabled: true, + })); + expect(await screen.findByText("Connection settings saved.")).toBeInTheDocument(); + expect(screen.getByText("Editing acme/widget; settings version 8.")).toBeInTheDocument(); + await user.click(screen.getByRole("checkbox", { name: "Allow per-call approved write-back" })); + await user.click(screen.getByRole("button", { name: "Update connection" })); + await waitFor(() => expect(configureGitHubReview).toHaveBeenLastCalledWith(expect.objectContaining({ expected_generation: 8 }))); + }); + + it("preserves non-device credential kind and disabled connection configuration", async () => { + const user = userEvent.setup(); + const selected = { ...connection(), enabled: false, client_id: undefined, + credential: { name: "existing-pat", kind: "fine_grained_pat" } }; + const configureGitHubReview = vi.fn().mockResolvedValue({ connection: { ...selected, generation: 2 } }); + renderPanel(mockClient([selected], { configureGitHubReview })); + await screen.findByDisplayValue("existing-pat"); + expect(screen.queryByRole("button", { name: "Device sign-in" })).not.toBeInTheDocument(); + expect(screen.getByLabelText("GitHub App Client ID")).toBeDisabled(); + await user.click(screen.getByRole("button", { name: "Update connection" })); + await waitFor(() => expect(configureGitHubReview).toHaveBeenCalledWith(expect.objectContaining({ + credential: selected.credential, client_id: undefined, enabled: false, expected_generation: 1, + }))); + }); + + it("keeps explicit new-connection mode and creates with generation zero", async () => { + const user = userEvent.setup(); + const configureGitHubReview = vi.fn().mockResolvedValue({ connection: { ...connection(), id: "new-connection" } }); + renderPanel(mockClient([connection()], { configureGitHubReview })); + await screen.findByDisplayValue("acme/widget"); + await user.selectOptions(screen.getByLabelText("GitHub connection"), ""); + await user.click(screen.getByRole("button", { name: "Refresh connection and remote PR" })); + expect(screen.getByLabelText("GitHub connection")).toHaveValue(""); + expect(screen.getByLabelText("Repository")).toHaveValue(""); + await user.type(screen.getByLabelText("Repository"), "other/repository"); + await user.type(screen.getByLabelText("GitHub App Client ID"), "Iv1.new"); + await user.click(screen.getByRole("button", { name: "Create connection" })); + await waitFor(() => expect(configureGitHubReview).toHaveBeenCalledWith({ + repository: { host: "github.com", owner: "other", name: "repository", full_name: "other/repository", private: false }, + credential: { name: "prayu-github-app", kind: "github_app_device" }, client_id: "Iv1.new", + allowed_log_hosts: [], write_enabled: false, enabled: true, expected_generation: 0, + })); + }); + + it("preserves a conflicted draft until the operator reloads the latest version", async () => { + const user = userEvent.setup(); + const latest = { ...connection(), generation: 9, client_id: "Iv1.latest" }; + const configureGitHubReview = vi.fn().mockRejectedValueOnce(new APIRequestError("generation mismatch", "CONFLICT", 409)) + .mockResolvedValue({ connection: { ...latest, generation: 10 } }); + const githubReviewCredential = vi.fn().mockResolvedValueOnce(credentialView()).mockResolvedValue(credentialView(latest)); + renderPanel(mockClient([connection()], { configureGitHubReview, githubReviewCredential })); + await screen.findByDisplayValue("acme/widget"); + await user.clear(screen.getByLabelText("GitHub App Client ID")); + await user.type(screen.getByLabelText("GitHub App Client ID"), "Iv1.draft"); + await user.click(screen.getByRole("button", { name: "Update connection" })); + expect(await screen.findByText(/Your draft is preserved/)).toBeInTheDocument(); + expect(screen.getByLabelText("GitHub App Client ID")).toHaveValue("Iv1.draft"); + expect(screen.queryByText("Connection settings saved.")).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Update connection" })).toBeDisabled(); + await user.click(screen.getByRole("button", { name: "Reload latest settings" })); + expect(await screen.findByDisplayValue("Iv1.latest")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Update connection" })); + await waitFor(() => expect(configureGitHubReview).toHaveBeenLastCalledWith(expect.objectContaining({ expected_generation: 9 }))); + }); + + it.each(["success", "failure"])("does not replace a newly selected connection with an old save %s", async (result) => { + const user = userEvent.setup(); + const second = { ...connection(), id: "connection-2", client_id: "Iv1.second", + repository: { ...connection().repository, owner: "other", full_name: "other/widget" } }; + const request = deferred<{ connection: GitHubReviewConnectionView }>(); + const configureGitHubReview = vi.fn().mockReturnValue(request.promise); + renderPanel(mockClient([connection(), second], { configureGitHubReview })); + await screen.findByDisplayValue("acme/widget"); + await user.click(screen.getByRole("button", { name: "Update connection" })); + await user.selectOptions(screen.getByLabelText("GitHub connection"), second.id); + await act(async () => { + if (result === "success") request.resolve({ connection: { ...connection(), generation: 2 } }); + else request.reject(new Error("old save failed")); + }); + expect(screen.getByLabelText("GitHub connection")).toHaveValue(second.id); + expect(screen.getByLabelText("GitHub App Client ID")).toHaveValue("Iv1.second"); + expect(screen.queryByText("Connection settings saved.")).not.toBeInTheDocument(); + expect(screen.queryByText("old save failed")).not.toBeInTheDocument(); + }); + + it("confirms local credential deletion, supports cancellation, and refreshes signed-out state", async () => { + const user = userEvent.setup(); + let signedIn = true; + const disconnectGitHubReview = vi.fn().mockImplementation(async () => { + signedIn = false; return credentialView(connection(), false); + }); + const onRetainedReviewChange = vi.fn(); + renderPanel(mockClient([connection()], { + githubReviewCredential: vi.fn().mockImplementation(async () => credentialView(connection(), signedIn)), + githubReviewConnections: vi.fn().mockImplementation(async () => [credentialView(connection(), signedIn)]), + disconnectGitHubReview, + beginGitHubReviewDeviceFlow: vi.fn().mockResolvedValue({ session_id: "device-1", user_code: "DEVICE-CODE", verification_uri: "https://github.com/login/device" }), + }), undefined, vi.fn(), onRetainedReviewChange); + await createPreview(user); + await user.click(screen.getByRole("button", { name: "Device sign-in" })); + expect(await screen.findByText("DEVICE-CODE")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Delete local credential…" })); + expect(screen.getByRole("dialog", { name: "Delete local GitHub credential" })).toBeInTheDocument(); + expect(screen.getByText(/this does not revoke authorization on GitHub/)).toBeInTheDocument(); + expect(disconnectGitHubReview).not.toHaveBeenCalled(); + await user.click(screen.getByRole("button", { name: "Cancel" })); + expect(screen.getByText("DEVICE-CODE")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Delete local credential…" })); + await user.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Delete local credential" })); + await waitFor(() => expect(disconnectGitHubReview).toHaveBeenCalledWith("connection-1")); + expect(await screen.findByText("Local credential deleted for this connection.")).toBeInTheDocument(); + expect(await screen.findByText("No local credential is configured.")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Delete local credential…" })).toBeDisabled(); + expect(screen.queryByText("DEVICE-CODE")).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Execute approved write" })).not.toBeInTheDocument(); + expect(onRetainedReviewChange).toHaveBeenCalledWith(null); + expect(screen.getByLabelText("Repository")).toHaveValue("acme/widget"); + }); + + it("reports deletion failure without claiming the credential was deleted", async () => { + const user = userEvent.setup(); + const disconnectGitHubReview = vi.fn().mockRejectedValue(new Error("credential store unavailable")); + renderPanel(mockClient([connection()], { disconnectGitHubReview })); + await screen.findByText("Local credential is configured."); + await user.click(screen.getByRole("button", { name: "Delete local credential…" })); + await user.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Delete local credential" })); + expect(await screen.findByText("credential store unavailable")).toBeInTheDocument(); + expect(screen.getByText("Local credential is configured.")).toBeInTheDocument(); + expect(screen.queryByText("Local credential deleted for this connection.")).not.toBeInTheDocument(); + }); + + it("gates credential deletion on process control and credential store capability", async () => { + const disconnectGitHubReview = vi.fn(); + const { unmount } = renderPanel(mockClient([connection()], { hasGitHubReviewControl: false, disconnectGitHubReview })); + expect(screen.queryByRole("button", { name: "Delete local credential…" })).not.toBeInTheDocument(); + unmount(); + const unavailable = credentialView(); + unavailable.credential.store_available = false; + renderPanel(mockClient([connection()], { githubReviewCredential: vi.fn().mockResolvedValue(unavailable), disconnectGitHubReview })); + await screen.findByText("Local credential is configured."); + expect(screen.getByRole("button", { name: "Delete local credential…" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Device sign-in" })).toBeDisabled(); + expect(disconnectGitHubReview).not.toHaveBeenCalled(); + }); + + it("ignores device authorization completed after selecting another connection", async () => { + const user = userEvent.setup(); + const request = deferred<{ session_id: string; user_code: string; verification_uri: string }>(); + const second = { ...connection(), id: "connection-2" }; + renderPanel(mockClient([connection(), second], { beginGitHubReviewDeviceFlow: vi.fn().mockReturnValue(request.promise) })); + await screen.findByText("Local credential is configured."); + await user.click(screen.getByRole("button", { name: "Device sign-in" })); + await user.selectOptions(screen.getByLabelText("GitHub connection"), second.id); + await act(async () => request.resolve({ session_id: "old-device", user_code: "OLD-CODE", verification_uri: "https://github.com/login/device" })); + expect(screen.queryByText("OLD-CODE")).not.toBeInTheDocument(); + }); + + it("discards a pending exact preview when the selected snapshot changes", async () => { + const user = userEvent.setup(); + const request = deferred(); + const client = mockClient([connection()], { reviewGitHubWrite: vi.fn().mockReturnValue(request.promise) }); + const { queryClient } = renderPanel(client); + await screen.findByText("Local credential is configured."); + await user.click(screen.getByRole("button", { name: "Create exact preview" })); + const next = projection(); + next.snapshots[0].id = "snapshot-2"; + next.snapshots[0].identity.head_sha = "3".repeat(40); + await act(async () => queryClient.setQueryData(["run", "run-1", "github-review", "connection-1", 0], next)); + await act(async () => request.resolve(reviewedWrite())); + expect(screen.queryByRole("button", { name: "Execute approved write" })).not.toBeInTheDocument(); + }); + + it("rejects a retained write belonging to another Run", async () => { + const user = userEvent.setup(); + const executeGitHubWrite = vi.fn(); + const onRetainedReviewChange = vi.fn(); + const client = mockClient([connection()], { executeGitHubWrite }); + const first = renderPanel(client, undefined, vi.fn(), onRetainedReviewChange); + await createPreview(user); + const retained = onRetainedReviewChange.mock.calls.at(-1)![0] as GitHubReviewWriteReviewResultView; + first.unmount(); + renderPanel(client, { ...retained, operation: { ...retained.operation, run_id: "other-run" } }); + await screen.findByText("Local credential is configured."); + expect(screen.queryByRole("button", { name: "Execute approved write" })).not.toBeInTheDocument(); + expect(executeGitHubWrite).not.toHaveBeenCalled(); + }); + + it("keeps the draft visible after refresh failure and blocks preparing a write from the old snapshot", async () => { + const user = userEvent.setup(); + let refreshFailed = false; + const client = mockClient([connection()], { + githubReviewConnections: vi.fn().mockImplementation(async () => { + if (refreshFailed) throw new Error("connection refresh failed"); + return [credentialView()]; + }), + fetchGitHubReview: vi.fn().mockRejectedValue(new Error("snapshot refresh failed")), + }); + renderPanel(client); + await createPreview(user); + refreshFailed = true; + await user.clear(screen.getByLabelText("GitHub App Client ID")); + await user.type(screen.getByLabelText("GitHub App Client ID"), "Iv1.unsaved"); + await user.click(screen.getByRole("button", { name: "Refresh connection and remote PR" })); + expect(await screen.findByText("snapshot refresh failed")).toBeInTheDocument(); + expect(await screen.findByText("connection refresh failed")).toBeInTheDocument(); + expect(screen.getByLabelText("GitHub App Client ID")).toHaveValue("Iv1.unsaved"); + expect(screen.queryByRole("button", { name: "Execute approved write" })).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Create exact preview" })).toBeDisabled(); + }); + + it("does not retain a usable reviewed write when refreshing credential status fails", async () => { + const user = userEvent.setup(); + let refreshFailed = false; + const client = mockClient([connection()], { + githubReviewCredential: vi.fn().mockImplementation(async () => { + if (refreshFailed) throw new Error("credential refresh failed"); + return credentialView(); + }), + }); + const { queryClient } = renderPanel(client); + await createPreview(user); + refreshFailed = true; + await act(async () => { await queryClient.invalidateQueries({ queryKey: ["github-review", "credential"] }); }); + expect(await screen.findByText("credential refresh failed")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Execute approved write" })).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Create exact preview" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Delete local credential…" })).toBeDisabled(); + }); + + it("signs out other cached connections sharing the deleted credential reference", async () => { + const user = userEvent.setup(); + const second = { ...connection(), id: "connection-2", + repository: { ...connection().repository, owner: "other", full_name: "other/widget" } }; + const request = deferred>(); + let signedIn = true; + const client = mockClient([connection(), second], { + disconnectGitHubReview: vi.fn().mockReturnValue(request.promise), + githubReviewCredential: vi.fn().mockImplementation(async (id: string) => credentialView(id === second.id ? second : connection(), signedIn)), + githubReviewConnections: vi.fn().mockImplementation(async () => [connection(), second].map((item) => credentialView(item, signedIn))), + }); + const { queryClient } = renderPanel(client); + await screen.findByText("Local credential is configured."); + await user.click(screen.getByRole("button", { name: "Delete local credential…" })); + await user.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Delete local credential" })); + await user.selectOptions(screen.getByLabelText("GitHub connection"), second.id); + await screen.findByText("Local credential is configured."); + signedIn = false; + await act(async () => request.resolve(credentialView(connection(), false))); + expect(await screen.findByText("No local credential is configured.")).toBeInTheDocument(); + expect(screen.getByLabelText("GitHub connection")).toHaveValue(second.id); + expect(screen.queryByText("Local credential deleted for this connection.")).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Create exact preview" })).toBeDisabled(); + expect(queryClient.getQueryData>(["github-review", "credential", second.id])?.credential.configured).toBe(false); + }); + + it("does not let an old client reload overwrite the same Run's new client state or cache", async () => { + const user = userEvent.setup(); + const request = deferred>(); + const oldClient = mockClient([connection()], { + githubReviewCredential: vi.fn().mockResolvedValueOnce(credentialView()).mockReturnValue(request.promise), + }); + const newer = { ...connection(), generation: 10, client_id: "Iv1.current-client" }; + const newClient = mockClient([newer]); + const { queryClient, rerenderPanel } = renderPanel(oldClient); + await screen.findByText("Local credential is configured."); + await user.click(screen.getByRole("button", { name: "Reload latest settings" })); + rerenderPanel(newClient); + await screen.findByDisplayValue("Iv1.current-client"); + await act(async () => request.resolve(credentialView({ ...connection(), generation: 2, client_id: "Iv1.old-response" }))); + expect(screen.getByLabelText("GitHub App Client ID")).toHaveValue("Iv1.current-client"); + expect(screen.queryByText("Latest settings loaded. Review them before saving.")).not.toBeInTheDocument(); + expect(queryClient.getQueryData>(["github-review", "credential", connection().id])?.connection.generation).toBe(10); + }); + + it("ignores a pending preview after its review draft changes", async () => { + const user = userEvent.setup(); + const request = deferred(); + renderPanel(mockClient([connection()], { reviewGitHubWrite: vi.fn().mockReturnValue(request.promise) })); + await screen.findByText("Local credential is configured."); + await user.click(screen.getByRole("button", { name: "Create exact preview" })); + await user.type(screen.getByLabelText("Review body"), "Newer draft"); + await act(async () => request.resolve(reviewedWrite())); + expect(screen.getByLabelText("Review body")).toHaveValue("Newer draft"); + expect(screen.queryByRole("button", { name: "Execute approved write" })).not.toBeInTheDocument(); + }); + + it("rejects a retained API result that has no original frontend source binding", async () => { + const executeGitHubWrite = vi.fn(); + renderPanel(mockClient([connection()], { executeGitHubWrite }), reviewedWrite()); + await screen.findByText("Local credential is configured."); + expect(screen.queryByRole("button", { name: "Execute approved write" })).not.toBeInTheDocument(); + expect(executeGitHubWrite).not.toHaveBeenCalled(); + }); + + it("rejects the original preview after approval-panel remount sees a different snapshot with the same HEAD and capability", async () => { + const user = userEvent.setup(); + let source = projection(); + const executeGitHubWrite = vi.fn(); + const client = mockClient([connection()], { + githubReviewProjection: vi.fn().mockImplementation(async (_runID: string, _id: string, number: number) => ({ + ...source, snapshots: source.snapshots.filter((item) => !number || item.identity.number === number), + })), executeGitHubWrite, + }); + const onRetainedReviewChange = vi.fn(); + const first = renderPanel(client, undefined, vi.fn(), onRetainedReviewChange); + await createPreview(user); + const retained = onRetainedReviewChange.mock.calls.at(-1)![0] as GitHubReviewWriteReviewResultView; + await user.click(screen.getByRole("button", { name: "Open approvals" })); + first.unmount(); + source = { ...source, snapshots: [{ ...source.snapshots[0], id: "snapshot-2" }] }; + renderPanel(client, retained); + await screen.findByText("Local credential is configured."); + expect(screen.getByLabelText("PR number")).toHaveValue(118); + expect(screen.queryByRole("button", { name: "Execute approved write" })).not.toBeInTheDocument(); + expect(executeGitHubWrite).not.toHaveBeenCalled(); + }); + + it("rejects the original preview after approval-panel remount sees a newer connection generation", async () => { + const user = userEvent.setup(); + const selected = [connection()]; + const executeGitHubWrite = vi.fn(); + const client = mockClient(selected, { executeGitHubWrite }); + const onRetainedReviewChange = vi.fn(); + const first = renderPanel(client, undefined, vi.fn(), onRetainedReviewChange); + await createPreview(user); + const retained = onRetainedReviewChange.mock.calls.at(-1)![0] as GitHubReviewWriteReviewResultView; + await user.click(screen.getByRole("button", { name: "Open approvals" })); + first.unmount(); + selected[0] = { ...selected[0], generation: 2, client_id: "Iv1.updated", + network: { ...selected[0].network, allowed_log_hosts: ["updated.example"] } }; + renderPanel(client, retained); + await screen.findByDisplayValue("Iv1.updated"); + expect(screen.queryByRole("button", { name: "Execute approved write" })).not.toBeInTheDocument(); + expect(executeGitHubWrite).not.toHaveBeenCalled(); + }); + + it("rejects a retained preview when remounted with a different API client", async () => { + const user = userEvent.setup(); + const onRetainedReviewChange = vi.fn(); + const first = renderPanel(mockClient(), undefined, vi.fn(), onRetainedReviewChange); + await createPreview(user); + const retained = onRetainedReviewChange.mock.calls.at(-1)![0] as GitHubReviewWriteReviewResultView; + first.unmount(); + const executeGitHubWrite = vi.fn(); + renderPanel(mockClient([connection()], { executeGitHubWrite }), retained); + await screen.findByText("Local credential is configured."); + expect(screen.queryByRole("button", { name: "Execute approved write" })).not.toBeInTheDocument(); + expect(executeGitHubWrite).not.toHaveBeenCalled(); + }); + + it("restores the reviewed PR rather than the connection's newer snapshot for a different PR", async () => { + const user = userEvent.setup(); + let source = projection(); + const githubReviewProjection = vi.fn().mockImplementation(async (_runID: string, _id: string, number: number) => ({ + ...source, snapshots: source.snapshots.filter((item) => !number || item.identity.number === number), + })); + const executeGitHubWrite = vi.fn().mockResolvedValue({ operation: { id: "write-1" } }); + const client = mockClient([connection()], { githubReviewProjection, executeGitHubWrite }); + const onRetainedReviewChange = vi.fn(); + const first = renderPanel(client, undefined, vi.fn(), onRetainedReviewChange); + await createPreview(user); + const retained = onRetainedReviewChange.mock.calls.at(-1)![0] as GitHubReviewWriteReviewResultView; + await user.click(screen.getByRole("button", { name: "Open approvals" })); + first.unmount(); + source = { ...source, snapshots: [{ ...source.snapshots[0], id: "snapshot-other-pr", + identity: { ...source.snapshots[0].identity, number: 119, node_id: "PR_119", head_sha: "3".repeat(40) }, + fetched_at: "2026-08-21T11:00:00Z" }, ...source.snapshots] }; + renderPanel(client, retained); + await waitFor(() => expect(screen.getByRole("button", { name: "Execute approved write" })).toBeEnabled()); + expect(screen.getByLabelText("PR number")).toHaveValue(118); + expect(githubReviewProjection).toHaveBeenLastCalledWith("run-1", "connection-1", 118, expect.any(AbortSignal)); + await user.click(screen.getByRole("button", { name: "Execute approved write" })); + await waitFor(() => expect(executeGitHubWrite).toHaveBeenCalledWith("run-1", "write-1", "approval-1")); + }); + + it("closes credential deletion confirmation if it observes a newer connection generation", async () => { + const user = userEvent.setup(); + const disconnectGitHubReview = vi.fn(); + const { queryClient } = renderPanel(mockClient([connection()], { disconnectGitHubReview })); + await screen.findByText("Local credential is configured."); + await user.click(screen.getByRole("button", { name: "Delete local credential…" })); + expect(screen.getByText(/local credential currently used by connection connection-1 \(acme\/widget\)/)).toBeInTheDocument(); + const next = credentialView({ ...connection(), generation: 2, credential: { name: "updated-reference", kind: "github_app_device" } }); + await act(async () => queryClient.setQueryData(["github-review", "credential", "connection-1"], next)); + expect(await screen.findByText("Connection settings changed. Reload the latest settings before deleting its local credential.")).toBeInTheDocument(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + expect(disconnectGitHubReview).not.toHaveBeenCalled(); + }); }); function renderPanel(client: APIClient, retainedReview?: GitHubReviewWriteReviewResultView | null, - onOpenDelivery: () => void = vi.fn()) { + onOpenDelivery: () => void = vi.fn(), + onRetainedReviewChange = vi.fn()) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false }, } }); - return render( - + - ); + retainedReview={retainedReview} onRetainedReviewChange={onRetainedReviewChange} runID="run-1" /> + ; + const result = render(panel(client)); + return { queryClient, ...result, rerenderPanel: (nextClient: APIClient) => result.rerender(panel(nextClient)) }; } diff --git a/web/src/components/github-review-panel.tsx b/web/src/components/github-review-panel.tsx index f04153e4..f664a4d7 100644 --- a/web/src/components/github-review-panel.tsx +++ b/web/src/components/github-review-panel.tsx @@ -1,11 +1,13 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState, type RefObject } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { ExternalLink, GitPullRequest, RefreshCw, ShieldCheck } from "lucide-react"; -import type { APIClient } from "../api/client"; -import type { GitHubReviewWriteReviewResultView, GitHubReviewWriteSpecView } from "../api/types"; +import { APIRequestError, type APIClient } from "../api/client"; +import type { GitHubReviewConnectionView, GitHubReviewCredentialView, GitHubReviewProjectionView, + GitHubReviewWriteReviewResultView, GitHubReviewWriteSpecView } from "../api/types"; import { formatDate, shortID } from "../lib/format"; import { useLocale } from "../lib/locale"; import { EmptyState, ErrorState, KeyValue, LoadingState, StatusBadge } from "./common"; +import { V2ConfirmDialog } from "../v2/components/dialog"; function operationKey(): string { if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { @@ -14,33 +16,119 @@ function operationKey(): string { return `desktop-github-review-${Date.now()}-${Math.random().toString(16).slice(2)}`; } -export function GitHubReviewPanel({ client, runID, onOpenApprovals, - onOpenDelivery, retainedReview, onRetainedReviewChange }: { +type GitHubReviewSourceBinding = { + runID: string; + connectionID: string; + snapshotID: string; + connectionGeneration: number; + credentialName: string; + credentialKind: string; + clientID: string; + apiClient: APIClient; +}; + +// Kept only in the parent's React state, never sent as part of an API request. +type RetainedGitHubReview = GitHubReviewWriteReviewResultView & { sourceBinding?: GitHubReviewSourceBinding }; + +type GitHubReviewPanelProps = { client: APIClient; runID: string; onOpenApprovals: () => void; onOpenDelivery?: () => void; - retainedReview?: GitHubReviewWriteReviewResultView | null; - onRetainedReviewChange?: (value: GitHubReviewWriteReviewResultView | null) => void; + retainedReview?: RetainedGitHubReview | null; + onRetainedReviewChange?: (value: RetainedGitHubReview | null) => void; +}; + +type ConnectionForm = { + connection: GitHubReviewConnectionView | null; + repository: string; + credentialName: string; + clientID: string; + writeEnabled: boolean; +}; + +type RequestScope = { client: APIClient; runID: string; connectionID: string; revision: number }; +type ReviewScope = RequestScope & { snapshotID: string; reviewRevision: number; number: number; + sourceBinding: GitHubReviewSourceBinding }; + +function sourceMatches(binding: GitHubReviewSourceBinding | undefined, + connection: GitHubReviewConnectionView | undefined | null, snapshotID: string | undefined, + client: APIClient, runID: string): boolean { + return Boolean(binding && connection && binding.apiClient === client && binding.runID === runID && + binding.connectionID === connection.id && binding.snapshotID === snapshotID && + binding.connectionGeneration === connection.generation && binding.credentialName === connection.credential.name && + binding.credentialKind === connection.credential.kind && binding.clientID === (connection.client_id ?? "")); +} + +function connectionForm(connection: GitHubReviewConnectionView | null): ConnectionForm { + return { connection, repository: connection?.repository.full_name ?? "", + credentialName: connection?.credential.name ?? "prayu-github-app", + clientID: connection?.client_id ?? "", writeEnabled: connection?.network.write_enabled ?? false }; +} + +export function GitHubReviewPanel(props: GitHubReviewPanelProps) { + // A reviewed write and pending mutations belong to the original Run, even when the inspector changes Runs. + const clientContext = useRef({ client: props.client, version: 0 }); + if (clientContext.current.client !== props.client) { + clientContext.current = { client: props.client, version: clientContext.current.version + 1 }; + } + return ; +} + +function GitHubReviewWorkspace({ client, runID, onOpenApprovals, + onOpenDelivery, retainedReview, onRetainedReviewChange, clientContext }: GitHubReviewPanelProps & { + clientContext: RefObject<{ client: APIClient; version: number }>; }) { const { t } = useLocale(); const queryClient = useQueryClient(); - const [repository, setRepository] = useState(""); - const [credentialName, setCredentialName] = useState("prayu-github-app"); - const [clientID, setClientID] = useState(""); - const [writeEnabled, setWriteEnabled] = useState(false); - const [connectionID, setConnectionID] = useState(""); - const [pullRequest, setPullRequest] = useState(0); + const [form, setForm] = useState(() => connectionForm(null)); + const [selectionReady, setSelectionReady] = useState(false); + const connectionID = form.connection?.id ?? ""; + const [pullRequest, setPullRequest] = useState(retainedReview?.preview.identity.number ?? 0); const [device, setDevice] = useState<{ session_id: string; user_code: string; verification_uri: string } | null>(null); const [reviewBody, setReviewBody] = useState(""); const [reviewEvent, setReviewEvent] = useState("COMMENT"); - const [localReview, setLocalReview] = useState(null); - const review = retainedReview === undefined ? localReview : retainedReview; - const setReview = (value: GitHubReviewWriteReviewResultView | null) => { + const [localReview, setLocalReview] = useState(retainedReview ?? null); + const [error, setError] = useState(null); + const [notice, setNotice] = useState(""); + const [conflict, setConflict] = useState(false); + const [snapshotRefreshRequired, setSnapshotRefreshRequired] = useState(false); + const [disconnectTarget, setDisconnectTarget] = useState(null); + const disconnectButton = useRef(null); + const mounted = useRef(true); + const revision = useRef(0); + const reviewRevision = useRef(0); + const current = useRef({ client, connectionID }); + current.current = { client, connectionID }; + const previousRetained = useRef(retainedReview); + useEffect(() => { + mounted.current = true; + return () => { mounted.current = false; }; + }, []); + useEffect(() => { + if (retainedReview !== previousRetained.current) { + previousRetained.current = retainedReview; + if (retainedReview !== undefined) setLocalReview(retainedReview); + } + }, [retainedReview]); + const setReview = (value: RetainedGitHubReview | null) => { setLocalReview(value); onRetainedReviewChange?.(value); }; + const clearReview = () => { + reviewRevision.current += 1; + setReview(null); + }; + const scope = (): RequestScope => ({ client, runID, connectionID, revision: revision.current }); + const isCurrent = (request: RequestScope) => mounted.current && + request.client === clientContext.current.client && request.client === current.current.client && request.runID === runID && + request.connectionID === current.current.connectionID && request.revision === revision.current; + const startRequest = () => { setError(null); setNotice(""); }; + const reportError = (value: unknown, request: RequestScope) => { + if (isCurrent(request)) setError(value); + }; const connections = useQuery({ queryKey: ["github-review", "connections"], @@ -48,82 +136,238 @@ export function GitHubReviewPanel({ client, runID, onOpenApprovals, enabled: client.hasGitHubReviewControl, }); useEffect(() => { - if (!connectionID && connections.data?.[0]) setConnectionID(connections.data[0].connection.id); - }, [connectionID, connections.data]); + if (selectionReady || !connections.data || connections.isFetching) return; + const retainedID = retainedReview?.operation.connection_id; + const selected = connections.data.find((item) => item.connection.id === retainedID) ?? connections.data[0]; + setForm(connectionForm(selected?.connection ?? null)); + setSelectionReady(true); + }, [selectionReady, connections.data, connections.isFetching, retainedReview]); + const credential = useQuery({ + queryKey: ["github-review", "credential", connectionID], + queryFn: ({ signal }) => client.githubReviewCredential(connectionID, signal), + enabled: client.hasGitHubReviewControl && Boolean(connectionID), + }); const projection = useQuery({ queryKey: ["run", runID, "github-review", connectionID, pullRequest], queryFn: ({ signal }) => client.githubReviewProjection(runID, connectionID, pullRequest, signal), enabled: client.hasGitHubReviewControl && Boolean(runID && connectionID), }); - const invalidate = () => { + const latest = projection.data?.snapshots[0]; + const isCurrentReview = (request: ReviewScope) => { + const source = queryClient.getQueryData( + ["run", request.runID, "github-review", request.connectionID, request.number]); + const sourceCredential = queryClient.getQueryData( + ["github-review", "credential", request.connectionID]); + return isCurrent(request) && request.reviewRevision === reviewRevision.current && + source?.snapshots[0]?.id === request.snapshotID && + sourceMatches(request.sourceBinding, source.connection, request.snapshotID, request.client, request.runID) && + sourceMatches(request.sourceBinding, sourceCredential?.connection, request.snapshotID, request.client, request.runID); + }; + const review = localReview && latest && !projection.isError && !credential.isError && localReview.operation.run_id === runID && + localReview.operation.connection_id === connectionID && + localReview.preview.identity.repository.full_name === latest.identity.repository.full_name && + localReview.preview.identity.number === latest.identity.number && + localReview.preview.identity.node_id === latest.identity.node_id && + localReview.preview.identity.state === latest.identity.state && + localReview.preview.identity.merged === latest.identity.merged && + localReview.preview.identity.draft === latest.identity.draft && + localReview.preview.identity.head_sha === latest.identity.head_sha && + localReview.preview.identity.base_sha === latest.identity.base_sha && + localReview.preview.capability_generation === latest.capability.generation && + localReview.preview.credential.name === form.connection?.credential.name && + localReview.preview.credential.kind === form.connection?.credential.kind && + sourceMatches(localReview.sourceBinding, form.connection, latest.id, client, runID) && + sourceMatches(localReview.sourceBinding, projection.data?.connection, latest.id, client, runID) && + sourceMatches(localReview.sourceBinding, credential.data?.connection, latest.id, client, runID) ? localReview : null; + useEffect(() => { + if (!localReview || !latest || !credential.data || projection.isFetching || credential.isFetching) return; + if (!review) clearReview(); + }, [localReview, latest, review, credential.data, credential.isFetching, projection.isFetching]); + + const invalidate = (request: RequestScope) => { void queryClient.invalidateQueries({ queryKey: ["github-review"] }); - void queryClient.invalidateQueries({ queryKey: ["run", runID, "github-review"] }); + void queryClient.invalidateQueries({ queryKey: ["run", request.runID, "github-review"] }); }; const configure = useMutation({ - mutationFn: () => { - const [owner, name, extra] = repository.trim().split("/"); + mutationFn: (request: RequestScope & { form: ConnectionForm }) => { + const draft = request.form; + const [owner, name, extra] = draft.repository.trim().split("/"); if (!owner || !name || extra) throw new Error(t("仓库必须为 owner/name", "Repository must be owner/name")); - return client.configureGitHubReview({ - repository: { host: "github.com", owner, name, full_name: `${owner}/${name}`, private: false }, - credential: { name: credentialName.trim(), kind: "github_app_device" }, - client_id: clientID.trim(), allowed_log_hosts: [], write_enabled: writeEnabled, - enabled: true, expected_generation: 0, + const existing = draft.connection; + return request.client.configureGitHubReview({ + ...(existing ? { connection_id: existing.id } : {}), + repository: { ...(existing?.repository ?? { owner, name, full_name: `${owner}/${name}`, private: false }), host: "github.com" }, + credential: { name: draft.credentialName.trim(), + kind: (existing?.credential.kind ?? "github_app_device") as "github_app_device" | "oauth_user" | "fine_grained_pat" }, + client_id: draft.clientID.trim() || undefined, + allowed_log_hosts: existing?.network.allowed_log_hosts ?? [], write_enabled: draft.writeEnabled, + enabled: existing?.enabled ?? true, expected_generation: existing?.generation ?? 0, }); }, - onSuccess: (value) => { setConnectionID(value.connection.id); invalidate(); }, + onSuccess: (value, request) => { + invalidate(request); + if (!isCurrent(request)) return; + revision.current += 1; + setForm(connectionForm(value.connection)); + setDevice(null); + clearReview(); + setConflict(false); + setNotice(t("连接设置已保存。", "Connection settings saved.")); + }, + onError: (value, request) => { + if (!isCurrent(request)) return; + setError(value); + setConflict(value instanceof APIRequestError && value.code === "CONFLICT"); + }, + }); + const reload = useMutation({ + mutationFn: (request: RequestScope) => request.client.githubReviewCredential(request.connectionID), + onSuccess: (value, request) => { + if (request.client !== clientContext.current.client) return; + queryClient.setQueryData(["github-review", "credential", request.connectionID], value); + if (!isCurrent(request)) return; + revision.current += 1; + setForm(connectionForm(value.connection)); + setDevice(null); + clearReview(); + setError(null); + setConflict(false); + setNotice(t("已载入最新设置;请重新检查后保存。", "Latest settings loaded. Review them before saving.")); + }, + onError: reportError, }); const beginDevice = useMutation({ - mutationFn: () => client.beginGitHubReviewDeviceFlow(connectionID), - onSuccess: (value) => setDevice(value), + mutationFn: (request: RequestScope) => request.client.beginGitHubReviewDeviceFlow(request.connectionID), + onSuccess: (value, request) => { if (isCurrent(request)) setDevice(value); }, + onError: reportError, }); const pollDevice = useMutation({ - mutationFn: () => { - if (!device) throw new Error("Device Flow session is unavailable"); - return client.pollGitHubReviewDeviceFlow(connectionID, device.session_id); + mutationFn: (request: RequestScope & { sessionID: string }) => + request.client.pollGitHubReviewDeviceFlow(request.connectionID, request.sessionID), + onSuccess: (value, request) => { + invalidate(request); + if (isCurrent(request) && value.configured) { setDevice(null); clearReview(); } + }, + onError: reportError, + }); + const disconnect = useMutation({ + mutationFn: (request: RequestScope) => request.client.disconnectGitHubReview(request.connectionID), + onSuccess: (value, request) => { + if (request.client !== clientContext.current.client) return; + const signedOut = (item: GitHubReviewCredentialView): GitHubReviewCredentialView => + item.connection.credential.name === value.connection.credential.name ? { + ...item, credential: { ...item.credential, configured: false, refreshable: false, + expires_at: undefined, refresh_expires_at: undefined }, + } : item; + // The secret store is keyed by credential reference, which may be shared by multiple repositories. + queryClient.setQueriesData({ queryKey: ["github-review", "credential"] }, + (item) => item ? signedOut(item) : item); + queryClient.setQueryData(["github-review", "credential", request.connectionID], value); + queryClient.setQueryData(["github-review", "connections"], (items) => + items?.map((item) => item.connection.id === request.connectionID ? value : signedOut(item))); + invalidate(request); + void queryClient.invalidateQueries({ predicate: (query) => + query.queryKey[0] === "run" && query.queryKey[2] === "github-review" }); + if (!isCurrent(request)) return; + setNotice(t("已删除此连接的本机凭据。", "Local credential deleted for this connection.")); }, - onSuccess: (value) => { if (value.configured) setDevice(null); invalidate(); }, + onError: reportError, + }); + const qualify = useMutation({ + mutationFn: (request: RequestScope & { number: number }) => request.client.qualifyGitHubReview(request.connectionID, request.number), + onError: reportError, }); - const qualify = useMutation({ mutationFn: () => client.qualifyGitHubReview(connectionID, pullRequest) }); const fetchSnapshot = useMutation({ - mutationFn: (number?: number) => client.fetchGitHubReview(connectionID, number ?? pullRequest), onSuccess: invalidate, + mutationFn: (request: RequestScope & { number: number }) => request.client.fetchGitHubReview(request.connectionID, request.number), + onSuccess: (_, request) => { + invalidate(request); + if (isCurrent(request)) setSnapshotRefreshRequired(false); + }, + onError: reportError, }); const buildEvidence = useMutation({ - mutationFn: (snapshotID: string) => client.buildGitHubReviewEvidence(runID, snapshotID), - onSuccess: invalidate, + mutationFn: (request: RequestScope & { snapshotID: string }) => request.client.buildGitHubReviewEvidence(request.runID, request.snapshotID), + onSuccess: (_, request) => invalidate(request), onError: reportError, }); const reviewWrite = useMutation({ - mutationFn: () => { - const snapshot = projection.data?.snapshots[0]; - if (!snapshot) throw new Error(t("请先抓取 PR 快照", "Fetch a PR snapshot first")); - const spec: GitHubReviewWriteSpecView = { - protocol_version: "github-review-write.v1", operation: "submit_review", - identity: snapshot.identity, credential: projection.data!.connection.credential, - capability_generation: snapshot.capability.generation, body: reviewBody, - review_event: reviewEvent, reviewers: [], - validation_summary: "Operator-reviewed Traverse Board evidence graph", - }; - return client.reviewGitHubWrite(runID, { connection_id: connectionID, - snapshot_id: snapshot.id, operation_key: operationKey(), spec }); + mutationFn: (request: ReviewScope & { spec: GitHubReviewWriteSpecView }) => + request.client.reviewGitHubWrite(request.runID, { connection_id: request.connectionID, + snapshot_id: request.snapshotID, operation_key: operationKey(), spec: request.spec }), + onSuccess: (value, request) => { + invalidate(request); + void queryClient.invalidateQueries({ queryKey: ["run", request.runID, "approvals"] }); + if (!isCurrentReview(request)) return; + setReview({ ...value, sourceBinding: request.sourceBinding }); }, - onSuccess: (value) => { setReview(value); invalidate(); - void queryClient.invalidateQueries({ queryKey: ["run", runID, "approvals"] }); }, + onError: (value, request) => { if (isCurrentReview(request)) setError(value); }, }); const executeWrite = useMutation({ - mutationFn: () => { - const approvalID = review && "ID" in review.approval ? String(review.approval.ID) : ""; - if (!review || !approvalID) throw new Error("Approval identity is unavailable"); - return client.executeGitHubWrite(runID, review.operation.id, approvalID); + mutationFn: (request: ReviewScope & { review: RetainedGitHubReview }) => { + const approvalID = "ID" in request.review.approval ? String(request.review.approval.ID) : ""; + if (!isCurrentReview(request) || request.review.operation.run_id !== request.runID || + request.review.operation.connection_id !== request.connectionID || !approvalID) { + throw new Error("The exact reviewed write is no longer current"); + } + return request.client.executeGitHubWrite(request.runID, request.review.operation.id, approvalID); + }, + onSuccess: (_, request) => { + invalidate(request); + if (isCurrentReview(request)) { clearReview(); setReviewBody(""); } }, - onSuccess: () => { setReview(null); setReviewBody(""); invalidate(); }, + onError: (value, request) => { if (isCurrentReview(request)) setError(value); }, }); - const pending = configure.isPending || beginDevice.isPending || pollDevice.isPending || - qualify.isPending || fetchSnapshot.isPending || buildEvidence.isPending || - reviewWrite.isPending || executeWrite.isPending; - const error = configure.error || beginDevice.error || pollDevice.error || qualify.error || - fetchSnapshot.error || buildEvidence.error || reviewWrite.error || executeWrite.error; - const latest = projection.data?.snapshots[0]; - const connectionWriteEnabled = projection.data?.connection.network.write_enabled === true; + const mutations = [configure, reload, beginDevice, pollDevice, disconnect, qualify, + fetchSnapshot, buildEvidence, reviewWrite, executeWrite]; + const pending = mutations.some((mutation) => mutation.isPending && mutation.variables && isCurrent(mutation.variables)); + const connectionWriteEnabled = form.connection?.network.write_enabled === true; + const credentialCurrent = !credential.isError && credential.data?.connection.generation === form.connection?.generation && + credential.data?.connection.credential.name === form.connection?.credential.name; + const canSignIn = credentialCurrent && credential.data?.credential.store_available && + form.connection?.enabled && form.connection.credential.kind === "github_app_device"; + const canDisconnect = credentialCurrent && credential.data?.credential.store_available && credential.data.credential.configured; + const canWrite = credentialCurrent && !snapshotRefreshRequired && !credential.isFetching && !projection.isError && !projection.isFetching && + credential.data?.credential.configured && form.connection?.enabled && + projection.data?.connection.generation === form.connection.generation && latest?.capability.review && + latest.capability.credential.name === form.connection.credential.name && + latest.capability.credential.kind === form.connection.credential.kind && connectionWriteEnabled; + const disconnectConnectionChanged = (target: GitHubReviewConnectionView) => { + const cachedCredential = queryClient.getQueryData(["github-review", "credential", target.id]); + const cachedConnections = queryClient.getQueryData(["github-review", "connections"]); + const cachedProjection = queryClient.getQueryData(["run", runID, "github-review", target.id, pullRequest]); + return [cachedCredential?.connection, cachedConnections?.find((item) => item.connection.id === target.id)?.connection, + cachedProjection?.connection].some((item) => item && item.generation > target.generation); + }; + const rejectChangedDisconnect = () => { + setDisconnectTarget(null); + setError(new Error(t("连接设置已改变;重新载入最新设置后再删除本机凭据。", + "Connection settings changed. Reload the latest settings before deleting its local credential."))); + }; + useEffect(() => { + if (disconnectTarget && disconnectConnectionChanged(disconnectTarget)) rejectChangedDisconnect(); + }, [disconnectTarget, credential.data, connections.data, projection.data]); + const selectConnection = (id: string) => { + revision.current += 1; + current.current.connectionID = id; + setSelectionReady(true); + setForm(connectionForm(connections.data?.find((item) => item.connection.id === id)?.connection ?? null)); + setDevice(null); + setPullRequest(0); + setReviewBody(""); + setReviewEvent("COMMENT"); + clearReview(); + setError(null); + setConflict(false); + setSnapshotRefreshRequired(false); + setNotice(""); + setDisconnectTarget(null); + }; + const fetchRemote = (number: number) => { + startRequest(); + clearReview(); + setSnapshotRefreshRequired(true); + fetchSnapshot.mutate({ ...scope(), number }); + }; const failedJobs = useMemo(() => latest?.jobs.filter((job) => job.conclusion && !["success", "skipped", "neutral"].includes(job.conclusion)) ?? [], [latest]); const staleMappings = useMemo(() => projection.data?.evidence.flatMap((item) => @@ -133,56 +377,87 @@ export function GitHubReviewPanel({ client, runID, onOpenApprovals,

GitHub Review

{t("当前进程未启用 GitHub 审阅控制。", "GitHub review control is disabled for this process.")} ; - if (connections.isLoading) return ; - if (connections.isError) return ; + if (connections.isLoading || (!selectionReady && !connections.isError)) return ; + if (connections.isError && !connections.data) return ; return

GitHub Review

- {error && } + {Boolean(error) && } + {connections.isError && } + {notice &&

{notice}

} + {conflict &&

{t( + "此连接已被其他操作更新。草稿已保留;重新载入最新设置后再编辑保存。", + "This connection was updated elsewhere. Your draft is preserved; reload the latest settings before editing and saving again.", + )}

}

{t("账户与仓库", "Account & repository")}

- setRepository(event.target.value)} - placeholder="owner/repository" value={repository} /> - setCredentialName(event.target.value)} - placeholder="prayu-github-app" value={credentialName} /> - setClientID(event.target.value)} - placeholder="GitHub App Client ID" value={clientID} /> -
+ {form.connection ? t( + `正在编辑 ${form.connection.repository.full_name};设置版本 ${form.connection.generation}。`, + `Editing ${form.connection.repository.full_name}; settings version ${form.connection.generation}.`, + ) : t("新连接使用 GitHub App 设备登录。", "New connections use GitHub App device sign-in.")} {connectionID &&
- + + {form.connection?.credential.kind === "github_app_device" && } +
} + {credential.isError && } + {credentialCurrent && {credential.data?.credential.configured ? t("本机凭据已配置。", "Local credential is configured.") : + t("未配置本机凭据。", "No local credential is configured.")}} {device &&
{device.user_code} github.com/login/device - +
}
{connectionID &&

{t("拉取请求证据", "Pull request evidence")}

setPullRequest(Number(event.target.value))} type="number" value={pullRequest || ""} /> - -
- {qualify.data &&
+ onChange={(event) => { clearReview(); setPullRequest(Number(event.target.value)); }} type="number" value={pullRequest || ""} /> + +
+ {qualify.data && qualify.variables && isCurrent(qualify.variables) && qualify.variables.number === pullRequest && +
{qualify.data.qualification.diagnostics.map((item) => {item.code}: {item.message})}
} {projection.isLoading && } {projection.isError && } @@ -202,7 +477,7 @@ export function GitHubReviewPanel({ client, runID, onOpenApprovals,
} + onClick={() => { startRequest(); buildEvidence.mutate({ ...scope(), snapshotID: latest.id }); }} type="button">{t("绑定本地证据", "Bind local evidence")}} {failedJobs.map((job) =>
{job.name} {job.failed_log.text || job.log_reason || t("无日志摘录", "No log excerpt")} @@ -222,21 +497,57 @@ export function GitHubReviewPanel({ client, runID, onOpenApprovals,
} {latest && connectionWriteEnabled &&

{t("审批后回写", "Approval-gated write-back")}

-
{ clearReview(); setReviewEvent(event.target.value); }}> -