mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
fix(panel): forms catch up with the backend (#614)
New Project claimed GitLab/Gitea were 'planned' while both providers are fully shipped, showed a hardcoded GitHub badge, and never sent git_provider at all — a non-GitHub project could not be created without an immediate edit. It now carries the same forge Select the edit dialog has; the edit dialog's own 'GitLab support is planned' tooltip is corrected too. Also: the git actions panel's hardcoded 'main' (wrong PR-eligibility and target label for master-default and env-ladder projects) is replaced by the project's resolved head branch; acceptance criteria become editable in the edit-task dialog; three feature flags get their missing descriptions; and three forms swap raw-UUID text inputs for the existing Task/Agent selectors. Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
@@ -29,6 +29,17 @@ vi.mock("@/store/rate-limit-store", () => ({
|
|||||||
useRateLimitStore: { getState: vi.fn(() => ({ hitRateLimit: vi.fn() })) },
|
useRateLimitStore: { getState: vi.fn(() => ({ hitRateLimit: vi.fn() })) },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// TaskSelector is a data-fetching combobox (useTasks); stub it with a button
|
||||||
|
// that reports a fixed task id, mirroring create-task-dialog.test.tsx's
|
||||||
|
// approach to the same component.
|
||||||
|
vi.mock("@/components/tasks/task-selector", () => ({
|
||||||
|
TaskSelector: ({ onChange }: { onChange: (v: string | null) => void }) => (
|
||||||
|
<button type="button" onClick={() => onChange("task-123")}>
|
||||||
|
Set Task
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
import { SpawnAgentDialog } from "../spawn-agent-dialog";
|
import { SpawnAgentDialog } from "../spawn-agent-dialog";
|
||||||
|
|
||||||
function openDialog() {
|
function openDialog() {
|
||||||
@@ -59,9 +70,7 @@ describe("SpawnAgentDialog", () => {
|
|||||||
mutateAsync.mockResolvedValue({ already_running: false });
|
mutateAsync.mockResolvedValue({ already_running: false });
|
||||||
openDialog();
|
openDialog();
|
||||||
|
|
||||||
fireEvent.change(screen.getByLabelText(/Task ID/i), {
|
fireEvent.click(screen.getByRole("button", { name: "Set Task" }));
|
||||||
target: { value: "task-123" },
|
|
||||||
});
|
|
||||||
fireEvent.change(screen.getByLabelText(/Initial Prompt/i), {
|
fireEvent.change(screen.getByLabelText(/Initial Prompt/i), {
|
||||||
target: { value: "go fix it" },
|
target: { value: "go fix it" },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||||
import { HelpTip } from "@/components/ui/help-tip";
|
import { HelpTip } from "@/components/ui/help-tip";
|
||||||
|
import { TaskSelector } from "@/components/tasks/task-selector";
|
||||||
import { Play } from "lucide-react";
|
import { Play } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
@@ -31,7 +32,7 @@ export function SpawnAgentDialog({
|
|||||||
trigger,
|
trigger,
|
||||||
}: SpawnAgentDialogProps) {
|
}: SpawnAgentDialogProps) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [taskId, setTaskId] = useState("");
|
const [taskId, setTaskId] = useState<string | null>(null);
|
||||||
const [initialPrompt, setInitialPrompt] = useState("");
|
const [initialPrompt, setInitialPrompt] = useState("");
|
||||||
const spawnAgent = useSpawnAgent();
|
const spawnAgent = useSpawnAgent();
|
||||||
// Synchronous re-entrancy guard: `spawnAgent.isPending` only flips on a
|
// Synchronous re-entrancy guard: `spawnAgent.isPending` only flips on a
|
||||||
@@ -66,7 +67,7 @@ export function SpawnAgentDialog({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
setTaskId("");
|
setTaskId(null);
|
||||||
setInitialPrompt("");
|
setInitialPrompt("");
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -102,18 +103,19 @@ export function SpawnAgentDialog({
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<HelpTip label="Pre-claims this task on spawn instead of pulling from the pool">
|
<HelpTip label="Pre-claims this task on spawn instead of pulling from the pool">
|
||||||
<Label htmlFor="taskId" className="w-fit">Task ID (optional)</Label>
|
<Label className="w-fit">Task (optional)</Label>
|
||||||
</HelpTip>
|
</HelpTip>
|
||||||
<Input
|
<TaskSelector
|
||||||
id="taskId"
|
|
||||||
value={taskId}
|
value={taskId}
|
||||||
onChange={(e) => setTaskId(e.target.value)}
|
onChange={setTaskId}
|
||||||
placeholder="UUID of task to assign"
|
placeholder="Select task to assign (optional)..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<HelpTip label="Extra instructions passed to the agent's first turn">
|
<HelpTip label="Extra instructions passed to the agent's first turn">
|
||||||
<Label htmlFor="initialPrompt" className="w-fit">Initial Prompt (optional)</Label>
|
<Label htmlFor="initialPrompt" className="w-fit">
|
||||||
|
Initial Prompt (optional)
|
||||||
|
</Label>
|
||||||
</HelpTip>
|
</HelpTip>
|
||||||
<Input
|
<Input
|
||||||
id="initialPrompt"
|
id="initialPrompt"
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { HelpTip } from "@/components/ui/help-tip";
|
import { HelpTip } from "@/components/ui/help-tip";
|
||||||
|
import { TaskSelector } from "@/components/tasks/task-selector";
|
||||||
|
import { AgentSelector } from "@/components/agents/agent-selector";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -53,8 +55,8 @@ export function CreateFlagDialog({
|
|||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
const [severity, setSeverity] = useState<FlagSeverity>(FlagSeverity.INFO);
|
const [severity, setSeverity] = useState<FlagSeverity>(FlagSeverity.INFO);
|
||||||
const [category, setCategory] = useState("quality");
|
const [category, setCategory] = useState("quality");
|
||||||
const [relatedTaskId, setRelatedTaskId] = useState("");
|
const [relatedTaskId, setRelatedTaskId] = useState<string | null>(null);
|
||||||
const [relatedAgentId, setRelatedAgentId] = useState("");
|
const [relatedAgentId, setRelatedAgentId] = useState<string | null>(null);
|
||||||
|
|
||||||
const createFlag = useCreateAuditorFlag();
|
const createFlag = useCreateAuditorFlag();
|
||||||
|
|
||||||
@@ -72,8 +74,8 @@ export function CreateFlagDialog({
|
|||||||
description: description.trim(),
|
description: description.trim(),
|
||||||
severity,
|
severity,
|
||||||
category,
|
category,
|
||||||
related_task_id: relatedTaskId.trim() || undefined,
|
related_task_id: relatedTaskId || undefined,
|
||||||
related_agent_id: relatedAgentId.trim() || undefined,
|
related_agent_id: relatedAgentId || undefined,
|
||||||
});
|
});
|
||||||
toast.success("Flag created successfully");
|
toast.success("Flag created successfully");
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
@@ -88,8 +90,8 @@ export function CreateFlagDialog({
|
|||||||
setDescription("");
|
setDescription("");
|
||||||
setSeverity(FlagSeverity.INFO);
|
setSeverity(FlagSeverity.INFO);
|
||||||
setCategory("quality");
|
setCategory("quality");
|
||||||
setRelatedTaskId("");
|
setRelatedTaskId(null);
|
||||||
setRelatedAgentId("");
|
setRelatedAgentId(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -172,24 +174,22 @@ export function CreateFlagDialog({
|
|||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<HelpTip label="Optional. Links this flag to a task — shown as a quick-link in the flags list">
|
<HelpTip label="Optional. Links this flag to a task — shown as a quick-link in the flags list">
|
||||||
<Label htmlFor="task">Related Task ID (optional)</Label>
|
<Label>Related Task (optional)</Label>
|
||||||
</HelpTip>
|
</HelpTip>
|
||||||
<Input
|
<TaskSelector
|
||||||
id="task"
|
|
||||||
value={relatedTaskId}
|
value={relatedTaskId}
|
||||||
onChange={(e) => setRelatedTaskId(e.target.value)}
|
onChange={setRelatedTaskId}
|
||||||
placeholder="Task UUID"
|
placeholder="Select task (optional)..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<HelpTip label="Optional. Associates this flag with a specific agent for audit tracking">
|
<HelpTip label="Optional. Associates this flag with a specific agent for audit tracking">
|
||||||
<Label htmlFor="agent">Related Agent ID (optional)</Label>
|
<Label>Related Agent (optional)</Label>
|
||||||
</HelpTip>
|
</HelpTip>
|
||||||
<Input
|
<AgentSelector
|
||||||
id="agent"
|
|
||||||
value={relatedAgentId}
|
value={relatedAgentId}
|
||||||
onChange={(e) => setRelatedAgentId(e.target.value)}
|
onChange={setRelatedAgentId}
|
||||||
placeholder="Agent ID"
|
placeholder="Select agent (optional)..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -45,6 +45,9 @@ interface GitActionsPanelProps {
|
|||||||
projectSlug: string;
|
projectSlug: string;
|
||||||
taskId: string;
|
taskId: string;
|
||||||
agentId: string;
|
agentId: string;
|
||||||
|
// Real project default/head branch (env-ladder-aware) — PRs target this,
|
||||||
|
// not a hardcoded "main". See useGitBrowser.
|
||||||
|
defaultBranch: string;
|
||||||
onCommit: (message: string) => void;
|
onCommit: (message: string) => void;
|
||||||
onPush: (force?: boolean) => void;
|
onPush: (force?: boolean) => void;
|
||||||
onCreatePR: (title: string, body: string) => void;
|
onCreatePR: (title: string, body: string) => void;
|
||||||
@@ -68,6 +71,7 @@ export function GitActionsPanel({
|
|||||||
projectSlug,
|
projectSlug,
|
||||||
taskId,
|
taskId,
|
||||||
agentId: _agentId,
|
agentId: _agentId,
|
||||||
|
defaultBranch,
|
||||||
onCommit,
|
onCommit,
|
||||||
onPush,
|
onPush,
|
||||||
onCreatePR,
|
onCreatePR,
|
||||||
@@ -98,7 +102,8 @@ export function GitActionsPanel({
|
|||||||
const hasStagedChanges = (status?.staged_files.length ?? 0) > 0;
|
const hasStagedChanges = (status?.staged_files.length ?? 0) > 0;
|
||||||
const hasUnpushedCommits = (status?.ahead ?? 0) > 0;
|
const hasUnpushedCommits = (status?.ahead ?? 0) > 0;
|
||||||
const canPush = hasUnpushedCommits;
|
const canPush = hasUnpushedCommits;
|
||||||
const canCreatePR = hasUnpushedCommits || status?.current_branch !== "main";
|
const canCreatePR =
|
||||||
|
hasUnpushedCommits || status?.current_branch !== defaultBranch;
|
||||||
|
|
||||||
const handleCommitDialogOpenChange = (newOpen: boolean) => {
|
const handleCommitDialogOpenChange = (newOpen: boolean) => {
|
||||||
if (!newOpen) setCommitMessage("");
|
if (!newOpen) setCommitMessage("");
|
||||||
@@ -251,10 +256,7 @@ export function GitActionsPanel({
|
|||||||
: "Nothing to push — no local commits sit ahead of the remote branch yet."
|
: "Nothing to push — no local commits sit ahead of the remote branch yet."
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span
|
<span className="block w-full" tabIndex={!canPush ? 0 : undefined}>
|
||||||
className="block w-full"
|
|
||||||
tabIndex={!canPush ? 0 : undefined}
|
|
||||||
>
|
|
||||||
<Button
|
<Button
|
||||||
className="w-full justify-start"
|
className="w-full justify-start"
|
||||||
variant={canPush ? "default" : "outline"}
|
variant={canPush ? "default" : "outline"}
|
||||||
@@ -284,8 +286,8 @@ export function GitActionsPanel({
|
|||||||
<HelpTip
|
<HelpTip
|
||||||
label={
|
label={
|
||||||
canCreatePR
|
canCreatePR
|
||||||
? "Opens GitHub's PR creation flow for this branch against main."
|
? `Opens the forge's PR creation flow for this branch against ${defaultBranch}.`
|
||||||
: "Already on main with nothing ahead of it — there's no branch content to open a PR for."
|
: `Already on ${defaultBranch} with nothing ahead of it — there's no branch content to open a PR for.`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
@@ -313,7 +315,7 @@ export function GitActionsPanel({
|
|||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground w-fit">
|
<div className="flex items-center gap-2 text-sm text-muted-foreground w-fit">
|
||||||
<Badge variant="outline">{status?.current_branch}</Badge>
|
<Badge variant="outline">{status?.current_branch}</Badge>
|
||||||
<span>→</span>
|
<span>→</span>
|
||||||
<Badge variant="outline">main</Badge>
|
<Badge variant="outline">{defaultBranch}</Badge>
|
||||||
</div>
|
</div>
|
||||||
</HelpTip>
|
</HelpTip>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -383,9 +385,7 @@ export function GitActionsPanel({
|
|||||||
<div className="space-y-4 py-4">
|
<div className="space-y-4 py-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<HelpTip label="The GitHub PR number to merge, e.g. the 42 in .../pull/42.">
|
<HelpTip label="The GitHub PR number to merge, e.g. the 42 in .../pull/42.">
|
||||||
<label className="text-sm font-medium w-fit">
|
<label className="text-sm font-medium w-fit">PR Number</label>
|
||||||
PR Number
|
|
||||||
</label>
|
|
||||||
</HelpTip>
|
</HelpTip>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { useState } from "react";
|
|||||||
import { GitBranchListResponse, BranchType } from "@/types/git";
|
import { GitBranchListResponse, BranchType } from "@/types/git";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import {
|
import {
|
||||||
@@ -24,6 +23,7 @@ import {
|
|||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { GitBranch, Check, Cloud, Plus, RefreshCw } from "lucide-react";
|
import { GitBranch, Check, Cloud, Plus, RefreshCw } from "lucide-react";
|
||||||
import { HelpTip } from "@/components/ui/help-tip";
|
import { HelpTip } from "@/components/ui/help-tip";
|
||||||
|
import { TaskSelector } from "@/components/tasks/task-selector";
|
||||||
|
|
||||||
interface GitBranchPanelProps {
|
interface GitBranchPanelProps {
|
||||||
branches: GitBranchListResponse | undefined;
|
branches: GitBranchListResponse | undefined;
|
||||||
@@ -44,13 +44,13 @@ export function GitBranchPanel({
|
|||||||
}: GitBranchPanelProps) {
|
}: GitBranchPanelProps) {
|
||||||
const [showCreateDialog, setShowCreateDialog] = useState(false);
|
const [showCreateDialog, setShowCreateDialog] = useState(false);
|
||||||
const [newBranchType, setNewBranchType] = useState<BranchType>("feature");
|
const [newBranchType, setNewBranchType] = useState<BranchType>("feature");
|
||||||
const [taskId, setTaskId] = useState("");
|
const [taskId, setTaskId] = useState<string | null>(null);
|
||||||
|
|
||||||
const handleCreateBranch = () => {
|
const handleCreateBranch = () => {
|
||||||
if (taskId.trim()) {
|
if (taskId) {
|
||||||
onCreateBranch(newBranchType, taskId.trim());
|
onCreateBranch(newBranchType, taskId);
|
||||||
setShowCreateDialog(false);
|
setShowCreateDialog(false);
|
||||||
setTaskId("");
|
setTaskId(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -131,14 +131,12 @@ export function GitBranchPanel({
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<HelpTip label="Identifies the task this branch is for — gets embedded in the branch name (shortened to 8 chars).">
|
<HelpTip label="Identifies the task this branch is for — gets embedded in the branch name (shortened to 8 chars).">
|
||||||
<label className="text-sm font-medium w-fit">
|
<label className="text-sm font-medium w-fit">Task</label>
|
||||||
Task ID
|
|
||||||
</label>
|
|
||||||
</HelpTip>
|
</HelpTip>
|
||||||
<Input
|
<TaskSelector
|
||||||
placeholder="Enter task ID..."
|
|
||||||
value={taskId}
|
value={taskId}
|
||||||
onChange={(e) => setTaskId(e.target.value)}
|
onChange={setTaskId}
|
||||||
|
placeholder="Select task..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -151,7 +149,7 @@ export function GitBranchPanel({
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleCreateBranch}
|
onClick={handleCreateBranch}
|
||||||
disabled={!taskId.trim() || isCreating}
|
disabled={!taskId || isCreating}
|
||||||
>
|
>
|
||||||
{isCreating && (
|
{isCreating && (
|
||||||
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
|
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ function GitBrowserContent() {
|
|||||||
taskId,
|
taskId,
|
||||||
projects,
|
projects,
|
||||||
loadingProjects,
|
loadingProjects,
|
||||||
|
defaultBranch,
|
||||||
status,
|
status,
|
||||||
loadingStatus,
|
loadingStatus,
|
||||||
log,
|
log,
|
||||||
@@ -134,6 +135,7 @@ function GitBrowserContent() {
|
|||||||
projectSlug={projectSlug}
|
projectSlug={projectSlug}
|
||||||
taskId={taskId}
|
taskId={taskId}
|
||||||
agentId="pm"
|
agentId="pm"
|
||||||
|
defaultBranch={defaultBranch}
|
||||||
onCommit={handleCommit}
|
onCommit={handleCommit}
|
||||||
onPush={handlePush}
|
onPush={handlePush}
|
||||||
onCreatePR={handleCreatePR}
|
onCreatePR={handleCreatePR}
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ import { Team, type ProjectCreate } from "@/types";
|
|||||||
import { EnvironmentLadderEditor } from "@/components/projects/environment-ladder-editor";
|
import { EnvironmentLadderEditor } from "@/components/projects/environment-ladder-editor";
|
||||||
import { validateLadder } from "@/components/projects/ladder-validation";
|
import { validateLadder } from "@/components/projects/ladder-validation";
|
||||||
import { HelpTip } from "@/components/ui/help-tip";
|
import { HelpTip } from "@/components/ui/help-tip";
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
|
|
||||||
const cells: { value: Team; label: string }[] = [
|
const cells: { value: Team; label: string }[] = [
|
||||||
{ value: Team.BACKEND, label: "Backend" },
|
{ value: Team.BACKEND, label: "Backend" },
|
||||||
@@ -54,6 +53,9 @@ export function CreateProjectDialog() {
|
|||||||
default_branch: "main",
|
default_branch: "main",
|
||||||
environments: null,
|
environments: null,
|
||||||
});
|
});
|
||||||
|
// "auto" is a UI-only sentinel — never sent as-is; null on the wire lets
|
||||||
|
// the backend auto-detect from the Git URL host at creation time.
|
||||||
|
const [gitProvider, setGitProvider] = useState("auto");
|
||||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||||
|
|
||||||
const createProject = useCreateProject();
|
const createProject = useCreateProject();
|
||||||
@@ -90,6 +92,7 @@ export function CreateProjectDialog() {
|
|||||||
name: formData.name,
|
name: formData.name,
|
||||||
slug: formData.slug,
|
slug: formData.slug,
|
||||||
git_url: formData.git_url,
|
git_url: formData.git_url,
|
||||||
|
git_provider: gitProvider === "auto" ? null : gitProvider,
|
||||||
assigned_cell: formData.assigned_cell,
|
assigned_cell: formData.assigned_cell,
|
||||||
git_token: formData.git_token || undefined,
|
git_token: formData.git_token || undefined,
|
||||||
default_branch: formData.default_branch || "main",
|
default_branch: formData.default_branch || "main",
|
||||||
@@ -112,6 +115,7 @@ export function CreateProjectDialog() {
|
|||||||
default_branch: "main",
|
default_branch: "main",
|
||||||
environments: null,
|
environments: null,
|
||||||
});
|
});
|
||||||
|
setGitProvider("auto");
|
||||||
setShowAdvanced(false);
|
setShowAdvanced(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(
|
toast.error(
|
||||||
@@ -189,17 +193,26 @@ export function CreateProjectDialog() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Forge (read-only — GitHub-only today) */}
|
{/* Forge */}
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<HelpTip label="Auto-detected from the Git URL's host at creation; RoboCo's PR/CI/review surface is GitHub-only today. GitLab & Gitea support planned.">
|
<HelpTip label="Which forge API serves PR/CI/review operations. Auto-detect resolves from the Git URL's host at creation (github.com -> GitHub, gitlab.com -> GitLab); a self-hosted Gitea/GitLab instance or GitHub Enterprise can't be told apart by host alone and must be set explicitly.">
|
||||||
<Label>Forge</Label>
|
<Label>Forge</Label>
|
||||||
</HelpTip>
|
</HelpTip>
|
||||||
<div>
|
<Select value={gitProvider} onValueChange={setGitProvider}>
|
||||||
<Badge variant="secondary">GitHub</Badge>
|
<SelectTrigger>
|
||||||
</div>
|
<SelectValue placeholder="Auto-detect" />
|
||||||
<p className="text-xs text-muted-foreground">
|
</SelectTrigger>
|
||||||
GitLab & Gitea support planned.
|
<SelectContent>
|
||||||
</p>
|
<SelectItem value="auto">Auto-detect</SelectItem>
|
||||||
|
<SelectItem value="github">
|
||||||
|
GitHub / GitHub Enterprise
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="gitea">Gitea (self-hosted)</SelectItem>
|
||||||
|
<SelectItem value="gitlab">
|
||||||
|
GitLab (gitlab.com / self-hosted)
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Git Token */}
|
{/* Git Token */}
|
||||||
@@ -263,14 +276,17 @@ export function CreateProjectDialog() {
|
|||||||
placeholder="main"
|
placeholder="main"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Where PRs land and releases are cut when no environment ladder is set below.
|
Where PRs land and releases are cut when no environment ladder
|
||||||
|
is set below.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Environment ladder */}
|
{/* Environment ladder */}
|
||||||
<EnvironmentLadderEditor
|
<EnvironmentLadderEditor
|
||||||
rungs={formData.environments ?? null}
|
rungs={formData.environments ?? null}
|
||||||
onChange={(rungs) => setFormData({ ...formData, environments: rungs })}
|
onChange={(rungs) =>
|
||||||
|
setFormData({ ...formData, environments: rungs })
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Advanced Options Toggle */}
|
{/* Advanced Options Toggle */}
|
||||||
@@ -369,7 +385,9 @@ export function CreateProjectDialog() {
|
|||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<HelpTip label="When set, replaces the Lint + Typecheck pair as the dev's complete pre-submit gate command.">
|
<HelpTip label="When set, replaces the Lint + Typecheck pair as the dev's complete pre-submit gate command.">
|
||||||
<Label htmlFor="quality_command">Quality Gate Command</Label>
|
<Label htmlFor="quality_command">
|
||||||
|
Quality Gate Command
|
||||||
|
</Label>
|
||||||
</HelpTip>
|
</HelpTip>
|
||||||
<Input
|
<Input
|
||||||
id="quality_command"
|
id="quality_command"
|
||||||
@@ -387,6 +405,12 @@ export function CreateProjectDialog() {
|
|||||||
run in the dev's workspace at hand-off to QA.
|
run in the dev's workspace at hand-off to QA.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Autonomous maintenance (CI-watch, video engine,
|
||||||
|
dependency-update bot, sandbox DB/Redis/Mongo) is configured
|
||||||
|
after creation, from this project's Edit Project dialog.
|
||||||
|
</p>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -127,10 +127,14 @@ function EditProjectForm({
|
|||||||
// Initialize form state from project
|
// Initialize form state from project
|
||||||
const [name, setName] = useState(project.name);
|
const [name, setName] = useState(project.name);
|
||||||
const [gitUrl, setGitUrl] = useState(project.git_url);
|
const [gitUrl, setGitUrl] = useState(project.git_url);
|
||||||
const [gitProvider, setGitProvider] = useState(project.git_provider ?? "auto");
|
const [gitProvider, setGitProvider] = useState(
|
||||||
|
project.git_provider ?? "auto",
|
||||||
|
);
|
||||||
const [assignedCell, setAssignedCell] = useState(project.assigned_cell);
|
const [assignedCell, setAssignedCell] = useState(project.assigned_cell);
|
||||||
const [defaultBranch, setDefaultBranch] = useState(project.default_branch);
|
const [defaultBranch, setDefaultBranch] = useState(project.default_branch);
|
||||||
const [environments, setEnvironments] = useState(project.environments ?? null);
|
const [environments, setEnvironments] = useState(
|
||||||
|
project.environments ?? null,
|
||||||
|
);
|
||||||
const [isActive, setIsActive] = useState(project.is_active);
|
const [isActive, setIsActive] = useState(project.is_active);
|
||||||
const [testCommand, setTestCommand] = useState(project.test_command || "");
|
const [testCommand, setTestCommand] = useState(project.test_command || "");
|
||||||
const [lintCommand, setLintCommand] = useState(project.lint_command || "");
|
const [lintCommand, setLintCommand] = useState(project.lint_command || "");
|
||||||
@@ -312,7 +316,7 @@ function EditProjectForm({
|
|||||||
|
|
||||||
{/* Forge provider */}
|
{/* Forge provider */}
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<HelpTip label="Which forge API serves PR/CI/review operations. Auto-detect covers github.com; a self-hosted Gitea instance (or GitHub Enterprise) must be set explicitly — the host comes from the Git URL. GitLab support is planned.">
|
<HelpTip label="Which forge API serves PR/CI/review operations. Auto-detect resolves from the Git URL's host — but only at creation time, so changing the Git URL's host here needs an explicit provider re-pick (github.com -> GitHub, gitlab.com -> GitLab); a self-hosted Gitea/GitLab instance or GitHub Enterprise can't be told apart by host alone and must always be set explicitly.">
|
||||||
<Label>Forge</Label>
|
<Label>Forge</Label>
|
||||||
</HelpTip>
|
</HelpTip>
|
||||||
<Select value={gitProvider} onValueChange={setGitProvider}>
|
<Select value={gitProvider} onValueChange={setGitProvider}>
|
||||||
@@ -323,7 +327,9 @@ function EditProjectForm({
|
|||||||
<SelectItem value="auto">Auto-detect (github.com)</SelectItem>
|
<SelectItem value="auto">Auto-detect (github.com)</SelectItem>
|
||||||
<SelectItem value="github">GitHub / GitHub Enterprise</SelectItem>
|
<SelectItem value="github">GitHub / GitHub Enterprise</SelectItem>
|
||||||
<SelectItem value="gitea">Gitea (self-hosted)</SelectItem>
|
<SelectItem value="gitea">Gitea (self-hosted)</SelectItem>
|
||||||
<SelectItem value="gitlab">GitLab (gitlab.com / self-hosted)</SelectItem>
|
<SelectItem value="gitlab">
|
||||||
|
GitLab (gitlab.com / self-hosted)
|
||||||
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
@@ -434,7 +440,8 @@ function EditProjectForm({
|
|||||||
placeholder="main"
|
placeholder="main"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Where PRs land and releases are cut when no environment ladder is set below.
|
Where PRs land and releases are cut when no environment ladder is
|
||||||
|
set below.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -154,4 +154,26 @@ describe("FeatureFlagsCard — M42 off-transition confirm + pending-keys Set", (
|
|||||||
const unmapped = screen.getByText("Alpha");
|
const unmapped = screen.getByText("Alpha");
|
||||||
expect(unmapped.getAttribute("data-state")).toBeNull();
|
expect(unmapped.getAttribute("data-state")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// FLAG_DESCRIPTIONS previously lagged FLAG_TOOLTIPS for three vault/docs
|
||||||
|
// flags — the always-visible paragraph silently rendered empty for them.
|
||||||
|
it("renders an always-visible description for every vault/docs-sync flag", async () => {
|
||||||
|
getFeatureFlags.mockResolvedValueOnce({
|
||||||
|
flags: [
|
||||||
|
{ key: "docs_sync_enabled", label: "Docs Sync", enabled: false },
|
||||||
|
{
|
||||||
|
key: "obsidian_vault_enabled",
|
||||||
|
label: "Obsidian Vault",
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
{ key: "vault_intake_enabled", label: "Vault Intake", enabled: false },
|
||||||
|
],
|
||||||
|
note: "Changes take effect on the next backend restart.",
|
||||||
|
});
|
||||||
|
render(withQueryClient(<FeatureFlagsCard />));
|
||||||
|
|
||||||
|
expect(await screen.findByText(/docs-update task/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/wikilinked Obsidian vault/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/board-review drafts/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -67,6 +67,8 @@ const FLAG_DESCRIPTIONS: Record<string, string> = {
|
|||||||
"Periodically probe opted-in projects for dependency updates and open an update task when a lockfile would change (per-project opt-in; never auto-merges).",
|
"Periodically probe opted-in projects for dependency updates and open an update task when a lockfile would change (per-project opt-in; never auto-merges).",
|
||||||
env_sync_enabled:
|
env_sync_enabled:
|
||||||
"Cascade each project's declared environment ladder prod→dev via GitHub's merges API so dev never falls behind prod; a conflicted rung opens a sync PR for you to merge (per-project opt-in; never pushes prod).",
|
"Cascade each project's declared environment ladder prod→dev via GitHub's merges API so dev never falls behind prod; a conflicted rung opens a sync PR for you to merge (per-project opt-in; never pushes prod).",
|
||||||
|
docs_sync_enabled:
|
||||||
|
"When a release publishes and the public docs site (roboco-website) has drifted from what shipped, open ONE docs-update task that rides the normal delivery flow (+ PR-review gate) — release-triggered, not polling; never auto-merges. Needs the docs-site repo registered as a project with a git token.",
|
||||||
release_manager_enabled:
|
release_manager_enabled:
|
||||||
"Run the deterministic release-readiness sweep and propose a release for you to approve or reject — it never publishes without your approval, and the executor is fail-closed on a red gate.",
|
"Run the deterministic release-readiness sweep and propose a release for you to approve or reject — it never publishes without your approval, and the executor is fail-closed on a red gate.",
|
||||||
org_memory_enabled:
|
org_memory_enabled:
|
||||||
@@ -91,6 +93,10 @@ const FLAG_DESCRIPTIONS: Record<string, string> = {
|
|||||||
"Also open a video-authoring task when a release publishes. Off by default even with video_engine_enabled on.",
|
"Also open a video-authoring task when a release publishes. Off by default even with video_engine_enabled on.",
|
||||||
video_on_spotlight:
|
video_on_spotlight:
|
||||||
"Also open a video-authoring task when you approve a feature-spotlight draft that requests one. Off by default even with video_engine_enabled on.",
|
"Also open a video-authoring task when you approve a feature-spotlight draft that requests one. Off by default even with video_engine_enabled on.",
|
||||||
|
obsidian_vault_enabled:
|
||||||
|
"Project tasks, journals, and A2A digests into a human-readable, wikilinked Obsidian vault on disk (RoboCo/Tasks, Journals, A2A, Agents) — a rebuildable, DB-derived projection, never the system of record. Needs ROBOCO_VAULT_PATH set.",
|
||||||
|
vault_intake_enabled:
|
||||||
|
"Watch the vault's inbox folder for #roboco-tagged notes and turn them into board-review drafts — the same Product-Owner-reviewed path a chat-confirmed task takes, never straight into delivery. Needs the Obsidian vault projection on.",
|
||||||
vault_report_enabled:
|
vault_report_enabled:
|
||||||
"Materialize a weekly org-report note (velocity, cycle time, rework, cost) in the vault's Reports/ folder and notify you — deterministic numbers, no LLM. Needs the Obsidian vault projection on.",
|
"Materialize a weekly org-report note (velocity, cycle time, rework, cost) in the vault's Reports/ folder and notify you — deterministic numbers, no LLM. Needs the Obsidian vault projection on.",
|
||||||
vault_kb_enabled:
|
vault_kb_enabled:
|
||||||
@@ -148,11 +154,11 @@ const FLAG_TOOLTIPS: Record<string, string> = {
|
|||||||
video_engine_enabled:
|
video_engine_enabled:
|
||||||
"Authors and renders motion-graphics videos for social posts.",
|
"Authors and renders motion-graphics videos for social posts.",
|
||||||
video_on_release: "Drafts a video whenever a release publishes.",
|
video_on_release: "Drafts a video whenever a release publishes.",
|
||||||
video_on_spotlight:
|
video_on_spotlight: "Drafts a video whenever a feature spotlight is drafted.",
|
||||||
"Drafts a video whenever a feature spotlight is drafted.",
|
|
||||||
roadmap_engine_enabled:
|
roadmap_engine_enabled:
|
||||||
"Weekly has the Board draft a themed roadmap for CEO approval.",
|
"Weekly has the Board draft a themed roadmap for CEO approval.",
|
||||||
fable_mode_enabled: "Adopts the Fable/Ponytail behavioral doctrine fleet-wide.",
|
fable_mode_enabled:
|
||||||
|
"Adopts the Fable/Ponytail behavioral doctrine fleet-wide.",
|
||||||
obsidian_vault_enabled:
|
obsidian_vault_enabled:
|
||||||
"Projects tasks/journals/A2A into a human-readable Obsidian vault.",
|
"Projects tasks/journals/A2A into a human-readable Obsidian vault.",
|
||||||
vault_intake_enabled:
|
vault_intake_enabled:
|
||||||
@@ -297,7 +303,9 @@ export function FeatureFlagsCard() {
|
|||||||
trigger itself would clobber its open/closed
|
trigger itself would clobber its open/closed
|
||||||
data-state (same trap as Switch/TabsTrigger). */}
|
data-state (same trap as Switch/TabsTrigger). */}
|
||||||
<HelpTip label="Only takes effect once x_engine_enabled above is on.">
|
<HelpTip label="Only takes effect once x_engine_enabled above is on.">
|
||||||
<span className="text-sm">X (Twitter) credentials</span>
|
<span className="text-sm">
|
||||||
|
X (Twitter) credentials
|
||||||
|
</span>
|
||||||
</HelpTip>
|
</HelpTip>
|
||||||
{xCredsOpen ? (
|
{xCredsOpen ? (
|
||||||
<ChevronDown className="h-4 w-4" />
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
import { ChevronDown, ChevronRight, GitBranch } from "lucide-react";
|
import { ChevronDown, ChevronRight, GitBranch } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { MarkdownEditor } from "./markdown-editor";
|
import { MarkdownEditor } from "./markdown-editor";
|
||||||
|
import { AcceptanceCriteriaEditor } from "./acceptance-criteria-editor";
|
||||||
import { AgentSelector } from "@/components/agents/agent-selector";
|
import { AgentSelector } from "@/components/agents/agent-selector";
|
||||||
import { ProjectSelector } from "@/components/projects/project-selector";
|
import { ProjectSelector } from "@/components/projects/project-selector";
|
||||||
import { HelpTip } from "@/components/ui/help-tip";
|
import { HelpTip } from "@/components/ui/help-tip";
|
||||||
@@ -67,7 +68,8 @@ const TASK_TYPE_OPTIONS = [
|
|||||||
// (branch, commits, PR) — this only classifies the kind of artifact.
|
// (branch, commits, PR) — this only classifies the kind of artifact.
|
||||||
const TASK_TYPE_DESCRIPTIONS: Record<TaskType, string> = {
|
const TASK_TYPE_DESCRIPTIONS: Record<TaskType, string> = {
|
||||||
[TaskType.CODE]: "Source code changes. Follows the full git workflow.",
|
[TaskType.CODE]: "Source code changes. Follows the full git workflow.",
|
||||||
[TaskType.DOCUMENTATION]: "Documentation updates. Follows the full git workflow.",
|
[TaskType.DOCUMENTATION]:
|
||||||
|
"Documentation updates. Follows the full git workflow.",
|
||||||
[TaskType.RESEARCH]:
|
[TaskType.RESEARCH]:
|
||||||
"Research findings, committed as notes. Follows the full git workflow.",
|
"Research findings, committed as notes. Follows the full git workflow.",
|
||||||
[TaskType.PLANNING]:
|
[TaskType.PLANNING]:
|
||||||
@@ -102,6 +104,10 @@ function EditTaskDialogInner({
|
|||||||
const [nature, setNature] = useState<TaskNature>(
|
const [nature, setNature] = useState<TaskNature>(
|
||||||
task.nature ?? TaskNature.TECHNICAL,
|
task.nature ?? TaskNature.TECHNICAL,
|
||||||
);
|
);
|
||||||
|
const [acceptanceCriteria, setAcceptanceCriteria] = useState<string[]>(
|
||||||
|
task.acceptance_criteria,
|
||||||
|
);
|
||||||
|
const [acError, setAcError] = useState<string | undefined>();
|
||||||
const [taskType, setTaskType] = useState<TaskType>(
|
const [taskType, setTaskType] = useState<TaskType>(
|
||||||
task.task_type ?? TaskType.CODE,
|
task.task_type ?? TaskType.CODE,
|
||||||
);
|
);
|
||||||
@@ -131,6 +137,19 @@ function EditTaskDialogInner({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (acceptanceCriteria.length === 0) {
|
||||||
|
setAcError("At least one acceptance criterion is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAcError(undefined);
|
||||||
|
|
||||||
|
const trimmedCriteria = acceptanceCriteria
|
||||||
|
.map((c) => c.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const criteriaChanged =
|
||||||
|
JSON.stringify(trimmedCriteria) !==
|
||||||
|
JSON.stringify(task.acceptance_criteria);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await updateTask.mutateAsync({
|
await updateTask.mutateAsync({
|
||||||
taskId: task.id,
|
taskId: task.id,
|
||||||
@@ -145,6 +164,7 @@ function EditTaskDialogInner({
|
|||||||
project_id: projectId,
|
project_id: projectId,
|
||||||
assigned_to: assignedTo,
|
assigned_to: assignedTo,
|
||||||
target_date: targetDate ? new Date(targetDate).toISOString() : null,
|
target_date: targetDate ? new Date(targetDate).toISOString() : null,
|
||||||
|
...(criteriaChanged && { acceptance_criteria: trimmedCriteria }),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
toast.success("Task updated successfully");
|
toast.success("Task updated successfully");
|
||||||
@@ -267,6 +287,13 @@ function EditTaskDialogInner({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Acceptance Criteria */}
|
||||||
|
<AcceptanceCriteriaEditor
|
||||||
|
criteria={acceptanceCriteria}
|
||||||
|
onChange={setAcceptanceCriteria}
|
||||||
|
error={acError}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Advanced Options */}
|
{/* Advanced Options */}
|
||||||
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
|
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
|
||||||
<CollapsibleTrigger asChild>
|
<CollapsibleTrigger asChild>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useGitBrowser } from "../use-git-browser";
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
mockUseProjects,
|
mockUseProjects,
|
||||||
|
mockUseProject,
|
||||||
mockUseGitStatus,
|
mockUseGitStatus,
|
||||||
mockUseGitLog,
|
mockUseGitLog,
|
||||||
mockUseGitBranches,
|
mockUseGitBranches,
|
||||||
@@ -16,6 +17,7 @@ const {
|
|||||||
mockToastError,
|
mockToastError,
|
||||||
} = vi.hoisted(() => ({
|
} = vi.hoisted(() => ({
|
||||||
mockUseProjects: vi.fn(),
|
mockUseProjects: vi.fn(),
|
||||||
|
mockUseProject: vi.fn(),
|
||||||
mockUseGitStatus: vi.fn(),
|
mockUseGitStatus: vi.fn(),
|
||||||
mockUseGitLog: vi.fn(),
|
mockUseGitLog: vi.fn(),
|
||||||
mockUseGitBranches: vi.fn(),
|
mockUseGitBranches: vi.fn(),
|
||||||
@@ -30,6 +32,7 @@ const {
|
|||||||
|
|
||||||
vi.mock("@/hooks/use-projects", () => ({
|
vi.mock("@/hooks/use-projects", () => ({
|
||||||
useProjects: () => mockUseProjects(),
|
useProjects: () => mockUseProjects(),
|
||||||
|
useProject: (...args: unknown[]) => mockUseProject(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/hooks/use-git", () => ({
|
vi.mock("@/hooks/use-git", () => ({
|
||||||
@@ -95,6 +98,7 @@ describe("useGitBrowser", () => {
|
|||||||
registeredCallbacks.length = 0;
|
registeredCallbacks.length = 0;
|
||||||
|
|
||||||
mockUseProjects.mockReturnValue(buildQueryResult([]));
|
mockUseProjects.mockReturnValue(buildQueryResult([]));
|
||||||
|
mockUseProject.mockReturnValue(buildQueryResult(null));
|
||||||
mockUseGitStatus.mockReturnValue(buildQueryResult(null));
|
mockUseGitStatus.mockReturnValue(buildQueryResult(null));
|
||||||
mockUseGitLog.mockReturnValue(buildQueryResult(null));
|
mockUseGitLog.mockReturnValue(buildQueryResult(null));
|
||||||
mockUseGitBranches.mockReturnValue(buildQueryResult(null));
|
mockUseGitBranches.mockReturnValue(buildQueryResult(null));
|
||||||
@@ -121,6 +125,52 @@ describe("useGitBrowser", () => {
|
|||||||
expect(result.current.taskId).toBe("t1");
|
expect(result.current.taskId).toBe("t1");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Bug: GitActionsPanel used to hardcode "main" for the PR-target branch,
|
||||||
|
// wrong for the fleet default ("master") and any project on a real
|
||||||
|
// environment ladder. defaultBranch resolves the selected project's real
|
||||||
|
// head rung (or default_branch), not a literal.
|
||||||
|
it("resolves defaultBranch from the selected project's environment ladder head rung", () => {
|
||||||
|
mockUseProjects.mockReturnValue(
|
||||||
|
buildQueryResult([{ id: "proj-1", slug: "roboco", name: "RoboCo" }]),
|
||||||
|
);
|
||||||
|
mockUseProject.mockReturnValue(
|
||||||
|
buildQueryResult({
|
||||||
|
id: "proj-1",
|
||||||
|
default_branch: "master",
|
||||||
|
environments: [
|
||||||
|
{ name: "head", branch: "slave" },
|
||||||
|
{ name: "prod", branch: "master" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useGitBrowser());
|
||||||
|
expect(mockUseProject).toHaveBeenCalledWith("proj-1");
|
||||||
|
expect(result.current.defaultBranch).toBe("slave");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to default_branch when no environment ladder is set", () => {
|
||||||
|
mockUseProjects.mockReturnValue(
|
||||||
|
buildQueryResult([{ id: "proj-1", slug: "roboco", name: "RoboCo" }]),
|
||||||
|
);
|
||||||
|
mockUseProject.mockReturnValue(
|
||||||
|
buildQueryResult({
|
||||||
|
id: "proj-1",
|
||||||
|
default_branch: "master",
|
||||||
|
environments: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useGitBrowser());
|
||||||
|
expect(result.current.defaultBranch).toBe("master");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to "main" before any project has loaded', () => {
|
||||||
|
const { result } = renderHook(() => useGitBrowser());
|
||||||
|
expect(mockUseProject).toHaveBeenCalledWith("");
|
||||||
|
expect(result.current.defaultBranch).toBe("main");
|
||||||
|
});
|
||||||
|
|
||||||
it("passes project slug and enabled flag to git query hooks", () => {
|
it("passes project slug and enabled flag to git query hooks", () => {
|
||||||
renderHook(() => useGitBrowser());
|
renderHook(() => useGitBrowser());
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useCallback, useEffect, useRef } from "react";
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useProjects } from "@/hooks/use-projects";
|
import { useProjects, useProject } from "@/hooks/use-projects";
|
||||||
import {
|
import {
|
||||||
useGitStatus,
|
useGitStatus,
|
||||||
useGitLog,
|
useGitLog,
|
||||||
@@ -20,6 +20,10 @@ export interface UseGitBrowserResult {
|
|||||||
taskId: string;
|
taskId: string;
|
||||||
projects: ReturnType<typeof useProjects>["data"];
|
projects: ReturnType<typeof useProjects>["data"];
|
||||||
loadingProjects: boolean;
|
loadingProjects: boolean;
|
||||||
|
// Real head/default branch of the selected project (env-ladder rung 0,
|
||||||
|
// falling back to default_branch, then "main" before any project loads) —
|
||||||
|
// never a hardcoded "main".
|
||||||
|
defaultBranch: string;
|
||||||
status: ReturnType<typeof useGitStatus>["data"];
|
status: ReturnType<typeof useGitStatus>["data"];
|
||||||
loadingStatus: boolean;
|
loadingStatus: boolean;
|
||||||
log: ReturnType<typeof useGitLog>["data"];
|
log: ReturnType<typeof useGitLog>["data"];
|
||||||
@@ -80,6 +84,19 @@ export function useGitBrowser(): UseGitBrowserResult {
|
|||||||
refetch: refetchProjects,
|
refetch: refetchProjects,
|
||||||
} = useProjects();
|
} = useProjects();
|
||||||
|
|
||||||
|
// The project list is a lightweight ProjectSummary (no default_branch /
|
||||||
|
// environments) — resolve its id, then fetch the full Project for the
|
||||||
|
// ladder/branch fields.
|
||||||
|
const currentProjectId =
|
||||||
|
projects?.find((p) => p.slug === projectSlug)?.id ?? "";
|
||||||
|
const { data: currentProject } = useProject(currentProjectId);
|
||||||
|
// Ladder rung 0 (head) when an environment ladder is declared, else the
|
||||||
|
// plain default_branch shim — mirrors roboco/models/env_branches.head_branch.
|
||||||
|
const defaultBranch =
|
||||||
|
currentProject?.environments?.[0]?.branch ??
|
||||||
|
currentProject?.default_branch ??
|
||||||
|
"main";
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: status,
|
data: status,
|
||||||
isLoading: loadingStatus,
|
isLoading: loadingStatus,
|
||||||
@@ -168,7 +185,9 @@ export function useGitBrowser(): UseGitBrowserResult {
|
|||||||
} = useGitOperations();
|
} = useGitOperations();
|
||||||
|
|
||||||
// Resume point for a capped stale-branch sweep, per project.
|
// Resume point for a capped stale-branch sweep, per project.
|
||||||
const cleanupCursorRef = useRef<{ slug: string; cursor: string } | null>(null);
|
const cleanupCursorRef = useRef<{ slug: string; cursor: string } | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
const handleCheckout = useCallback(
|
const handleCheckout = useCallback(
|
||||||
async (branch: string) => {
|
async (branch: string) => {
|
||||||
@@ -362,6 +381,7 @@ export function useGitBrowser(): UseGitBrowserResult {
|
|||||||
taskId,
|
taskId,
|
||||||
projects,
|
projects,
|
||||||
loadingProjects,
|
loadingProjects,
|
||||||
|
defaultBranch,
|
||||||
status,
|
status,
|
||||||
loadingStatus,
|
loadingStatus,
|
||||||
log,
|
log,
|
||||||
|
|||||||
Reference in New Issue
Block a user