mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
feat(external-pr): CEO decision surface — panel PR-review queue
The panel half of the PR-review gate. A PrReviewQueue card on the Command
Center lists external PRs the org has reviewed and awaiting the CEO's call,
each with Supersede / Dismiss / View-on-GitHub. Hidden when empty.
- tasksApi.getExternalPrReviews / supersedeExternalPr / dismissExternalPr,
typed to the exact backend response shapes (GET /tasks/external-pr-reviews,
POST /tasks/{id}/supersede-external-pr, POST .../dismiss-external-pr).
- Mock mode returns [] so the queue hides and the actions are unreachable —
no mock-masked contract (the #194 trap avoided; verified FE paths/shapes
against the real routes + route ordering).
This commit is contained in:
@@ -9,6 +9,7 @@ import { ActiveBlockersPanel } from "./active-blockers-panel";
|
||||
import { RecentActivityFeed } from "./recent-activity-feed";
|
||||
import { QuickActionsBar } from "./quick-actions-bar";
|
||||
import { CeoApprovalQueue } from "./ceo-approval-queue";
|
||||
import { PrReviewQueue } from "./pr-review-queue";
|
||||
import { StrategySignalsPanel } from "./strategy-signals-panel";
|
||||
import type { Activity } from "./activity-item";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -77,6 +78,9 @@ export function CommandCenter() {
|
||||
<StrategySignalsPanel />
|
||||
</div>
|
||||
|
||||
{/* External-PR review decision queue (hidden when empty) */}
|
||||
<PrReviewQueue />
|
||||
|
||||
{/* Metrics, Alerts, and Usage Row */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 xl:grid-cols-3 2xl:grid-cols-3 gap-6">
|
||||
<KeyMetricsPanel
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export { CommandCenter } from "./command-center";
|
||||
export { PrReviewQueue } from "./pr-review-queue";
|
||||
export { TeamHealthCards } from "./team-health-cards";
|
||||
export { TeamHealthCard } from "./team-health-card";
|
||||
export { KeyMetricsPanel } from "./key-metrics-panel";
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { tasksApi } from "@/lib/api";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { GitPullRequest, ExternalLink, Rocket, XCircle, FileText } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { type Task } from "@/types";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface PrReviewQueueProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The CEO's decision queue for inbound external PRs the org has reviewed.
|
||||
*
|
||||
* The PR reviewer is read-only — it posts one change-request and stops; the CEO
|
||||
* decides what happens next. This surfaces each reviewed PR with two actions:
|
||||
* Supersede (the org takes the contribution over and finishes it) or Dismiss
|
||||
* (drop it from the queue; the review stays on the GitHub PR). Hidden when empty.
|
||||
*/
|
||||
export function PrReviewQueue({ className }: PrReviewQueueProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [selected, setSelected] = useState<Task | null>(null);
|
||||
const [action, setAction] = useState<"supersede" | "dismiss" | null>(null);
|
||||
|
||||
const { data: reviews, isLoading } = useQuery({
|
||||
queryKey: ["tasks", "external-pr-reviews"],
|
||||
queryFn: () => tasksApi.getExternalPrReviews(),
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
const supersedeMutation = useMutation({
|
||||
mutationFn: (taskId: string) => tasksApi.supersedeExternalPr(taskId),
|
||||
onSuccess: (res) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["tasks"] });
|
||||
toast.success(
|
||||
res.ok
|
||||
? "Superseding — the org is taking the PR over"
|
||||
: "Supersede did not start",
|
||||
);
|
||||
close();
|
||||
},
|
||||
onError: (e) =>
|
||||
toast.error(
|
||||
`Supersede failed: ${e instanceof Error ? e.message : "Unknown error"}`,
|
||||
),
|
||||
});
|
||||
|
||||
const dismissMutation = useMutation({
|
||||
mutationFn: (taskId: string) => tasksApi.dismissExternalPr(taskId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["tasks"] });
|
||||
toast.success("Review dismissed");
|
||||
close();
|
||||
},
|
||||
onError: (e) =>
|
||||
toast.error(
|
||||
`Dismiss failed: ${e instanceof Error ? e.message : "Unknown error"}`,
|
||||
),
|
||||
});
|
||||
|
||||
const open = (task: Task, a: "supersede" | "dismiss") => {
|
||||
setSelected(task);
|
||||
setAction(a);
|
||||
};
|
||||
const close = () => {
|
||||
setSelected(null);
|
||||
setAction(null);
|
||||
};
|
||||
const confirm = () => {
|
||||
if (!selected) return;
|
||||
if (action === "supersede") supersedeMutation.mutate(selected.id);
|
||||
else if (action === "dismiss") dismissMutation.mutate(selected.id);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<GitPullRequest className="h-5 w-5" />
|
||||
PR Reviews
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
External PRs reviewed and awaiting your call
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{[1, 2].map((i) => (
|
||||
<Skeleton key={i} className="h-20 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const items = reviews || [];
|
||||
if (items.length === 0) return null; // keep the dashboard clean when there's nothing to decide
|
||||
|
||||
const isPending = supersedeMutation.isPending || dismissMutation.isPending;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<GitPullRequest className="h-5 w-5" />
|
||||
PR Reviews
|
||||
<Badge variant="secondary" className="ml-2">
|
||||
{items.length}
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
External PRs the org reviewed — supersede or dismiss
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{items.map((task) => (
|
||||
<div
|
||||
key={task.id}
|
||||
className="flex items-start justify-between p-4 border rounded-lg hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<Link
|
||||
href={`/tasks/${task.id}`}
|
||||
className="font-medium hover:underline line-clamp-1"
|
||||
>
|
||||
{task.title}
|
||||
</Link>
|
||||
{task.description && (
|
||||
<p className="text-sm text-muted-foreground mt-1 line-clamp-2">
|
||||
{task.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-4 flex-shrink-0">
|
||||
{task.pr_url && (
|
||||
<a
|
||||
href={task.pr_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="View PR on GitHub"
|
||||
>
|
||||
<Button variant="ghost" size="sm">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
<Link href={`/tasks/${task.id}`} title="Review details">
|
||||
<Button variant="ghost" size="sm">
|
||||
<FileText className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={() => open(task, "dismiss")}
|
||||
>
|
||||
<XCircle className="h-4 w-4 mr-1" />
|
||||
Dismiss
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-blue-600 hover:bg-blue-700"
|
||||
onClick={() => open(task, "supersede")}
|
||||
>
|
||||
<Rocket className="h-4 w-4 mr-1" />
|
||||
Supersede
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={!!selected && !!action} onOpenChange={() => close()}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{action === "supersede" ? "Supersede this PR" : "Dismiss this review"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{action === "supersede"
|
||||
? "The org takes the contribution over: a roboco-owned branch is cut from the contributor's commits, a cell finishes and hardens it, then opens our own PR. This authorizes fetching and running the contributor's code."
|
||||
: "Removes this PR from your review queue. The review stays on the GitHub PR; the org takes no further action."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{selected && (
|
||||
<div className="py-2">
|
||||
<p className="font-medium">{selected.title}</p>
|
||||
{selected.pr_url && (
|
||||
<a
|
||||
href={selected.pr_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-primary flex items-center gap-1 mt-2 hover:underline"
|
||||
>
|
||||
View PR on GitHub <ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={close}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={confirm}
|
||||
disabled={isPending}
|
||||
variant={action === "dismiss" ? "destructive" : "default"}
|
||||
className={action === "supersede" ? "bg-blue-600 hover:bg-blue-700" : ""}
|
||||
>
|
||||
{isPending
|
||||
? "Processing..."
|
||||
: action === "supersede"
|
||||
? "Supersede"
|
||||
: "Dismiss"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -697,4 +697,34 @@ export const tasksApi = {
|
||||
});
|
||||
return data;
|
||||
},
|
||||
|
||||
// Inbound external PRs that were reviewed and await the CEO's decision
|
||||
// (the PR-review decision queue).
|
||||
getExternalPrReviews: async (): Promise<Task[]> => {
|
||||
if (isMockMode()) return [];
|
||||
const { data } = await api.get<Task[]>("/tasks/external-pr-reviews");
|
||||
return data;
|
||||
},
|
||||
|
||||
// CEO authorizes the org to take over a reviewed external PR.
|
||||
supersedeExternalPr: async (
|
||||
taskId: string,
|
||||
): Promise<{ ok: boolean; supersede_task_id?: string; branch?: string }> => {
|
||||
const { data } = await api.post<{
|
||||
ok: boolean;
|
||||
supersede_task_id?: string;
|
||||
branch?: string;
|
||||
}>("/tasks/" + taskId + "/supersede-external-pr");
|
||||
return data;
|
||||
},
|
||||
|
||||
// CEO declines to act on a reviewed external PR (drops it from the queue).
|
||||
dismissExternalPr: async (
|
||||
taskId: string,
|
||||
): Promise<{ ok: boolean; task_id: string }> => {
|
||||
const { data } = await api.post<{ ok: boolean; task_id: string }>(
|
||||
"/tasks/" + taskId + "/dismiss-external-pr",
|
||||
);
|
||||
return data;
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user