feat(external-pr): surface in-flight reviews in the panel, not just completed

The PR-review queue only listed COMPLETED reviews and hid when empty, so while
a review was in_progress the panel showed nothing — no sign a review was
happening or where its findings go (the reviewer posts its change-request on
the PR itself). Add TaskService.list_external_pr_reviews (active reviews +
awaiting-decision, minus cancelled/decided/dismissed); the route uses it. The
panel card now shows active reviews with a 'Reviewing' badge and a link to the
PR where the change-request lands, and the Supersede/Dismiss actions only once
the review completes.
This commit is contained in:
Renn F
2026-06-17 07:16:49 +02:00
parent bca3b073ac
commit 9cc63125d2
4 changed files with 126 additions and 59 deletions
@@ -21,7 +21,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { GitPullRequest, ExternalLink, Rocket, XCircle, FileText } from "lucide-react";
import { GitPullRequest, ExternalLink, Rocket, XCircle, FileText, Loader2 } from "lucide-react";
import Link from "next/link";
import { type Task } from "@/types";
import { toast } from "sonner";
@@ -133,67 +133,89 @@ export function PrReviewQueue({ className }: PrReviewQueueProps) {
</Badge>
</CardTitle>
<CardDescription>
External PRs the org reviewed supersede or dismiss
External PRs the org is reviewing or has reviewed the reviewer
posts its change-request on the PR; supersede or dismiss once it
lands
</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"
>
{items.map((task) => {
const awaiting = (task.status || "").toLowerCase() === "completed";
return (
<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">
<div className="flex items-center gap-2">
<Link
href={`/tasks/${task.id}`}
className="font-medium hover:underline line-clamp-1"
>
{task.title}
</Link>
{awaiting ? (
<Badge variant="secondary" className="shrink-0">
Awaiting your call
</Badge>
) : (
<Badge variant="outline" className="shrink-0 gap-1">
<Loader2 className="h-3 w-3 animate-spin" />
Reviewing
</Badge>
)}
</div>
{task.description && (
<p className="text-sm text-muted-foreground mt-1 line-clamp-2">
{task.description}
</p>
)}
{task.pr_url && (
<a
href={task.pr_url}
target="_blank"
rel="noopener noreferrer"
className="mt-1 inline-flex items-center gap-1 text-xs text-primary hover:underline"
>
{awaiting
? "Change-request posted on the PR"
: "Findings will post as a change-request on the PR"}
<ExternalLink className="h-3 w-3" />
</a>
)}
</div>
<div className="flex items-center gap-2 ml-4 flex-shrink-0">
<Link href={`/tasks/${task.id}`} title="Review details">
<Button variant="ghost" size="sm">
<ExternalLink className="h-4 w-4" />
<FileText 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>
</Link>
{awaiting && (
<>
<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>
))}
);
})}
</div>
</CardContent>
</Card>
+7 -4
View File
@@ -647,10 +647,13 @@ async def get_external_pr_reviews(
agent: CurrentAgentContext,
permissions: PermissionServiceDep,
) -> list[TaskResponse]:
"""Inbound external PRs that were reviewed and await the CEO's decision.
"""Inbound external PRs the org is reviewing or has reviewed.
The PR-review decision queue: completed external-PR review tasks the CEO has
neither superseded nor dismissed. Org-wide; visible to PMs and above.
The PR-review queue: external-PR review tasks still in flight (the reviewer
is working) OR completed and awaiting the CEO's decision (not yet superseded
or dismissed). Active reviews surface so the panel shows a review underway
and links to the PR where the change-request is posted, instead of going
dark until it finishes. Org-wide; visible to PMs and above.
"""
can_view_all = permissions.can_perform_task_action(agent, TaskAction.VIEW_ALL)
is_pm = agent.role in (AgentRole.CELL_PM, AgentRole.MAIN_PM)
@@ -661,7 +664,7 @@ async def get_external_pr_reviews(
detail="Only PMs and management can view the PR-review queue",
)
service = get_task_service(db)
tasks = await service.list_external_pr_reviews_awaiting_decision()
tasks = await service.list_external_pr_reviews()
return task_list_to_response(tasks)
+31
View File
@@ -741,6 +741,37 @@ class TaskService(BaseService):
if "dismissed=1" not in (t.quick_context or "").split()
]
async def list_external_pr_reviews(self) -> list[TaskTable]:
"""Live external-PR reviews for the panel: in-flight PLUS awaiting-decision.
Every ``source='external_pr'`` task that is not finished-and-decided:
active reviews (the reviewer is still working pending / claimed /
in_progress / verifying / blocked / paused) AND completed reviews the CEO
has neither superseded (``confirmed_by_human=True``) nor dismissed
(``dismissed=1`` in quick_context). Cancelled tasks are excluded.
Active reviews are surfaced on purpose: the reviewer posts its
change-request to the PR itself, so the panel must show that a review is
underway and link to that PR rather than going dark until the review
finishes. Each task carries its ``status`` so the panel can tell
"reviewing" apart from "awaiting your decision".
"""
result = await self.session.execute(
select(TaskTable).where(
TaskTable.source == "external_pr",
TaskTable.status != TaskStatus.CANCELLED,
or_(
TaskTable.status != TaskStatus.COMPLETED,
TaskTable.confirmed_by_human.is_(False),
),
)
)
return [
t
for t in result.scalars().all()
if "dismissed=1" not in (t.quick_context or "").split()
]
async def dismiss_external_pr_review(self, task_id: UUID) -> TaskTable | None:
"""CEO declines to act on a reviewed external PR — drop it from the queue.
@@ -76,6 +76,17 @@ async def test_list_awaiting_decision_excludes_dismissed() -> None:
assert out == [pending]
@pytest.mark.asyncio
async def test_list_external_pr_reviews_excludes_dismissed() -> None:
# The panel queue surfaces in-flight reviews too (the status filter lives in
# SQL); here we pin the post-query behavior: dismissed reviews drop out.
reviewing = MagicMock(quick_context="external_pr_head=abc")
dismissed = MagicMock(quick_context="external_pr_head=def dismissed=1")
svc = _service([reviewing, dismissed])
out = await svc.list_external_pr_reviews()
assert out == [reviewing]
@pytest.mark.asyncio
async def test_dismiss_marks_and_is_idempotent() -> None:
task = MagicMock(source="external_pr", quick_context="external_pr_head=abc")