fix(frontend): populate session.pullRequest from /pr endpoint (#251) (#279)

* fix(frontend): populate session.pullRequest from /pr endpoint (#251)

WorkspaceSession.pullRequest was declared but never populated, so every
Boolean(session.pullRequest) check was dead: the Summary tab gated its PR
fetch on it (never ran), and the Board card and /prs page read it directly
(always empty).

Hydrate the field centrally in fetchWorkspaces — the single place that
builds session objects for the workspace, board, PR page, and sidebar — by
fetching GET /sessions/{sessionId}/pr per non-terminated session in parallel
and attaching {number, state}. A per-session fetch error degrades to "no PR"
rather than failing the whole workspace query; terminated sessions are
skipped. GET /sessions/{sessionId}/pr stays the single source of truth, so
no new backend endpoint and no changes to the consuming components.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore: format with prettier [skip ci]

* test(frontend): verify PR hydration end-to-end for a normal project (#251)

Drives the real useWorkspaceQuery + real SessionsBoard / PullRequestsPage
for an ordinary project (from /api/v1/projects) whose session has an open PR,
mocking only the HTTP client and router. Confirms PR facts fetched from
/sessions/{id}/pr flow through the shared workspace cache into both the Board
card ("PR #278 · open") and the PR page row.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(frontend): move PR hydration integration test into __tests__/integration

Cross-component integration test belongs in a dedicated folder, separate
from the co-located unit tests. No behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
neversettle 2026-06-17 17:48:03 +05:30 committed by GitHub
parent 3986d488c8
commit 6ac65d2cd3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 240 additions and 3 deletions

View File

@ -0,0 +1,100 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ReactNode } from "react";
// Drives the real useWorkspaceQuery + real Board / PR-page consumers end to end
// for a normal project, mocking only the HTTP client and the router. Proves the
// #251 fix: PR facts fetched from /pr flow through the shared workspace cache
// into every consumer.
const { getMock, navigateMock } = vi.hoisted(() => ({ getMock: vi.fn(), navigateMock: vi.fn() }));
vi.mock("../../lib/api-client", () => ({
apiClient: { GET: getMock, POST: vi.fn() },
apiErrorMessage: (e: unknown) => (e instanceof Error ? e.message : "error"),
}));
vi.mock("@tanstack/react-router", async (importOriginal) => {
const actual = await importOriginal<typeof import("@tanstack/react-router")>();
return { ...actual, useNavigate: () => navigateMock };
});
import { SessionsBoard } from "../../components/SessionsBoard";
import { PullRequestsPage } from "../../components/PullRequestsPage";
// One ordinary project with one worker session that has an open PR (#278).
function respondWithProjectAndPR() {
getMock.mockImplementation(async (url: string, options?: { params?: { path?: { sessionId?: string } } }) => {
if (url === "/api/v1/projects") {
return { data: { projects: [{ id: "proj-1", name: "my-app", path: "/repo/my-app" }] }, error: undefined };
}
if (url === "/api/v1/sessions") {
return {
data: {
sessions: [
{
id: "sess-1",
projectId: "proj-1",
displayName: "fix the bug",
harness: "claude-code",
status: "pr_open",
isTerminated: false,
updatedAt: "2026-06-10T16:15:04Z",
},
],
},
error: undefined,
};
}
if (url === "/api/v1/sessions/{sessionId}/pr") {
expect(options?.params?.path?.sessionId).toBe("sess-1");
return {
data: {
sessionId: "sess-1",
prs: [
{
number: 278,
state: "open",
url: "https://github.com/aoagents/ReverbCode/pull/278",
ci: "passing",
review: "approved",
mergeability: "clean",
reviewComments: false,
updatedAt: "2026-06-10T16:15:04Z",
},
],
},
error: undefined,
};
}
throw new Error(`unexpected GET ${url}`);
});
}
function renderWithProviders(node: ReactNode) {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={queryClient}>{node}</QueryClientProvider>);
}
beforeEach(() => {
getMock.mockReset();
navigateMock.mockReset();
respondWithProjectAndPR();
});
describe("PR hydration for a normal project (#251)", () => {
it("renders the PR on the Board card instead of 'no PR yet'", async () => {
renderWithProviders(<SessionsBoard />);
expect(await screen.findByText("PR #278 · open")).toBeInTheDocument();
expect(screen.queryByText("no PR yet")).not.toBeInTheDocument();
});
it("lists the session on the PR page instead of being empty", async () => {
renderWithProviders(<PullRequestsPage />);
expect(await screen.findByText("#278")).toBeInTheDocument();
expect(screen.queryByText("No open pull requests.")).not.toBeInTheDocument();
expect(screen.getByText("fix the bug")).toBeInTheDocument();
});
});

View File

@ -20,10 +20,15 @@ function wrapper({ children }: { children: ReactNode }) {
function respondWith(payload: {
projects?: { data?: unknown; error?: unknown };
sessions?: { data?: unknown; error?: unknown };
prsBySession?: Record<string, { data?: unknown; error?: unknown }>;
}) {
getMock.mockImplementation(async (url: string) => {
getMock.mockImplementation(async (url: string, options?: { params?: { path?: { sessionId?: string } } }) => {
if (url === "/api/v1/projects") return payload.projects ?? { data: { projects: [] }, error: undefined };
if (url === "/api/v1/sessions") return payload.sessions ?? { data: { sessions: [] }, error: undefined };
if (url === "/api/v1/sessions/{sessionId}/pr") {
const sessionId = options?.params?.path?.sessionId ?? "";
return payload.prsBySession?.[sessionId] ?? { data: { sessionId, prs: [] }, error: undefined };
}
throw new Error(`unexpected GET ${url}`);
});
}
@ -91,6 +96,113 @@ describe("useWorkspaceQuery", () => {
});
});
it("hydrates each session's pullRequest from the /pr endpoint (issue #251)", async () => {
respondWith({
projects: { data: { projects: [{ id: "proj-1", name: "my-app", path: "/p" }] }, error: undefined },
sessions: {
data: {
sessions: [
{
id: "sess-1",
projectId: "proj-1",
status: "pr_open",
isTerminated: false,
updatedAt: "2026-06-10T16:15:04Z",
},
{
id: "sess-2",
projectId: "proj-1",
status: "working",
isTerminated: false,
updatedAt: "2026-06-10T16:15:04Z",
},
],
},
error: undefined,
},
prsBySession: {
"sess-1": {
data: {
sessionId: "sess-1",
prs: [
{
number: 278,
state: "open",
url: "u",
ci: "passing",
review: "approved",
mergeability: "clean",
reviewComments: false,
updatedAt: "2026-06-10T16:15:04Z",
},
],
},
error: undefined,
},
},
});
const { result } = renderHook(() => useWorkspaceQuery(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
const sessions = result.current.data?.[0].sessions ?? [];
expect(sessions[0].pullRequest).toEqual({ number: 278, state: "open" });
// No PR for the endpoint's empty response → undefined, so the empty states render.
expect(sessions[1].pullRequest).toBeUndefined();
});
it("treats a per-session PR fetch error as no PR without failing the query", async () => {
respondWith({
projects: { data: { projects: [{ id: "proj-1", name: "my-app", path: "/p" }] }, error: undefined },
sessions: {
data: {
sessions: [
{
id: "sess-1",
projectId: "proj-1",
status: "pr_open",
isTerminated: false,
updatedAt: "2026-06-10T16:15:04Z",
},
],
},
error: undefined,
},
prsBySession: { "sess-1": { data: undefined, error: new Error("pr backend down") } },
});
const { result } = renderHook(() => useWorkspaceQuery(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data?.[0].sessions[0].pullRequest).toBeUndefined();
});
it("skips the PR fetch for terminated sessions", async () => {
respondWith({
projects: { data: { projects: [{ id: "proj-1", name: "my-app", path: "/p" }] }, error: undefined },
sessions: {
data: {
sessions: [
{
id: "sess-1",
projectId: "proj-1",
status: "merged",
isTerminated: true,
updatedAt: "2026-06-10T16:15:04Z",
},
],
},
error: undefined,
},
});
const { result } = renderHook(() => useWorkspaceQuery(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(getMock).not.toHaveBeenCalledWith("/api/v1/sessions/{sessionId}/pr", expect.anything());
expect(result.current.data?.[0].sessions[0].pullRequest).toBeUndefined();
});
it("marks terminated sessions regardless of their reported status", async () => {
respondWith({
projects: { data: { projects: [{ id: "proj-1", name: "my-app", path: "/p" }] }, error: undefined },

View File

@ -1,11 +1,25 @@
import { useQuery } from "@tanstack/react-query";
import { apiClient } from "../lib/api-client";
import { mockWorkspaces } from "../lib/mock-data";
import { toAgentProvider, toSessionStatus, type WorkspaceSummary } from "../types/workspace";
import { toAgentProvider, toSessionStatus, type WorkspaceSession, type WorkspaceSummary } from "../types/workspace";
export const workspaceQueryKey = ["workspaces"] as const;
const usePreviewData = import.meta.env.VITE_NO_ELECTRON === "1";
// GET /sessions/{sessionId}/pr is the single source of truth for PR facts — no
// PR data rides on the session list — so we hydrate each session's lightweight
// {number, state} here, centrally, for every consumer (Summary, Board, PR page,
// Sidebar) that reads this query's cache. A per-session failure is treated as
// "no PR" rather than failing the whole workspace query.
async function fetchSessionPR(sessionId: string): Promise<WorkspaceSession["pullRequest"]> {
const { data, error } = await apiClient.GET("/api/v1/sessions/{sessionId}/pr", {
params: { path: { sessionId } },
});
if (error) return undefined;
const pr = data?.prs?.[0];
return pr ? { number: pr.number, state: pr.state } : undefined;
}
async function fetchWorkspaces(): Promise<WorkspaceSummary[]> {
if (usePreviewData) {
return mockWorkspaces;
@ -16,11 +30,21 @@ async function fetchWorkspaces(): Promise<WorkspaceSummary[]> {
if (projectsError || sessionsError) throw projectsError ?? sessionsError;
const sessions = sessionsData?.sessions ?? [];
// Skip terminated sessions — their PRs are archived and the call is wasted.
const prBySession = new Map(
await Promise.all(
sessions
.filter((session) => !session.isTerminated)
.map(async (session) => [session.id, await fetchSessionPR(session.id)] as const),
),
);
return (projectsData?.projects ?? []).map((project) => ({
id: project.id,
name: project.name,
path: project.path,
sessions: (sessionsData?.sessions ?? [])
sessions: sessions
.filter((session) => session.projectId === project.id)
.map((session) => ({
id: session.id,
@ -34,6 +58,7 @@ async function fetchWorkspaces(): Promise<WorkspaceSummary[]> {
status: toSessionStatus(session.status, session.isTerminated),
createdAt: session.createdAt,
updatedAt: session.updatedAt,
pullRequest: prBySession.get(session.id),
})),
}));
}