Merge remote-tracking branch 'origin/master' into feat/task-decomposition-enforce-floor

This commit is contained in:
Renn F
2026-06-14 08:12:13 +02:00
16 changed files with 1290 additions and 40 deletions
@@ -21,6 +21,7 @@ import {
Hash,
RefreshCw,
} from "lucide-react";
import { CopyButton } from "@/components/ui/copy-button";
import { formatDistanceToNow, format } from "date-fns";
import { toast } from "sonner";
import Link from "next/link";
@@ -238,7 +239,7 @@ function SessionDetailContent() {
{messages.map((message) => (
<div
key={message.id}
className="flex gap-3 p-3 rounded-lg border bg-card hover:bg-muted/30 transition-colors"
className="group relative flex gap-3 p-3 rounded-lg border bg-card hover:bg-muted/30 transition-colors"
>
<div className="h-9 w-10 rounded-lg bg-primary/10 flex items-center justify-center shrink-0 border">
<span className="text-[10px] font-bold tracking-tight">
@@ -259,6 +260,11 @@ function SessionDetailContent() {
<Markdown>{message.content}</Markdown>
</div>
</div>
{/* Copy button — visible on hover */}
<CopyButton
value={message.content}
className="absolute right-2 top-2 opacity-0 transition-opacity group-hover:opacity-100"
/>
</div>
))}
</div>
@@ -1,14 +1,16 @@
"use client";
import { use, useState } from "react";
import axios from "axios";
import { useTask, useTaskLifecycle } from "@/hooks/use-tasks";
import { useProject } from "@/hooks/use-projects";
import { useCreateBranch, useCreatePR } from "@/hooks/use-git";
import { useCreateBranch, useCreatePR, useMergePR } from "@/hooks/use-git";
import { Team, TaskStatus } from "@/types";
import { TaskHeader, TaskMetadata, TaskTabs } from "@/components/tasks/task-detail";
import { ApproveAndStartButton } from "@/components/tasks/approve-and-start-button";
import {
EscalateToCeoDialog,
ApproveAndMergeDialog,
CeoApproveDialog,
CeoRejectDialog,
CreateBranchDialog,
@@ -35,9 +37,11 @@ export default function TaskDetailPage({ params }: TaskDetailPageProps) {
const lifecycle = useTaskLifecycle();
const createBranch = useCreateBranch();
const createPR = useCreatePR();
const mergePR = useMergePR();
// Dialog states
const [escalateDialogOpen, setEscalateDialogOpen] = useState(false);
const [approveAndMergeDialogOpen, setApproveAndMergeDialogOpen] = useState(false);
const [approveDialogOpen, setApproveDialogOpen] = useState(false);
const [rejectDialogOpen, setRejectDialogOpen] = useState(false);
const [branchDialogOpen, setBranchDialogOpen] = useState(false);
@@ -109,6 +113,9 @@ export default function TaskDetailPage({ params }: TaskDetailPageProps) {
case "submit-pm-review":
setSubmitPmReviewDialogOpen(true);
return; // Don't refetch yet — dialog collects the required note
case "approve-and-merge":
setApproveAndMergeDialogOpen(true);
return; // Don't refetch yet — dialog handles confirmation
case "ceo-approve":
setApproveDialogOpen(true);
return; // Don't refetch yet — dialog collects the required note
@@ -136,6 +143,23 @@ export default function TaskDetailPage({ params }: TaskDetailPageProps) {
}
setPrDialogOpen(true);
return; // Don't refetch yet, dialog will handle it
case "merge-pr":
if (!project) {
toast.error("Project not found - cannot merge PR");
return;
}
if (!task.pr_number) {
toast.error("No PR number found on this task");
return;
}
await mergePR.mutateAsync({
project_slug: project.slug,
pr_number: task.pr_number,
task_id: task.id,
agent_id: "ceo", // CEO is merging the PR from the panel
});
toast.success(`PR #${task.pr_number} merged successfully`);
break;
default:
console.warn("Unknown action:", action);
}
@@ -173,6 +197,30 @@ export default function TaskDetailPage({ params }: TaskDetailPageProps) {
}
};
const handleApproveAndMerge = async () => {
if (!task) return;
try {
await lifecycle.approveAndMerge.mutateAsync(task.id);
toast.success("Task approved and PR merged");
setApproveAndMergeDialogOpen(false);
refetch();
} catch (err) {
if (axios.isAxiosError(err)) {
const detail = (err.response?.data as { detail?: string } | undefined)?.detail ?? "";
if (typeof detail === "string" && detail.startsWith("NO_PR")) {
toast.error("No PR found for this task. Create a pull request before merging.");
} else if (typeof detail === "string" && detail.startsWith("Merge failed")) {
toast.error("Merge failed: " + (detail.slice("Merge failed".length).replace(/^[: ]+/, "") || "the merge could not be completed"));
} else {
toast.error("Failed to approve and merge task");
}
} else {
toast.error("Failed to approve and merge task");
}
console.error(err);
}
};
const handleCeoApprove = async (notes: string) => {
if (!task) return;
try {
@@ -406,6 +454,13 @@ export default function TaskDetailPage({ params }: TaskDetailPageProps) {
isPending={lifecycle.escalateToCeo.isPending}
/>
<ApproveAndMergeDialog
open={approveAndMergeDialogOpen}
onOpenChange={setApproveAndMergeDialogOpen}
onConfirm={handleApproveAndMerge}
isPending={lifecycle.approveAndMerge.isPending}
/>
<CeoApproveDialog
open={approveDialogOpen}
onOpenChange={setApproveDialogOpen}
@@ -41,8 +41,13 @@ export function ResolveWaitDialog({ agentId }: ResolveWaitDialogProps) {
}
};
const handleOpenChange = (newOpen: boolean) => {
if (!newOpen) setResolution("");
setOpen(newOpen);
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
<Button>
<Send className="h-4 w-4 mr-2" />
@@ -4,6 +4,7 @@ import { Message } from "@/types";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Markdown } from "@/components/ui/markdown";
import { CopyButton } from "@/components/ui/copy-button";
import { MessageTypeBadge } from "./message-type-badge";
import { Clock, Link2 } from "lucide-react";
import Link from "next/link";
@@ -23,7 +24,7 @@ function formatTime(timestamp: string): string {
export function MessageItem({ message }: MessageItemProps) {
return (
<div className="flex gap-3 py-3 hover:bg-muted/30 px-2 rounded-lg">
<div className="group relative flex gap-3 py-3 hover:bg-muted/30 px-2 rounded-lg">
<Avatar className="h-8 w-8 shrink-0">
<AvatarFallback className="bg-primary/10 text-primary text-xs">
{getAgentInitials(message.agent_id)}
@@ -61,6 +62,11 @@ export function MessageItem({ message }: MessageItemProps) {
</Link>
)}
</div>
{/* Copy button — visible on hover */}
<CopyButton
value={message.content}
className="absolute right-2 top-3 opacity-0 transition-opacity group-hover:opacity-100"
/>
</div>
);
}
+82 -2
View File
@@ -19,6 +19,7 @@ import {
GitCommit,
Upload,
GitPullRequest,
GitMerge,
RefreshCw,
ArrowUp,
} from "lucide-react";
@@ -31,9 +32,11 @@ interface GitActionsPanelProps {
onCommit: (message: string) => void;
onPush: (force?: boolean) => void;
onCreatePR: (title: string, body: string) => void;
onMergePR: (prNumber: number) => void;
isCommitting: boolean;
isPushing: boolean;
isCreatingPR: boolean;
isMerging: boolean;
}
export function GitActionsPanel({
@@ -44,22 +47,53 @@ export function GitActionsPanel({
onCommit,
onPush,
onCreatePR,
onMergePR,
isCommitting,
isPushing,
isCreatingPR,
isMerging,
}: GitActionsPanelProps) {
void _agentId; // Reserved for future use
const [showCommitDialog, setShowCommitDialog] = useState(false);
const [showPRDialog, setShowPRDialog] = useState(false);
const [showMergeDialog, setShowMergeDialog] = useState(false);
const [commitMessage, setCommitMessage] = useState("");
const [prTitle, setPrTitle] = useState("");
const [prBody, setPrBody] = useState("");
const [mergePrNumber, setMergePrNumber] = useState("");
const hasStagedChanges = (status?.staged_files.length ?? 0) > 0;
const hasUnpushedCommits = (status?.ahead ?? 0) > 0;
const canPush = hasUnpushedCommits;
const canCreatePR = hasUnpushedCommits || status?.current_branch !== "main";
const handleCommitDialogOpenChange = (newOpen: boolean) => {
if (!newOpen) setCommitMessage("");
setShowCommitDialog(newOpen);
};
const handlePRDialogOpenChange = (newOpen: boolean) => {
if (!newOpen) {
setPrTitle("");
setPrBody("");
}
setShowPRDialog(newOpen);
};
const handleMergeDialogOpenChange = (newOpen: boolean) => {
if (!newOpen) setMergePrNumber("");
setShowMergeDialog(newOpen);
};
const handleMergePR = () => {
const prNum = parseInt(mergePrNumber, 10);
if (!isNaN(prNum) && prNum > 0) {
onMergePR(prNum);
setShowMergeDialog(false);
setMergePrNumber("");
}
};
const handleCommit = () => {
if (commitMessage.trim()) {
onCommit(commitMessage.trim());
@@ -84,7 +118,7 @@ export function GitActionsPanel({
</CardHeader>
<CardContent className="space-y-3">
{/* Commit Action */}
<Dialog open={showCommitDialog} onOpenChange={setShowCommitDialog}>
<Dialog open={showCommitDialog} onOpenChange={handleCommitDialogOpenChange}>
<DialogTrigger asChild>
<Button
className="w-full justify-start"
@@ -168,7 +202,7 @@ export function GitActionsPanel({
</Button>
{/* Create PR Action */}
<Dialog open={showPRDialog} onOpenChange={setShowPRDialog}>
<Dialog open={showPRDialog} onOpenChange={handlePRDialogOpenChange}>
<DialogTrigger asChild>
<Button
className="w-full justify-start"
@@ -222,6 +256,52 @@ export function GitActionsPanel({
</DialogContent>
</Dialog>
{/* Merge PR Action */}
<Dialog open={showMergeDialog} onOpenChange={handleMergeDialogOpenChange}>
<DialogTrigger asChild>
<Button
className="w-full justify-start"
variant="outline"
>
{isMerging ? (
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
) : (
<GitMerge className="h-4 w-4 mr-2" />
)}
Merge PR
</Button>
</DialogTrigger>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Merge Pull Request</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<label className="text-sm font-medium">PR Number</label>
<Input
type="number"
placeholder="e.g. 42"
value={mergePrNumber}
onChange={(e) => setMergePrNumber(e.target.value)}
min={1}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowMergeDialog(false)}>
Cancel
</Button>
<Button
onClick={handleMergePR}
disabled={!mergePrNumber.trim() || isNaN(parseInt(mergePrNumber, 10)) || isMerging}
>
{isMerging && <RefreshCw className="h-4 w-4 mr-2 animate-spin" />}
Merge
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Status Summary */}
{status && (
<div className="pt-2 border-t text-xs text-muted-foreground space-y-1">
+17 -1
View File
@@ -49,7 +49,7 @@ function GitBrowserContent() {
const { data: unstagedDiff, isLoading: loadingUnstagedDiff } = useGitDiff(projectSlug, false, undefined, !!projectSlug);
// Git operations
const { commit, push, createBranch, checkout, createPR } = useGitOperations();
const { commit, push, createBranch, checkout, createPR, mergePR } = useGitOperations();
// Update URL params
const updateParams = useCallback(
@@ -160,6 +160,20 @@ function GitBrowserContent() {
}
};
const handleMergePR = async (prNumber: number) => {
try {
const result = await mergePR.mutateAsync({
project_slug: projectSlug,
pr_number: prNumber,
task_id: taskId || "manual",
agent_id: "ceo",
});
toast.success(`Merged PR #${result.pr_number}${result.target_branch}`);
} catch {
toast.error("Failed to merge PR");
}
};
// Check offline
const isOffline = projectsError && (
projectsError.message?.includes("Network Error") ||
@@ -243,9 +257,11 @@ function GitBrowserContent() {
onCommit={handleCommit}
onPush={handlePush}
onCreatePR={handleCreatePR}
onMergePR={handleMergePR}
isCommitting={commit.isPending}
isPushing={push.isPending}
isCreatingPR={createPR.isPending}
isMerging={mergePR.isPending}
/>
</div>
@@ -91,8 +91,12 @@ export function ChatMessages({
if (msg.role === "user") {
return (
<div key={msg.id} className="flex justify-end">
<div className="max-w-[70%] rounded-2xl rounded-tr-sm bg-primary px-4 py-3 text-sm text-primary-foreground">
<div className="group relative max-w-[70%] rounded-2xl rounded-tr-sm bg-primary px-4 py-3 text-sm text-primary-foreground">
<MarkdownBody content={msg.content} />
<CopyButton
value={msg.content}
className="absolute right-1.5 top-1.5 bg-primary-foreground/10 text-primary-foreground opacity-0 transition-opacity group-hover:opacity-100"
/>
</div>
</div>
);
@@ -101,9 +105,13 @@ export function ChatMessages({
if (msg.role === "error") {
return (
<div key={msg.id} className="flex justify-start">
<div className="flex max-w-[70%] items-start gap-2 rounded-2xl rounded-tl-sm border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
<div className="group relative flex max-w-[70%] items-start gap-2 rounded-2xl rounded-tl-sm border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<span>{msg.content}</span>
<CopyButton
value={msg.content}
className="absolute right-1.5 top-1.5 bg-background/80 opacity-0 transition-opacity group-hover:opacity-100"
/>
</div>
</div>
);
@@ -115,11 +123,15 @@ export function ChatMessages({
<div className="flex justify-start">
<div
className={cn(
"max-w-[70%] rounded-2xl rounded-tl-sm bg-muted px-4 py-3 text-sm text-foreground",
"group relative max-w-[70%] rounded-2xl rounded-tl-sm bg-muted px-4 py-3 text-sm text-foreground",
msg.draft && "max-w-[85%]"
)}
>
<MarkdownBody content={msg.content} />
<CopyButton
value={msg.content}
className="absolute right-1.5 top-1.5 bg-background/80 opacity-0 transition-opacity group-hover:opacity-100"
/>
</div>
</div>
@@ -44,8 +44,13 @@ export function EscalateToCeoDialog({
}
};
const handleOpenChange = (newOpen: boolean) => {
if (!newOpen) setReason("");
onOpenChange(newOpen);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Escalate to CEO</DialogTitle>
@@ -101,8 +106,13 @@ export function CeoRejectDialog({
}
};
const handleOpenChange = (newOpen: boolean) => {
if (!newOpen) setNotes("");
onOpenChange(newOpen);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Request Changes</DialogTitle>
@@ -139,6 +149,44 @@ export function CeoRejectDialog({
);
}
// Approve & Merge Dialog — simple confirmation for POST /tasks/{id}/approve-and-merge.
// The backend endpoint accepts NO notes parameter, so no text input is needed here.
interface ApproveAndMergeDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: () => void;
isPending?: boolean;
}
export function ApproveAndMergeDialog({
open,
onOpenChange,
onConfirm,
isPending,
}: ApproveAndMergeDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Approve &amp; Merge</DialogTitle>
<DialogDescription>
This will approve the completed work and merge the pull request into the
target branch. This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isPending}>
Cancel
</Button>
<Button onClick={onConfirm} disabled={isPending}>
{isPending ? "Merging..." : "Approve & Merge"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// CEO Approve Dialog — the sign-off note is the audit record for merging to
// production, so it is REQUIRED and must be substantive (>= 20 chars), matching
// the server's CEO_NOTES_REQUIRED gate.
@@ -167,8 +215,13 @@ export function CeoApproveDialog({
}
};
const handleOpenChange = (newOpen: boolean) => {
if (!newOpen) setNotes("");
onOpenChange(newOpen);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Approve &amp; Merge</DialogTitle>
@@ -244,8 +297,13 @@ export function RequiredNotesDialog({
}
};
const handleOpenChange = (newOpen: boolean) => {
if (!newOpen) setText("");
onOpenChange(newOpen);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
@@ -304,8 +362,14 @@ export function CreateBranchDialog({
onConfirm(branchType);
};
// Reset branchType to 'feature' when dialog is dismissed without confirming
const handleOpenChange = (newOpen: boolean) => {
if (!newOpen) setBranchType("feature");
onOpenChange(newOpen);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Branch</DialogTitle>
@@ -373,10 +437,14 @@ export function CreatePRDialog({
}
};
// Reset title when dialog opens with new default
// On open: seed title from defaultTitle. On close without confirming: reset both fields to empty.
const handleOpenChange = (newOpen: boolean) => {
if (newOpen && defaultTitle) {
setTitle(defaultTitle);
if (newOpen) {
if (defaultTitle) setTitle(defaultTitle);
} else {
// Reset both fields when dismissed without confirming
setTitle("");
setBody("");
}
onOpenChange(newOpen);
};
@@ -3,7 +3,7 @@
import { useState, useRef, useEffect } from "react";
import { useRouter } from "next/navigation";
import { Task, TaskStatus, Team } from "@/types";
import { useDeleteTask, useUpdateTask } from "@/hooks/use-tasks";
import { useDeleteTask, useUpdateTask, useTaskValidTransitions } from "@/hooks/use-tasks";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
@@ -40,6 +40,7 @@ import {
AlertTriangle,
Trash2,
GitBranch,
GitMerge,
GitPullRequest,
FileCheck,
Send,
@@ -94,6 +95,10 @@ export function TaskHeader({ task, onAction }: TaskHeaderProps) {
const router = useRouter();
const deleteTask = useDeleteTask();
const updateTask = useUpdateTask();
// Fetch valid next statuses from GET /tasks/{id}/valid-transitions.
// Falls back to [] while loading or on error — the Select is disabled during loading.
const { data: validTransitionsData, isLoading: isTransitionsLoading } = useTaskValidTransitions(task.id);
const nextStatuses: TaskStatus[] = validTransitionsData ?? [];
const [deleteOpen, setDeleteOpen] = useState(false);
// Inline editing states
@@ -256,7 +261,7 @@ export function TaskHeader({ task, onAction }: TaskHeaderProps) {
actions.push({ label: "Request Changes", action: "request-changes", icon: <ThumbsDown className="h-4 w-4 mr-2" /> });
break;
case TaskStatus.AWAITING_CEO_APPROVAL:
actions.push({ label: "Approve & Merge", action: "ceo-approve", icon: <ThumbsUp className="h-4 w-4 mr-2" /> });
actions.push({ label: "Approve & Merge", action: "approve-and-merge", icon: <ThumbsUp className="h-4 w-4 mr-2" /> });
actions.push({ label: "Request Changes", action: "ceo-reject", icon: <ThumbsDown className="h-4 w-4 mr-2" /> });
break;
case TaskStatus.CANCELLED:
@@ -269,6 +274,11 @@ export function TaskHeader({ task, onAction }: TaskHeaderProps) {
actions.push({ label: "Cancel Task", action: "cancel", icon: <XCircle className="h-4 w-4 mr-2" /> });
}
// Merge PR is available whenever task.pr_number is set and the task is not in a terminal state
if (task.pr_number && task.status !== TaskStatus.COMPLETED && task.status !== TaskStatus.CANCELLED) {
actions.push({ label: "Merge PR", action: "merge-pr", icon: <GitMerge className="h-4 w-4 mr-2" /> });
}
return actions;
};
@@ -307,13 +317,24 @@ export function TaskHeader({ task, onAction }: TaskHeaderProps) {
</h1>
)}
{/* Status Dropdown */}
{/* Status Dropdown — only current status + valid next statuses from backend */}
<Select value={task.status} onValueChange={(v) => handleStatusChange(v as TaskStatus)}>
<SelectTrigger className={`w-auto h-7 text-xs font-medium border-0 ${statusColors[task.status]}`}>
<SelectTrigger
className={`w-auto h-7 text-xs font-medium border-0 ${statusColors[task.status]}`}
disabled={isTransitionsLoading}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.values(TaskStatus).map((status) => (
{/* Always render the current status first so the trigger value is always present */}
<SelectItem key={task.status} value={task.status}>
<span className={`px-2 py-0.5 rounded ${statusColors[task.status]}`}>
{statusLabels[task.status]}
</span>
</SelectItem>
{/* nextStatuses sourced exclusively from useTaskValidTransitions
(GET /tasks/{id}/valid-transitions) no local fallback array */}
{nextStatuses.map((status) => (
<SelectItem key={status} value={status}>
<span className={`px-2 py-0.5 rounded ${statusColors[status]}`}>
{statusLabels[status]}
+27 -7
View File
@@ -2,6 +2,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { tasksApi, type TaskFilters } from "@/lib/api/tasks";
import {
Team,
TaskStatus,
type Task,
type TaskCreate,
type ProgressRequest,
@@ -64,15 +65,26 @@ export function useBoardReview(taskId: string, enabled = true) {
}
export function useSubtasks(parentTaskId: string) {
const { data: allTasks = [] } = useTasks();
return useQuery({
queryKey: taskKeys.subtasks(parentTaskId),
queryFn: async (): Promise<Task[]> => {
// Filter tasks where parent_task_id matches
return allTasks.filter((task) => task.parent_task_id === parentTaskId);
},
enabled: !!parentTaskId && allTasks.length > 0,
// Calls tasksApi.getSubtasks which hits GET /tasks/{id}/subtasks
queryFn: () => tasksApi.getSubtasks(parentTaskId),
enabled: !!parentTaskId,
});
}
/**
* Fetches valid next statuses for a task from GET /tasks/{id}/valid-transitions.
* Returns undefined while loading; on error (including 404) gracefully returns
* undefined so callers can fall back to a hardcoded map.
*/
export function useTaskValidTransitions(taskId: string) {
return useQuery<TaskStatus[]>({
queryKey: ["tasks", "valid-transitions", taskId] as const,
queryFn: () => tasksApi.getValidTransitions(taskId),
enabled: !!taskId,
staleTime: 30000, // 30 seconds
retry: false, // don't retry on 404 or other errors — caller falls back to hardcoded map
});
}
@@ -279,6 +291,13 @@ export function useTaskLifecycle() {
},
});
// CEO gate #2: approve completed work and merge the PR.
// Calls POST /tasks/{id}/approve-and-merge with no request body.
const approveAndMerge = useMutation({
mutationFn: (taskId: string) => tasksApi.approveAndMerge(taskId),
onSuccess: invalidateTask,
});
return {
// Lifecycle
claim,
@@ -308,6 +327,7 @@ export function useTaskLifecycle() {
ceoApprove,
ceoReject,
escalateToCeo,
approveAndMerge,
};
}
+6 -3
View File
@@ -84,13 +84,16 @@ api.interceptors.response.use(
};
useRateLimitStore.getState().hitRateLimit(hitEvent);
// Track retry count; retry the request until exhausted, then toast
// Track retry count; retry the request (after backoff delay) until exhausted, then toast
const retryCount = (error.config?._retryCount ?? 0) + 1;
if (error.config) {
error.config._retryCount = retryCount;
if (retryCount < RATE_LIMIT_MAX_RETRIES) {
// Retry the request — interceptor re-runs on each subsequent 429
return api(error.config);
// Wait retryAfterSeconds before retrying — interceptor re-runs on each subsequent 429
const delayMs = safeRetryAfter * 1000;
return new Promise<void>((resolve) => setTimeout(resolve, delayMs)).then(
() => api(error.config!)
);
}
}
// Retries exhausted — notify the user via Sonner toast
+20
View File
@@ -399,10 +399,22 @@ export const tasksApi = {
if (isMockMode()) {
return mockTasks.filter((t) => t.parent_task_id === taskId);
}
// Hits GET /tasks/{id}/subtasks
const { data } = await api.get<Task[]>("/tasks/" + taskId + "/subtasks");
return data;
},
// Returns the valid next statuses for a task from GET /tasks/{id}/valid-transitions
getValidTransitions: async (taskId: string): Promise<TaskStatus[]> => {
if (isMockMode()) {
return [];
}
const { data } = await api.get<{ valid_statuses: TaskStatus[] }>(
"/tasks/" + taskId + "/valid-transitions"
);
return data.valid_statuses;
},
// =========================================================================
// STATS
// =========================================================================
@@ -593,6 +605,14 @@ export const tasksApi = {
return data;
},
// CEO gate #2: approve the completed work and merge the PR.
// No request body — the backend endpoint accepts no notes parameter.
// May throw HTTP 400 with detail starting 'NO_PR' or 'Merge failed'.
approveAndMerge: async (taskId: string): Promise<Task> => {
const { data } = await api.post<Task>("/tasks/" + taskId + "/approve-and-merge");
return data;
},
// CEO rejects a task (sends back for revision)
ceoReject: async (taskId: string, notes: string): Promise<Task> => {
if (isMockMode()) {
+382 -2
View File
@@ -8,6 +8,7 @@ from typing import Annotated, Any, cast
from uuid import UUID
from fastapi import APIRouter, Body, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from roboco.api.deps import (
CurrentAgentContext,
@@ -38,13 +39,16 @@ from roboco.api.schemas.tasks import (
TaskSessionLinkResponse,
TaskUpdate,
TeamTasksQuery,
ValidTransitionsResponse,
enrich_task_with_context,
task_list_to_response,
task_to_response,
transform_update_data,
)
from roboco.exceptions import TaskLifecycleError
from roboco.enforcement import get_valid_transitions
from roboco.exceptions import GitError, TaskLifecycleError
from roboco.foundation.policy import task_completeness as tc
from roboco.logging import get_logger
from roboco.models.base import AgentRole, TaskStatus, Team
from roboco.models.task import TaskCreate
from roboco.services.audit import get_audit_service
@@ -70,12 +74,20 @@ from roboco.services.task import (
from roboco.utils.converters import require_uuid
router = APIRouter()
_logger = get_logger(__name__)
# Minimum character count for notes fields that must be substantive
# (QA pass notes, doc-complete notes, escalation notes). Below this the
# note is useless for the next reader, so the transition is refused.
_MIN_NOTES_CHARS = 20
# Nullable task fields that may be explicitly cleared via PATCH.
# After TaskService.update() gains its not-None guard, null-clears for these
# fields are handled at the route layer by direct setattr on the ORM object.
_NULLABLE_TASK_FIELDS: frozenset[str] = frozenset(
{"assigned_to", "parent_task_id", "project_id"}
)
def _translate_error(e: ServiceError) -> HTTPException:
"""Service errors → HTTP status. Kept at route layer; everything else moves."""
@@ -90,6 +102,190 @@ def _translate_error(e: ServiceError) -> HTTPException:
)
# ---------------------------------------------------------------------------
# Route-layer helpers — extracted to keep the three complex routes ≤ rank B.
# ---------------------------------------------------------------------------
def _task_is_awaiting_pm_review(task: Any) -> bool:
"""Return True if the task is in the awaiting_pm_review state."""
from roboco.models.base import TaskStatus as _TS
return (
task.status == _TS.AWAITING_PM_REVIEW
or getattr(task.status, "value", None) == "awaiting_pm_review"
)
def _pop_null_clears(updates: dict[str, Any]) -> dict[str, None]:
"""Remove and return explicitly-set-to-None nullable fields from *updates*.
TaskService.update() skips None values (not-None guard), so null-clearing
a field must be done at the route layer. This helper splits the intent:
it pops the null-clears from *updates* (modifying it in-place) and returns
them so the caller can apply them directly on the ORM object.
"""
clears: dict[str, None] = {}
for field in _NULLABLE_TASK_FIELDS:
if field in updates and updates[field] is None:
clears[field] = updates.pop(field)
return clears
def _apply_null_clears(task: Any, null_clears: dict[str, None]) -> None:
"""Set *null_clears* fields to None on the ORM task object."""
for field in null_clears:
setattr(task, field, None)
async def _resolve_assigned_to_slug(
data: "TaskUpdate", db: AsyncSession
) -> "TaskUpdate":
"""Resolve an assigned_to slug to a UUID string; returns (possibly modified) data.
If assigned_to was not set or is already a valid UUID or null, returns
*data* unchanged. If it is an agent slug, looks up the agent and replaces
the slug with the UUID string so downstream transform helpers parse it
correctly. Raises HTTPException 422 when the slug cannot be found.
"""
if "assigned_to" not in data.model_fields_set or data.assigned_to is None:
return data
try:
UUID(data.assigned_to)
return data # already a valid UUID — no resolution needed
except ValueError:
pass
from roboco.services.repositories.query_helpers import get_agent_by_slug
agent_row = await get_agent_by_slug(db, data.assigned_to)
if agent_row is None:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail={
"error": {
"code": "ASSIGNEE_NOT_FOUND",
"message": f"No agent with slug or UUID '{data.assigned_to}'",
"hint": "Use an agent slug (e.g. 'be-dev-1') or UUID",
}
},
) from None
return data.model_copy(update={"assigned_to": str(agent_row.id)})
async def _project_for_complete(task: Any, db: AsyncSession) -> Any:
"""Resolve the project for complete_task's pre-merge step.
Returns the project or None if unresolvable (no exception raised the
caller simply skips the merge when no project can be found).
"""
from roboco.services.project import get_project_service
project_service = get_project_service(db)
if task.project_id is not None:
return await project_service.get(UUID(str(task.project_id)))
if task.product_id is not None:
from roboco.services.product import get_product_service
product_service = get_product_service(db)
pids = await product_service.distinct_project_ids(UUID(str(task.product_id)))
if pids:
return await project_service.get(pids[0])
return None
async def _merge_pr_if_awaiting_pm_review(
task_id: UUID,
pre_task: Any,
agent: Any,
db: AsyncSession,
) -> None:
"""Merge the task's PR when it is in awaiting_pm_review.
Does nothing when pre_task is None, has no PR, or is not in the right
state. Raises HTTPException 400 when the merge itself fails.
After this returns successfully, *_auto_complete_on_merge* inside the
git service will have already transitioned the task to *completed*.
"""
if pre_task is None or pre_task.pr_number is None:
return
if not _task_is_awaiting_pm_review(pre_task):
return
project = await _project_for_complete(pre_task, db)
if project is None:
return
from roboco.api.schemas.git import GitMergePRRequest
from roboco.services.git import get_git_service
git_service = get_git_service(db)
try:
await git_service.merge_pr_for_task(
agent.agent_id,
agent.role,
GitMergePRRequest(
project_slug=project.slug,
pr_number=pre_task.pr_number,
task_id=task_id,
merge_method="squash",
agent_id=str(agent.agent_id),
),
)
except (ServiceError, GitError) as e:
msg = getattr(e, "message", str(e))
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"PR merge failed before completion: {msg}",
) from e
async def _resolve_project_for_merge(task: Any, db: AsyncSession) -> Any:
"""Resolve and return the Project required for a merge operation.
Handles both direct project_id and product_idproject resolution.
Raises HTTPException 400 if no project can be resolved or found.
"""
from roboco.services.project import get_project_service
project_service = get_project_service(db)
if task.project_id is not None:
resolved_id = UUID(str(task.project_id))
elif task.product_id is not None:
from roboco.services.product import get_product_service
product_service = get_product_service(db)
project_ids = await product_service.distinct_project_ids(
UUID(str(task.product_id))
)
if not project_ids:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
f"NO_PROJECT: Product {task.product_id} has no cell->project "
"mapping; cannot resolve workspace for merge."
),
)
resolved_id = project_ids[0]
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
"NO_PROJECT: Task has neither project_id nor product_id; "
"cannot resolve workspace for merge. Set project_id on the task first."
),
)
project = await project_service.get(resolved_id)
if not project:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
f"NO_PROJECT: Project {resolved_id} not found; "
"cannot resolve workspace for merge."
),
)
return project
# =============================================================================
# CRUD ENDPOINTS
# =============================================================================
@@ -446,6 +642,45 @@ async def get_awaiting_ceo_approval_tasks(
return task_list_to_response(tasks)
@router.get("/lifecycle-transitions", response_model=dict[str, list[str]])
async def get_lifecycle_transitions() -> dict[str, list[str]]:
"""Return the task lifecycle state graph as a JSON-serialisable dict.
Each key is a status name (string); each value is a list of valid next
status names (strings). The data is drawn directly from the canonical
``STATUS_GRAPH`` constant so it is always in sync with the enforcement
layer.
"""
from roboco.foundation.policy.lifecycle import STATUS_GRAPH
return {
src.value: sorted(tgt.value for tgt in targets)
for src, targets in STATUS_GRAPH.items()
}
@router.get("/{task_id}/valid-transitions", response_model=ValidTransitionsResponse)
async def get_valid_transitions_for_task(
task_id: UUID,
db: DbSession,
) -> ValidTransitionsResponse:
"""Return valid next statuses for a task given its current state.
Uses the canonical lifecycle enforcement layer so the response is always
in sync with what the backend will actually allow.
"""
service = get_task_service(db)
task = await service.get(task_id)
if not task:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Task not found"
)
valid_statuses = get_valid_transitions(task.status)
return ValidTransitionsResponse(
valid_statuses=[TaskStatus(s) for s in valid_statuses]
)
@router.get("/{task_id}", response_model=TaskResponse)
async def get_task(
task_id: UUID,
@@ -526,7 +761,10 @@ async def update_task(
detail="Not authorized to update this task",
)
# Transform input data for database storage
# Resolve assigned_to slug → UUID (null is left for the null-clear path).
data = await _resolve_assigned_to_slug(data, db)
# Transform input data for database storage.
updates = transform_update_data(data)
# `status` is not a free-form field — it is an audited admin override so a
@@ -535,12 +773,18 @@ async def update_task(
# through the audited path, gated on elevated permissions.
new_status = updates.pop("status", None)
# Pop explicitly-set-to-None nullable fields. TaskService.update() skips
# None values (not-None guard), so null-clear intent is re-applied directly
# on the ORM object after the update returns.
null_clears = _pop_null_clears(updates)
task = await service.update(task_id, **updates)
if not task:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Task update failed unexpectedly",
)
_apply_null_clears(task, null_clears)
if new_status is not None and new_status != task.status:
if not has_higher_perms:
raise HTTPException(
@@ -1249,6 +1493,20 @@ async def complete_task(
),
)
service = get_task_service(db)
# For tasks in awaiting_pm_review that still have an open PR, merge the PR
# first so the branch lands before the task is marked completed.
# _auto_complete_on_merge inside the git service will transition the task
# to completed automatically; re-fetch and detect that to avoid a
# double-completion error.
pre_task = await service.get(task_id)
await _merge_pr_if_awaiting_pm_review(task_id, pre_task, agent, db)
# Re-fetch: if the merge auto-completed the task, return without a second call.
merged_task = await service.get(task_id)
if merged_task and merged_task.status == TaskStatus.COMPLETED:
return task_to_response(merged_task)
try:
task = await service.complete_task_for_agent(
task_id,
@@ -1380,6 +1638,128 @@ async def ceo_approve_task(
return task_to_response(task)
@router.get("/{task_id}/ceo-approve", response_model=TaskResponse)
async def ceo_approve_eligibility_check(
task_id: UUID,
db: DbSession,
agent: CurrentAgentContext,
) -> TaskResponse:
"""Pre-flight check: can this task be CEO-approved?
Returns the task if it is eligible (has a PR attached).
Returns HTTP 400 with 'NO_PR' if the task has no pull request.
Useful for panel gates and automated pre-checks before POSTing to
ceo-approve or approve-and-merge.
"""
if agent.role != AgentRole.CEO:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only CEO can check CEO-approval eligibility",
)
service = get_task_service(db)
task = await service.get(task_id)
if not task:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Task not found"
)
if task.pr_number is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
"NO_PR: Task has no pull request attached. A PR must be "
"opened and approved by QA before CEO approval. Use the "
"developer's open_pr flow to create the PR."
),
)
return task_to_response(task)
@router.post("/{task_id}/approve-and-merge", response_model=TaskResponse)
async def approve_and_merge_task(
task_id: UUID,
db: DbSession,
agent: CurrentAgentContext,
) -> TaskResponse:
"""CEO merge + complete in one step.
Merges the task's PR, updates the work session, and marks the task
completed. Only CEO can perform this action. The PR must already exist
on the task (pr_number set). Merge failures are returned as structured
HTTP errors rather than unhandled exceptions.
"""
if agent.role != AgentRole.CEO:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only CEO can approve-and-merge tasks",
)
service = get_task_service(db)
task = await service.get(task_id)
if not task:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Task not found"
)
if task.pr_number is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
"NO_PR: Cannot approve-and-merge — task has no PR. "
"The developer must open a PR (open_pr gateway verb or "
"POST /api/git/create-pr) before CEO can merge."
),
)
# Resolve the project from the task's project_id / product_id.
project = await _resolve_project_for_merge(task, db)
from roboco.api.schemas.git import GitMergePRRequest
from roboco.services.git import get_git_service
git_service = get_git_service(db)
try:
await git_service.merge_pr_for_task(
agent.agent_id,
agent.role,
GitMergePRRequest(
project_slug=project.slug,
pr_number=task.pr_number,
task_id=task_id,
merge_method="squash",
agent_id=str(agent.agent_id),
),
)
except (ServiceError, GitError) as e:
msg = getattr(e, "message", str(e))
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Merge failed: {msg}",
) from e
except Exception as e:
_logger.exception(
"Unexpected error in approve-and-merge",
task_id=str(task_id),
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Merge failed due to an unexpected error",
) from e
# merge_pr_for_task commits the session internally; re-fetch the
# updated task to return the merged state.
updated_task = await service.get(task_id)
if not updated_task:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Task disappeared after merge",
)
return task_to_response(updated_task)
@router.post("/{task_id}/approve-and-start", response_model=TaskResponse)
async def approve_and_start_task(
task_id: UUID,
+12 -1
View File
@@ -207,6 +207,11 @@ class TaskUpdate(BaseModel):
target_date: datetime | None = None
estimated_complexity: Complexity | None = None
# Classification
nature: TaskNature | None = None
task_type: TaskType | None = None
project_id: str | None = None # UUID string
# Ownership & assignment
team: Team | None = None
assigned_to: str | None = None # UUID string or null to unassign
@@ -514,6 +519,12 @@ class TaskCountResponse(BaseModel):
counts: dict[str, int]
class ValidTransitionsResponse(BaseModel):
"""Valid next statuses for a task given its current state."""
valid_statuses: list[TaskStatus]
class ListTasksQuery(BaseModel):
"""Query params for listing tasks."""
@@ -775,7 +786,7 @@ def _parse_uuid_list(id_strings: list[str] | None) -> list[UUID]:
return [UUID(id_str) for id_str in id_strings if id_str]
_SINGLE_UUID_FIELDS = ("assigned_to", "parent_task_id")
_SINGLE_UUID_FIELDS = ("assigned_to", "parent_task_id", "project_id")
_UUID_LIST_FIELDS = ("dependency_ids", "blocker_ids")
+528 -4
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
from http import HTTPStatus
from pathlib import Path
from types import SimpleNamespace
from typing import TYPE_CHECKING
from unittest.mock import AsyncMock, patch
@@ -22,7 +23,9 @@ from roboco.api.routes.tasks import (
router as tasks_router,
)
from roboco.db.tables import AgentTable, ProjectTable, TaskTable
from roboco.exceptions import TaskLifecycleError
from roboco.exceptions import GitError, TaskLifecycleError
from roboco.foundation.policy.lifecycle import STATUS_GRAPH
from roboco.foundation.policy.lifecycle import Status as LifecycleStatus
from roboco.models import AgentRole, AgentStatus, Team
from roboco.models.base import (
TaskNature,
@@ -36,8 +39,11 @@ from roboco.services.base import (
UnauthorizedError,
ValidationError,
)
from roboco.services.base import ServiceError as SvcError
from roboco.services.git import GitService
from roboco.services.notification_delivery import EscalationError
from roboco.services.permissions import PermissionService
from roboco.services.task import TaskService
if TYPE_CHECKING:
from collections.abc import AsyncIterator
@@ -111,9 +117,9 @@ def _seed_task(
acceptance_criteria=["ac"],
status=status,
priority=kw.pop("priority", 2),
task_type=TaskType.CODE,
nature=TaskNature.TECHNICAL,
project_id=setup["project"].id,
task_type=kw.pop("task_type", TaskType.CODE),
nature=kw.pop("nature", TaskNature.TECHNICAL),
project_id=kw.pop("project_id", setup["project"].id),
created_by=kw.pop("created_by", setup["agent"].id),
team=kw.pop("team", Team.BACKEND),
**kw,
@@ -350,6 +356,35 @@ async def test_get_task_stats_by_team(task_client: dict) -> None:
assert response.status_code == HTTPStatus.OK
@pytest.mark.asyncio
async def test_lifecycle_transitions_parity(task_client: dict) -> None:
"""GET /lifecycle-transitions returns the exact STATUS_GRAPH as strings.
Parity check: response keys and values must match
roboco.foundation.policy.lifecycle.STATUS_GRAPH.
"""
client = task_client["client"]
response = await client.get("/api/tasks/lifecycle-transitions", headers=_HDR)
assert response.status_code == HTTPStatus.OK
body = response.json()
# Keys must be exactly the set of status string values
expected_keys = {s.value for s in STATUS_GRAPH}
assert set(body.keys()) == expected_keys, (
f"Key mismatch: extra={set(body.keys()) - expected_keys}, "
f"missing={expected_keys - set(body.keys())}"
)
# Values must match STATUS_GRAPH (as sorted lists of strings)
for status_str, next_statuses in body.items():
src = LifecycleStatus(status_str)
expected_targets = sorted(t.value for t in STATUS_GRAPH[src])
assert sorted(next_statuses) == expected_targets, (
f"Targets for {status_str!r} mismatch: "
f"got {sorted(next_statuses)!r}, want {expected_targets!r}"
)
# ---------------------------------------------------------------------------
# Lifecycle: claim/unclaim (404 paths)
# ---------------------------------------------------------------------------
@@ -2925,3 +2960,492 @@ async def test_get_sessions_for_task_not_found(task_client: dict) -> None:
f"/api/tasks/{uuid4()}/sessions", headers=_HDR
)
assert response.status_code == HTTPStatus.NOT_FOUND
# ---------------------------------------------------------------------------
# TaskUpdate schema: nature / task_type / project_id (AC: schema fix)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_patch_nature_persists(task_client: dict) -> None:
"""PATCH with nature=non_technical persists; GET returns updated value."""
task = _seed_task(task_client, nature=TaskNature.TECHNICAL)
await task_client["db"].flush()
response = await task_client["client"].patch(
f"/api/tasks/{task.id}",
json={"nature": "non_technical"},
headers=_HDR,
)
assert response.status_code == HTTPStatus.OK
body = response.json()
assert body["nature"] == "non_technical"
@pytest.mark.asyncio
async def test_patch_task_type_persists(task_client: dict) -> None:
"""PATCH with task_type=research persists; GET returns updated value."""
task = _seed_task(task_client, task_type=TaskType.CODE)
await task_client["db"].flush()
response = await task_client["client"].patch(
f"/api/tasks/{task.id}",
json={"task_type": "research"},
headers=_HDR,
)
assert response.status_code == HTTPStatus.OK
body = response.json()
assert body["task_type"] == "research"
@pytest.mark.asyncio
async def test_patch_project_id_persists(task_client: dict) -> None:
"""PATCH with project_id=<valid-uuid> persists; GET returns updated value."""
task = _seed_task(task_client)
# Create a second project to switch to
second_project = ProjectTable(
id=uuid4(),
name="Proj2",
slug=f"proj2-{uuid4().hex[:6]}",
git_url="https://example.com/proj2.git",
assigned_cell=Team.BACKEND,
created_by=task_client["agent"].id,
)
task_client["db"].add(second_project)
await task_client["db"].flush()
response = await task_client["client"].patch(
f"/api/tasks/{task.id}",
json={"project_id": str(second_project.id)},
headers=_HDR,
)
assert response.status_code == HTTPStatus.OK
body = response.json()
assert body["project_id"] == str(second_project.id)
@pytest.mark.asyncio
async def test_patch_title_only_changes_title(task_client: dict) -> None:
"""PATCH with only title does not mutate nature/task_type/status/team."""
task = _seed_task(
task_client,
title="original title",
nature=TaskNature.TECHNICAL,
task_type=TaskType.CODE,
team=Team.BACKEND,
)
await task_client["db"].flush()
response = await task_client["client"].patch(
f"/api/tasks/{task.id}",
json={"title": "updated title"},
headers=_HDR,
)
assert response.status_code == HTTPStatus.OK
body = response.json()
assert body["title"] == "updated title"
# Other fields unchanged
assert body["nature"] == "technical"
assert body["task_type"] == "code"
assert body["team"] == "backend"
# ---------------------------------------------------------------------------
# assigned_to: slug resolution and null guard (AC: slug-resolution fix)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_patch_assigned_to_slug_resolves_to_uuid(task_client: dict) -> None:
"""PATCH assigned_to with agent slug resolves to agent UUID."""
dev = await _seed_agent(task_client)
task = _seed_task(task_client)
await task_client["db"].flush()
response = await task_client["client"].patch(
f"/api/tasks/{task.id}",
json={"assigned_to": dev.slug},
headers=_HDR,
)
assert response.status_code == HTTPStatus.OK
body = response.json()
assert body["assigned_to"] == str(dev.id)
@pytest.mark.asyncio
async def test_patch_assigned_to_null_unassigns(task_client: dict) -> None:
"""PATCH assigned_to: null sets assigned_to to null (unassign)."""
dev = await _seed_agent(task_client)
task = _seed_task(task_client, assigned_to=dev.id)
await task_client["db"].flush()
response = await task_client["client"].patch(
f"/api/tasks/{task.id}",
json={"assigned_to": None},
headers=_HDR,
)
assert response.status_code == HTTPStatus.OK
body = response.json()
assert body["assigned_to"] is None
@pytest.mark.asyncio
async def test_patch_assigned_to_unknown_slug_returns_422(task_client: dict) -> None:
"""PATCH assigned_to with unknown slug returns 422 ASSIGNEE_NOT_FOUND."""
task = _seed_task(task_client)
await task_client["db"].flush()
response = await task_client["client"].patch(
f"/api/tasks/{task.id}",
json={"assigned_to": "totally-nonexistent-slug"},
headers=_HDR,
)
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
detail = response.json()["detail"]
assert isinstance(detail, dict)
assert detail["error"]["code"] == "ASSIGNEE_NOT_FOUND"
# ---------------------------------------------------------------------------
# GET /tasks/{id}/ceo-approve — eligibility check (AC: ceo-approve fix)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_ceo_approve_get_no_pr_returns_400(ceo_client: dict) -> None:
"""GET /ceo-approve with no pr_number on the task → 400 NO_PR."""
task = _seed_task_ceo(ceo_client, pr_number=None)
await ceo_client["db"].flush()
response = await ceo_client["client"].get(
f"/api/tasks/{task.id}/ceo-approve",
headers=_HDR,
)
assert response.status_code == HTTPStatus.BAD_REQUEST
assert "NO_PR" in response.json()["detail"]
@pytest.mark.asyncio
async def test_ceo_approve_get_with_pr_returns_200(ceo_client: dict) -> None:
"""GET /ceo-approve with pr_number set → 200 with task."""
task = _seed_task_ceo(ceo_client, pr_number=42)
await ceo_client["db"].flush()
response = await ceo_client["client"].get(
f"/api/tasks/{task.id}/ceo-approve",
headers=_HDR,
)
assert response.status_code == HTTPStatus.OK
body = response.json()
_expected_pr = 42
assert body["pr_number"] == _expected_pr
@pytest.mark.asyncio
async def test_ceo_approve_get_not_ceo_returns_403(task_client: dict) -> None:
"""GET /ceo-approve by non-CEO → 403 Forbidden."""
response = await task_client["client"].get(
f"/api/tasks/{uuid4()}/ceo-approve",
headers=_HDR,
)
assert response.status_code == HTTPStatus.FORBIDDEN
@pytest.mark.asyncio
async def test_ceo_approve_get_task_not_found(ceo_client: dict) -> None:
"""GET /ceo-approve for unknown task → 404."""
response = await ceo_client["client"].get(
f"/api/tasks/{uuid4()}/ceo-approve",
headers=_HDR,
)
assert response.status_code == HTTPStatus.NOT_FOUND
# ---------------------------------------------------------------------------
# POST /tasks/{id}/approve-and-merge (AC: approve-and-merge fix)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_approve_and_merge_no_pr_returns_400(ceo_client: dict) -> None:
"""POST /approve-and-merge with no pr_number → 400 NO_PR."""
task = _seed_task_ceo(ceo_client, pr_number=None)
await ceo_client["db"].flush()
response = await ceo_client["client"].post(
f"/api/tasks/{task.id}/approve-and-merge",
headers=_HDR,
)
assert response.status_code == HTTPStatus.BAD_REQUEST
assert "NO_PR" in response.json()["detail"]
@pytest.mark.asyncio
async def test_approve_and_merge_not_ceo_returns_403(task_client: dict) -> None:
"""POST /approve-and-merge by non-CEO → 403 Forbidden."""
response = await task_client["client"].post(
f"/api/tasks/{uuid4()}/approve-and-merge",
headers=_HDR,
)
assert response.status_code == HTTPStatus.FORBIDDEN
@pytest.mark.asyncio
async def test_approve_and_merge_task_not_found(ceo_client: dict) -> None:
"""POST /approve-and-merge for unknown task → 404."""
with patch("roboco.api.routes.tasks.get_task_service") as mock_factory:
instance = AsyncMock()
instance.get = AsyncMock(return_value=None)
mock_factory.return_value = instance
response = await ceo_client["client"].post(
f"/api/tasks/{uuid4()}/approve-and-merge",
headers=_HDR,
)
assert response.status_code == HTTPStatus.NOT_FOUND
@pytest.mark.asyncio
async def test_approve_and_merge_success(ceo_client: dict) -> None:
"""POST /approve-and-merge with PR + fully mocked services → 200 task."""
task = _seed_task_ceo(ceo_client, pr_number=99)
await ceo_client["db"].flush()
# The handler does lazy imports of get_project_service and get_git_service.
# Patch them at their source modules so the lazy import picks up the mock.
with (
patch("roboco.api.routes.tasks.get_task_service") as mock_task_factory,
patch("roboco.services.project.get_project_service") as mock_proj_factory,
patch("roboco.services.git.get_git_service") as mock_git_factory,
):
task_instance = AsyncMock()
# service.get is called twice: once for the initial check, once to re-fetch.
task_instance.get = AsyncMock(side_effect=[task, task])
mock_task_factory.return_value = task_instance
proj_instance = AsyncMock()
proj_instance.get = AsyncMock(return_value=ceo_client["project"])
mock_proj_factory.return_value = proj_instance
git_instance = AsyncMock()
git_instance.merge_pr_for_task = AsyncMock(return_value=("main", "abc1234"))
mock_git_factory.return_value = git_instance
response = await ceo_client["client"].post(
f"/api/tasks/{task.id}/approve-and-merge",
headers=_HDR,
)
assert response.status_code == HTTPStatus.OK
body = response.json()
_expected_pr = 99
assert body["pr_number"] == _expected_pr
@pytest.mark.asyncio
async def test_approve_and_merge_merge_failure_returns_structured_error(
ceo_client: dict,
) -> None:
"""POST /approve-and-merge where git merge fails → 400 with descriptive message.
The error must NOT be an unhandled exception (500 with traceback); it must
be a structured HTTP error (400 or 500) with a human-readable message.
"""
task = _seed_task_ceo(ceo_client, pr_number=55)
await ceo_client["db"].flush()
with (
patch("roboco.api.routes.tasks.get_task_service") as mock_task_factory,
patch("roboco.services.project.get_project_service") as mock_proj_factory,
patch("roboco.services.git.get_git_service") as mock_git_factory,
):
task_instance = AsyncMock()
task_instance.get = AsyncMock(return_value=task)
mock_task_factory.return_value = task_instance
proj_instance = AsyncMock()
proj_instance.get = AsyncMock(return_value=ceo_client["project"])
mock_proj_factory.return_value = proj_instance
git_instance = AsyncMock()
git_instance.merge_pr_for_task = AsyncMock(
side_effect=SvcError("GitHub refused the merge: 409 Conflict")
)
mock_git_factory.return_value = git_instance
response = await ceo_client["client"].post(
f"/api/tasks/{task.id}/approve-and-merge",
headers=_HDR,
)
# Must be a structured error, not an unhandled 500
_ok_statuses = (HTTPStatus.BAD_REQUEST, HTTPStatus.INTERNAL_SERVER_ERROR)
assert response.status_code in _ok_statuses
body = response.json()
# The detail must be a string (not a raw traceback or empty)
assert isinstance(body.get("detail"), str)
assert len(body["detail"]) > 0
@pytest.mark.asyncio
async def test_approve_and_merge_git_error_returns_structured_error(
ceo_client: dict,
) -> None:
"""POST /approve-and-merge: GitError → 400 with descriptive message."""
task = _seed_task_ceo(ceo_client, pr_number=66)
await ceo_client["db"].flush()
with (
patch("roboco.api.routes.tasks.get_task_service") as mock_task_factory,
patch("roboco.services.project.get_project_service") as mock_proj_factory,
patch("roboco.services.git.get_git_service") as mock_git_factory,
):
task_instance = AsyncMock()
task_instance.get = AsyncMock(return_value=task)
mock_task_factory.return_value = task_instance
proj_instance = AsyncMock()
proj_instance.get = AsyncMock(return_value=ceo_client["project"])
mock_proj_factory.return_value = proj_instance
git_instance = AsyncMock()
git_instance.merge_pr_for_task = AsyncMock(
side_effect=GitError(
"GitHub API refused PR merge (422): branch protected",
{"pr": 66},
)
)
mock_git_factory.return_value = git_instance
response = await ceo_client["client"].post(
f"/api/tasks/{task.id}/approve-and-merge",
headers=_HDR,
)
# Must be a structured error — NOT an unhandled traceback
_ok_statuses = (HTTPStatus.BAD_REQUEST, HTTPStatus.INTERNAL_SERVER_ERROR)
assert response.status_code in _ok_statuses
body = response.json()
assert isinstance(body.get("detail"), str)
assert len(body["detail"]) > 0
# ---------------------------------------------------------------------------
# POST /tasks/{id}/complete — PM merge path (AC: pm-merge-path fix)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_cell_pm_complete_merges_then_completes(task_client: dict) -> None:
"""POST /complete on an awaiting_pm_review task with a pr_number triggers
merge_pr_for_task before the task is marked completed.
The git service and project service are mocked so no real GitHub call is
made; the test verifies the call ordering and the final 200 response.
"""
task = _seed_task(task_client, status=TaskStatus.AWAITING_PM_REVIEW, pr_number=77)
await task_client["db"].flush()
with (
patch("roboco.api.routes.tasks.get_task_service") as mock_task_factory,
patch("roboco.services.project.get_project_service") as mock_proj_factory,
patch("roboco.services.git.get_git_service") as mock_git_factory,
):
# Task service: get() returns the seeded task; complete_task_for_agent
# simulates the service marking it completed and returning it.
task_instance = AsyncMock()
task_instance.get = AsyncMock(return_value=task)
completed_task = task # same object; status already set on the mock
task_instance.complete_task_for_agent = AsyncMock(return_value=completed_task)
mock_task_factory.return_value = task_instance
proj_instance = AsyncMock()
proj_instance.get = AsyncMock(return_value=task_client["project"])
mock_proj_factory.return_value = proj_instance
git_instance = AsyncMock()
git_instance.merge_pr_for_task = AsyncMock(return_value=("main", "abc1234"))
mock_git_factory.return_value = git_instance
response = await task_client["client"].post(
f"/api/tasks/{task.id}/complete",
json={"justification": "All criteria met; QA and docs signed off."},
headers=_HDR,
)
assert response.status_code == HTTPStatus.OK
# merge_pr_for_task must have been called exactly once
git_instance.merge_pr_for_task.assert_called_once()
call_args = git_instance.merge_pr_for_task.call_args
# The GitMergePRRequest passed to merge_pr_for_task must carry the right pr_number
merge_request = call_args.args[2] # positional: agent_id, agent_role, request
_expected_pr = 77
assert merge_request.pr_number == _expected_pr
# ---------------------------------------------------------------------------
# POST /tasks/{id}/complete — PM merge path end-to-end (AC: double-completion fix)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_pm_merge_auto_completes_without_double_completion(
task_client: dict,
) -> None:
"""POST /complete on awaiting_pm_review calls real merge_pr_for_task and
real complete_task_for_agent is NOT called the task is auto-completed
by _auto_complete_on_merge inside the git service, and the route detects
the completed state from the re-fetch and returns 200 directly.
Only the git-workspace / GitHub-API layer is mocked (GitService.get_workspace
and GitService.merge_pull_request). merge_pr_for_task and complete_task_for_agent
run for real so this exercises the fix for the double-completion 500.
"""
# Seed a task in awaiting_pm_review with a PR. No work_session_id so that
# _assert_pr_merged_for_complete returns True without querying WorkSession.
task = _seed_task(
task_client,
status=TaskStatus.AWAITING_PM_REVIEW,
pr_number=99,
# work_session_id intentionally omitted (defaults to None)
)
await task_client["db"].flush()
# Spy: we want to assert complete_task_for_agent is never reached.
# If it were called on an already-completed task it would raise ValidationError
# and the route would return a non-200 — but we assert explicitly to be clear.
complete_for_agent_spy = AsyncMock(
wraps=TaskService.complete_task_for_agent,
name="complete_task_for_agent_spy",
)
_mock_workspace = Path("/tmp/mock_workspace")
with (
patch.object(
GitService,
"get_workspace",
new=AsyncMock(return_value=_mock_workspace),
),
patch.object(
GitService,
"merge_pull_request",
new=AsyncMock(return_value=("main", "dead1234")),
),
patch.object(
TaskService,
"complete_task_for_agent",
new=complete_for_agent_spy,
),
):
response = await task_client["client"].post(
f"/api/tasks/{task.id}/complete",
json={"justification": "All criteria met and QA signed off."},
headers=_HDR,
)
# The route must return 200; if the double-completion bug were present the
# second call to complete() would fail (task already completed) → 422/400.
assert response.status_code == HTTPStatus.OK, response.text
body = response.json()
assert body["status"] == "completed", f"expected completed, got {body['status']}"
# complete_task_for_agent must NOT have been called: the task was already
# auto-completed by _auto_complete_on_merge inside merge_pr_for_task.
complete_for_agent_spy.assert_not_called()
+23
View File
@@ -8,6 +8,7 @@ session boundary and checks the method's contract.
from __future__ import annotations
from datetime import datetime
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
@@ -730,3 +731,25 @@ def test_resolve_doc_abspath_leaves_external_absolute_path() -> None:
"""An absolute path outside the docs root is left as-is for the indexer to skip."""
external = "/data/workspaces/panel/frontend/fe-dev-1/src/page.tsx"
assert TaskService._resolve_doc_abspath(external) == external
@pytest.mark.asyncio
async def test_update_skips_none_to_protect_partial_callers() -> None:
"""update() must skip None values, not write them.
Callers pass field=dict.get('x'), which is None when the key is absent
e.g. the board-redraft update_live_draft path passes title/acceptance_criteria
that way. Without the None-skip guard those None values would null-wipe
existing data. Explicit clearing is the update ROUTE's job (a field
whitelist), never this shared service method. Locks that contract so the
guard can't be silently removed again.
"""
task = SimpleNamespace(title="original", acceptance_criteria=["keep me"])
svc = TaskService(MagicMock(flush=AsyncMock()))
svc.get = AsyncMock(return_value=task)
result = await svc.update(uuid4(), title="updated", acceptance_criteria=None)
assert result is task
assert task.title == "updated" # explicit, non-None value is applied
assert task.acceptance_criteria == ["keep me"] # None skipped, not wiped