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;
|
||||
}
|
||||
|
||||
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 {
|
||||
title: 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));
|
||||
}
|
||||
|
||||
export function fetchPrReviews(id: string, projectId?: string): Promise<PrReviewsResponse> {
|
||||
return api<PrReviewsResponse>(withProjectId(`/tasks/${id}/pr/reviews`, projectId));
|
||||
}
|
||||
|
||||
// --- Issue Management API ---
|
||||
|
||||
/** Re-export GitHub badge-related types for convenience */
|
||||
|
||||
@@ -123,6 +123,32 @@
|
||||
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) {
|
||||
.pr-panel-checks-rollup {
|
||||
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 { 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 { PrChecksList } from "./PrChecksList";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { linkifyFilePaths } from "../utils/filePathLinkify";
|
||||
import "./PrPanel.css";
|
||||
|
||||
interface PrPanelProps {
|
||||
@@ -12,6 +13,7 @@ interface PrPanelProps {
|
||||
projectId?: string;
|
||||
prInfo?: PrInfo;
|
||||
automationStatus?: string | null;
|
||||
taskColumn?: string;
|
||||
autoMerge?: boolean;
|
||||
isManualPrFlow?: boolean;
|
||||
prAuthAvailable: boolean;
|
||||
@@ -44,6 +46,7 @@ export function PrPanel({
|
||||
projectId,
|
||||
prInfo,
|
||||
automationStatus,
|
||||
taskColumn,
|
||||
autoMerge = false,
|
||||
isManualPrFlow = false,
|
||||
prAuthAvailable,
|
||||
@@ -53,6 +56,17 @@ export function PrPanel({
|
||||
}: PrPanelProps) {
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
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 () => {
|
||||
if (!prInfo) return;
|
||||
@@ -62,6 +76,8 @@ export function PrPanel({
|
||||
const updated = await refreshPrStatus(taskId, projectId);
|
||||
setRefreshState(updated);
|
||||
onPrUpdated(updated.prInfo);
|
||||
const latestReviews = await fetchPrReviews(taskId, projectId);
|
||||
setReviewsState(latestReviews);
|
||||
addToast("PR status refreshed", "success");
|
||||
} catch (err) {
|
||||
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 blockingReasons = refreshState?.blockingReasons ?? [];
|
||||
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(() => {
|
||||
if (!checks) return "unknown" as const;
|
||||
@@ -190,6 +216,30 @@ export function PrPanel({
|
||||
)}
|
||||
</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 === "awaiting-pr-checks" && (
|
||||
<div className="pr-hint pr-hint--info">
|
||||
|
||||
@@ -3289,6 +3289,7 @@ export function TaskDetailContent({
|
||||
projectId={projectId}
|
||||
prInfo={task.prInfo}
|
||||
automationStatus={task.status ?? null}
|
||||
taskColumn={task.column}
|
||||
autoMerge={settings?.autoMerge ?? false}
|
||||
isManualPrFlow={isManualPrFlow}
|
||||
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", () => ({
|
||||
refreshPrStatus: vi.fn(),
|
||||
fetchPrChecks: vi.fn(),
|
||||
fetchPrReviews: vi.fn(),
|
||||
}));
|
||||
|
||||
import { refreshPrStatus, fetchPrChecks } from "../../api";
|
||||
import { refreshPrStatus, fetchPrChecks, fetchPrReviews } from "../../api";
|
||||
|
||||
const mockAddToast = vi.fn();
|
||||
const mockOnPrUpdated = vi.fn();
|
||||
@@ -32,6 +33,7 @@ describe("PrPanel", () => {
|
||||
rollup: "unknown",
|
||||
lastCheckedAt: new Date().toISOString(),
|
||||
});
|
||||
(fetchPrReviews as ReturnType<typeof vi.fn>).mockResolvedValue({ snapshot: { decision: null, items: [] }, comments: [] });
|
||||
});
|
||||
|
||||
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,
|
||||
IssueInfo,
|
||||
PrInfo,
|
||||
RunAuditEventInput,
|
||||
Task,
|
||||
TaskStore,
|
||||
} from "@fusion/core";
|
||||
import { getCurrentRepo, isGhAuthenticated } from "@fusion/core";
|
||||
@@ -19,7 +21,7 @@ import {
|
||||
rateLimited,
|
||||
unauthorized,
|
||||
} 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 { GitHubTrackingCommentService } from "../github-tracking-comments.js";
|
||||
import { GitHubTrackingStateService } from "../github-tracking-state.js";
|
||||
@@ -1024,6 +1026,74 @@ export function appendBatchStatusError(results: BatchStatusResult, taskId: strin
|
||||
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> {
|
||||
try {
|
||||
let owner: string;
|
||||
@@ -1053,10 +1123,18 @@ export async function refreshPrInBackground(store: TaskStore, taskId: string, cu
|
||||
}
|
||||
|
||||
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 syncPrReviewsToTask(store, task, reviewSnapshot);
|
||||
await applyChangesRequestedTransition(store, task, reviewSnapshot, prInfo);
|
||||
} catch {
|
||||
// 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 reviewSnapshot = await client.getPrReviewSnapshot(owner, repo, task.prInfo.number);
|
||||
const mergeStatus = await client.getPrMergeStatus(owner, repo, task.prInfo.number);
|
||||
|
||||
const prInfo = {
|
||||
...mergeStatus.prInfo,
|
||||
...reviewSnapshot.prInfo,
|
||||
lastCheckedAt: new Date().toISOString(),
|
||||
lastReviewDecision: reviewSnapshot.decision,
|
||||
};
|
||||
|
||||
// Update stored PR info
|
||||
await scopedStore.updatePrInfo(task.id, prInfo);
|
||||
await syncPrReviewsToTask(scopedStore, task, reviewSnapshot);
|
||||
await applyChangesRequestedTransition(scopedStore, task, reviewSnapshot, prInfo);
|
||||
|
||||
res.json({
|
||||
prInfo,
|
||||
mergeReady: mergeStatus.mergeReady,
|
||||
blockingReasons: mergeStatus.blockingReasons,
|
||||
reviewDecision: mergeStatus.reviewDecision,
|
||||
reviewDecision: reviewSnapshot.decision,
|
||||
checks: mergeStatus.checks,
|
||||
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
|
||||
* Fetch all PR checks (required + optional) and rollup derived from required checks.
|
||||
|
||||
Reference in New Issue
Block a user