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 { RecentActivityFeed } from "./recent-activity-feed";
|
||||||
import { QuickActionsBar } from "./quick-actions-bar";
|
import { QuickActionsBar } from "./quick-actions-bar";
|
||||||
import { CeoApprovalQueue } from "./ceo-approval-queue";
|
import { CeoApprovalQueue } from "./ceo-approval-queue";
|
||||||
|
import { PrReviewQueue } from "./pr-review-queue";
|
||||||
import { StrategySignalsPanel } from "./strategy-signals-panel";
|
import { StrategySignalsPanel } from "./strategy-signals-panel";
|
||||||
import type { Activity } from "./activity-item";
|
import type { Activity } from "./activity-item";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -77,6 +78,9 @@ export function CommandCenter() {
|
|||||||
<StrategySignalsPanel />
|
<StrategySignalsPanel />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* External-PR review decision queue (hidden when empty) */}
|
||||||
|
<PrReviewQueue />
|
||||||
|
|
||||||
{/* Metrics, Alerts, and Usage Row */}
|
{/* 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">
|
<div className="grid grid-cols-1 lg:grid-cols-3 xl:grid-cols-3 2xl:grid-cols-3 gap-6">
|
||||||
<KeyMetricsPanel
|
<KeyMetricsPanel
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export { CommandCenter } from "./command-center";
|
export { CommandCenter } from "./command-center";
|
||||||
|
export { PrReviewQueue } from "./pr-review-queue";
|
||||||
export { TeamHealthCards } from "./team-health-cards";
|
export { TeamHealthCards } from "./team-health-cards";
|
||||||
export { TeamHealthCard } from "./team-health-card";
|
export { TeamHealthCard } from "./team-health-card";
|
||||||
export { KeyMetricsPanel } from "./key-metrics-panel";
|
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;
|
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