diff --git a/alembic/versions/073_project_environments.py b/alembic/versions/073_project_environments.py new file mode 100644 index 00000000..5765ad58 --- /dev/null +++ b/alembic/versions/073_project_environments.py @@ -0,0 +1,42 @@ +"""Per-project ordered environment ladder column. + +Replaces the single ``default_branch`` as the source of truth for a project's +PR target (head rung) and release target (prod rung). The ladder is an ordered +``list[{name, branch}]``: index 0 = head (where dev/cell/leaf PRs land), index +-1 = prod (where the gated release executor commits + tags), middle rungs = +intermediates (qa/stag). The ``EnvSyncEngine`` cascades prod→…→head so dev +never falls behind prod, and the CEO-gated release promotes the full chain +head→…→prod before bumping. + +Additive and nullable: an unset (null) ``environments`` falls back to a +degenerate single-branch ladder synthesized from ``default_branch`` at read +time (``roboco/services/env_branches.py``), so every existing project keeps +behaving byte-for-byte as before until the operator declares a real ladder +in the panel. ``default_branch`` is retained as the legacy/shim source. + +Revision ID: 073_project_environments +Revises: 072_project_sandbox_extensions +Create Date: 2026-07-15 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision = "073_project_environments" +down_revision = "072_project_sandbox_extensions" +branch_labels: dict[str, str] | None = None +depends_on: dict[str, str] | None = None + + +def upgrade() -> None: + op.add_column( + "projects", + sa.Column("environments", postgresql.JSONB(), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("projects", "environments") diff --git a/roboco/api/routes/project.py b/roboco/api/routes/project.py index ae0b3430..e4b95acb 100644 --- a/roboco/api/routes/project.py +++ b/roboco/api/routes/project.py @@ -141,6 +141,7 @@ async def create_project( git_url=data.git_url, default_branch=data.default_branch, protected_branches=protected_branches, + environments=data.environments, assigned_cell=data.assigned_cell, git_token=data.git_token, test_command=data.test_command, diff --git a/roboco/api/schemas/project.py b/roboco/api/schemas/project.py index 0940fcf7..cee1cfa0 100644 --- a/roboco/api/schemas/project.py +++ b/roboco/api/schemas/project.py @@ -31,6 +31,7 @@ class ProjectResponse(BaseModel): git_url: str default_branch: str protected_branches: list[str] + environments: list[dict[str, str]] | None = None assigned_cell: Team # Git authentication status (token never exposed, only boolean) @@ -105,6 +106,14 @@ class ProjectCreateRequest(BaseModel): default=None, description="Branches to protect. Defaults to [default_branch].", ) + environments: list[dict[str, str]] | None = Field( + default=None, + description=( + "Ordered environment ladder [{name, branch}]; first = head (PR " + "target), last = prod (release target). null → inherits " + "default_branch (head == prod)." + ), + ) assigned_cell: Team # Git authentication (will be encrypted and stored securely) @@ -129,6 +138,7 @@ class ProjectUpdateRequest(BaseModel): git_url: str | None = None default_branch: str | None = None protected_branches: list[str] | None = None + environments: list[dict[str, str]] | None = None assigned_cell: Team | None = None # Git authentication (empty string clears token, None leaves unchanged) @@ -226,6 +236,7 @@ def project_to_response(project: "ProjectTable") -> ProjectResponse: git_url=str(project.git_url), default_branch=str(default_branch) if default_branch else "master", protected_branches=list(project.protected_branches or []), + environments=list(project.environments) if project.environments else None, assigned_cell=project.assigned_cell, has_git_token=bool(project.git_token_encrypted), test_command=project.test_command, @@ -266,3 +277,4 @@ def project_to_summary(project: "ProjectTable") -> ProjectSummaryResponse: has_git_token=bool(project.git_token_encrypted), video_engine_enabled=bool(project.video_engine_enabled), ) + diff --git a/roboco/db/tables.py b/roboco/db/tables.py index 04149fd4..dcc08147 100644 --- a/roboco/db/tables.py +++ b/roboco/db/tables.py @@ -498,6 +498,14 @@ class ProjectTable(Base): protected_branches: Mapped[list[str]] = mapped_column( ARRAY(String), default=lambda: ["main", "master"] ) + # Ordered environment ladder [{name, branch}]: index 0 = head (PR target), + # index -1 = prod (release target), middle = intermediates. Null → + # roboco.services.env_branches synthesizes a degenerate single-branch + # ladder from default_branch (head == prod == default_branch), so behavior + # is unchanged until the operator declares a real split. + environments: Mapped[list[dict[str, Any]] | None] = mapped_column( + JSONB, nullable=True + ) git_token_encrypted: Mapped[str | None] = mapped_column( Text, nullable=True ) # Fernet-encrypted GitHub PAT diff --git a/roboco/models/env_branches.py b/roboco/models/env_branches.py new file mode 100644 index 00000000..a66e66ab --- /dev/null +++ b/roboco/models/env_branches.py @@ -0,0 +1,119 @@ +"""Per-project environment ladder helpers. + +A project's environment ladder is an ordered ``list[{name, branch}]``: index 0 +is **head** (the branch dev/cell/leaf PRs target — the dev trunk), index -1 is +**prod** (the branch the gated release executor commits + tags on), and the +middle rungs are intermediates (qa/stag). This separates "where work lands" +from "what prod is", which a single ``default_branch`` could not. + +When ``environments`` is null/empty the ladder is synthesized from +``default_branch`` as a degenerate single-branch ladder (head == prod == +``default_branch``), so every consumer — PR target, release target, sync — +keeps behaving exactly as before the column existed. Operators declare a real +split in the panel. + +These helpers are the single chokepoint every consumer routes through; never +read ``project.environments`` raw. Pure domain logic (pydantic-only), kept in +``roboco.models`` so the Project model can import it without a services-cycle. +""" + +from __future__ import annotations + +from pydantic import BaseModel + +# A project or project-row duck-type: both the pydantic ``Project`` and the +# SQLAlchemy ``ProjectTable`` expose ``environments`` and ``default_branch``. +_ENV_LADDER = "head" +_PROD_LADDER = "prod" + + +class EnvRung(BaseModel): + """One rung of the environment ladder.""" + + name: str + branch: str + + +def _coerce_rungs( + raw: list[dict[str, str]] | list[EnvRung] | None, +) -> list[EnvRung] | None: + """Normalize a raw list of dicts/models into EnvRungs; None/empty -> None.""" + if not raw: + return None + out: list[EnvRung] = [] + for item in raw: + if isinstance(item, EnvRung): + out.append(item) + else: + out.append(EnvRung.model_validate(item)) + return out + + +def effective_environments(project: object) -> list[EnvRung]: + """Return the project's resolved environment ladder (never empty). + + Falls back to a degenerate single-branch ladder synthesized from + ``default_branch`` when ``environments`` is null/empty, so behavior is + unchanged until the operator declares a real ladder. + """ + rungs = _coerce_rungs(getattr(project, "environments", None)) # type: ignore[arg-type] + if rungs: + return rungs + fallback = str(getattr(project, "default_branch", None) or "master") + return [ + EnvRung(name=_ENV_LADDER, branch=fallback), + EnvRung(name=_PROD_LADDER, branch=fallback), + ] + + +def head_branch(project: object) -> str: + """The branch dev/cell/leaf PRs target (ladder index 0).""" + return effective_environments(project)[0].branch + + +def prod_branch(project: object) -> str: + """The branch the gated release executor commits + tags on (last rung).""" + return effective_environments(project)[-1].branch + + +def ladder_pairs(project: object) -> list[tuple[EnvRung, EnvRung]]: + """Adjacent rung pairs for the prod->head cascade, top-down. + + For ``[head, qa, stag, prod]`` (index 0..3) returns + ``[(prod, stag), (stag, qa), (qa, head)]`` — each ``(upper, lower)`` pair + means "merge ``upper`` into ``lower``". Empty for a 1-rung ladder. + """ + rungs = effective_environments(project) + return [(rungs[i], rungs[i - 1]) for i in range(len(rungs) - 1, 0, -1)] + + +def normalize_environments( + value: list[dict[str, str]] | list[EnvRung] | None, +) -> list[dict[str, str]] | None: + """Validate + normalize a ladder before persistence. + + Rejects empty name/branch, de-dupes by branch, preserves the declared + order. Returns None for None/empty so the column stays null (the shim + synthesizes the degenerate ladder from ``default_branch`` at read time). + """ + rungs = _coerce_rungs(value) + if not rungs: + return None + seen: set[str] = set() + out: list[dict[str, str]] = [] + for rung in rungs: + name = rung.name.strip() + branch = rung.branch.strip() + if not name or not branch: + raise ValueError( + "each environment rung needs a non-empty name and branch" + ) + if branch in seen: + raise ValueError( + f"duplicate environment branch {branch!r}; " + "each rung must target a distinct branch" + ) + seen.add(branch) + out.append({"name": name, "branch": branch}) + return out + diff --git a/roboco/models/project.py b/roboco/models/project.py index bf48ffb1..3f1643b9 100644 --- a/roboco/models/project.py +++ b/roboco/models/project.py @@ -13,6 +13,7 @@ from uuid import UUID, uuid4 from pydantic import Field, field_validator from roboco.models.base import RobocoBase, Team, TimestampMixin +from roboco.models.env_branches import normalize_environments from roboco.models.sandbox import ( SANDBOX_ENGINE_FEATURES, SANDBOX_ENGINES, @@ -119,6 +120,26 @@ class Project(TimestampMixin): default_factory=lambda: ["main", "master"], description="Branches that cannot be pushed to directly", ) + # Ordered environment ladder: index 0 = head (PR target / dev trunk), + # index -1 = prod (release target), middle = intermediates (qa/stag). + # null/empty → synthesized from default_branch as a degenerate single- + # branch ladder (head == prod == default_branch), so behavior is unchanged + # until the operator declares a real split in the panel. + environments: list[dict[str, str]] | None = Field( + default=None, + description=( + "Ordered environment ladder [{name, branch}]; first = head (PR " + "target), last = prod (release target). null → inherits " + "default_branch (head == prod). Validated by _check_environments." + ), + ) + + @field_validator("environments") + @classmethod + def _check_environments( + cls, v: list[dict[str, str]] | None + ) -> list[dict[str, str]] | None: + return normalize_environments(v) # CI/CD Commands (optional - project may not have all) test_command: str | None = Field( @@ -239,6 +260,7 @@ class ProjectCreate(RobocoBase): git_url: str default_branch: str = "master" protected_branches: list[str] = Field(default_factory=lambda: ["main", "master"]) + environments: list[dict[str, str]] | None = None assigned_cell: Team # Git authentication (will be encrypted and stored securely) @@ -263,6 +285,7 @@ class ProjectUpdate(RobocoBase): git_url: str | None = None default_branch: str | None = None protected_branches: list[str] | None = None + environments: list[dict[str, str]] | None = None # Git authentication (empty string clears token, None leaves unchanged) git_token: str | None = Field( @@ -298,3 +321,10 @@ class ProjectUpdate(RobocoBase): cls, v: dict[str, list[str]] | None ) -> dict[str, list[str]] | None: return _normalize_sandbox_extensions(v) + + @field_validator("environments") + @classmethod + def _check_environments( + cls, v: list[dict[str, str]] | None + ) -> list[dict[str, str]] | None: + return normalize_environments(v) diff --git a/roboco/services/project.py b/roboco/services/project.py index d42a910d..951c074d 100644 --- a/roboco/services/project.py +++ b/roboco/services/project.py @@ -96,6 +96,7 @@ class ProjectService(BaseService): git_url=data.git_url, default_branch=data.default_branch, protected_branches=data.protected_branches, + environments=data.environments, assigned_cell=data.assigned_cell, git_token_encrypted=encrypted_token, test_command=data.test_command,