feat(FN-3279): refresh review state on manual trigger and restore roadmap e
Adds review tab refresh UX, metadata, and API (FN-3279) alongside permanent-agent action gating coordination tools (FN-3724), and introduces two new workspace plugin packages: `fusion-plugin-even-cards` (board/task cards with auth and board-routes) and `fusion-plugin-even-realities-glasses` (cards, Fusion-Task-Id: FN-3279
This commit is contained in:
@@ -258,11 +258,13 @@ export interface TaskReviewResponse {
|
||||
reviewState: NonNullable<TaskDetail["reviewState"]>;
|
||||
automationStatus: string | null;
|
||||
emptyMessage?: string | null;
|
||||
prInfo?: TaskDetail["prInfo"];
|
||||
}
|
||||
|
||||
export interface RefreshTaskReviewResponse {
|
||||
reviewState: NonNullable<TaskDetail["reviewState"]>;
|
||||
automationStatus: string | null;
|
||||
prInfo?: TaskDetail["prInfo"];
|
||||
}
|
||||
|
||||
export interface ReviseTaskReviewResponse {
|
||||
|
||||
@@ -114,6 +114,16 @@
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.task-review-tab__refresh-meta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.task-review-tab__refresh-meta .status-dot {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.task-review-tab__error {
|
||||
color: var(--color-error);
|
||||
}
|
||||
@@ -145,4 +155,8 @@
|
||||
.task-review-tab__actions .btn {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.task-review-tab__refresh-meta {
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,12 @@ function formatTimestamp(value?: string): string {
|
||||
return new Date(value).toLocaleString();
|
||||
}
|
||||
|
||||
function formatRefreshSource(source?: "manual" | "auto" | "initial-load"): string {
|
||||
if (source === "manual") return "Manual";
|
||||
if (source === "auto") return "Background";
|
||||
return "Initial load";
|
||||
}
|
||||
|
||||
type ReviewItem = NonNullable<TaskDetail["reviewState"]>["items"][number];
|
||||
|
||||
function getItemStatus(item: ReviewItem): "queued" | "in-progress" | "addressed" | "failed" {
|
||||
@@ -84,6 +90,20 @@ export function TaskReviewTab({ task, projectId, onTaskUpdated, addToast }: Prop
|
||||
? (review.summary as { reviewDecision?: string } | undefined)?.reviewDecision
|
||||
: (review.summary as { verdict?: string } | undefined)?.verdict;
|
||||
|
||||
const refreshStatus = refreshing ? "refreshing" : (review?.refreshStatus ?? "ready");
|
||||
const refreshToneClass =
|
||||
refreshStatus === "error"
|
||||
? "status-dot status-dot--error"
|
||||
: refreshStatus === "refreshing"
|
||||
? "status-dot status-dot--pending"
|
||||
: "status-dot status-dot--online";
|
||||
const refreshLabel =
|
||||
refreshStatus === "error"
|
||||
? "Refresh failed"
|
||||
: refreshStatus === "refreshing"
|
||||
? "Refreshing"
|
||||
: "Up to date";
|
||||
|
||||
const toggleSelected = (id: string) => {
|
||||
setSelected((prev) => (prev.includes(id) ? prev.filter((value) => value !== id) : [...prev, id]));
|
||||
};
|
||||
@@ -94,11 +114,19 @@ export function TaskReviewTab({ task, projectId, onTaskUpdated, addToast }: Prop
|
||||
setRefreshing(true);
|
||||
const result = await refreshTaskReview(task.id, projectId);
|
||||
setReview(result.reviewState);
|
||||
onTaskUpdated?.({ ...task, reviewState: result.reviewState } as Task);
|
||||
onTaskUpdated?.({ ...task, reviewState: result.reviewState, prInfo: result.prInfo ?? task.prInfo } as Task);
|
||||
if (result.reviewState.refreshStatus === "error") {
|
||||
const refreshMessage = result.reviewState.refreshError ?? "Failed to refresh review data.";
|
||||
setError(refreshMessage);
|
||||
addToast(refreshMessage, "error");
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
addToast("Review refreshed", "success");
|
||||
} catch {
|
||||
setError(REVIEW_LOAD_ERROR_MESSAGE);
|
||||
addToast(REVIEW_LOAD_ERROR_MESSAGE, "error");
|
||||
} catch (refreshError) {
|
||||
const message = refreshError instanceof Error ? refreshError.message : REVIEW_LOAD_ERROR_MESSAGE;
|
||||
setError(message);
|
||||
addToast(message, "error");
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
@@ -132,11 +160,14 @@ export function TaskReviewTab({ task, projectId, onTaskUpdated, addToast }: Prop
|
||||
) : null}
|
||||
</div>
|
||||
<div className="task-review-tab__actions">
|
||||
<button className="btn btn-sm" onClick={onRefresh} disabled={refreshing || !isPrMode}>{refreshing ? "Refreshing…" : "Refresh"}</button>
|
||||
<button className="btn btn-sm" onClick={onRefresh} disabled={refreshing || loading}>{refreshing ? "Refreshing…" : "Refresh"}</button>
|
||||
<button className="btn btn-primary btn-sm" disabled={!canRevise || !isPrMode} onClick={onRevise}>{revising ? "Queueing…" : "Request revision"}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="task-review-tab__meta">Last refreshed: {formatTimestamp(review?.lastRefreshedAt)}</div>
|
||||
<div className="task-review-tab__meta task-review-tab__refresh-meta" aria-live="polite">
|
||||
<span className={refreshToneClass} aria-hidden="true" />
|
||||
<span>{refreshLabel} · Last refreshed: {formatTimestamp(review?.lastRefreshedAt)} · {formatRefreshSource(review?.refreshSource)}</span>
|
||||
</div>
|
||||
{loading ? <div className="task-review-tab__meta">Loading review data…</div> : null}
|
||||
{!loading && error ? <div className="task-review-tab__error">{error}</div> : null}
|
||||
{!loading && !error && !isPrMode && review?.items?.length === 0 ? (
|
||||
|
||||
@@ -325,6 +325,60 @@ describe("TaskDetailModal", () => {
|
||||
expect(await screen.findByText("No reviewer feedback yet — this task has not produced reviewer-agent feedback in direct mode.")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("keeps Comments tab available after Review refresh", async () => {
|
||||
vi.mocked(dashboardApi.fetchTaskReview).mockResolvedValueOnce({
|
||||
reviewState: {
|
||||
source: "pull-request",
|
||||
summary: {
|
||||
reviewDecision: "REVIEW_REQUIRED",
|
||||
reviewers: [],
|
||||
blockingReasons: [],
|
||||
checks: [],
|
||||
},
|
||||
items: [],
|
||||
addressing: [],
|
||||
},
|
||||
automationStatus: null,
|
||||
emptyMessage: null,
|
||||
});
|
||||
vi.mocked(dashboardApi.refreshTaskReview).mockResolvedValueOnce({
|
||||
reviewState: {
|
||||
source: "pull-request",
|
||||
summary: {
|
||||
reviewDecision: "APPROVED",
|
||||
reviewers: [{ login: "octocat", state: "APPROVED" }],
|
||||
blockingReasons: [],
|
||||
checks: [],
|
||||
},
|
||||
items: [],
|
||||
addressing: [],
|
||||
refreshStatus: "ready",
|
||||
},
|
||||
automationStatus: null,
|
||||
});
|
||||
|
||||
render(
|
||||
<TaskDetailModal
|
||||
task={makeTask({ reviewState: { source: "pull-request", summary: { reviewDecision: "REVIEW_REQUIRED", reviewers: [], blockingReasons: [], checks: [] }, items: [], addressing: [] } })}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Review" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Refresh" }));
|
||||
expect(await screen.findByText("APPROVED")).toBeTruthy();
|
||||
|
||||
const commentsTab = screen.getByRole("button", { name: "Comments" });
|
||||
expect(commentsTab).toBeInTheDocument();
|
||||
fireEvent.click(commentsTab);
|
||||
expect(screen.getByRole("heading", { name: "Comments" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows PR review decision details in Review tab", async () => {
|
||||
vi.mocked(dashboardApi.fetchTaskReview).mockResolvedValueOnce({
|
||||
reviewState: {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { TaskReviewTab } from "../TaskReviewTab";
|
||||
import { makeTask } from "./TaskDetailModal.test-helpers";
|
||||
|
||||
@@ -32,13 +32,67 @@ describe("TaskReviewTab", () => {
|
||||
expect(screen.getByRole("button", { name: "Request revision" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("calls refresh endpoint", async () => {
|
||||
const task = makeTask({ reviewState: { source: "pull-request", summary: { reviewDecision: "REVIEW_REQUIRED", reviewers: [], blockingReasons: [], checks: [] }, items: [], addressing: [] } });
|
||||
it("calls refresh endpoint and updates rendered PR content in place", async () => {
|
||||
const addToast = vi.fn();
|
||||
const task = makeTask({ reviewState: { source: "pull-request", summary: { reviewDecision: "REVIEW_REQUIRED", reviewers: [], blockingReasons: [], checks: [] }, items: [], addressing: [], refreshStatus: "ready" } });
|
||||
apiMocks.fetchTaskReview.mockResolvedValue({ reviewState: task.reviewState, automationStatus: null, emptyMessage: null });
|
||||
apiMocks.refreshTaskReview.mockResolvedValue({ reviewState: task.reviewState, automationStatus: null });
|
||||
render(<TaskReviewTab task={task} addToast={vi.fn()} />);
|
||||
apiMocks.refreshTaskReview.mockResolvedValue({
|
||||
reviewState: {
|
||||
source: "pull-request",
|
||||
summary: { reviewDecision: "APPROVED", reviewers: [{ login: "octocat", state: "APPROVED" }], blockingReasons: [], checks: [] },
|
||||
items: [{ id: "ri-2", body: "Looks good", author: { login: "octocat" }, createdAt: new Date().toISOString() }],
|
||||
addressing: [],
|
||||
refreshStatus: "ready",
|
||||
},
|
||||
automationStatus: null,
|
||||
});
|
||||
render(<TaskReviewTab task={task} addToast={addToast} />);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Refresh" }));
|
||||
expect(apiMocks.refreshTaskReview).toHaveBeenCalledWith(task.id, undefined);
|
||||
expect(await screen.findByText("APPROVED")).toBeInTheDocument();
|
||||
expect(screen.getByText("Looks good")).toBeInTheDocument();
|
||||
expect(addToast).toHaveBeenCalledWith("Review refreshed", "success");
|
||||
});
|
||||
|
||||
it("shows in-flight refresh state while refresh is pending", async () => {
|
||||
let resolveRefresh: ((value: unknown) => void) | undefined;
|
||||
const refreshPromise = new Promise((resolve) => {
|
||||
resolveRefresh = resolve;
|
||||
});
|
||||
|
||||
const task = makeTask({ reviewState: { source: "pull-request", summary: { reviewDecision: "REVIEW_REQUIRED", reviewers: [], blockingReasons: [], checks: [] }, items: [], addressing: [] } });
|
||||
apiMocks.fetchTaskReview.mockResolvedValue({ reviewState: task.reviewState, automationStatus: null, emptyMessage: null });
|
||||
apiMocks.refreshTaskReview.mockReturnValue(refreshPromise as Promise<never>);
|
||||
|
||||
render(<TaskReviewTab task={task} addToast={vi.fn()} />);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Refresh" }));
|
||||
|
||||
expect(screen.getByRole("button", { name: "Refreshing…" })).toBeDisabled();
|
||||
|
||||
resolveRefresh?.({ reviewState: task.reviewState, automationStatus: null });
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: "Refresh" })).toBeEnabled());
|
||||
});
|
||||
|
||||
it("shows scoped refresh error when refresh response reports error state", async () => {
|
||||
const addToast = vi.fn();
|
||||
const task = makeTask({ reviewState: { source: "pull-request", summary: { reviewDecision: "REVIEW_REQUIRED", reviewers: [], blockingReasons: [], checks: [] }, items: [], addressing: [] } });
|
||||
apiMocks.fetchTaskReview.mockResolvedValue({ reviewState: task.reviewState, automationStatus: null, emptyMessage: null });
|
||||
apiMocks.refreshTaskReview.mockResolvedValue({
|
||||
reviewState: {
|
||||
...task.reviewState,
|
||||
refreshStatus: "error",
|
||||
refreshError: "GitHub rate limit reached",
|
||||
},
|
||||
automationStatus: null,
|
||||
prInfo: task.prInfo,
|
||||
});
|
||||
|
||||
render(<TaskReviewTab task={task} addToast={addToast} />);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Refresh" }));
|
||||
|
||||
expect(await screen.findByText("GitHub rate limit reached")).toBeInTheDocument();
|
||||
expect(addToast).toHaveBeenCalledWith("GitHub rate limit reached", "error");
|
||||
});
|
||||
|
||||
it("renders PR decision and status modifiers", async () => {
|
||||
@@ -93,6 +147,50 @@ describe("TaskReviewTab", () => {
|
||||
expect(apiMocks.reviseTaskReviewItems).toHaveBeenCalledWith(task.id, ["ri-1"], undefined);
|
||||
});
|
||||
|
||||
it("refreshes and updates direct-mode reviewer-agent content", async () => {
|
||||
const addToast = vi.fn();
|
||||
const task = makeTask();
|
||||
apiMocks.fetchTaskReview.mockResolvedValue({
|
||||
reviewState: {
|
||||
source: "reviewer-agent",
|
||||
summary: { summary: "No feedback" },
|
||||
items: [],
|
||||
addressing: [],
|
||||
},
|
||||
automationStatus: null,
|
||||
emptyMessage: null,
|
||||
});
|
||||
apiMocks.refreshTaskReview.mockResolvedValue({
|
||||
reviewState: {
|
||||
source: "reviewer-agent",
|
||||
summary: { verdict: "APPROVE", reviewType: "code", summary: "Ship it" },
|
||||
items: [
|
||||
{
|
||||
id: "reviewer-code-2",
|
||||
body: "## Code Review:\n\n### Verdict:\nAPPROVE",
|
||||
author: { login: "reviewer-agent" },
|
||||
createdAt: new Date().toISOString(),
|
||||
reviewType: "code",
|
||||
verdict: "APPROVE",
|
||||
step: 3,
|
||||
summary: "code review Step 3: APPROVE",
|
||||
},
|
||||
],
|
||||
addressing: [],
|
||||
refreshStatus: "ready",
|
||||
},
|
||||
automationStatus: null,
|
||||
});
|
||||
|
||||
render(<TaskReviewTab task={task} addToast={addToast} />);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Refresh" }));
|
||||
|
||||
expect((await screen.findAllByText("APPROVE")).length).toBeGreaterThan(0);
|
||||
expect(screen.getByText("Step 3")).toBeInTheDocument();
|
||||
expect(addToast).toHaveBeenCalledWith("Review refreshed", "success");
|
||||
});
|
||||
|
||||
it("renders reviewer-agent entries in direct mode", async () => {
|
||||
const task = makeTask();
|
||||
apiMocks.fetchTaskReview.mockResolvedValue({
|
||||
|
||||
@@ -1012,8 +1012,58 @@ describe("GitHubClient", () => {
|
||||
const snapshot = await client.getPrReviewSnapshot("owner", "repo", 1);
|
||||
expect(snapshot.items).toHaveLength(2);
|
||||
expect(snapshot.summary?.reviewDecision).toBe("CHANGES_REQUESTED");
|
||||
expect(snapshot.prInfo.number).toBe(1);
|
||||
expect(snapshot.commentCount).toBe(1);
|
||||
expect(snapshot.summary?.reviewers[0]).toEqual(expect.objectContaining({ login: "octocat", state: "CHANGES_REQUESTED" }));
|
||||
});
|
||||
|
||||
it("falls back to API review details when gh fails and token is available", async () => {
|
||||
mockRunGhJsonAsync.mockImplementation(() => {
|
||||
throw new Error("gh down");
|
||||
});
|
||||
const clientWithToken = new GitHubClient("ghp_token");
|
||||
const mockFetch = vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: {
|
||||
repository: {
|
||||
pullRequest: {
|
||||
reviewDecision: "APPROVED",
|
||||
comments: { nodes: [{ id: "C_1", body: "lgtm", createdAt: "2024-01-01T00:00:00Z", updatedAt: "2024-01-01T00:00:01Z", url: "https://example.com/c1", author: { login: "bot" } }] },
|
||||
reviews: { nodes: [{ id: "R_1", state: "APPROVED", body: "good", submittedAt: "2024-01-01T00:00:00Z", url: "https://example.com/r1", author: { login: "reviewer" } }] },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: {
|
||||
repository: {
|
||||
pullRequest: {
|
||||
number: 1,
|
||||
url: "https://github.com/owner/repo/pull/1",
|
||||
title: "PR",
|
||||
state: "OPEN",
|
||||
reviewDecision: "APPROVED",
|
||||
baseRefName: "main",
|
||||
headRefName: "fn/fn-1",
|
||||
comments: { totalCount: 1 },
|
||||
commits: { nodes: [{ commit: { statusCheckRollup: { contexts: { nodes: [] } } } }] },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
global.fetch = mockFetch as any;
|
||||
|
||||
const snapshot = await clientWithToken.getPrReviewSnapshot("owner", "repo", 1);
|
||||
expect(snapshot.summary?.reviewDecision).toBe("APPROVED");
|
||||
expect(snapshot.items).toHaveLength(2);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergePr", () => {
|
||||
|
||||
@@ -2137,6 +2137,16 @@ describe("POST /tasks/:id/review/refresh", () => {
|
||||
vi.spyOn(GitHubClient.prototype, "getPrReviewSnapshot").mockResolvedValue({
|
||||
decision: "CHANGES_REQUESTED",
|
||||
checks: [],
|
||||
prInfo: {
|
||||
url: "https://github.com/owner/repo/pull/42",
|
||||
number: 42,
|
||||
status: "open",
|
||||
title: "PR",
|
||||
headBranch: "fusion/fn-1",
|
||||
baseBranch: "main",
|
||||
commentCount: 1,
|
||||
},
|
||||
commentCount: 1,
|
||||
summary: {
|
||||
reviewDecision: "CHANGES_REQUESTED",
|
||||
reviewers: [],
|
||||
@@ -2153,10 +2163,42 @@ describe("POST /tasks/:id/review/refresh", () => {
|
||||
expect(res.status).toBe(200);
|
||||
expect((store.updateTask as ReturnType<typeof vi.fn>)).toHaveBeenCalledWith(
|
||||
"FN-001",
|
||||
expect.objectContaining({ reviewState: expect.objectContaining({ source: "pull-request" }) }),
|
||||
expect.objectContaining({
|
||||
reviewState: expect.objectContaining({
|
||||
source: "pull-request",
|
||||
refreshSource: "manual",
|
||||
refreshStatus: "ready",
|
||||
lastRefreshedAt: expect.any(String),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns scoped refresh error payload in PR mode when GitHub refresh fails", async () => {
|
||||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
...FAKE_TASK_DETAIL,
|
||||
prInfo: {
|
||||
url: "https://github.com/owner/repo/pull/42",
|
||||
number: 42,
|
||||
status: "open",
|
||||
title: "PR",
|
||||
headBranch: "fusion/fn-1",
|
||||
baseBranch: "main",
|
||||
commentCount: 0,
|
||||
},
|
||||
reviewState: { source: "pull-request", items: [], addressing: [] },
|
||||
});
|
||||
vi.spyOn(GitHubClient.prototype, "getPrReviewSnapshot").mockRejectedValue(new Error("GitHub outage"));
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/FN-001/review/refresh", JSON.stringify({}), {
|
||||
"content-type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.reviewState.refreshStatus).toBe("error");
|
||||
expect(res.body.reviewState.refreshError).toContain("GitHub outage");
|
||||
});
|
||||
|
||||
it("refreshes direct-mode review payload without PR", async () => {
|
||||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
...FAKE_TASK_DETAIL,
|
||||
@@ -2167,13 +2209,34 @@ describe("POST /tasks/:id/review/refresh", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const getSnapshotSpy = vi.spyOn(GitHubClient.prototype, "getPrReviewSnapshot");
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/FN-001/review/refresh", JSON.stringify({}), {
|
||||
"content-type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect((store.updateTask as ReturnType<typeof vi.fn>)).toHaveBeenCalled();
|
||||
expect((store.updateTask as ReturnType<typeof vi.fn>)).toHaveBeenCalledWith(
|
||||
"FN-001",
|
||||
expect.objectContaining({
|
||||
reviewState: expect.objectContaining({
|
||||
source: "reviewer-agent",
|
||||
refreshSource: "manual",
|
||||
refreshStatus: "ready",
|
||||
lastRefreshedAt: expect.any(String),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(res.body.reviewState.source).toBe("reviewer-agent");
|
||||
expect(getSnapshotSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 404 when task is missing", async () => {
|
||||
(store.getTask as ReturnType<typeof vi.fn>).mockRejectedValue(Object.assign(new Error("not found"), { code: "ENOENT" }));
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/FN-404/review/refresh", JSON.stringify({}), {
|
||||
"content-type": "application/json",
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -114,6 +114,8 @@ export interface PrReviewSnapshot {
|
||||
checks: PrCheckStatus[];
|
||||
items: PrReviewStateItem[];
|
||||
summary?: PrReviewSummary;
|
||||
prInfo: PrInfo;
|
||||
commentCount: number;
|
||||
}
|
||||
|
||||
export interface PrMergeStatus {
|
||||
@@ -181,6 +183,12 @@ interface GhPrViewJson {
|
||||
reviews?: GhReviewJson[];
|
||||
}
|
||||
|
||||
interface PrReviewDetails {
|
||||
reviewDecision: ReviewDecision;
|
||||
comments: GhPrViewJson["comments"];
|
||||
reviews: GhReviewJson[];
|
||||
}
|
||||
|
||||
interface GhPrListJson {
|
||||
number: number;
|
||||
url: string;
|
||||
@@ -576,20 +584,12 @@ export class GitHubClient {
|
||||
|
||||
async getPrReviewSnapshot(owner: string | undefined, repo: string | undefined, number: number): Promise<PrReviewSnapshot> {
|
||||
const { owner: resolvedOwner, repo: resolvedRepo } = this.resolveRepo(owner, repo);
|
||||
const pr = await runGhJsonAsync<GhPrViewJson>([
|
||||
"pr",
|
||||
"view",
|
||||
String(number),
|
||||
"--repo",
|
||||
`${resolvedOwner}/${resolvedRepo}`,
|
||||
"--json",
|
||||
"reviewDecision,reviews,comments",
|
||||
]);
|
||||
|
||||
const details = await this.getPrReviewDetails(resolvedOwner, resolvedRepo, number);
|
||||
const mergeStatus = await this.getPrMergeStatus(resolvedOwner, resolvedRepo, number);
|
||||
const checks = mergeStatus.checks;
|
||||
const commentItems: PrReviewStateItem[] = (pr.comments ?? []).map((comment) => ({
|
||||
const commentItems: PrReviewStateItem[] = (details.comments ?? []).map((comment) => ({
|
||||
id: `gh-comment-${comment.id}`,
|
||||
threadId: `thread-comment-${comment.id}`,
|
||||
githubCommentId: Number.parseInt(comment.id, 10),
|
||||
body: comment.body,
|
||||
author: { login: comment.author?.login ?? "reviewer" },
|
||||
@@ -599,10 +599,11 @@ export class GitHubClient {
|
||||
state: "COMMENTED",
|
||||
}));
|
||||
|
||||
const reviewItems: PrReviewStateItem[] = (pr.reviews ?? []).map((review) => {
|
||||
const reviewItems: PrReviewStateItem[] = (details.reviews ?? []).map((review) => {
|
||||
const createdAt = review.submittedAt ?? new Date().toISOString();
|
||||
return {
|
||||
id: `gh-review-${review.id}`,
|
||||
threadId: `thread-review-${review.id}`,
|
||||
body: review.body ?? `Review ${review.state}`,
|
||||
author: { login: review.author?.login ?? "reviewer" },
|
||||
createdAt,
|
||||
@@ -613,12 +614,14 @@ export class GitHubClient {
|
||||
});
|
||||
|
||||
return {
|
||||
decision: pr.reviewDecision ?? null,
|
||||
decision: details.reviewDecision ?? null,
|
||||
checks,
|
||||
items: [...reviewItems, ...commentItems],
|
||||
prInfo: mergeStatus.prInfo,
|
||||
commentCount: commentItems.length,
|
||||
summary: {
|
||||
reviewDecision: pr.reviewDecision ?? null,
|
||||
reviewers: (pr.reviews ?? []).map((review) => ({
|
||||
reviewDecision: details.reviewDecision ?? null,
|
||||
reviewers: (details.reviews ?? []).map((review) => ({
|
||||
login: review.author?.login ?? "reviewer",
|
||||
state: review.state === "APPROVED" || review.state === "CHANGES_REQUESTED" || review.state === "COMMENTED" || review.state === "PENDING" ? review.state : "COMMENTED",
|
||||
submittedAt: review.submittedAt ?? undefined,
|
||||
@@ -629,6 +632,128 @@ export class GitHubClient {
|
||||
};
|
||||
}
|
||||
|
||||
private async getPrReviewDetails(owner: string, repo: string, number: number): Promise<PrReviewDetails> {
|
||||
if (this.hasGhAuth()) {
|
||||
try {
|
||||
return await this.getPrReviewDetailsWithGh(owner, repo, number);
|
||||
} catch (err) {
|
||||
if (this.token) {
|
||||
return this.getPrReviewDetailsWithApi(owner, repo, number);
|
||||
}
|
||||
throw new Error(getGhErrorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
if (this.token) {
|
||||
return this.getPrReviewDetailsWithApi(owner, repo, number);
|
||||
}
|
||||
|
||||
throw new Error("GitHub CLI (gh) is not available or not authenticated, and no GITHUB_TOKEN provided.");
|
||||
}
|
||||
|
||||
private async getPrReviewDetailsWithGh(owner: string, repo: string, number: number): Promise<PrReviewDetails> {
|
||||
const pr = await runGhJsonAsync<GhPrViewJson>([
|
||||
"pr",
|
||||
"view",
|
||||
String(number),
|
||||
"--repo",
|
||||
`${owner}/${repo}`,
|
||||
"--json",
|
||||
"reviewDecision,reviews,comments",
|
||||
]);
|
||||
return {
|
||||
reviewDecision: pr.reviewDecision ?? null,
|
||||
comments: pr.comments ?? [],
|
||||
reviews: pr.reviews ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
private async getPrReviewDetailsWithApi(owner: string, repo: string, number: number): Promise<PrReviewDetails> {
|
||||
const response = await fetch(`${this.baseUrl}/graphql`, {
|
||||
method: "POST",
|
||||
headers: this.buildHeaders(),
|
||||
body: JSON.stringify({
|
||||
query: `query PullRequestReviewDetails($owner: String!, $repo: String!, $number: Int!) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
pullRequest(number: $number) {
|
||||
reviewDecision
|
||||
comments(first: 100) {
|
||||
nodes {
|
||||
id
|
||||
body
|
||||
createdAt
|
||||
updatedAt
|
||||
url
|
||||
author { login }
|
||||
}
|
||||
}
|
||||
reviews(first: 100) {
|
||||
nodes {
|
||||
id
|
||||
state
|
||||
body
|
||||
submittedAt
|
||||
url
|
||||
author { login }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
variables: { owner, repo, number },
|
||||
}),
|
||||
});
|
||||
|
||||
const payload = await response.json() as {
|
||||
data?: {
|
||||
repository?: {
|
||||
pullRequest?: {
|
||||
reviewDecision?: ReviewDecision;
|
||||
comments?: { nodes?: Array<{ id: string; body: string; createdAt: string; updatedAt: string; url: string; author?: { login?: string | null } | null } | null> };
|
||||
reviews?: { nodes?: Array<{ id: string; state: string; body?: string | null; submittedAt?: string | null; url?: string | null; author?: { login?: string | null } | null } | null> };
|
||||
};
|
||||
};
|
||||
};
|
||||
errors?: Array<{ message: string }>;
|
||||
};
|
||||
|
||||
if (!response.ok || payload.errors?.length) {
|
||||
const message = payload.errors?.[0]?.message || response.statusText;
|
||||
throw new Error(`GitHub API error: ${response.status} ${message}`);
|
||||
}
|
||||
|
||||
const pr = payload.data?.repository?.pullRequest;
|
||||
if (!pr) {
|
||||
throw new Error(`PR #${number} not found in ${owner}/${repo}`);
|
||||
}
|
||||
|
||||
return {
|
||||
reviewDecision: pr.reviewDecision ?? null,
|
||||
comments: (pr.comments?.nodes ?? []).flatMap((comment) => {
|
||||
if (!comment) return [];
|
||||
return [{
|
||||
id: comment.id,
|
||||
body: comment.body,
|
||||
createdAt: comment.createdAt,
|
||||
updatedAt: comment.updatedAt,
|
||||
url: comment.url,
|
||||
author: { login: comment.author?.login ?? "reviewer" },
|
||||
}];
|
||||
}),
|
||||
reviews: (pr.reviews?.nodes ?? []).flatMap((review) => {
|
||||
if (!review) return [];
|
||||
return [{
|
||||
id: review.id,
|
||||
state: review.state,
|
||||
body: review.body,
|
||||
submittedAt: review.submittedAt,
|
||||
url: review.url,
|
||||
author: { login: review.author?.login ?? "reviewer" },
|
||||
}];
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async getPrMergeStatus(owner: string | undefined, repo: string | undefined, number: number): Promise<PrMergeStatus> {
|
||||
if (this.hasGhAuth()) {
|
||||
try {
|
||||
|
||||
@@ -3241,10 +3241,13 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
|
||||
items: [],
|
||||
addressing: [],
|
||||
};
|
||||
reviewState.refreshStatus = reviewState.refreshStatus ?? "ready";
|
||||
reviewState.refreshSource = reviewState.refreshSource ?? "initial-load";
|
||||
res.json({
|
||||
reviewState,
|
||||
automationStatus: task.status ?? null,
|
||||
emptyMessage: !hasPrReview && reviewState.items.length === 0 ? DIRECT_REVIEW_EMPTY_MESSAGE : null,
|
||||
prInfo: task.prInfo,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
@@ -3265,6 +3268,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
|
||||
const task = await scopedStore.getTask(req.params.id);
|
||||
|
||||
let reviewState = task.reviewState;
|
||||
let nextPrInfo = task.prInfo;
|
||||
const now = new Date().toISOString();
|
||||
|
||||
if (task.prInfo) {
|
||||
@@ -3275,26 +3279,59 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
|
||||
throw badRequest("Could not determine GitHub repository for PR review refresh");
|
||||
}
|
||||
|
||||
const client = new GitHubClient();
|
||||
const snapshot = await client.getPrReviewSnapshot(owner, repo, task.prInfo.number);
|
||||
const previousAddressing = task.reviewState?.addressing ?? [];
|
||||
const availableIds = new Set(snapshot.items.map((item) => item.id));
|
||||
const addressing = previousAddressing.map((record) => availableIds.has(record.itemId) ? record : { ...record, stale: true });
|
||||
const client = new GitHubClient(githubToken);
|
||||
try {
|
||||
const snapshot = await client.getPrReviewSnapshot(owner, repo, task.prInfo.number);
|
||||
const previousAddressing = task.reviewState?.addressing ?? [];
|
||||
const availableIds = new Set(snapshot.items.map((item) => item.id));
|
||||
const addressing = previousAddressing.map((record) => availableIds.has(record.itemId) ? record : { ...record, stale: true });
|
||||
|
||||
reviewState = {
|
||||
source: "pull-request",
|
||||
lastRefreshedAt: now,
|
||||
summary: snapshot.summary,
|
||||
items: snapshot.items,
|
||||
addressing,
|
||||
};
|
||||
reviewState = {
|
||||
source: "pull-request",
|
||||
lastRefreshedAt: now,
|
||||
refreshSource: "manual",
|
||||
refreshStatus: "ready",
|
||||
refreshError: undefined,
|
||||
summary: snapshot.summary,
|
||||
items: snapshot.items,
|
||||
addressing,
|
||||
};
|
||||
|
||||
nextPrInfo = {
|
||||
...task.prInfo,
|
||||
...snapshot.prInfo,
|
||||
commentCount: snapshot.commentCount,
|
||||
lastCheckedAt: now,
|
||||
};
|
||||
} catch (refreshError) {
|
||||
const message = refreshError instanceof Error ? refreshError.message : "Failed to refresh GitHub review data";
|
||||
reviewState = {
|
||||
source: "pull-request",
|
||||
lastRefreshedAt: now,
|
||||
refreshSource: "manual",
|
||||
refreshStatus: "error",
|
||||
refreshError: message,
|
||||
summary: task.reviewState?.summary,
|
||||
items: task.reviewState?.items ?? [],
|
||||
addressing: task.reviewState?.addressing ?? [],
|
||||
};
|
||||
await scopedStore.updateTask(task.id, { reviewState });
|
||||
res.json({ reviewState, automationStatus: task.status ?? null, prInfo: task.prInfo });
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
reviewState = await buildDirectReviewState(task, scopedStore);
|
||||
reviewState.lastRefreshedAt = now;
|
||||
reviewState.refreshSource = "manual";
|
||||
reviewState.refreshStatus = "ready";
|
||||
reviewState.refreshError = undefined;
|
||||
}
|
||||
|
||||
await scopedStore.updateTask(task.id, { reviewState });
|
||||
res.json({ reviewState, automationStatus: task.status ?? null });
|
||||
if (nextPrInfo) {
|
||||
await scopedStore.updatePrInfo(task.id, nextPrInfo);
|
||||
}
|
||||
res.json({ reviewState, automationStatus: task.status ?? null, prInfo: nextPrInfo });
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
|
||||
Reference in New Issue
Block a user