feat(FN-4761): sync PR reviews and surface review threads
Fusion-Task-Id: FN-4761 Fusion-Task-Lineage: b6b1ed73-c396-4dd2-b97f-366272d05c7f
This commit is contained in:
committed by
gsxdsm
parent
cfadcf3835
commit
5bde911bdb
@@ -2224,6 +2224,31 @@ export interface PrChecksResponse {
|
|||||||
lastCheckedAt: string;
|
lastCheckedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PrReviewThreadItem {
|
||||||
|
id: string;
|
||||||
|
author: string;
|
||||||
|
text: string;
|
||||||
|
source?: "github-review" | "github-review-comment";
|
||||||
|
externalId?: string;
|
||||||
|
reviewState?: "APPROVED" | "CHANGES_REQUESTED" | "COMMENTED";
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PrReviewsResponse {
|
||||||
|
snapshot: {
|
||||||
|
decision: "APPROVED" | "CHANGES_REQUESTED" | "REVIEW_REQUIRED" | null;
|
||||||
|
items: Array<{
|
||||||
|
id: string;
|
||||||
|
author: { login: string };
|
||||||
|
body: string;
|
||||||
|
state?: string;
|
||||||
|
htmlUrl?: string;
|
||||||
|
createdAt: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
comments: PrReviewThreadItem[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface PrMetadataResponse {
|
export interface PrMetadataResponse {
|
||||||
title: string;
|
title: string;
|
||||||
body: string;
|
body: string;
|
||||||
@@ -2328,6 +2353,10 @@ export function fetchPrChecks(id: string, projectId?: string): Promise<PrChecksR
|
|||||||
return api<PrChecksResponse>(withProjectId(`/tasks/${id}/pr/checks`, projectId));
|
return api<PrChecksResponse>(withProjectId(`/tasks/${id}/pr/checks`, projectId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function fetchPrReviews(id: string, projectId?: string): Promise<PrReviewsResponse> {
|
||||||
|
return api<PrReviewsResponse>(withProjectId(`/tasks/${id}/pr/reviews`, projectId));
|
||||||
|
}
|
||||||
|
|
||||||
// --- Issue Management API ---
|
// --- Issue Management API ---
|
||||||
|
|
||||||
/** Re-export GitHub badge-related types for convenience */
|
/** Re-export GitHub badge-related types for convenience */
|
||||||
|
|||||||
@@ -123,6 +123,32 @@
|
|||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pr-panel-review-thread {
|
||||||
|
border: var(--btn-border-width) solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
padding: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-panel-review-thread-header {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-panel-review-item {
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-panel-review-item:hover {
|
||||||
|
color: var(--text);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.pr-panel-checks-rollup {
|
.pr-panel-checks-rollup {
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { GitPullRequest, ExternalLink, RefreshCw, Plus, MessageSquare, CircleDot, XCircle, GitMerge } from "lucide-react";
|
import { GitPullRequest, ExternalLink, RefreshCw, Plus, MessageSquare, CircleDot, XCircle, GitMerge } from "lucide-react";
|
||||||
import { getErrorMessage } from "@fusion/core";
|
import { getErrorMessage } from "@fusion/core";
|
||||||
import { refreshPrStatus, type PrCheckStatus, type PrInfo, type PrRefreshResponse } from "../api";
|
import { fetchPrReviews, refreshPrStatus, type PrCheckStatus, type PrInfo, type PrRefreshResponse, type PrReviewsResponse } from "../api";
|
||||||
import { usePrChecksStream } from "../hooks/usePrChecksStream";
|
import { usePrChecksStream } from "../hooks/usePrChecksStream";
|
||||||
import { PrChecksList } from "./PrChecksList";
|
import { PrChecksList } from "./PrChecksList";
|
||||||
import type { ToastType } from "../hooks/useToast";
|
import type { ToastType } from "../hooks/useToast";
|
||||||
|
import { linkifyFilePaths } from "../utils/filePathLinkify";
|
||||||
import "./PrPanel.css";
|
import "./PrPanel.css";
|
||||||
|
|
||||||
interface PrPanelProps {
|
interface PrPanelProps {
|
||||||
@@ -12,6 +13,7 @@ interface PrPanelProps {
|
|||||||
projectId?: string;
|
projectId?: string;
|
||||||
prInfo?: PrInfo;
|
prInfo?: PrInfo;
|
||||||
automationStatus?: string | null;
|
automationStatus?: string | null;
|
||||||
|
taskColumn?: string;
|
||||||
autoMerge?: boolean;
|
autoMerge?: boolean;
|
||||||
isManualPrFlow?: boolean;
|
isManualPrFlow?: boolean;
|
||||||
prAuthAvailable: boolean;
|
prAuthAvailable: boolean;
|
||||||
@@ -44,6 +46,7 @@ export function PrPanel({
|
|||||||
projectId,
|
projectId,
|
||||||
prInfo,
|
prInfo,
|
||||||
automationStatus,
|
automationStatus,
|
||||||
|
taskColumn,
|
||||||
autoMerge = false,
|
autoMerge = false,
|
||||||
isManualPrFlow = false,
|
isManualPrFlow = false,
|
||||||
prAuthAvailable,
|
prAuthAvailable,
|
||||||
@@ -53,6 +56,17 @@ export function PrPanel({
|
|||||||
}: PrPanelProps) {
|
}: PrPanelProps) {
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
const [refreshState, setRefreshState] = useState<PrRefreshResponse | null>(null);
|
const [refreshState, setRefreshState] = useState<PrRefreshResponse | null>(null);
|
||||||
|
const [reviewsState, setReviewsState] = useState<PrReviewsResponse | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!prInfo) {
|
||||||
|
setReviewsState(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void fetchPrReviews(taskId, projectId)
|
||||||
|
.then((data) => setReviewsState(data))
|
||||||
|
.catch(() => setReviewsState(null));
|
||||||
|
}, [taskId, projectId, prInfo]);
|
||||||
|
|
||||||
const handleRefresh = useCallback(async () => {
|
const handleRefresh = useCallback(async () => {
|
||||||
if (!prInfo) return;
|
if (!prInfo) return;
|
||||||
@@ -62,6 +76,8 @@ export function PrPanel({
|
|||||||
const updated = await refreshPrStatus(taskId, projectId);
|
const updated = await refreshPrStatus(taskId, projectId);
|
||||||
setRefreshState(updated);
|
setRefreshState(updated);
|
||||||
onPrUpdated(updated.prInfo);
|
onPrUpdated(updated.prInfo);
|
||||||
|
const latestReviews = await fetchPrReviews(taskId, projectId);
|
||||||
|
setReviewsState(latestReviews);
|
||||||
addToast("PR status refreshed", "success");
|
addToast("PR status refreshed", "success");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
addToast(getErrorMessage(err) || "Failed to refresh PR", "error");
|
addToast(getErrorMessage(err) || "Failed to refresh PR", "error");
|
||||||
@@ -125,7 +141,17 @@ export function PrPanel({
|
|||||||
const statusIcon = STATUS_ICONS[prInfo.status] ?? <CircleDot size={16} />;
|
const statusIcon = STATUS_ICONS[prInfo.status] ?? <CircleDot size={16} />;
|
||||||
const blockingReasons = refreshState?.blockingReasons ?? [];
|
const blockingReasons = refreshState?.blockingReasons ?? [];
|
||||||
const checks = refreshState?.checks;
|
const checks = refreshState?.checks;
|
||||||
const reviewDecision = refreshState?.reviewDecision ?? null;
|
const reviewDecision = refreshState?.reviewDecision ?? reviewsState?.snapshot.decision ?? prInfo.lastReviewDecision ?? null;
|
||||||
|
const groupedReviews = useMemo(() => {
|
||||||
|
const grouped = new Map<string, Array<PrReviewsResponse["snapshot"]["items"][number]>>();
|
||||||
|
for (const item of reviewsState?.snapshot.items ?? []) {
|
||||||
|
const key = item.author.login;
|
||||||
|
const list = grouped.get(key) ?? [];
|
||||||
|
list.push(item);
|
||||||
|
grouped.set(key, list);
|
||||||
|
}
|
||||||
|
return Array.from(grouped.entries());
|
||||||
|
}, [reviewsState]);
|
||||||
|
|
||||||
const checkSummary = useMemo(() => {
|
const checkSummary = useMemo(() => {
|
||||||
if (!checks) return "unknown" as const;
|
if (!checks) return "unknown" as const;
|
||||||
@@ -190,6 +216,30 @@ export function PrPanel({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="pr-panel-section">
|
||||||
|
<div className="pr-panel-row-label">Reviews</div>
|
||||||
|
{groupedReviews.length === 0 ? <span className="pr-panel-tone-muted">No review comments synced yet</span> : null}
|
||||||
|
{groupedReviews.map(([reviewer, items]) => (
|
||||||
|
<div key={reviewer} className="pr-panel-review-thread">
|
||||||
|
<div className="pr-panel-review-thread-header">
|
||||||
|
<strong>@{reviewer}</strong>
|
||||||
|
<span className={`pr-panel-review-badge pr-panel-review-badge--${getReviewTone((items.at(-1)?.state as PrRefreshResponse["reviewDecision"]) ?? "REVIEW_REQUIRED")}`}>
|
||||||
|
{items.at(-1)?.state ?? "COMMENTED"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{items.map((item) => (
|
||||||
|
<a key={item.id} href={item.htmlUrl} target="_blank" rel="noreferrer" className="pr-panel-review-item">
|
||||||
|
{linkifyFilePaths(item.body, { keyPrefix: item.id })}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{reviewDecision === "CHANGES_REQUESTED" && taskColumn === "todo" && (
|
||||||
|
<div className="pr-hint pr-hint--warning">Auto-moved to Todo — reviewer feedback ready</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{automationStatus === "merging-pr" && <div className="pr-hint pr-hint--info">fn is merging this pull request automatically.</div>}
|
{automationStatus === "merging-pr" && <div className="pr-hint pr-hint--info">fn is merging this pull request automatically.</div>}
|
||||||
{automationStatus === "awaiting-pr-checks" && (
|
{automationStatus === "awaiting-pr-checks" && (
|
||||||
<div className="pr-hint pr-hint--info">
|
<div className="pr-hint pr-hint--info">
|
||||||
|
|||||||
@@ -3289,6 +3289,7 @@ export function TaskDetailContent({
|
|||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
prInfo={task.prInfo}
|
prInfo={task.prInfo}
|
||||||
automationStatus={task.status ?? null}
|
automationStatus={task.status ?? null}
|
||||||
|
taskColumn={task.column}
|
||||||
autoMerge={settings?.autoMerge ?? false}
|
autoMerge={settings?.autoMerge ?? false}
|
||||||
isManualPrFlow={isManualPrFlow}
|
isManualPrFlow={isManualPrFlow}
|
||||||
prAuthAvailable={prAuthAvailable ?? false}
|
prAuthAvailable={prAuthAvailable ?? false}
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { PrPanel } from "../PrPanel";
|
||||||
|
|
||||||
|
vi.mock("../../api", async () => {
|
||||||
|
const actual = await vi.importActual<object>("../../api");
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
refreshPrStatus: vi.fn(),
|
||||||
|
fetchPrChecks: vi.fn().mockResolvedValue({ checks: [], rollup: "unknown", lastCheckedAt: new Date().toISOString() }),
|
||||||
|
fetchPrReviews: vi.fn().mockResolvedValue({
|
||||||
|
snapshot: {
|
||||||
|
decision: "CHANGES_REQUESTED",
|
||||||
|
items: [
|
||||||
|
{ id: "r1", author: { login: "alice" }, body: "See src/index.ts:12", state: "CHANGES_REQUESTED", htmlUrl: "https://github.com/rev/1", createdAt: new Date().toISOString() },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
comments: [],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PrPanel reviews", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders synced reviewer thread and todo banner", async () => {
|
||||||
|
render(
|
||||||
|
<PrPanel
|
||||||
|
taskId="FN-1"
|
||||||
|
prInfo={{ url: "https://github.com/o/r/pull/1", number: 1, status: "open", title: "Title", headBranch: "x", baseBranch: "main", commentCount: 0 }}
|
||||||
|
taskColumn="todo"
|
||||||
|
prAuthAvailable
|
||||||
|
onPrUpdated={() => {}}
|
||||||
|
addToast={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await screen.findByText("@alice")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Auto-moved to Todo — reviewer feedback ready")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,9 +5,10 @@ import { PrPanel } from "../PrPanel";
|
|||||||
vi.mock("../../api", () => ({
|
vi.mock("../../api", () => ({
|
||||||
refreshPrStatus: vi.fn(),
|
refreshPrStatus: vi.fn(),
|
||||||
fetchPrChecks: vi.fn(),
|
fetchPrChecks: vi.fn(),
|
||||||
|
fetchPrReviews: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { refreshPrStatus, fetchPrChecks } from "../../api";
|
import { refreshPrStatus, fetchPrChecks, fetchPrReviews } from "../../api";
|
||||||
|
|
||||||
const mockAddToast = vi.fn();
|
const mockAddToast = vi.fn();
|
||||||
const mockOnPrUpdated = vi.fn();
|
const mockOnPrUpdated = vi.fn();
|
||||||
@@ -32,6 +33,7 @@ describe("PrPanel", () => {
|
|||||||
rollup: "unknown",
|
rollup: "unknown",
|
||||||
lastCheckedAt: new Date().toISOString(),
|
lastCheckedAt: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
|
(fetchPrReviews as ReturnType<typeof vi.fn>).mockResolvedValue({ snapshot: { decision: null, items: [] }, comments: [] });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders create button and calls onRequestCreatePr", () => {
|
it("renders create button and calls onRequestCreatePr", () => {
|
||||||
|
|||||||
121
packages/dashboard/src/__tests__/routes-pr-reviews.test.ts
Normal file
121
packages/dashboard/src/__tests__/routes-pr-reviews.test.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
// @vitest-environment node
|
||||||
|
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { Task, TaskStore } from "@fusion/core";
|
||||||
|
import { createServer } from "../server.js";
|
||||||
|
import { get as performGet, request as performRequest } from "../test-request.js";
|
||||||
|
import { GitHubClient } from "../github.js";
|
||||||
|
import { githubRateLimiter } from "../github-poll.js";
|
||||||
|
|
||||||
|
function createTask(overrides: Partial<Task> = {}): Task {
|
||||||
|
return {
|
||||||
|
id: "FN-001",
|
||||||
|
title: "Task",
|
||||||
|
description: "desc",
|
||||||
|
column: "in-review",
|
||||||
|
status: "in-review",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
prInfo: {
|
||||||
|
url: "https://github.com/owner/repo/pull/1",
|
||||||
|
number: 1,
|
||||||
|
status: "open",
|
||||||
|
title: "PR",
|
||||||
|
headBranch: "feature",
|
||||||
|
baseBranch: "main",
|
||||||
|
commentCount: 0,
|
||||||
|
},
|
||||||
|
comments: [],
|
||||||
|
...overrides,
|
||||||
|
} as Task;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStore(task: Task): TaskStore {
|
||||||
|
return {
|
||||||
|
getTask: vi.fn().mockResolvedValue(task),
|
||||||
|
updatePrInfo: vi.fn().mockResolvedValue(undefined),
|
||||||
|
addComment: vi.fn().mockResolvedValue(task),
|
||||||
|
moveTask: vi.fn().mockResolvedValue({ ...task, column: "todo" }),
|
||||||
|
writeTaskDocument: vi.fn().mockResolvedValue({ key: "review-feedback" }),
|
||||||
|
getRootDir: vi.fn().mockReturnValue("/tmp/project"),
|
||||||
|
listTasks: vi.fn().mockResolvedValue([]),
|
||||||
|
createTask: vi.fn(),
|
||||||
|
updateTask: vi.fn(),
|
||||||
|
deleteTask: vi.fn(),
|
||||||
|
mergeTask: vi.fn(),
|
||||||
|
archiveTask: vi.fn(),
|
||||||
|
unarchiveTask: vi.fn(),
|
||||||
|
getSettings: vi.fn().mockResolvedValue({}),
|
||||||
|
updateSettings: vi.fn(),
|
||||||
|
logEntry: vi.fn(),
|
||||||
|
getAgentLogs: vi.fn().mockResolvedValue([]),
|
||||||
|
addSteeringComment: vi.fn(),
|
||||||
|
updateIssueInfo: vi.fn(),
|
||||||
|
getFusionDir: vi.fn().mockReturnValue("/tmp/project/.fusion"),
|
||||||
|
getDatabase: vi.fn().mockReturnValue({
|
||||||
|
exec: vi.fn(),
|
||||||
|
prepare: vi.fn().mockReturnValue({
|
||||||
|
run: vi.fn().mockReturnValue({ changes: 0 }),
|
||||||
|
get: vi.fn(),
|
||||||
|
all: vi.fn().mockReturnValue([]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
getMissionStore: vi.fn().mockReturnValue({ listMissions: vi.fn().mockReturnValue([]) }),
|
||||||
|
on: vi.fn(),
|
||||||
|
off: vi.fn(),
|
||||||
|
recordRunAuditEvent: vi.fn(),
|
||||||
|
} as unknown as TaskStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("PR reviews routes", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.spyOn(githubRateLimiter, "canMakeRequest").mockReturnValue(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns review snapshot and fused comments", async () => {
|
||||||
|
const task = createTask({
|
||||||
|
comments: [{ id: "1", text: "review", author: "github:a", createdAt: new Date().toISOString(), source: "github-review", externalId: "x" }],
|
||||||
|
});
|
||||||
|
const store = createStore(task);
|
||||||
|
vi.spyOn(GitHubClient.prototype, "getPrReviewSnapshot").mockResolvedValue({
|
||||||
|
decision: "COMMENTED",
|
||||||
|
checks: [],
|
||||||
|
items: [],
|
||||||
|
prInfo: task.prInfo!,
|
||||||
|
commentCount: 0,
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const app = createServer(store);
|
||||||
|
const response = await performGet(app, "/api/tasks/FN-001/pr/reviews");
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body.comments).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves in-review task to todo once on changes-requested refresh", async () => {
|
||||||
|
const task = createTask();
|
||||||
|
const store = createStore(task);
|
||||||
|
vi.spyOn(GitHubClient.prototype, "getPrReviewSnapshot").mockResolvedValue({
|
||||||
|
decision: "CHANGES_REQUESTED",
|
||||||
|
checks: [],
|
||||||
|
items: [{ id: "gh-review-1", body: "Please fix", author: { login: "alice" }, state: "CHANGES_REQUESTED", createdAt: new Date().toISOString() }],
|
||||||
|
prInfo: task.prInfo!,
|
||||||
|
commentCount: 0,
|
||||||
|
summary: { reviewDecision: "CHANGES_REQUESTED", reviewers: [], blockingReasons: [], checks: [] },
|
||||||
|
} as never);
|
||||||
|
vi.spyOn(GitHubClient.prototype, "getPrMergeStatus").mockResolvedValue({
|
||||||
|
prInfo: task.prInfo!, reviewDecision: "CHANGES_REQUESTED", checks: [], mergeReady: false, blockingReasons: ["changes requested"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const app = createServer(store);
|
||||||
|
const response = await performRequest(app, "POST", "/api/tasks/FN-001/pr/refresh", "{}", { "content-type": "application/json" });
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(store.moveTask).toHaveBeenCalledTimes(1);
|
||||||
|
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo", expect.objectContaining({ preserveProgress: true, preserveWorktree: true }));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,6 +7,8 @@ import type {
|
|||||||
BatchStatusResult,
|
BatchStatusResult,
|
||||||
IssueInfo,
|
IssueInfo,
|
||||||
PrInfo,
|
PrInfo,
|
||||||
|
RunAuditEventInput,
|
||||||
|
Task,
|
||||||
TaskStore,
|
TaskStore,
|
||||||
} from "@fusion/core";
|
} from "@fusion/core";
|
||||||
import { getCurrentRepo, isGhAuthenticated } from "@fusion/core";
|
import { getCurrentRepo, isGhAuthenticated } from "@fusion/core";
|
||||||
@@ -19,7 +21,7 @@ import {
|
|||||||
rateLimited,
|
rateLimited,
|
||||||
unauthorized,
|
unauthorized,
|
||||||
} from "../api-error.js";
|
} from "../api-error.js";
|
||||||
import { GitHubClient, parseBadgeUrl } from "../github.js";
|
import { GitHubClient, type PrReviewSnapshot, parseBadgeUrl } from "../github.js";
|
||||||
import { GitHubIssueCommentService } from "../github-issue-comment.js";
|
import { GitHubIssueCommentService } from "../github-issue-comment.js";
|
||||||
import { GitHubTrackingCommentService } from "../github-tracking-comments.js";
|
import { GitHubTrackingCommentService } from "../github-tracking-comments.js";
|
||||||
import { GitHubTrackingStateService } from "../github-tracking-state.js";
|
import { GitHubTrackingStateService } from "../github-tracking-state.js";
|
||||||
@@ -1024,6 +1026,74 @@ export function appendBatchStatusError(results: BatchStatusResult, taskId: strin
|
|||||||
entry.stale = true;
|
entry.stale = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function syncPrReviewsToTask(store: TaskStore, task: Task, snapshot: PrReviewSnapshot): Promise<void> {
|
||||||
|
for (const item of snapshot.items) {
|
||||||
|
const isReviewItem = item.id.startsWith("gh-review-");
|
||||||
|
const source = isReviewItem ? "github-review" : "github-review-comment";
|
||||||
|
const externalId = String(item.githubCommentId ?? item.id);
|
||||||
|
const reviewState = item.state === "APPROVED" || item.state === "CHANGES_REQUESTED" || item.state === "COMMENTED"
|
||||||
|
? item.state
|
||||||
|
: undefined;
|
||||||
|
const header = isReviewItem
|
||||||
|
? `**Review by @${item.author.login} — ${item.state ?? "COMMENTED"}**`
|
||||||
|
: `**Inline comment by @${item.author.login}**`;
|
||||||
|
const body = `${header}\n\n${item.body}`;
|
||||||
|
|
||||||
|
await store.addComment(task.id, body, `github:${item.author.login}`, {
|
||||||
|
skipRefinement: true,
|
||||||
|
source,
|
||||||
|
externalId,
|
||||||
|
reviewState,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyChangesRequestedTransition(
|
||||||
|
store: TaskStore,
|
||||||
|
task: Task,
|
||||||
|
snapshot: PrReviewSnapshot,
|
||||||
|
prInfo: PrInfo,
|
||||||
|
): Promise<void> {
|
||||||
|
if (snapshot.decision !== "CHANGES_REQUESTED") return;
|
||||||
|
if (task.column !== "in-review") return;
|
||||||
|
if (task.prInfo?.lastReviewDecision === "CHANGES_REQUESTED") return;
|
||||||
|
|
||||||
|
const reviewItems = snapshot.items.filter((item) => item.id.startsWith("gh-review-") && item.state === "CHANGES_REQUESTED");
|
||||||
|
const commentItems = snapshot.items.filter((item) => item.id.startsWith("gh-comment-")).slice(-5);
|
||||||
|
const latestReview = reviewItems.at(-1);
|
||||||
|
const feedbackBody = [
|
||||||
|
`Reviewer requested changes for PR #${prInfo.number}.`,
|
||||||
|
latestReview ? `\nLatest review by @${latestReview.author.login}:\n${latestReview.body}` : "",
|
||||||
|
commentItems.length > 0
|
||||||
|
? `\nRecent inline comments:\n${commentItems.map((item) => `- @${item.author.login}: ${item.body}`).join("\n")}`
|
||||||
|
: "",
|
||||||
|
].join("\n").trim();
|
||||||
|
|
||||||
|
await store.writeTaskDocument(task.id, "review-feedback", feedbackBody || "Reviewer requested changes.", "system");
|
||||||
|
await store.moveTask(task.id, "todo", {
|
||||||
|
preserveProgress: true,
|
||||||
|
preserveWorktree: true,
|
||||||
|
moveSource: "engine",
|
||||||
|
});
|
||||||
|
|
||||||
|
if ("recordRunAuditEvent" in store && typeof store.recordRunAuditEvent === "function") {
|
||||||
|
const auditInput: RunAuditEventInput = {
|
||||||
|
taskId: task.id,
|
||||||
|
agentId: "dashboard-api",
|
||||||
|
runId: `dashboard-pr-refresh-${task.id}`,
|
||||||
|
domain: "database",
|
||||||
|
mutationType: "pr:changes-requested-auto-move",
|
||||||
|
target: task.id,
|
||||||
|
metadata: {
|
||||||
|
reviewDecision: snapshot.decision,
|
||||||
|
reviewCount: reviewItems.length,
|
||||||
|
commentCount: commentItems.length,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
store.recordRunAuditEvent(auditInput);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function refreshPrInBackground(store: TaskStore, taskId: string, currentPrInfo: PrInfo, token?: string): Promise<void> {
|
export async function refreshPrInBackground(store: TaskStore, taskId: string, currentPrInfo: PrInfo, token?: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
let owner: string;
|
let owner: string;
|
||||||
@@ -1053,10 +1123,18 @@ export async function refreshPrInBackground(store: TaskStore, taskId: string, cu
|
|||||||
}
|
}
|
||||||
|
|
||||||
const client = new GitHubClient(token);
|
const client = new GitHubClient(token);
|
||||||
|
const task = await store.getTask(taskId);
|
||||||
|
|
||||||
|
const reviewSnapshot = await client.getPrReviewSnapshot(owner, repo, currentPrInfo.number);
|
||||||
|
const prInfo = {
|
||||||
|
...reviewSnapshot.prInfo,
|
||||||
|
lastCheckedAt: new Date().toISOString(),
|
||||||
|
lastReviewDecision: reviewSnapshot.decision,
|
||||||
|
};
|
||||||
|
|
||||||
const prInfo = await client.getPrStatus(owner, repo, currentPrInfo.number);
|
|
||||||
prInfo.lastCheckedAt = new Date().toISOString();
|
|
||||||
await store.updatePrInfo(taskId, prInfo);
|
await store.updatePrInfo(taskId, prInfo);
|
||||||
|
await syncPrReviewsToTask(store, task, reviewSnapshot);
|
||||||
|
await applyChangesRequestedTransition(store, task, reviewSnapshot, prInfo);
|
||||||
} catch {
|
} catch {
|
||||||
// best-effort
|
// best-effort
|
||||||
}
|
}
|
||||||
@@ -3208,23 +3286,26 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch fresh PR status + merge readiness
|
// Fetch fresh PR status + merge readiness + reviews
|
||||||
const client = new GitHubClient();
|
const client = new GitHubClient();
|
||||||
|
const reviewSnapshot = await client.getPrReviewSnapshot(owner, repo, task.prInfo.number);
|
||||||
const mergeStatus = await client.getPrMergeStatus(owner, repo, task.prInfo.number);
|
const mergeStatus = await client.getPrMergeStatus(owner, repo, task.prInfo.number);
|
||||||
|
|
||||||
const prInfo = {
|
const prInfo = {
|
||||||
...mergeStatus.prInfo,
|
...reviewSnapshot.prInfo,
|
||||||
lastCheckedAt: new Date().toISOString(),
|
lastCheckedAt: new Date().toISOString(),
|
||||||
|
lastReviewDecision: reviewSnapshot.decision,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Update stored PR info
|
|
||||||
await scopedStore.updatePrInfo(task.id, prInfo);
|
await scopedStore.updatePrInfo(task.id, prInfo);
|
||||||
|
await syncPrReviewsToTask(scopedStore, task, reviewSnapshot);
|
||||||
|
await applyChangesRequestedTransition(scopedStore, task, reviewSnapshot, prInfo);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
prInfo,
|
prInfo,
|
||||||
mergeReady: mergeStatus.mergeReady,
|
mergeReady: mergeStatus.mergeReady,
|
||||||
blockingReasons: mergeStatus.blockingReasons,
|
blockingReasons: mergeStatus.blockingReasons,
|
||||||
reviewDecision: mergeStatus.reviewDecision,
|
reviewDecision: reviewSnapshot.decision,
|
||||||
checks: mergeStatus.checks,
|
checks: mergeStatus.checks,
|
||||||
automationStatus: task.status ?? null,
|
automationStatus: task.status ?? null,
|
||||||
});
|
});
|
||||||
@@ -3242,6 +3323,62 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/tasks/:id/pr/reviews
|
||||||
|
* Fetch PR review snapshot and merged Fusion comment thread view.
|
||||||
|
*/
|
||||||
|
router.get("/tasks/:id/pr/reviews", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { store: scopedStore } = await getProjectContext(req);
|
||||||
|
const task = await scopedStore.getTask(req.params.id);
|
||||||
|
|
||||||
|
if (!task.prInfo) {
|
||||||
|
throw notFound("Task has no associated PR");
|
||||||
|
}
|
||||||
|
|
||||||
|
let owner: string;
|
||||||
|
let repo: string;
|
||||||
|
const badgeParsed = parseBadgeUrl(task.prInfo.url);
|
||||||
|
if (badgeParsed) {
|
||||||
|
owner = badgeParsed.owner;
|
||||||
|
repo = badgeParsed.repo;
|
||||||
|
} else {
|
||||||
|
const envRepo = process.env.GITHUB_REPOSITORY;
|
||||||
|
if (envRepo) {
|
||||||
|
const [o, r] = envRepo.split("/");
|
||||||
|
owner = o;
|
||||||
|
repo = r;
|
||||||
|
} else {
|
||||||
|
const gitRepo = getCurrentRepo(scopedStore.getRootDir());
|
||||||
|
if (!gitRepo) {
|
||||||
|
throw badRequest("Could not determine GitHub repository");
|
||||||
|
}
|
||||||
|
owner = gitRepo.owner;
|
||||||
|
repo = gitRepo.repo;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = new GitHubClient();
|
||||||
|
const snapshot = await client.getPrReviewSnapshot(owner, repo, task.prInfo.number);
|
||||||
|
const fusionThread = (task.comments ?? []).filter((comment) =>
|
||||||
|
comment.source === "github-review" || comment.source === "github-review-comment"
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
snapshot,
|
||||||
|
comments: fusionThread,
|
||||||
|
});
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (err instanceof ApiError) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
||||||
|
throw notFound(`Task ${req.params.id} not found`);
|
||||||
|
}
|
||||||
|
rethrowAsApiError(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/tasks/:id/pr/checks
|
* GET /api/tasks/:id/pr/checks
|
||||||
* Fetch all PR checks (required + optional) and rollup derived from required checks.
|
* Fetch all PR checks (required + optional) and rollup derived from required checks.
|
||||||
|
|||||||
Reference in New Issue
Block a user