mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
[w8b] Fix release-proposal flow: reject frees dedup, surface execute outcome (#525)
Reject cancelled the proposal's status but never moved it out of the held-proposal set, so the one-open-proposal dedup blocked the release manager from ever re-assessing — a rejected proposal deadlocked the cycle. reject() now sets CANCELLED (mirroring video_post_service), which list_open_release_proposals already excludes, so a fresh proposal can originate next cycle. A failed ~40min background execute (gate red, CI red, or an unexpected crash) left the proposal silently PENDING with no signal to the CEO. _run_approve_background now writes a release_execute_outcome marker (status + detail) on every terminal outcome, and an 'error' marker on an unhandled exception. GET /proposal surfaces execute_status / execute_detail / execute_in_flight (derived from the in-memory _INFLIGHT_APPROVES registry) so the panel can show a running badge, a failure block with the reason, and a Retry-approve label instead of a silent wait. Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
@@ -124,3 +124,53 @@ describe("ReleaseProposalCard — query-failure surfacing (F082)", () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ReleaseProposalCard — execute outcome surfacing (W8b)", () => {
|
||||
it("renders the in-flight badge and disables actions while execute runs", () => {
|
||||
// execute_in_flight is the UX for the Redis-mutex-protected background
|
||||
// execute; the approve/reject buttons disable so the CEO can't double-click.
|
||||
mockUseQuery.mockReturnValue({
|
||||
data: { ...buildProposal(), execute_in_flight: true },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
|
||||
render(withPageRefresh(<ReleaseProposalCard />));
|
||||
|
||||
expect(
|
||||
screen.getByText(/release execute running in the background/i),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: /Reject with changes/i }),
|
||||
).toBeDisabled();
|
||||
// Approve stays present but disabled while the execute runs.
|
||||
const approve = screen.getByRole("button", { name: /Approve & publish/i });
|
||||
expect(approve).toBeDisabled();
|
||||
});
|
||||
|
||||
it("renders the failure block and a Retry label from a persisted execute_status", () => {
|
||||
// A failed ~40min execute left the proposal open with a persisted
|
||||
// execute_status; the card surfaces the reason and flips Approve to Retry.
|
||||
mockUseQuery.mockReturnValue({
|
||||
data: {
|
||||
...buildProposal(),
|
||||
execute_status: "gate_failed",
|
||||
execute_detail: "make quality failed",
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
|
||||
render(withPageRefresh(<ReleaseProposalCard />));
|
||||
|
||||
expect(screen.getByText(/last execute failed/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/make quality failed/i)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: /Retry approve & publish/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { CheckCircle2, XCircle, Rocket, AlertTriangle } from "lucide-react";
|
||||
import { CheckCircle2, XCircle, Rocket, AlertTriangle, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { usePageRefresh } from "@/hooks";
|
||||
import { HelpTip } from "@/components/ui/help-tip";
|
||||
@@ -101,7 +101,7 @@ export function ReleaseProposalCard({ className }: { className?: string }) {
|
||||
mutationFn: (changes: string) => releaseApi.reject(changes),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["release", "proposal"] });
|
||||
toast.success("Proposal sent back with required changes");
|
||||
toast.success("Proposal rejected — a fresh assessment runs next cycle");
|
||||
closeDialog();
|
||||
},
|
||||
onError: (error) => {
|
||||
@@ -158,6 +158,12 @@ export function ReleaseProposalCard({ className }: { className?: string }) {
|
||||
|
||||
const { report } = proposal;
|
||||
const pending = approveMutation.isPending || rejectMutation.isPending;
|
||||
// The ~40min execute runs in the background; the Redis mutex already refuses
|
||||
// a double-click server-side — execute_in_flight is the UX (disable approve,
|
||||
// show a running badge). A persisted execute_status on a still-open proposal
|
||||
// is a failure (a publish would have completed + hidden the card).
|
||||
const executeInFlight = !!proposal.execute_in_flight;
|
||||
const executeFailed = !!proposal.execute_status && !executeInFlight;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -238,12 +244,40 @@ export function ReleaseProposalCard({ className }: { className?: string }) {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{executeInFlight && (
|
||||
<div className="flex items-center gap-2 rounded-md border border-blue-500/40 bg-blue-500/10 p-3">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-blue-500" />
|
||||
<span className="text-sm text-blue-600 dark:text-blue-400">
|
||||
Release execute running in the background (~40 min) — this card
|
||||
updates when it finishes.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{executeFailed && (
|
||||
<div className="rounded-md border border-red-500/40 bg-red-500/10 p-3">
|
||||
<p className="flex items-center gap-1.5 text-sm font-medium text-red-600">
|
||||
<XCircle className="h-4 w-4" />
|
||||
Last execute failed ({proposal.execute_status})
|
||||
</p>
|
||||
{proposal.execute_detail && (
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{proposal.execute_detail}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Fix the cause and approve again to retry.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col-reverse gap-2 pt-1 sm:flex-row sm:items-center sm:justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={() => setAction("reject")}
|
||||
disabled={executeInFlight}
|
||||
>
|
||||
<XCircle className="mr-1 h-4 w-4" />
|
||||
Reject with changes
|
||||
@@ -252,9 +286,10 @@ export function ReleaseProposalCard({ className }: { className?: string }) {
|
||||
size="sm"
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
onClick={() => setAction("approve")}
|
||||
disabled={executeInFlight}
|
||||
>
|
||||
<CheckCircle2 className="mr-1 h-4 w-4" />
|
||||
Approve & publish
|
||||
{executeFailed ? "Retry approve & publish" : "Approve & publish"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -271,7 +306,7 @@ export function ReleaseProposalCard({ className }: { className?: string }) {
|
||||
<DialogDescription>
|
||||
{action === "approve"
|
||||
? "This runs the fail-closed executor: write the bumps + CHANGELOG, run make quality, commit, wait for green CI, then publish. It aborts on a red gate or red CI."
|
||||
: "Record what must change. The proposal stays open for revision; nothing is published."}
|
||||
: "Record what must change. The proposal is cancelled and the release manager re-assesses next cycle; nothing is published."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
|
||||
@@ -28,6 +28,9 @@ export interface ReleaseProposal {
|
||||
title: string;
|
||||
status: string;
|
||||
required_changes?: string | null;
|
||||
execute_status?: string | null;
|
||||
execute_detail?: string | null;
|
||||
execute_in_flight?: boolean;
|
||||
report: ReleaseReport;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user