From 84d6e6889fc40880c0396fc8c680c4584426e499 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Mon, 13 Apr 2026 00:01:05 -0500 Subject: [PATCH] Show recent PR review activity in the dashboard - Add recent maintainer reviews and decision summary to PR review - Surface PR Review in navigation and register Copilot settings --- apps/server/src/openclaw/GatewayClient.ts | 2 +- apps/server/src/prReview/Layers/PrReview.ts | 39 +++++++++ apps/web/src/appSettings.test.ts | 2 + apps/web/src/components/CommandPalette.tsx | 12 +++ apps/web/src/components/Sidebar.tsx | 5 +- .../src/components/chat/ProviderSetupCard.tsx | 1 + .../components/pr-review/PrReviewShell.tsx | 84 ++++++++++++++++++- apps/web/src/routes/_chat.settings.tsx | 11 +++ packages/contracts/src/prReview.ts | 8 ++ 9 files changed, 158 insertions(+), 6 deletions(-) diff --git a/apps/server/src/openclaw/GatewayClient.ts b/apps/server/src/openclaw/GatewayClient.ts index 734bbee23..3bff51c5f 100644 --- a/apps/server/src/openclaw/GatewayClient.ts +++ b/apps/server/src/openclaw/GatewayClient.ts @@ -466,7 +466,7 @@ export class OpenclawGatewayClient { if (frame.type === "event" && typeof frame.event === "string") { let matchedWaiter = false; - for (const waiter of [...this.pendingEventWaiters]) { + for (const waiter of this.pendingEventWaiters) { if (waiter.eventName === frame.event) { matchedWaiter = true; this.pendingEventWaiters.delete(waiter); diff --git a/apps/server/src/prReview/Layers/PrReview.ts b/apps/server/src/prReview/Layers/PrReview.ts index 454109ce8..12c0d052f 100644 --- a/apps/server/src/prReview/Layers/PrReview.ts +++ b/apps/server/src/prReview/Layers/PrReview.ts @@ -61,6 +61,17 @@ query PullRequestReviewDashboard($owner: String!, $name: String!, $number: Int!) headRefName baseRefOid headRefOid + reviews(last: 100) { + nodes { + state + body + submittedAt + authorAssociation + author { + login + } + } + } labels(first: 20) { nodes { name color } } @@ -300,6 +311,32 @@ function normalizeStatusChecks(raw: unknown): PrReviewSummary["statusChecks"] { return statusChecks; } +function normalizeRecentReviews(raw: unknown): PrReviewSummary["recentReviews"] { + if (!Array.isArray(raw)) return []; + const maintainerAssociations = new Set(["COLLABORATOR", "MEMBER", "OWNER"]); + return raw + .map((entry) => { + if (!entry || typeof entry !== "object") return null; + const record = entry as Record; + if (!maintainerAssociations.has(asString(record.authorAssociation) ?? "")) return null; + const submittedAt = asString(record.submittedAt); + const state = asString(record.state); + const author = + record.author && typeof record.author === "object" + ? asString((record.author as Record).login) + : null; + if (!submittedAt || !state || !author) return null; + return { + authorLogin: author, + state, + body: typeof record.body === "string" ? record.body : "", + submittedAt, + } satisfies PrReviewSummary["recentReviews"][number]; + }) + .filter((entry): entry is PrReviewSummary["recentReviews"][number] => entry !== null) + .toSorted((a, b) => Date.parse(b.submittedAt) - Date.parse(a.submittedAt)); +} + function normalizeDashboardResponse( raw: unknown, ): Pick { @@ -343,6 +380,7 @@ function normalizeDashboardResponse( ((pullRequest.commits as any)?.nodes?.[0] as any)?.commit?.statusCheckRollup?.contexts?.nodes ?? [], ); + const recentReviews = normalizeRecentReviews((pullRequest.reviews as any)?.nodes ?? []); const threads = Array.isArray((pullRequest.reviewThreads as any)?.nodes) ? ((pullRequest.reviewThreads as any).nodes as unknown[]) @@ -394,6 +432,7 @@ function normalizeDashboardResponse( .map((entry) => normalizeUser(entry)) .filter((entry): entry is GitHubUserPreview => entry !== null) .map((user) => ({ user, role: "requestedReviewer" as const })), + recentReviews, totalThreadCount: threads.length, unresolvedThreadCount: threads.filter((thread) => !thread.isResolved).length, headSha: asString(pullRequest.headRefOid), diff --git a/apps/web/src/appSettings.test.ts b/apps/web/src/appSettings.test.ts index d6fbb4ca3..41c453d1f 100644 --- a/apps/web/src/appSettings.test.ts +++ b/apps/web/src/appSettings.test.ts @@ -71,6 +71,8 @@ describe("getProviderStartOptions", () => { claudeAuthTokenHelperCommand: "op read op://shared/anthropic/token --no-newline", codexBinaryPath: "", codexHomePath: "", + copilotBinaryPath: "", + copilotConfigDir: "", openclawGatewayUrl: "", openclawPassword: "", }), diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 1960c2c11..87403fa34 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -13,6 +13,7 @@ import { SunIcon, GitBranchIcon, GitMergeIcon, + GitPullRequestIcon, SearchIcon, KeyboardIcon, } from "lucide-react"; @@ -222,6 +223,17 @@ function CommandsView() { void navigate({ to: "/skills", search: { create: undefined, name: undefined } }); }, }); + cmds.push({ + id: "nav-pr-review", + label: "Open PR Review", + keywords: ["pr review", "pull request", "review", "github", "maintainer"], + icon: GitPullRequestIcon, + group: "Navigation", + onSelect: () => { + closePalette(); + void navigate({ to: "/pr-review" }); + }, + }); // ── Project quick-switch (inline, first 5) ── for (const project of projects.slice(0, 5)) { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index a28cd2f35..d944ba8bf 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -33,7 +33,6 @@ import { ChevronsDownUpIcon, ChevronsUpDownIcon, CircleDotIcon, - ExternalLinkIcon, FolderIcon, GitBranchIcon, GitMergeIcon, @@ -2290,8 +2289,8 @@ export default function Sidebar() { className="gap-2 px-2 py-1.5 text-muted-foreground/70 hover:bg-accent hover:text-foreground" onClick={() => void navigate({ to: "/pr-review" })} > - - Open Workspace + + PR Review diff --git a/apps/web/src/components/chat/ProviderSetupCard.tsx b/apps/web/src/components/chat/ProviderSetupCard.tsx index 20eb2b586..811a0fa1e 100644 --- a/apps/web/src/components/chat/ProviderSetupCard.tsx +++ b/apps/web/src/components/chat/ProviderSetupCard.tsx @@ -35,6 +35,7 @@ const PROVIDER_CONFIG = { installCmd: "npm install -g @github/copilot", authCmd: "copilot login", verifyCmd: "gh auth status", + note: undefined, }, } as const; diff --git a/apps/web/src/components/pr-review/PrReviewShell.tsx b/apps/web/src/components/pr-review/PrReviewShell.tsx index d115c668a..4ade2b2c2 100644 --- a/apps/web/src/components/pr-review/PrReviewShell.tsx +++ b/apps/web/src/components/pr-review/PrReviewShell.tsx @@ -61,6 +61,34 @@ function resolvePrReviewConfigPath(projectCwd: string, configPath: string): stri return joinPath(projectCwd, configPath); } +function formatReviewDecision(decision: string | null | undefined): string { + if (!decision) return "No decision"; + return decision.toLowerCase().replaceAll("_", " "); +} + +function reviewDecisionTone(decision: string | null | undefined): string { + switch (decision) { + case "APPROVED": + return "text-emerald-600 dark:text-emerald-400"; + case "CHANGES_REQUESTED": + case "REVIEW_REQUIRED": + return "text-amber-600 dark:text-amber-400"; + default: + return "text-muted-foreground"; + } +} + +function formatReviewTimestamp(value: string): string { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }).format(date); +} + export function PrReviewShell({ project, projects, @@ -360,6 +388,8 @@ export function PrReviewShell({ const blockingWorkflowStepsComputed = (dashboardQuery.data?.workflowSteps ?? []).filter( (step) => step.status === "blocked" || step.status === "failed", ); + const recentReviews = dashboardQuery.data?.pullRequest.recentReviews ?? []; + const displayedRecentReviews = recentReviews.slice(0, 3); // Inspector props helper const inspectorProps = { @@ -531,7 +561,58 @@ export function PrReviewShell({ )} >
-
+
+
+
+
+ Review decision +
+
+ {formatReviewDecision(dashboardQuery.data?.pullRequest.reviewDecision)} +
+
+
+
+
+ Recent maintainer reviews +
+ {displayedRecentReviews.length > 0 ? ( +
+ {displayedRecentReviews.map((review) => ( +
+
+
+ + {review.authorLogin} + + + {review.state.toLowerCase().replaceAll("_", " ")} + +
+ + {formatReviewTimestamp(review.submittedAt)} + +
+ {review.body.trim().length > 0 ? ( +

+ {review.body} +

+ ) : null} +
+ ))} +
+ ) : ( +
No maintainer reviews yet.
+ )} +
{ setReviewBody(value); - // Auto-expand when user starts typing if (value.trim().length > 0 && !actionRailExpanded) { setActionRailExpanded(true); } diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index a3ab15563..6569ebd66 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -384,6 +384,12 @@ const PROVIDER_AUTH_GUIDES: Record< verifyCmd: "Test Connection", note: "OpenClaw uses the gateway URL and password below rather than a local CLI login. A configured gateway unlocks it for new-thread selection.", }, + copilot: { + installCmd: "npm install -g @github/copilot", + authCmd: "copilot login", + verifyCmd: "gh auth status", + note: "GitHub Copilot must be installed and authenticated before it can appear in the thread picker.", + }, }; function getAuthenticationBadgeCopy(input: { @@ -811,6 +817,7 @@ function SettingsRouteView() { codex: Boolean(settings.codexBinaryPath || settings.codexHomePath), claudeAgent: Boolean(settings.claudeBinaryPath || settings.claudeAuthTokenHelperCommand), openclaw: Boolean(settings.openclawGatewayUrl || settings.openclawPassword), + copilot: Boolean(settings.copilotBinaryPath || settings.copilotConfigDir), }); const [selectedCustomModelProvider, setSelectedCustomModelProvider] = useState("codex"); @@ -820,6 +827,7 @@ function SettingsRouteView() { codex: "", claudeAgent: "", openclaw: "", + copilot: "", }); const [customModelErrorByProvider, setCustomModelErrorByProvider] = useState< Partial> @@ -1187,12 +1195,14 @@ function SettingsRouteView() { codex: false, claudeAgent: false, openclaw: false, + copilot: false, }); setSelectedCustomModelProvider("codex"); setCustomModelInputByProvider({ codex: "", claudeAgent: "", openclaw: "", + copilot: "", }); setCustomModelErrorByProvider({}); @@ -2515,6 +2525,7 @@ function SettingsRouteView() { codex: false, claudeAgent: false, openclaw: false, + copilot: false, }); }} /> diff --git a/packages/contracts/src/prReview.ts b/packages/contracts/src/prReview.ts index 645426b37..aaf1e4b36 100644 --- a/packages/contracts/src/prReview.ts +++ b/packages/contracts/src/prReview.ts @@ -227,6 +227,14 @@ export const PrReviewSummary = Schema.Struct({ statusChecks: Schema.Array(PrReviewStatusCheck), participants: Schema.Array(PrReviewParticipant), reviewRequests: Schema.Array(PrReviewParticipant), + recentReviews: Schema.Array( + Schema.Struct({ + authorLogin: TrimmedNonEmptyString, + state: TrimmedNonEmptyString, + body: Schema.String, + submittedAt: Schema.String, + }), + ), totalThreadCount: NonNegativeInt, unresolvedThreadCount: NonNegativeInt, headSha: Schema.NullOr(TrimmedNonEmptyString),