mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
Merge remote-tracking branch 'origin/master' into feat/task-decomposition-enforce-floor
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 & 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 & 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]}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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_id→project 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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user