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:
Fusion (runfusion.ai)
2026-05-17 10:45:52 -07:00
committed by gsxdsm
parent cfadcf3835
commit 5bde911bdb
8 changed files with 420 additions and 11 deletions

View File

@@ -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 */

View File

@@ -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;

View File

@@ -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">

View File

@@ -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}

View File

@@ -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();
});
});

View File

@@ -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", () => {

View 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 }));
});
});

View File

@@ -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.