mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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>
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { validateLadder } from "@/components/projects/ladder-validation";
|
||||
import type { EnvironmentRung } from "@/types";
|
||||
|
||||
const rungs = (rows: [string, string][]): EnvironmentRung[] =>
|
||||
rows.map(([name, branch]) => ({ name, branch }));
|
||||
|
||||
describe("validateLadder", () => {
|
||||
it("accepts null (inherits default_branch via the shim)", () => {
|
||||
expect(validateLadder(null)).toBeNull();
|
||||
});
|
||||
|
||||
it("accepts an empty ladder", () => {
|
||||
expect(validateLadder([])).toBeNull();
|
||||
});
|
||||
|
||||
it("accepts a clean ordered ladder", () => {
|
||||
expect(
|
||||
validateLadder(rungs([["head", "dev"], ["prod", "master"]])),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects a rung missing a name", () => {
|
||||
expect(validateLadder(rungs([["", "dev"]]))).toMatch(/name and a branch/);
|
||||
});
|
||||
|
||||
it("rejects a rung missing a branch", () => {
|
||||
expect(validateLadder(rungs([["head", " "]]))).toMatch(/name and a branch/);
|
||||
});
|
||||
|
||||
it("rejects duplicate branches", () => {
|
||||
expect(
|
||||
validateLadder(rungs([["head", "dev"], ["prod", "dev"]])),
|
||||
).toMatch(/Duplicate branch "dev"/);
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,8 @@ import {
|
||||
import { Plus, Key } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Team, type ProjectCreate } 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" },
|
||||
@@ -48,6 +50,7 @@ export function CreateProjectDialog() {
|
||||
git_token: "",
|
||||
assigned_cell: Team.BACKEND,
|
||||
default_branch: "main",
|
||||
environments: null,
|
||||
});
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
|
||||
@@ -74,6 +77,12 @@ export function CreateProjectDialog() {
|
||||
return;
|
||||
}
|
||||
|
||||
const envError = validateLadder(formData.environments ?? null);
|
||||
if (envError) {
|
||||
toast.error(envError);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await createProject.mutateAsync({
|
||||
name: formData.name,
|
||||
@@ -82,6 +91,7 @@ export function CreateProjectDialog() {
|
||||
assigned_cell: formData.assigned_cell,
|
||||
git_token: formData.git_token || undefined,
|
||||
default_branch: formData.default_branch || "main",
|
||||
environments: formData.environments ?? undefined,
|
||||
test_command: formData.test_command || undefined,
|
||||
lint_command: formData.lint_command || undefined,
|
||||
format_command: formData.format_command || undefined,
|
||||
@@ -98,6 +108,7 @@ export function CreateProjectDialog() {
|
||||
git_token: "",
|
||||
assigned_cell: Team.BACKEND,
|
||||
default_branch: "main",
|
||||
environments: null,
|
||||
});
|
||||
setShowAdvanced(false);
|
||||
} catch (error) {
|
||||
@@ -222,8 +233,17 @@ export function CreateProjectDialog() {
|
||||
}
|
||||
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={formData.environments ?? null}
|
||||
onChange={(rungs) => setFormData({ ...formData, environments: rungs })}
|
||||
/>
|
||||
|
||||
{/* Advanced Options Toggle */}
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -27,6 +27,8 @@ 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" },
|
||||
@@ -82,6 +84,7 @@ function EditProjectForm({
|
||||
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 || "");
|
||||
@@ -152,12 +155,19 @@ function EditProjectForm({
|
||||
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,
|
||||
@@ -336,8 +346,17 @@ function EditProjectForm({
|
||||
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>
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { ArrowDown, ArrowUp, Plus, X } from "lucide-react";
|
||||
import type { EnvironmentRung } from "@/types";
|
||||
|
||||
interface EnvironmentLadderEditorProps {
|
||||
// null/empty => degenerate 1-rung ladder synthesized from default_branch.
|
||||
rungs: EnvironmentRung[] | null;
|
||||
onChange: (rungs: EnvironmentRung[] | null) => void;
|
||||
}
|
||||
|
||||
// An empty editor (no rungs) means "inherit default_branch" via the backend
|
||||
// shim, so we keep a plain array internally and emit null when it empties.
|
||||
function toRungs(value: EnvironmentRung[] | null): EnvironmentRung[] {
|
||||
return value ?? [];
|
||||
}
|
||||
|
||||
export function EnvironmentLadderEditor({
|
||||
rungs,
|
||||
onChange,
|
||||
}: EnvironmentLadderEditorProps) {
|
||||
const items = toRungs(rungs);
|
||||
|
||||
const emit = (next: EnvironmentRung[]) => {
|
||||
onChange(next.length ? next : null);
|
||||
};
|
||||
|
||||
const handleAdd = () => {
|
||||
emit([...items, { name: "", branch: "" }]);
|
||||
};
|
||||
|
||||
const handleRemove = (index: number) => {
|
||||
emit(items.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleUpdate = (index: number, field: keyof EnvironmentRung, value: string) => {
|
||||
const next = items.map((r, i) => (i === index ? { ...r, [field]: value } : r));
|
||||
emit(next);
|
||||
};
|
||||
|
||||
const handleMove = (index: number, direction: -1 | 1) => {
|
||||
const target = index + direction;
|
||||
if (target < 0 || target >= items.length) return;
|
||||
const next = [...items];
|
||||
[next[index], next[target]] = [next[target], next[index]];
|
||||
emit(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Environment ladder</Label>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{items.length} rung{items.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{items.length > 0 && (
|
||||
<div className="space-y-2 border rounded-lg p-3 bg-muted/30">
|
||||
{items.map((rung, index) => {
|
||||
const isFirst = index === 0;
|
||||
const isLast = index === items.length - 1;
|
||||
return (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<div className="flex flex-col">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
disabled={isFirst}
|
||||
onClick={() => handleMove(index, -1)}
|
||||
>
|
||||
<ArrowUp className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Move up (toward head)</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
disabled={isLast}
|
||||
onClick={() => handleMove(index, 1)}
|
||||
>
|
||||
<ArrowDown className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Move down (toward prod)</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<span className="text-[10px] uppercase text-muted-foreground w-10 text-center">
|
||||
{isFirst ? "head" : isLast ? "prod" : `rung ${index + 1}`}
|
||||
</span>
|
||||
<Input
|
||||
value={rung.name}
|
||||
onChange={(e) => handleUpdate(index, "name", e.target.value)}
|
||||
placeholder="Name (e.g. dev, qa, stag)"
|
||||
className="flex-1 h-8"
|
||||
/>
|
||||
<Input
|
||||
value={rung.branch}
|
||||
onChange={(e) => handleUpdate(index, "branch", e.target.value)}
|
||||
placeholder="Branch (e.g. dev, master)"
|
||||
className="flex-1 h-8"
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => handleRemove(index)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Remove this rung</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button type="button" variant="outline" size="sm" onClick={handleAdd}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add rung
|
||||
</Button>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Ordered top→bottom: the first rung is <strong>head</strong> (where dev PRs
|
||||
land) and the last is <strong>prod</strong> (the release target). Leave
|
||||
empty to inherit <em>default branch</em> for both — e.g.{" "}
|
||||
<code>dev → qa → stag → prod</code>, or just <code>prod</code> for a
|
||||
single-branch project. When set, this overrides <em>default branch</em>{" "}
|
||||
for the PR target and the release target.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { EnvironmentRung } from "@/types";
|
||||
|
||||
// Validate a ladder before submit. Returns an error string or null when valid.
|
||||
// null/empty is valid (inherits default_branch via the backend shim).
|
||||
export function validateLadder(rungs: EnvironmentRung[] | null): string | null {
|
||||
if (!rungs || rungs.length === 0) return null;
|
||||
const branches: string[] = [];
|
||||
for (const rung of rungs) {
|
||||
if (!rung.name.trim() || !rung.branch.trim()) {
|
||||
return "Every environment rung needs both a name and a branch.";
|
||||
}
|
||||
const branch = rung.branch.trim();
|
||||
if (branches.includes(branch)) {
|
||||
return `Duplicate branch "${branch}" — each rung must target a unique branch.`;
|
||||
}
|
||||
branches.push(branch);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -63,6 +63,8 @@ const FLAG_DESCRIPTIONS: Record<string, string> = {
|
||||
"Watch every opted-in project's CI and open a fix task when its default branch goes red (per-project opt-in; never auto-merges).",
|
||||
dep_update_enabled:
|
||||
"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).",
|
||||
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:
|
||||
|
||||
@@ -125,6 +125,7 @@ export const projectsApi = {
|
||||
slug: project.slug,
|
||||
git_url: project.git_url,
|
||||
default_branch: project.default_branch ?? "main",
|
||||
environments: project.environments ?? null,
|
||||
protected_branches: project.protected_branches ?? ["main", "master"],
|
||||
assigned_cell: project.assigned_cell,
|
||||
has_git_token: !!project.git_token, // Mock token status
|
||||
|
||||
@@ -1022,12 +1022,21 @@ export enum WorkSessionStatus {
|
||||
ABANDONED = "abandoned",
|
||||
}
|
||||
|
||||
// One rung of a project's ordered environment ladder.
|
||||
export interface EnvironmentRung {
|
||||
name: string;
|
||||
branch: string;
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
git_url: string;
|
||||
default_branch: string;
|
||||
// Ordered environment ladder (first=head/PR-target, last=prod/release-target).
|
||||
// Null/empty => degenerate 1-rung ladder synthesized from default_branch.
|
||||
environments: EnvironmentRung[] | null;
|
||||
protected_branches: string[];
|
||||
assigned_cell: Team;
|
||||
// Git authentication (token never exposed, only boolean indicator)
|
||||
@@ -1063,6 +1072,8 @@ export interface ProjectCreate {
|
||||
slug: string;
|
||||
git_url: string;
|
||||
default_branch?: string;
|
||||
// Ordered environment ladder; null/empty inherits default_branch (shim).
|
||||
environments?: EnvironmentRung[] | null;
|
||||
protected_branches?: string[];
|
||||
assigned_cell: Team;
|
||||
// Git authentication (stored encrypted, never returned)
|
||||
@@ -1079,6 +1090,8 @@ export interface ProjectUpdate {
|
||||
name?: string;
|
||||
git_url?: string;
|
||||
default_branch?: string;
|
||||
// Ordered environment ladder; null clears (reverts to default_branch shim).
|
||||
environments?: EnvironmentRung[] | null;
|
||||
protected_branches?: string[];
|
||||
assigned_cell?: Team;
|
||||
// Git authentication (empty string clears, undefined leaves unchanged)
|
||||
|
||||
Reference in New Issue
Block a user