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() })) },
|
||||
}));
|
||||
|
||||
// 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";
|
||||
|
||||
function openDialog() {
|
||||
@@ -59,9 +70,7 @@ describe("SpawnAgentDialog", () => {
|
||||
mutateAsync.mockResolvedValue({ already_running: false });
|
||||
openDialog();
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/Task ID/i), {
|
||||
target: { value: "task-123" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Set Task" }));
|
||||
fireEvent.change(screen.getByLabelText(/Initial Prompt/i), {
|
||||
target: { value: "go fix it" },
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||
import { HelpTip } from "@/components/ui/help-tip";
|
||||
import { TaskSelector } from "@/components/tasks/task-selector";
|
||||
import { Play } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -31,7 +32,7 @@ export function SpawnAgentDialog({
|
||||
trigger,
|
||||
}: SpawnAgentDialogProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [taskId, setTaskId] = useState("");
|
||||
const [taskId, setTaskId] = useState<string | null>(null);
|
||||
const [initialPrompt, setInitialPrompt] = useState("");
|
||||
const spawnAgent = useSpawnAgent();
|
||||
// Synchronous re-entrancy guard: `spawnAgent.isPending` only flips on a
|
||||
@@ -66,7 +67,7 @@ export function SpawnAgentDialog({
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setTaskId("");
|
||||
setTaskId(null);
|
||||
setInitialPrompt("");
|
||||
};
|
||||
|
||||
@@ -102,18 +103,19 @@ export function SpawnAgentDialog({
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<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>
|
||||
<Input
|
||||
id="taskId"
|
||||
<TaskSelector
|
||||
value={taskId}
|
||||
onChange={(e) => setTaskId(e.target.value)}
|
||||
placeholder="UUID of task to assign"
|
||||
onChange={setTaskId}
|
||||
placeholder="Select task to assign (optional)..."
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<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>
|
||||
<Input
|
||||
id="initialPrompt"
|
||||
|
||||
@@ -8,6 +8,8 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { HelpTip } from "@/components/ui/help-tip";
|
||||
import { TaskSelector } from "@/components/tasks/task-selector";
|
||||
import { AgentSelector } from "@/components/agents/agent-selector";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -53,8 +55,8 @@ export function CreateFlagDialog({
|
||||
const [description, setDescription] = useState("");
|
||||
const [severity, setSeverity] = useState<FlagSeverity>(FlagSeverity.INFO);
|
||||
const [category, setCategory] = useState("quality");
|
||||
const [relatedTaskId, setRelatedTaskId] = useState("");
|
||||
const [relatedAgentId, setRelatedAgentId] = useState("");
|
||||
const [relatedTaskId, setRelatedTaskId] = useState<string | null>(null);
|
||||
const [relatedAgentId, setRelatedAgentId] = useState<string | null>(null);
|
||||
|
||||
const createFlag = useCreateAuditorFlag();
|
||||
|
||||
@@ -72,8 +74,8 @@ export function CreateFlagDialog({
|
||||
description: description.trim(),
|
||||
severity,
|
||||
category,
|
||||
related_task_id: relatedTaskId.trim() || undefined,
|
||||
related_agent_id: relatedAgentId.trim() || undefined,
|
||||
related_task_id: relatedTaskId || undefined,
|
||||
related_agent_id: relatedAgentId || undefined,
|
||||
});
|
||||
toast.success("Flag created successfully");
|
||||
onOpenChange(false);
|
||||
@@ -88,8 +90,8 @@ export function CreateFlagDialog({
|
||||
setDescription("");
|
||||
setSeverity(FlagSeverity.INFO);
|
||||
setCategory("quality");
|
||||
setRelatedTaskId("");
|
||||
setRelatedAgentId("");
|
||||
setRelatedTaskId(null);
|
||||
setRelatedAgentId(null);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -172,24 +174,22 @@ export function CreateFlagDialog({
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<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>
|
||||
<Input
|
||||
id="task"
|
||||
<TaskSelector
|
||||
value={relatedTaskId}
|
||||
onChange={(e) => setRelatedTaskId(e.target.value)}
|
||||
placeholder="Task UUID"
|
||||
onChange={setRelatedTaskId}
|
||||
placeholder="Select task (optional)..."
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<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>
|
||||
<Input
|
||||
id="agent"
|
||||
<AgentSelector
|
||||
value={relatedAgentId}
|
||||
onChange={(e) => setRelatedAgentId(e.target.value)}
|
||||
placeholder="Agent ID"
|
||||
onChange={setRelatedAgentId}
|
||||
placeholder="Select agent (optional)..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -45,6 +45,9 @@ interface GitActionsPanelProps {
|
||||
projectSlug: string;
|
||||
taskId: 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;
|
||||
onPush: (force?: boolean) => void;
|
||||
onCreatePR: (title: string, body: string) => void;
|
||||
@@ -68,6 +71,7 @@ export function GitActionsPanel({
|
||||
projectSlug,
|
||||
taskId,
|
||||
agentId: _agentId,
|
||||
defaultBranch,
|
||||
onCommit,
|
||||
onPush,
|
||||
onCreatePR,
|
||||
@@ -98,7 +102,8 @@ export function GitActionsPanel({
|
||||
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 canCreatePR =
|
||||
hasUnpushedCommits || status?.current_branch !== defaultBranch;
|
||||
|
||||
const handleCommitDialogOpenChange = (newOpen: boolean) => {
|
||||
if (!newOpen) setCommitMessage("");
|
||||
@@ -251,10 +256,7 @@ export function GitActionsPanel({
|
||||
: "Nothing to push — no local commits sit ahead of the remote branch yet."
|
||||
}
|
||||
>
|
||||
<span
|
||||
className="block w-full"
|
||||
tabIndex={!canPush ? 0 : undefined}
|
||||
>
|
||||
<span className="block w-full" tabIndex={!canPush ? 0 : undefined}>
|
||||
<Button
|
||||
className="w-full justify-start"
|
||||
variant={canPush ? "default" : "outline"}
|
||||
@@ -284,8 +286,8 @@ export function GitActionsPanel({
|
||||
<HelpTip
|
||||
label={
|
||||
canCreatePR
|
||||
? "Opens GitHub's PR creation flow for this branch against main."
|
||||
: "Already on main with nothing ahead of it — there's no branch content to open a PR for."
|
||||
? `Opens the forge's PR creation flow for this branch against ${defaultBranch}.`
|
||||
: `Already on ${defaultBranch} with nothing ahead of it — there's no branch content to open a PR for.`
|
||||
}
|
||||
>
|
||||
<span
|
||||
@@ -313,7 +315,7 @@ export function GitActionsPanel({
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground w-fit">
|
||||
<Badge variant="outline">{status?.current_branch}</Badge>
|
||||
<span>→</span>
|
||||
<Badge variant="outline">main</Badge>
|
||||
<Badge variant="outline">{defaultBranch}</Badge>
|
||||
</div>
|
||||
</HelpTip>
|
||||
<div className="space-y-2">
|
||||
@@ -383,9 +385,7 @@ export function GitActionsPanel({
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<HelpTip label="The GitHub PR number to merge, e.g. the 42 in .../pull/42.">
|
||||
<label className="text-sm font-medium w-fit">
|
||||
PR Number
|
||||
</label>
|
||||
<label className="text-sm font-medium w-fit">PR Number</label>
|
||||
</HelpTip>
|
||||
<Input
|
||||
type="number"
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useState } from "react";
|
||||
import { GitBranchListResponse, BranchType } from "@/types/git";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
@@ -24,6 +23,7 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { GitBranch, Check, Cloud, Plus, RefreshCw } from "lucide-react";
|
||||
import { HelpTip } from "@/components/ui/help-tip";
|
||||
import { TaskSelector } from "@/components/tasks/task-selector";
|
||||
|
||||
interface GitBranchPanelProps {
|
||||
branches: GitBranchListResponse | undefined;
|
||||
@@ -44,13 +44,13 @@ export function GitBranchPanel({
|
||||
}: GitBranchPanelProps) {
|
||||
const [showCreateDialog, setShowCreateDialog] = useState(false);
|
||||
const [newBranchType, setNewBranchType] = useState<BranchType>("feature");
|
||||
const [taskId, setTaskId] = useState("");
|
||||
const [taskId, setTaskId] = useState<string | null>(null);
|
||||
|
||||
const handleCreateBranch = () => {
|
||||
if (taskId.trim()) {
|
||||
onCreateBranch(newBranchType, taskId.trim());
|
||||
if (taskId) {
|
||||
onCreateBranch(newBranchType, taskId);
|
||||
setShowCreateDialog(false);
|
||||
setTaskId("");
|
||||
setTaskId(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -131,14 +131,12 @@ export function GitBranchPanel({
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<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">
|
||||
Task ID
|
||||
</label>
|
||||
<label className="text-sm font-medium w-fit">Task</label>
|
||||
</HelpTip>
|
||||
<Input
|
||||
placeholder="Enter task ID..."
|
||||
<TaskSelector
|
||||
value={taskId}
|
||||
onChange={(e) => setTaskId(e.target.value)}
|
||||
onChange={setTaskId}
|
||||
placeholder="Select task..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -151,7 +149,7 @@ export function GitBranchPanel({
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreateBranch}
|
||||
disabled={!taskId.trim() || isCreating}
|
||||
disabled={!taskId || isCreating}
|
||||
>
|
||||
{isCreating && (
|
||||
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
|
||||
|
||||
@@ -26,6 +26,7 @@ function GitBrowserContent() {
|
||||
taskId,
|
||||
projects,
|
||||
loadingProjects,
|
||||
defaultBranch,
|
||||
status,
|
||||
loadingStatus,
|
||||
log,
|
||||
@@ -134,6 +135,7 @@ function GitBrowserContent() {
|
||||
projectSlug={projectSlug}
|
||||
taskId={taskId}
|
||||
agentId="pm"
|
||||
defaultBranch={defaultBranch}
|
||||
onCommit={handleCommit}
|
||||
onPush={handlePush}
|
||||
onCreatePR={handleCreatePR}
|
||||
|
||||
@@ -27,7 +27,6 @@ import { Team, type ProjectCreate } from "@/types";
|
||||
import { EnvironmentLadderEditor } from "@/components/projects/environment-ladder-editor";
|
||||
import { validateLadder } from "@/components/projects/ladder-validation";
|
||||
import { HelpTip } from "@/components/ui/help-tip";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
const cells: { value: Team; label: string }[] = [
|
||||
{ value: Team.BACKEND, label: "Backend" },
|
||||
@@ -54,6 +53,9 @@ export function CreateProjectDialog() {
|
||||
default_branch: "main",
|
||||
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 createProject = useCreateProject();
|
||||
@@ -90,6 +92,7 @@ export function CreateProjectDialog() {
|
||||
name: formData.name,
|
||||
slug: formData.slug,
|
||||
git_url: formData.git_url,
|
||||
git_provider: gitProvider === "auto" ? null : gitProvider,
|
||||
assigned_cell: formData.assigned_cell,
|
||||
git_token: formData.git_token || undefined,
|
||||
default_branch: formData.default_branch || "main",
|
||||
@@ -112,6 +115,7 @@ export function CreateProjectDialog() {
|
||||
default_branch: "main",
|
||||
environments: null,
|
||||
});
|
||||
setGitProvider("auto");
|
||||
setShowAdvanced(false);
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
@@ -189,17 +193,26 @@ export function CreateProjectDialog() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Forge (read-only — GitHub-only today) */}
|
||||
{/* Forge */}
|
||||
<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>
|
||||
</HelpTip>
|
||||
<div>
|
||||
<Badge variant="secondary">GitHub</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
GitLab & Gitea support planned.
|
||||
</p>
|
||||
<Select value={gitProvider} onValueChange={setGitProvider}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Auto-detect" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<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>
|
||||
|
||||
{/* Git Token */}
|
||||
@@ -263,14 +276,17 @@ export function CreateProjectDialog() {
|
||||
placeholder="main"
|
||||
/>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{/* Environment ladder */}
|
||||
<EnvironmentLadderEditor
|
||||
rungs={formData.environments ?? null}
|
||||
onChange={(rungs) => setFormData({ ...formData, environments: rungs })}
|
||||
onChange={(rungs) =>
|
||||
setFormData({ ...formData, environments: rungs })
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Advanced Options Toggle */}
|
||||
@@ -369,7 +385,9 @@ export function CreateProjectDialog() {
|
||||
|
||||
<div className="grid gap-2">
|
||||
<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>
|
||||
<Input
|
||||
id="quality_command"
|
||||
@@ -387,6 +405,12 @@ export function CreateProjectDialog() {
|
||||
run in the dev's workspace at hand-off to QA.
|
||||
</p>
|
||||
</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>
|
||||
|
||||
@@ -127,10 +127,14 @@ function EditProjectForm({
|
||||
// Initialize form state from project
|
||||
const [name, setName] = useState(project.name);
|
||||
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 [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 [testCommand, setTestCommand] = useState(project.test_command || "");
|
||||
const [lintCommand, setLintCommand] = useState(project.lint_command || "");
|
||||
@@ -312,7 +316,7 @@ function EditProjectForm({
|
||||
|
||||
{/* Forge provider */}
|
||||
<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>
|
||||
</HelpTip>
|
||||
<Select value={gitProvider} onValueChange={setGitProvider}>
|
||||
@@ -323,7 +327,9 @@ function EditProjectForm({
|
||||
<SelectItem value="auto">Auto-detect (github.com)</SelectItem>
|
||||
<SelectItem value="github">GitHub / GitHub Enterprise</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>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -434,7 +440,8 @@ function EditProjectForm({
|
||||
placeholder="main"
|
||||
/>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -154,4 +154,26 @@ describe("FeatureFlagsCard — M42 off-transition confirm + pending-keys Set", (
|
||||
const unmapped = screen.getByText("Alpha");
|
||||
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).",
|
||||
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).",
|
||||
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:
|
||||
"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:
|
||||
@@ -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.",
|
||||
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.",
|
||||
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:
|
||||
"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:
|
||||
@@ -148,11 +154,11 @@ const FLAG_TOOLTIPS: Record<string, string> = {
|
||||
video_engine_enabled:
|
||||
"Authors and renders motion-graphics videos for social posts.",
|
||||
video_on_release: "Drafts a video whenever a release publishes.",
|
||||
video_on_spotlight:
|
||||
"Drafts a video whenever a feature spotlight is drafted.",
|
||||
video_on_spotlight: "Drafts a video whenever a feature spotlight is drafted.",
|
||||
roadmap_engine_enabled:
|
||||
"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:
|
||||
"Projects tasks/journals/A2A into a human-readable Obsidian vault.",
|
||||
vault_intake_enabled:
|
||||
@@ -297,7 +303,9 @@ export function FeatureFlagsCard() {
|
||||
trigger itself would clobber its open/closed
|
||||
data-state (same trap as Switch/TabsTrigger). */}
|
||||
<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>
|
||||
{xCredsOpen ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
import { ChevronDown, ChevronRight, GitBranch } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { MarkdownEditor } from "./markdown-editor";
|
||||
import { AcceptanceCriteriaEditor } from "./acceptance-criteria-editor";
|
||||
import { AgentSelector } from "@/components/agents/agent-selector";
|
||||
import { ProjectSelector } from "@/components/projects/project-selector";
|
||||
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.
|
||||
const TASK_TYPE_DESCRIPTIONS: Record<TaskType, string> = {
|
||||
[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]:
|
||||
"Research findings, committed as notes. Follows the full git workflow.",
|
||||
[TaskType.PLANNING]:
|
||||
@@ -102,6 +104,10 @@ function EditTaskDialogInner({
|
||||
const [nature, setNature] = useState<TaskNature>(
|
||||
task.nature ?? TaskNature.TECHNICAL,
|
||||
);
|
||||
const [acceptanceCriteria, setAcceptanceCriteria] = useState<string[]>(
|
||||
task.acceptance_criteria,
|
||||
);
|
||||
const [acError, setAcError] = useState<string | undefined>();
|
||||
const [taskType, setTaskType] = useState<TaskType>(
|
||||
task.task_type ?? TaskType.CODE,
|
||||
);
|
||||
@@ -131,6 +137,19 @@ function EditTaskDialogInner({
|
||||
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 {
|
||||
await updateTask.mutateAsync({
|
||||
taskId: task.id,
|
||||
@@ -145,6 +164,7 @@ function EditTaskDialogInner({
|
||||
project_id: projectId,
|
||||
assigned_to: assignedTo,
|
||||
target_date: targetDate ? new Date(targetDate).toISOString() : null,
|
||||
...(criteriaChanged && { acceptance_criteria: trimmedCriteria }),
|
||||
},
|
||||
});
|
||||
toast.success("Task updated successfully");
|
||||
@@ -267,6 +287,13 @@ function EditTaskDialogInner({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Acceptance Criteria */}
|
||||
<AcceptanceCriteriaEditor
|
||||
criteria={acceptanceCriteria}
|
||||
onChange={setAcceptanceCriteria}
|
||||
error={acError}
|
||||
/>
|
||||
|
||||
{/* Advanced Options */}
|
||||
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useGitBrowser } from "../use-git-browser";
|
||||
|
||||
const {
|
||||
mockUseProjects,
|
||||
mockUseProject,
|
||||
mockUseGitStatus,
|
||||
mockUseGitLog,
|
||||
mockUseGitBranches,
|
||||
@@ -16,6 +17,7 @@ const {
|
||||
mockToastError,
|
||||
} = vi.hoisted(() => ({
|
||||
mockUseProjects: vi.fn(),
|
||||
mockUseProject: vi.fn(),
|
||||
mockUseGitStatus: vi.fn(),
|
||||
mockUseGitLog: vi.fn(),
|
||||
mockUseGitBranches: vi.fn(),
|
||||
@@ -30,6 +32,7 @@ const {
|
||||
|
||||
vi.mock("@/hooks/use-projects", () => ({
|
||||
useProjects: () => mockUseProjects(),
|
||||
useProject: (...args: unknown[]) => mockUseProject(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-git", () => ({
|
||||
@@ -95,6 +98,7 @@ describe("useGitBrowser", () => {
|
||||
registeredCallbacks.length = 0;
|
||||
|
||||
mockUseProjects.mockReturnValue(buildQueryResult([]));
|
||||
mockUseProject.mockReturnValue(buildQueryResult(null));
|
||||
mockUseGitStatus.mockReturnValue(buildQueryResult(null));
|
||||
mockUseGitLog.mockReturnValue(buildQueryResult(null));
|
||||
mockUseGitBranches.mockReturnValue(buildQueryResult(null));
|
||||
@@ -121,6 +125,52 @@ describe("useGitBrowser", () => {
|
||||
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", () => {
|
||||
renderHook(() => useGitBrowser());
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { useProjects } from "@/hooks/use-projects";
|
||||
import { useProjects, useProject } from "@/hooks/use-projects";
|
||||
import {
|
||||
useGitStatus,
|
||||
useGitLog,
|
||||
@@ -20,6 +20,10 @@ export interface UseGitBrowserResult {
|
||||
taskId: string;
|
||||
projects: ReturnType<typeof useProjects>["data"];
|
||||
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"];
|
||||
loadingStatus: boolean;
|
||||
log: ReturnType<typeof useGitLog>["data"];
|
||||
@@ -80,6 +84,19 @@ export function useGitBrowser(): UseGitBrowserResult {
|
||||
refetch: refetchProjects,
|
||||
} = 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 {
|
||||
data: status,
|
||||
isLoading: loadingStatus,
|
||||
@@ -168,7 +185,9 @@ export function useGitBrowser(): UseGitBrowserResult {
|
||||
} = useGitOperations();
|
||||
|
||||
// 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(
|
||||
async (branch: string) => {
|
||||
@@ -362,6 +381,7 @@ export function useGitBrowser(): UseGitBrowserResult {
|
||||
taskId,
|
||||
projects,
|
||||
loadingProjects,
|
||||
defaultBranch,
|
||||
status,
|
||||
loadingStatus,
|
||||
log,
|
||||
|
||||
Reference in New Issue
Block a user