Files
roboco/panel/src/components/projects/edit-project-dialog.tsx
T
d80dfb8bbe feat(env-branches): per-project ordered environment ladder (replaces default_branch) (#534)
* [env-bran] EnvSyncEngine: orchestrator-side prod→dev cascade (default-off)

- EnvSyncEngine mirrors CiWatchEngine: cascade ladder_pairs top-down via
  GitHub merges API; clean→auto-push lower rung, conflict→one sync PR +
  tracked MAIN_PM task + stop. Never pushes prod (lower rung is never prod
  by construction).
- GitService.sync_env_branch (merges API) + open_sync_pr (idempotent) +
  _env_merge_status/_post_sync_pr helpers (constants for 201/204/409).
- TaskService.ENV_SYNC_SOURCE + list_open_env_sync_tasks (per-repo dedup).
- config env_sync_enabled/_interval_seconds(1800)/_max_open_tasks(3)/_max_per_cycle(1).
- Orchestrator 4-touch registration + _load_env_sync_set (ladder+token opt-in).
- Feature-flags card + settings FEATURE_FLAGS entry for ROBOCO_ENV_SYNC_ENABLED.

* [env-bran] Panel: environment ladder editor + types + validation

- EnvironmentRung type + environments on Project/ProjectCreate/ProjectUpdate.
- EnvironmentLadderEditor (plain useState, add/remove/up-down reorder, head/
  prod labels) reused by create + edit project dialogs.
- validateLadder (non-empty name+branch, no duplicate branches) shared,
  toast.error on submit; empty editor => null => inherits default_branch shim.
- default_branch input kept with override-hint; API client passthrough.
- 6 unit tests for validateLadder.

* [env-bran] Tests + gate green: env ladder, EnvSyncEngine, promotion chain

- tests/unit/models/test_env_branches.py: shim, head/prod, ladder_pairs,
  promotion_chain, normalize (20 tests)
- tests/integration/services/test_env_sync_engine.py: cascade clean/conflict/
  missing_ref/tokenless/degenerate/caps/dedup/disabled (9 tests, DB)
- tests/integration/test_migration_env_branches.py: 073 defaults null + round-trip
- tests/unit/services/test_release_executor*.py: add env_chain=[] to
  _ReleaseContext constructions (promotion_chain field is now required)
- tests/unit/runtime/test_orchestrator_shutdown_drain.py: register _env_sync_task
  in the stop()-drain fixture (new named background loop)
- roboco/services/git.py: revert _project_head_branch rename back to
  _project_default_branch (modify-in-place per plan); the rename in the
  consumers commit broke ~15 unit-test mocks that bind the original name
- roboco/services/env_sync_engine.py + models/env_branches.py: ruff format
- roboco/api/schemas/project.py: trailing-newline format

Backend gate green (13013 passed / 439 skipped), mypy clean, ruff clean.
Panel gate green (typecheck/lint/522 tests).

* [env-bran] fix: add env_chain to _ReleaseContext in e2e smoke (CI red)

The release-executor promotion_chain change made _ReleaseContext.env_chain
required. I fixed the three unit/release test files but missed the
construction in tests/e2e_smoke/test_background_engines.py:98 — my local
gate ran 'mypy roboco/' (excludes tests/) and I skipped 'make e2e-smoke',
so CI's mypy-on-tests + the e2e runtime job caught it instead of me.

Verified locally with the CI-equivalent gates:
  uv run mypy roboco/ tests/   -> 1170 files, clean
  ROBOCO_E2E_SMOKE=1 uv run pytest tests/e2e_smoke -> 50 passed, 1 skipped

* [env-bran] fix: extract _ensure_prod_fetched to clear xenon rank C (CI red)

_production_assess grew past xenon --max-absolute B (rank C) when the
env-branches prod-tip fetch added an if/try/except branch. Extracted the
fetch-with-fallback into _ensure_prod_fetched (degan+fetch paths), moved
_run_git to the module-level import. Local make quality green (all gates
incl xenon/vulture/deptry/import-linter/foundation-check).

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-15 04:43:37 +02:00

659 lines
22 KiB
TypeScript

"use client";
import { useState } from "react";
import { useProject, useUpdateProject } from "@/hooks/use-projects";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ConventionsTab } from "@/components/conventions/conventions-tab";
import { Key, KeyRound } from "lucide-react";
import { toast } from "sonner";
import { Team, type ProjectUpdate, type Project } from "@/types";
import { EnvironmentLadderEditor } from "@/components/projects/environment-ladder-editor";
import { validateLadder } from "@/components/projects/ladder-validation";
const cells: { value: Team; label: string }[] = [
{ value: Team.BACKEND, label: "Backend" },
{ value: Team.FRONTEND, label: "Frontend" },
{ value: Team.UX_UI, label: "UX/UI" },
];
const SANDBOX_SERVICES = [
{ id: "postgres", label: "PostgreSQL" },
{ id: "redis", label: "Redis" },
{ id: "mongo", label: "MongoDB" },
] as const;
// Activatable extensions/modules per service, mirroring the backend allowlist
// (roboco/models/sandbox.py SANDBOX_ENGINE_FEATURES). The allowlist is the
// security containment — a plpython3u (superuser-RCE) is absent by design.
// Mongo has no activatable features and is intentionally absent here.
const SANDBOX_EXTENSIONS: Record<string, { id: string; label: string }[]> = {
postgres: [
{ id: "vector", label: "pgvector" },
{ id: "postgis", label: "PostGIS" },
{ id: "pg_trgm", label: "pg_trgm" },
{ id: "citext", label: "citext" },
{ id: "uuid-ossp", label: "uuid-ossp" },
],
redis: [
{ id: "search", label: "RediSearch" },
{ id: "json", label: "RedisJSON" },
{ id: "bloom", label: "RedisBloom" },
],
};
interface EditProjectDialogProps {
projectId: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}
// Inner form component - receives project directly, manages its own state
function EditProjectForm({
project,
onSuccess,
onCancel,
}: {
project: Project;
onSuccess: () => void;
onCancel: () => void;
}) {
const updateProject = useUpdateProject();
// Initialize form state from project
const [name, setName] = useState(project.name);
const [gitUrl, setGitUrl] = useState(project.git_url);
const [assignedCell, setAssignedCell] = useState(project.assigned_cell);
const [defaultBranch, setDefaultBranch] = useState(project.default_branch);
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 || "");
const [formatCommand, setFormatCommand] = useState(
project.format_command || "",
);
const [typecheckCommand, setTypecheckCommand] = useState(
project.typecheck_command || "",
);
const [buildCommand, setBuildCommand] = useState(project.build_command || "");
const [qualityCommand, setQualityCommand] = useState(
project.quality_command || "",
);
const [ciWatchEnabled, setCiWatchEnabled] = useState(
project.ci_watch_enabled,
);
const [ciWatchWorkflow, setCiWatchWorkflow] = useState(
project.ci_watch_workflow || "",
);
const [videoEngineEnabled, setVideoEngineEnabled] = useState(
project.video_engine_enabled,
);
const [depUpdateCommand, setDepUpdateCommand] = useState(
project.dep_update_command || "",
);
const [depUpdatePaths, setDepUpdatePaths] = useState(
(project.dep_update_paths || []).join(", "),
);
const sandboxServices = project.sandbox_services || [];
const [sandboxSet, setSandboxSet] = useState<Set<string>>(
new Set(sandboxServices),
);
// Per-service extension picks (only meaningful for services in sandboxSet).
const [sandboxExtensions, setSandboxExtensions] = useState<
Record<string, Set<string>>
>(() => {
const init: Record<string, Set<string>> = {};
for (const [svc, feats] of Object.entries(
project.sandbox_extensions || {},
)) {
init[svc] = new Set(feats);
}
return init;
});
const toggleExtension = (svc: string, feat: string, checked: boolean) => {
setSandboxExtensions((prev) => {
const next = { ...prev };
const set = new Set(next[svc] ?? []);
if (checked) set.add(feat);
else set.delete(feat);
next[svc] = set;
return next;
});
};
// Token handling
const [newToken, setNewToken] = useState("");
const [clearToken, setClearToken] = useState(false);
const [showAdvanced, setShowAdvanced] = useState(false);
const [showAutonomy, setShowAutonomy] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name || !gitUrl || !assignedCell) {
toast.error("Please fill in all required fields");
return;
}
const envError = validateLadder(environments);
if (envError) {
toast.error(envError);
return;
}
// Build update payload
const updates: ProjectUpdate = {
name,
git_url: gitUrl,
assigned_cell: assignedCell,
default_branch: defaultBranch || "main",
environments,
is_active: isActive,
test_command: testCommand || undefined,
lint_command: lintCommand || undefined,
format_command: formatCommand || undefined,
typecheck_command: typecheckCommand || undefined,
build_command: buildCommand || undefined,
quality_command: qualityCommand || undefined,
ci_watch_enabled: ciWatchEnabled,
ci_watch_workflow: ciWatchWorkflow || undefined,
video_engine_enabled: videoEngineEnabled,
dep_update_command: depUpdateCommand || undefined,
dep_update_paths: depUpdatePaths.trim()
? depUpdatePaths
.split(",")
.map((p) => p.trim())
.filter(Boolean)
: undefined,
sandbox_services: [...sandboxSet],
sandbox_extensions: (() => {
const extObj: Record<string, string[]> = {};
for (const svc of sandboxSet) {
const feats = sandboxExtensions[svc];
if (feats && feats.size > 0) extObj[svc] = [...feats].sort();
}
return extObj;
})(),
};
// Handle token update
if (clearToken) {
updates.git_token = ""; // Empty string clears the token
} else if (newToken) {
updates.git_token = newToken; // New token replaces old
}
// If neither, token is left unchanged (undefined)
try {
await updateProject.mutateAsync({ projectId: project.id, updates });
toast.success("Project updated successfully");
onSuccess();
} catch (error) {
toast.error(
`Failed to update project: ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
};
return (
<form onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle>Edit Project</DialogTitle>
<DialogDescription>
Update project settings. Slug cannot be changed.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
{/* Slug (read-only) */}
<div className="grid gap-2">
<Label htmlFor="slug">Slug</Label>
<Input
id="slug"
value={project.slug}
disabled
className="font-mono text-muted-foreground"
/>
</div>
{/* Name */}
<div className="grid gap-2">
<Label htmlFor="name">Project Name *</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="RoboCo API"
/>
</div>
{/* Git URL */}
<div className="grid gap-2">
<Label htmlFor="git_url">Git URL *</Label>
<Input
id="git_url"
value={gitUrl}
onChange={(e) => setGitUrl(e.target.value)}
placeholder="https://github.com/org/repo.git"
/>
</div>
{/* Git Token Section */}
<div className="grid gap-2 p-3 border rounded-lg bg-muted/30">
<div className="flex items-center justify-between">
<Label className="flex items-center gap-2">
{project.has_git_token ? (
<>
<Key className="h-4 w-4 text-green-500" />
<span className="text-green-600 dark:text-green-400">
Token is set
</span>
</>
) : (
<>
<KeyRound className="h-4 w-4 text-amber-500" />
<span className="text-amber-600 dark:text-amber-400">
No token configured
</span>
</>
)}
</Label>
{project.has_git_token && (
<div className="flex items-center gap-2">
<Label
htmlFor="clear-token"
className="text-xs text-muted-foreground"
>
Clear token
</Label>
<Switch
id="clear-token"
checked={clearToken}
onCheckedChange={(checked) => {
setClearToken(checked);
if (checked) setNewToken("");
}}
/>
</div>
)}
</div>
{!clearToken && (
<div className="grid gap-2">
<Label htmlFor="git_token" className="text-sm">
{project.has_git_token ? "Replace token" : "Set token"}
</Label>
<Input
id="git_token"
type="password"
value={newToken}
onChange={(e) => setNewToken(e.target.value)}
placeholder="ghp_xxxxxxxxxxxx..."
/>
<p className="text-xs text-muted-foreground">
Personal access token with repo access for clone, push, and PR
operations
</p>
</div>
)}
</div>
{/* Assigned Cell */}
<div className="grid gap-2">
<Label htmlFor="assigned_cell">Assigned Cell *</Label>
<Select
value={assignedCell}
onValueChange={(value: Team) => setAssignedCell(value)}
>
<SelectTrigger>
<SelectValue placeholder="Select cell" />
</SelectTrigger>
<SelectContent>
{cells.map((cell) => (
<SelectItem key={cell.value} value={cell.value}>
{cell.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Default Branch */}
<div className="grid gap-2">
<Label htmlFor="default_branch">Default Branch</Label>
<Input
id="default_branch"
value={defaultBranch}
onChange={(e) => setDefaultBranch(e.target.value)}
placeholder="main"
/>
<p className="text-xs text-muted-foreground">
Fallback head+prod branch when no environment ladder is set below.
</p>
</div>
{/* Environment ladder */}
<EnvironmentLadderEditor
rungs={environments}
onChange={setEnvironments}
/>
{/* Active Status */}
<div className="flex items-center justify-between">
<Label htmlFor="is_active">Active</Label>
<Switch
id="is_active"
checked={isActive}
onCheckedChange={setIsActive}
/>
</div>
{/* Advanced Options Toggle */}
<Button
type="button"
variant="ghost"
className="justify-start px-0 text-muted-foreground"
onClick={() => setShowAdvanced(!showAdvanced)}
>
{showAdvanced ? "Hide" : "Show"} CI/CD Commands
</Button>
{showAdvanced && (
<>
<div className="grid gap-2">
<Label htmlFor="test_command">Test Command</Label>
<Input
id="test_command"
value={testCommand}
onChange={(e) => setTestCommand(e.target.value)}
placeholder="uv run pytest"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="lint_command">Lint Command</Label>
<Input
id="lint_command"
value={lintCommand}
onChange={(e) => setLintCommand(e.target.value)}
placeholder="uv run ruff check ."
/>
</div>
<div className="grid gap-2">
<Label htmlFor="format_command">Format Command</Label>
<Input
id="format_command"
value={formatCommand}
onChange={(e) => setFormatCommand(e.target.value)}
placeholder="uv run ruff format ."
/>
</div>
<div className="grid gap-2">
<Label htmlFor="typecheck_command">Typecheck Command</Label>
<Input
id="typecheck_command"
value={typecheckCommand}
onChange={(e) => setTypecheckCommand(e.target.value)}
placeholder="uv run mypy src/"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="build_command">Build Command</Label>
<Input
id="build_command"
value={buildCommand}
onChange={(e) => setBuildCommand(e.target.value)}
placeholder="pnpm build"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="quality_command">Quality Gate Command</Label>
<Input
id="quality_command"
value={qualityCommand}
onChange={(e) => setQualityCommand(e.target.value)}
placeholder="make gate"
/>
<p className="text-xs text-muted-foreground">
Fast pre-submit gate (lint + types + complexity, no tests) run
in the dev&apos;s workspace at hand-off to QA.
</p>
</div>
</>
)}
{/* Autonomous Maintenance Toggle */}
<Button
type="button"
variant="ghost"
className="justify-start px-0 text-muted-foreground"
onClick={() => setShowAutonomy(!showAutonomy)}
>
{showAutonomy ? "Hide" : "Show"} Autonomous Maintenance
</Button>
{showAutonomy && (
<>
<div className="flex items-center justify-between">
<Label htmlFor="ci_watch_enabled">
CI-watch (open a fix task when CI goes red)
</Label>
<Switch
id="ci_watch_enabled"
checked={ciWatchEnabled}
onCheckedChange={setCiWatchEnabled}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="ci_watch_workflow">CI-watch Workflow</Label>
<Input
id="ci_watch_workflow"
value={ciWatchWorkflow}
onChange={(e) => setCiWatchWorkflow(e.target.value)}
placeholder="ci.yml"
/>
<p className="text-xs text-muted-foreground">
Workflow file to scope the CI signal to. Leave blank to use the
engine default.
</p>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="video_engine_enabled">
Video engine (author marketing videos into this project)
</Label>
<Switch
id="video_engine_enabled"
checked={videoEngineEnabled}
onCheckedChange={setVideoEngineEnabled}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="dep_update_command">
Dependency-Update Command
</Label>
<Input
id="dep_update_command"
value={depUpdateCommand}
onChange={(e) => setDepUpdateCommand(e.target.value)}
placeholder="uv lock --upgrade"
/>
<p className="text-xs text-muted-foreground">
Set to opt this project into the weekly dependency-update bot;
leave blank to opt out.
</p>
</div>
<div className="grid gap-2">
<Label htmlFor="dep_update_paths">
Dependency-Update Lockfile Paths
</Label>
<Input
id="dep_update_paths"
value={depUpdatePaths}
onChange={(e) => setDepUpdatePaths(e.target.value)}
placeholder="uv.lock, pnpm-lock.yaml"
/>
<p className="text-xs text-muted-foreground">
Comma-separated lockfile paths to watch. Leave blank to infer
uv.lock / pnpm-lock.yaml.
</p>
</div>
<div className="grid gap-2">
<Label>Sandbox Services</Label>
{SANDBOX_SERVICES.map((svc) => (
<div key={svc.id} className="flex items-center justify-between">
<Label
htmlFor={`sandbox_${svc.id}`}
className="text-sm font-normal"
>
{svc.label}
</Label>
<Switch
id={`sandbox_${svc.id}`}
checked={sandboxSet.has(svc.id)}
onCheckedChange={(checked) =>
setSandboxSet((prev) => {
const next = new Set(prev);
if (checked) next.add(svc.id);
else next.delete(svc.id);
return next;
})
}
/>
</div>
))}
<p className="text-xs text-muted-foreground">
Provision a throwaway sandbox DB/Redis per agent spawn for this
project instead of the production credentials.
</p>
</div>
{SANDBOX_SERVICES.filter(
(svc) => sandboxSet.has(svc.id) && SANDBOX_EXTENSIONS[svc.id],
).map((svc) => (
<div key={`ext_${svc.id}`} className="grid gap-2">
<Label>{svc.label} Extensions</Label>
{SANDBOX_EXTENSIONS[svc.id].map((ext) => (
<div
key={ext.id}
className="flex items-center justify-between"
>
<Label
htmlFor={`ext_${svc.id}_${ext.id}`}
className="text-sm font-normal"
>
{ext.label}
</Label>
<Switch
id={`ext_${svc.id}_${ext.id}`}
checked={sandboxExtensions[svc.id]?.has(ext.id) ?? false}
onCheckedChange={(checked) =>
toggleExtension(svc.id, ext.id, checked)
}
/>
</div>
))}
<p className="text-xs text-muted-foreground">
Activated on-demand in the sandbox {svc.label} container. Set
the full set here so agents can request subsets.
</p>
</div>
))}
</>
)}
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={onCancel}>
Cancel
</Button>
<Button type="submit" disabled={updateProject.isPending}>
{updateProject.isPending ? "Saving..." : "Save Changes"}
</Button>
</DialogFooter>
</form>
);
}
// Main dialog component - handles data fetching and dialog state
export function EditProjectDialog({
projectId,
open,
onOpenChange,
}: EditProjectDialogProps) {
const { data: project, isLoading } = useProject(projectId);
const [tab, setTab] = useState("settings");
return (
<Dialog open={open} onOpenChange={onOpenChange}>
{/* Settings is a compact form; Conventions uses a two-column grid, so it
gets a wider, responsive modal (capped so it stays sane on a 27"). */}
<DialogContent
className={`max-h-[90vh] overflow-y-auto ${
tab === "conventions"
? "sm:max-w-2xl lg:max-w-5xl xl:max-w-6xl"
: "sm:max-w-[525px]"
}`}
>
{isLoading ? (
<div className="space-y-4 py-4">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</div>
) : project ? (
<Tabs value={tab} onValueChange={setTab}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="settings">Settings</TabsTrigger>
<TabsTrigger value="conventions">Conventions</TabsTrigger>
</TabsList>
<TabsContent value="settings">
{/* Key forces remount when project changes, resetting form state */}
<EditProjectForm
key={project.id}
project={project}
onSuccess={() => onOpenChange(false)}
onCancel={() => onOpenChange(false)}
/>
</TabsContent>
<TabsContent value="conventions">
<ConventionsTab projectId={projectId} />
</TabsContent>
</Tabs>
) : (
<div className="py-8 text-center text-muted-foreground">
Project not found
</div>
)}
</DialogContent>
</Dialog>
);
}