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:
Renzo F
2026-07-15 04:43:37 +02:00
committed by GitHub
co-authored by Renn F
parent b7f2d84c77
commit d80dfb8bbe
25 changed files with 1399 additions and 24 deletions
@@ -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 topbottom: 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:
+1
View File
@@ -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
+13
View File
@@ -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)