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 { Plus, Key } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Team, type ProjectCreate } from "@/types"; 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 }[] = [ const cells: { value: Team; label: string }[] = [
{ value: Team.BACKEND, label: "Backend" }, { value: Team.BACKEND, label: "Backend" },
@@ -48,6 +50,7 @@ export function CreateProjectDialog() {
git_token: "", git_token: "",
assigned_cell: Team.BACKEND, assigned_cell: Team.BACKEND,
default_branch: "main", default_branch: "main",
environments: null,
}); });
const [showAdvanced, setShowAdvanced] = useState(false); const [showAdvanced, setShowAdvanced] = useState(false);
@@ -74,6 +77,12 @@ export function CreateProjectDialog() {
return; return;
} }
const envError = validateLadder(formData.environments ?? null);
if (envError) {
toast.error(envError);
return;
}
try { try {
await createProject.mutateAsync({ await createProject.mutateAsync({
name: formData.name, name: formData.name,
@@ -82,6 +91,7 @@ export function CreateProjectDialog() {
assigned_cell: formData.assigned_cell, assigned_cell: formData.assigned_cell,
git_token: formData.git_token || undefined, git_token: formData.git_token || undefined,
default_branch: formData.default_branch || "main", default_branch: formData.default_branch || "main",
environments: formData.environments ?? undefined,
test_command: formData.test_command || undefined, test_command: formData.test_command || undefined,
lint_command: formData.lint_command || undefined, lint_command: formData.lint_command || undefined,
format_command: formData.format_command || undefined, format_command: formData.format_command || undefined,
@@ -98,6 +108,7 @@ export function CreateProjectDialog() {
git_token: "", git_token: "",
assigned_cell: Team.BACKEND, assigned_cell: Team.BACKEND,
default_branch: "main", default_branch: "main",
environments: null,
}); });
setShowAdvanced(false); setShowAdvanced(false);
} catch (error) { } catch (error) {
@@ -222,8 +233,17 @@ export function CreateProjectDialog() {
} }
placeholder="main" placeholder="main"
/> />
<p className="text-xs text-muted-foreground">
Fallback head+prod branch when no environment ladder is set below.
</p>
</div> </div>
{/* Environment ladder */}
<EnvironmentLadderEditor
rungs={formData.environments ?? null}
onChange={(rungs) => setFormData({ ...formData, environments: rungs })}
/>
{/* Advanced Options Toggle */} {/* Advanced Options Toggle */}
<Button <Button
type="button" type="button"
@@ -27,6 +27,8 @@ import { ConventionsTab } from "@/components/conventions/conventions-tab";
import { Key, KeyRound } from "lucide-react"; import { Key, KeyRound } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Team, type ProjectUpdate, type Project } from "@/types"; 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 }[] = [ const cells: { value: Team; label: string }[] = [
{ value: Team.BACKEND, label: "Backend" }, { value: Team.BACKEND, label: "Backend" },
@@ -82,6 +84,7 @@ function EditProjectForm({
const [gitUrl, setGitUrl] = useState(project.git_url); const [gitUrl, setGitUrl] = useState(project.git_url);
const [assignedCell, setAssignedCell] = useState(project.assigned_cell); const [assignedCell, setAssignedCell] = useState(project.assigned_cell);
const [defaultBranch, setDefaultBranch] = useState(project.default_branch); const [defaultBranch, setDefaultBranch] = useState(project.default_branch);
const [environments, setEnvironments] = useState(project.environments ?? null);
const [isActive, setIsActive] = useState(project.is_active); const [isActive, setIsActive] = useState(project.is_active);
const [testCommand, setTestCommand] = useState(project.test_command || ""); const [testCommand, setTestCommand] = useState(project.test_command || "");
const [lintCommand, setLintCommand] = useState(project.lint_command || ""); const [lintCommand, setLintCommand] = useState(project.lint_command || "");
@@ -152,12 +155,19 @@ function EditProjectForm({
return; return;
} }
const envError = validateLadder(environments);
if (envError) {
toast.error(envError);
return;
}
// Build update payload // Build update payload
const updates: ProjectUpdate = { const updates: ProjectUpdate = {
name, name,
git_url: gitUrl, git_url: gitUrl,
assigned_cell: assignedCell, assigned_cell: assignedCell,
default_branch: defaultBranch || "main", default_branch: defaultBranch || "main",
environments,
is_active: isActive, is_active: isActive,
test_command: testCommand || undefined, test_command: testCommand || undefined,
lint_command: lintCommand || undefined, lint_command: lintCommand || undefined,
@@ -336,8 +346,17 @@ function EditProjectForm({
onChange={(e) => setDefaultBranch(e.target.value)} onChange={(e) => setDefaultBranch(e.target.value)}
placeholder="main" placeholder="main"
/> />
<p className="text-xs text-muted-foreground">
Fallback head+prod branch when no environment ladder is set below.
</p>
</div> </div>
{/* Environment ladder */}
<EnvironmentLadderEditor
rungs={environments}
onChange={setEnvironments}
/>
{/* Active Status */} {/* Active Status */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label htmlFor="is_active">Active</Label> <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).", "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: 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).", "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: 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.", "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: org_memory_enabled:
+1
View File
@@ -125,6 +125,7 @@ export const projectsApi = {
slug: project.slug, slug: project.slug,
git_url: project.git_url, git_url: project.git_url,
default_branch: project.default_branch ?? "main", default_branch: project.default_branch ?? "main",
environments: project.environments ?? null,
protected_branches: project.protected_branches ?? ["main", "master"], protected_branches: project.protected_branches ?? ["main", "master"],
assigned_cell: project.assigned_cell, assigned_cell: project.assigned_cell,
has_git_token: !!project.git_token, // Mock token status has_git_token: !!project.git_token, // Mock token status
+13
View File
@@ -1022,12 +1022,21 @@ export enum WorkSessionStatus {
ABANDONED = "abandoned", ABANDONED = "abandoned",
} }
// One rung of a project's ordered environment ladder.
export interface EnvironmentRung {
name: string;
branch: string;
}
export interface Project { export interface Project {
id: string; id: string;
name: string; name: string;
slug: string; slug: string;
git_url: string; git_url: string;
default_branch: 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[]; protected_branches: string[];
assigned_cell: Team; assigned_cell: Team;
// Git authentication (token never exposed, only boolean indicator) // Git authentication (token never exposed, only boolean indicator)
@@ -1063,6 +1072,8 @@ export interface ProjectCreate {
slug: string; slug: string;
git_url: string; git_url: string;
default_branch?: string; default_branch?: string;
// Ordered environment ladder; null/empty inherits default_branch (shim).
environments?: EnvironmentRung[] | null;
protected_branches?: string[]; protected_branches?: string[];
assigned_cell: Team; assigned_cell: Team;
// Git authentication (stored encrypted, never returned) // Git authentication (stored encrypted, never returned)
@@ -1079,6 +1090,8 @@ export interface ProjectUpdate {
name?: string; name?: string;
git_url?: string; git_url?: string;
default_branch?: string; default_branch?: string;
// Ordered environment ladder; null clears (reverts to default_branch shim).
environments?: EnvironmentRung[] | null;
protected_branches?: string[]; protected_branches?: string[];
assigned_cell?: Team; assigned_cell?: Team;
// Git authentication (empty string clears, undefined leaves unchanged) // Git authentication (empty string clears, undefined leaves unchanged)
-1
View File
@@ -295,4 +295,3 @@ def project_to_summary(
ci_watch_enabled=bool(project.ci_watch_enabled), ci_watch_enabled=bool(project.ci_watch_enabled),
task_counts=task_counts, task_counts=task_counts,
) )
+31
View File
@@ -818,6 +818,37 @@ class Settings(BaseSettings):
description="Max dep_update tasks the loop may originate in one cycle.", description="Max dep_update tasks the loop may originate in one cycle.",
) )
# Env-sync engine — cascades each opted-in project's env ladder prod→…→head
# (a clean merge auto-pushes to the lower rung; a conflict opens ONE sync PR)
# so dev never falls behind prod. Default-off; never pushes to prod (the
# cascade's lower/target rung is never prod by construction).
env_sync_enabled: bool = Field(
default=False,
description=(
"Master switch for the env-sync cascade loop. OFF by default; when "
"off the loop does not run. Only projects with a declared env "
"ladder (environments set) AND a git token participate."
),
)
env_sync_interval_seconds: int = Field(
default=1800,
ge=60,
description="Seconds between env-sync cascade passes.",
)
env_sync_max_open_tasks: int = Field(
default=3,
ge=1,
description=(
"Rolling cap on concurrently-open env_sync conflict tasks across "
"all repos; the loop originates nothing more while this many are open."
),
)
env_sync_max_per_cycle: int = Field(
default=1,
ge=1,
description="Max projects the env-sync loop may cascade in one cycle.",
)
# Gated release manager — at a logical point (accumulated unreleased changes # Gated release manager — at a logical point (accumulated unreleased changes
# past a threshold + green gate) the Secretary runs a deterministic readiness # past a threshold + green gate) the Secretary runs a deterministic readiness
# sweep and PROPOSES a release for the CEO to approve/reject. Default-off; # sweep and PROPOSES a release for the CEO to approve/reject. Default-off;
+13
View File
@@ -87,6 +87,19 @@ def ladder_pairs(project: object) -> list[tuple[EnvRung, EnvRung]]:
return [(rungs[i], rungs[i - 1]) for i in range(len(rungs) - 1, 0, -1)] return [(rungs[i], rungs[i - 1]) for i in range(len(rungs) - 1, 0, -1)]
def promotion_chain(project: object) -> list[str]:
"""Branches to merge into the prod checkout head->...->just-below-prod on a
CEO-gated release (the full-chain promotion).
Every rung except the last (prod), excluding any rung sharing the prod
branch so a degenerate (head==prod) ladder yields ``[]`` (no-op). Order
is head-first: ``[dev, qa, stag]`` for ``[dev, qa, stag, master]``.
"""
rungs = effective_environments(project)
prod = prod_branch(project)
return [r.branch for r in rungs[:-1] if r.branch != prod]
def normalize_environments( def normalize_environments(
value: list[dict[str, str]] | list[EnvRung] | None, value: list[dict[str, str]] | list[EnvRung] | None,
) -> list[dict[str, str]] | None: ) -> list[dict[str, str]] | None:
+69
View File
@@ -1109,6 +1109,7 @@ class AgentOrchestrator:
self._self_heal_task: asyncio.Task | None = None self._self_heal_task: asyncio.Task | None = None
self._ci_watch_task: asyncio.Task | None = None self._ci_watch_task: asyncio.Task | None = None
self._dep_update_task: asyncio.Task | None = None self._dep_update_task: asyncio.Task | None = None
self._env_sync_task: asyncio.Task | None = None
self._release_manager_task: asyncio.Task | None = None self._release_manager_task: asyncio.Task | None = None
self._x_mentions_task: asyncio.Task | None = None self._x_mentions_task: asyncio.Task | None = None
self._roadmap_engine_task: asyncio.Task | None = None self._roadmap_engine_task: asyncio.Task | None = None
@@ -1205,6 +1206,7 @@ class AgentOrchestrator:
self._self_heal_task = asyncio.create_task(self._self_heal_loop()) self._self_heal_task = asyncio.create_task(self._self_heal_loop())
self._ci_watch_task = asyncio.create_task(self._ci_watch_loop()) self._ci_watch_task = asyncio.create_task(self._ci_watch_loop())
self._dep_update_task = asyncio.create_task(self._dep_update_loop()) self._dep_update_task = asyncio.create_task(self._dep_update_loop())
self._env_sync_task = asyncio.create_task(self._env_sync_loop())
self._release_manager_task = asyncio.create_task(self._release_manager_loop()) self._release_manager_task = asyncio.create_task(self._release_manager_loop())
self._x_mentions_task = asyncio.create_task(self._x_mentions_poll_loop()) self._x_mentions_task = asyncio.create_task(self._x_mentions_poll_loop())
self._roadmap_engine_task = asyncio.create_task(self._roadmap_engine_loop()) self._roadmap_engine_task = asyncio.create_task(self._roadmap_engine_loop())
@@ -1321,6 +1323,7 @@ class AgentOrchestrator:
self._self_heal_task, self._self_heal_task,
self._ci_watch_task, self._ci_watch_task,
self._dep_update_task, self._dep_update_task,
self._env_sync_task,
self._release_manager_task, self._release_manager_task,
self._x_mentions_task, self._x_mentions_task,
self._roadmap_engine_task, self._roadmap_engine_task,
@@ -8137,6 +8140,72 @@ Start by:
await get_dep_update_engine(db).run_cycle(projects) await get_dep_update_engine(db).run_cycle(projects)
await db.commit() await db.commit()
async def _env_sync_loop(self) -> None:
"""Env-sync: cascade prod→…→head so dev never falls behind prod.
Dormant by default returns immediately unless ``env_sync_enabled``,
so a standard deployment adds zero behaviour. Each interval it loads the
opted-in projects (a declared env ladder + a git token), cascades the
ladder top-down via GitHub's merges API, and opens ONE sync PR + tracked
task per conflicted repo. Never pushes prod (the cascade's target is a
non-prod rung by construction), so "only the CEO merges master" holds.
"""
if not settings.env_sync_enabled:
return
interval = settings.env_sync_interval_seconds
self._record_loop_heartbeat("env_sync", interval)
while self._running:
try:
await asyncio.sleep(interval)
await self._run_env_sync_cycle()
self._record_loop_heartbeat("env_sync", interval)
except asyncio.CancelledError:
break
except Exception:
logger.exception("env-sync cycle failed")
async def _run_env_sync_cycle(self) -> None:
"""One env-sync pass: load the opted-in set, run the engine, commit.
Extracted from the loop so it is testable without the sleep. Warns when
env-sync is armed but no project has a declared env ladder so a
misconfiguration isn't mistaken for "everything is in sync".
"""
from roboco.db import get_db_context
from roboco.services.env_sync_engine import get_env_sync_engine
async with get_db_context() as db:
projects = await self._load_env_sync_set(db)
if not projects:
logger.warning(
"env-sync enabled but no project has a declared environment "
"ladder + git token — nothing to cascade"
)
return
await get_env_sync_engine(db).run_cycle(projects)
await db.commit()
async def _load_env_sync_set(self, db: Any) -> list[Any]:
"""Projects opted into env-sync: a declared env ladder (more than one
rung, so there are pairs to cascade) + a git_url, one per repo.
A degenerate ladder (head==prod, one rung) has no pairs to cascade, so it
is excluded here there is nothing to sync. Collapse to one canonical
project per repo: the engine's per-``git_url`` open-task dedup means a
monorepo's several cell-projects share one cascade anyway.
"""
from roboco.models.env_branches import ladder_pairs
from roboco.services.project import get_project_service
projects = await get_project_service(db).list_all(active_only=True)
eligible = [
p for p in projects if getattr(p, "git_url", None) and ladder_pairs(p)
]
return self._projects_one_per_key(
eligible,
key_fn=lambda p: (self._repo_key(str(getattr(p, "git_url", "") or "")),),
)
async def _release_manager_loop(self) -> None: async def _release_manager_loop(self) -> None:
"""Gated release manager: at a logical point, propose a CEO-gated release. """Gated release manager: at a logical point, propose a CEO-gated release.
+202
View File
@@ -0,0 +1,202 @@
"""Env-sync engine — cascade prod→…→head so dev never falls behind prod.
Dormant by default (``env_sync_enabled``). For each opted-in project (a declared
env ladder + a git token) it cascades the upper rungs into the lower ones top-down
via GitHub's merges API: a clean merge auto-pushes to the lower rung, and a
conflict opens ONE sync PR (upperlower) and stops that project's cascade for the
cycle. Like the other background engines it is conservative:
* **Default OFF** (``env_sync_enabled``) the orchestrator loop never starts.
* **Never pushes prod** the cascade's lower/target rung is never prod by
construction (``ladder_pairs`` yields ``(upper, lower)`` with lower the
non-prod rungs), so "only the CEO merges master" holds.
* **Bounded + deduped per repo** at most one open env_sync task per repo (a
conflict stops the cascade at that rung), plus per-cycle and rolling caps.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any, cast
from roboco.config import settings
from roboco.foundation import identity as _foundation
from roboco.models.base import Complexity, TaskNature, TaskStatus, TaskType, Team
from roboco.models.env_branches import ladder_pairs
from roboco.services.base import BaseService
from roboco.services.git import get_git_service
from roboco.services.task import (
ENV_SYNC_SOURCE,
TaskCreateRequest,
TaskService,
get_task_service,
)
if TYPE_CHECKING:
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from roboco.db.tables import TaskTable
class EnvSyncEngine(BaseService):
"""Cascade the env ladder prod→…→head so dev never falls behind prod."""
service_name = "env_sync_engine"
def __init__(self, session: AsyncSession) -> None:
super().__init__(session)
async def run_cycle(self, projects: list[Any]) -> list[TaskTable]:
"""Cascade each opted-in project's ladder; return conflict-PR tasks opened.
No-op unless ``env_sync_enabled``. Per-cycle + rolling caps; one open
env_sync task per repo (a conflict pauses the cascade at that rung).
Never pushes prod. Flushes; the caller (the orchestrator loop) owns the
commit.
"""
if not settings.env_sync_enabled:
return []
task_svc = get_task_service(self.session)
open_count = len(await task_svc.list_open_env_sync_tasks())
created: list[TaskTable] = []
for project in projects:
if len(created) >= settings.env_sync_max_per_cycle:
break
if open_count >= settings.env_sync_max_open_tasks:
self.log.info(
"env-sync open-task cap reached; not cascading",
cap=settings.env_sync_max_open_tasks,
)
break
if not await self._should_sync(task_svc, project):
continue
task = await self._cascade_project(project)
if task is not None:
created.append(task)
open_count += 1
return created
async def _should_sync(self, task_svc: TaskService, project: Any) -> bool:
"""True when ``project`` is opted in and not already being synced.
Opt-in = a declared env ladder (so there are pairs to cascade) + a git
token (the merges API needs a PAT). A degenerate ladder (head==prod)
has no pairs skip. Dedup is per repo: a project with an open env_sync
task has its cascade paused at a conflicted rung, so skip it until the
sync PR resolves.
"""
if project is None or getattr(project, "id", None) is None:
return False
# ProjectTable exposes ``git_token_encrypted`` (the column), not the
# ``has_git_token`` API-response boolean — the merges API needs a PAT.
if not getattr(project, "git_token_encrypted", None):
return False
if not ladder_pairs(project):
return False
existing = await task_svc.list_open_env_sync_tasks(
git_url=getattr(project, "git_url", None)
)
return not existing
async def _cascade_project(self, project: Any) -> TaskTable | None:
"""Cascade one project top→down; on a conflict open one sync PR + task.
Clean / already-ancestor steps continue down the ladder. A missing ref
(misconfigured ladder or API error) skips the project without opening a
PR. A conflict opens a sync PR and a tracked task, then stops never
cascade a dirty merge downward.
"""
slug = str(getattr(project, "slug", "") or "")
git = get_git_service(self.session)
for upper, lower in ladder_pairs(project):
pair = f"{upper.branch}{lower.branch}"
result = await git.sync_env_branch(slug, lower.branch, upper.branch)
status = result.get("status")
if status in ("merged", "already_ancestor"):
self.log.info("env-sync step", project=slug, pair=pair, status=status)
continue
if status == "missing_ref":
self.log.warning(
"env-sync step missing ref; skipping project",
project=slug,
pair=pair,
)
return None
# conflict: open a sync PR + tracked task, stop the cascade.
pr = await git.open_sync_pr(
slug,
upper.branch,
lower.branch,
body=(
f"The env-sync cascade could not merge `{upper.branch}` "
f"into `{lower.branch}` cleanly (non-fast-forward). Resolve "
f"the conflict and merge this PR so the cascade can resume.\n\n"
f"Opened automatically by the env-sync loop."
),
)
if pr is None:
self.log.warning(
"env-sync conflict but sync PR open failed; skipping project",
project=slug,
pair=pair,
)
return None
task = await self._open_sync_task(project, upper.branch, lower.branch, pr)
self.log.info(
"env-sync conflict; sync PR opened",
project=slug,
pair=pair,
pr=pr["number"],
)
return task
return None
async def _open_sync_task(
self,
project: Any,
upper_branch: str,
lower_branch: str,
pr: dict[str, Any],
) -> TaskTable:
"""Open ONE PENDING, dispatchable coordination task tracking the sync PR."""
task_svc = get_task_service(self.session)
slug = str(getattr(project, "slug", "") or "")
return await task_svc.create(
TaskCreateRequest(
title=(
f"env-sync: resolve {upper_branch}{lower_branch} conflict "
f"on {slug} (PR #{pr['number']})"
),
description=(
f"The env-sync cascade could not merge `{upper_branch}` into "
f"`{lower_branch}` cleanly (conflict). A sync PR was opened:\n"
f"{pr['url']}\n\n"
"Resolve the conflict and merge the PR so the cascade can "
"resume. This is a Main-PM coordination root: decompose the "
"resolution and delegate the code work to a cell dev — the "
"Main PM does not resolve it directly. Opened automatically by "
"the env-sync loop; it still ships through the normal gates "
"(QA, PR review, and the CEO's merge)."
),
acceptance_criteria=[
f"The sync PR {pr['url']} is merged (conflict resolved)",
f"`{lower_branch}` is not behind `{upper_branch}`",
],
team=Team.MAIN_PM,
assigned_to=_foundation.AGENTS["main-pm"].uuid,
created_by=_foundation.AGENTS["system"].uuid,
task_type=TaskType.PLANNING,
nature=TaskNature.TECHNICAL,
estimated_complexity=Complexity.MEDIUM,
project_id=cast("UUID", project.id),
status=TaskStatus.PENDING,
source=ENV_SYNC_SOURCE,
confirmed_by_human=True,
)
)
def get_env_sync_engine(session: AsyncSession) -> EnvSyncEngine:
"""Construct an EnvSyncEngine bound to ``session``."""
return EnvSyncEngine(session)
+159 -7
View File
@@ -298,6 +298,10 @@ _REV_LIST_PARTS = 2
# GitHub REST API status codes # GitHub REST API status codes
_GH_UNPROCESSABLE = 422 _GH_UNPROCESSABLE = 422
# merges-API success codes: 201 = merge commit created + pushed; 204 = nothing
# to merge (head already an ancestor of base).
_HTTP_CREATED = 201
_HTTP_NO_CONTENT = 204
# 404 means the PR (or repo) does not exist; surfaced as a typed GitError # 404 means the PR (or repo) does not exist; surfaced as a typed GitError
# by `update_pr_for_task` so the gateway can convert it into a specific # by `update_pr_for_task` so the gateway can convert it into a specific
# invalid_state envelope rather than the generic refusal message. # invalid_state envelope rather than the generic refusal message.
@@ -1122,9 +1126,9 @@ class GitService(BaseService):
parent = await task_service.get(UUID(str(task.parent_task_id))) parent = await task_service.get(UUID(str(task.parent_task_id)))
if parent and parent.branch_name: if parent and parent.branch_name:
return str(parent.branch_name) return str(parent.branch_name)
return await self._project_head_branch(project_slug) return await self._project_default_branch(project_slug)
async def _project_head_branch(self, project_slug: str) -> str: async def _project_default_branch(self, project_slug: str) -> str:
"""Return the project's head environment branch (ladder index 0). """Return the project's head environment branch (ladder index 0).
This is where dev/cell/leaf PRs target the dev trunk. Falls back to This is where dev/cell/leaf PRs target the dev trunk. Falls back to
@@ -1196,7 +1200,7 @@ class GitService(BaseService):
base_branch = await self._resolve_base_branch( base_branch = await self._resolve_base_branch(
task_id, request.parent_branch, request.project_slug, task_service task_id, request.parent_branch, request.project_slug, task_service
) )
default_branch = await self._project_head_branch(request.project_slug) default_branch = await self._project_default_branch(request.project_slug)
# Token for any remote-touching git command below (fetch, ls-remote, # Token for any remote-touching git command below (fetch, ls-remote,
# pull, push). Injected into a single `http.extraheader` config for # pull, push). Injected into a single `http.extraheader` config for
@@ -2422,7 +2426,7 @@ class GitService(BaseService):
Returns: (pr_number, pr_url, title, source_branch, target_branch) Returns: (pr_number, pr_url, title, source_branch, target_branch)
""" """
source_branch = await self._pr_head_branch(workspace, request) source_branch = await self._pr_head_branch(workspace, request)
default_branch = await self._project_head_branch(request.project_slug) default_branch = await self._project_default_branch(request.project_slug)
git_token = await self._get_project_token_or_raise(request.project_slug) git_token = await self._get_project_token_or_raise(request.project_slug)
target_branch, pr_title, pr_body = await self._resolve_new_pr_context( target_branch, pr_title, pr_body = await self._resolve_new_pr_context(
workspace, request, source_branch, default_branch, git_token workspace, request, source_branch, default_branch, git_token
@@ -2463,6 +2467,154 @@ class GitService(BaseService):
target_branch, target_branch,
) )
async def sync_env_branch(
self, project_slug: str, target_branch: str, source_branch: str
) -> dict[str, Any]:
"""Merge ``source_branch`` (an upper env rung) into ``target_branch`` (the
lower rung) server-side via GitHub's merges API — one step of the
prodhead cascade. The merge commit lands on ``target_branch`` (the
clean-cascade auto-push). The cascade's target is never prod by
construction (``ladder_pairs``), so prod is never pushed here.
Returns ``{"status": ...}``:
* ``already_ancestor`` target already contains source (HTTP 204).
* ``merged`` merge commit created + pushed to target (HTTP 201; ``sha``).
* ``conflict`` non-fast-forward / merge conflict (HTTP 409); no commit.
* ``missing_ref`` no token / unparseable remote / a branch absent (422).
Never raises into the engine loop. Does NOT open a PR on conflict
the caller decides that.
"""
project = await get_project_service(self.session).get_by_slug(project_slug)
if project is None or not project.git_url:
return {"status": "missing_ref"}
git_token = await self._token_for_project(project_slug)
if not git_token:
return {"status": "missing_ref"}
try:
owner, repo = self._parse_git_url(project.git_url)
except GitError:
return {"status": "missing_ref"}
try:
async with httpx.AsyncClient(timeout=_default_git_timeout()) as client:
resp = await client.post(
f"{_api_base()}/repos/{owner}/{repo}/merges",
headers={
"Authorization": f"Bearer {git_token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
json={
"base": target_branch,
"head": source_branch,
"commit_message": f"sync: {source_branch}{target_branch}",
},
)
except httpx.HTTPError as exc:
self.log.warning(
"env-sync merges API error", project=project_slug, error=str(exc)
)
return {"status": "missing_ref"}
return self._env_merge_status(resp, project_slug)
def _env_merge_status(
self, resp: httpx.Response, project_slug: str
) -> dict[str, Any]:
"""Map a GitHub merges-API response to an env-sync status dict.
``merged`` carries the new merge ``sha``; ``conflict`` (409) leaves the
target untouched; any other code (incl. 422 missing-ref / no-merge) is
``missing_ref`` so the engine skips without opening a PR.
"""
if resp.status_code == _HTTP_CREATED:
return {"status": "merged", "sha": resp.json().get("sha")}
if resp.status_code == _HTTP_NO_CONTENT:
return {"status": "already_ancestor"}
if resp.status_code == _HTTP_CONFLICT:
return {"status": "conflict"}
self.log.warning(
"env-sync merges API unexpected status",
project=project_slug,
status=resp.status_code,
body=resp.text[:200],
)
return {"status": "missing_ref"}
async def open_sync_pr(
self, project_slug: str, source_branch: str, target_branch: str, body: str
) -> dict[str, Any] | None:
"""Open (or reuse) a sync PR ``source_branch → target_branch``.
Idempotent: reuses an already-open PR for the same headbase. Returns
``{"number", "url"}`` or None on a missing token / unparseable remote /
GitHub error never raises into the engine loop.
"""
project = await get_project_service(self.session).get_by_slug(project_slug)
if project is None or not project.git_url:
return None
git_token = await self._token_for_project(project_slug)
if not git_token:
return None
try:
owner_repo = self._parse_git_url(project.git_url)
except GitError:
return None
return await self._post_sync_pr(
owner_repo, git_token, (source_branch, target_branch), body, project_slug
)
async def _post_sync_pr(
self,
owner_repo: tuple[str, str],
git_token: str,
branches: tuple[str, str],
body: str,
project_slug: str,
) -> dict[str, Any] | None:
"""Reuse an open sync PR or create a new one for ``source→target``.
Never raises into the engine loop: a missing existing PR, a rejected
create, or a transport error all return None.
"""
owner, repo = owner_repo
source_branch, target_branch = branches
existing = await self._find_existing_pr(
owner, repo, source_branch, target_branch, git_token
)
if existing is not None:
return {
"number": int(existing["number"]),
"url": str(existing.get("html_url", "")),
}
try:
resp = await self._post_pr(
owner,
repo,
git_token,
{
"title": f"sync: {source_branch}{target_branch}",
"body": body,
"head": source_branch,
"base": target_branch,
},
)
except GitError as exc:
self.log.warning(
"env-sync PR create failed", project=project_slug, error=str(exc)
)
return None
if not resp.is_success:
self.log.warning(
"env-sync PR create rejected",
project=project_slug,
status=resp.status_code,
body=resp.text[:200],
)
return None
data = resp.json()
return {"number": int(data["number"]), "url": str(data.get("html_url", ""))}
async def _resolve_new_pr_context( async def _resolve_new_pr_context(
self, self,
workspace: Path, workspace: Path,
@@ -3514,7 +3666,7 @@ class GitService(BaseService):
await self._delete_pr_branch_best_effort(owner, repo, pr_number, git_token) await self._delete_pr_branch_best_effort(owner, repo, pr_number, git_token)
target_branch = await self._project_head_branch(project_slug) target_branch = await self._project_default_branch(project_slug)
# Default branch always exists on origin, so the plain sync is correct # Default branch always exists on origin, so the plain sync is correct
# here. The best-effort variant guards the agent-facing pr_merge path, # here. The best-effort variant guards the agent-facing pr_merge path,
# whose target can be an integration branch deleted from origin. # whose target can be an integration branch deleted from origin.
@@ -3930,7 +4082,7 @@ class GitService(BaseService):
layering is preserved. Fall back to the default branch only if the layering is preserved. Fall back to the default branch only if the
create push itself fails. create push itself fails.
""" """
default_branch = await self._project_head_branch(project_slug) default_branch = await self._project_default_branch(project_slug)
if base_branch == default_branch: if base_branch == default_branch:
return base_branch return base_branch
ls = await self._run_git( ls = await self._run_git(
@@ -4296,7 +4448,7 @@ class GitService(BaseService):
# repo's default branch — a root→master PR is merged solely by the CEO # repo's default branch — a root→master PR is merged solely by the CEO
# via approve-&-merge (merge_pr_for_task, CEO-gated from # via approve-&-merge (merge_pr_for_task, CEO-gated from
# awaiting_ceo_approval). Agents open the master PR and escalate. # awaiting_ceo_approval). Agents open the master PR and escalate.
default_branch = await self._project_head_branch(project.slug) default_branch = await self._project_default_branch(project.slug)
if target == default_branch: if target == default_branch:
raise UnauthorizedError( raise UnauthorizedError(
action="pr_merge", action="pr_merge",
+3 -5
View File
@@ -527,7 +527,7 @@ def _resolve_release_ci_workflow() -> str:
async def get_release_executor(session: AsyncSession) -> ReleaseExecutor: async def get_release_executor(session: AsyncSession) -> ReleaseExecutor:
"""Build a ReleaseExecutor with a production ops over a fresh writable clone.""" """Build a ReleaseExecutor with a production ops over a fresh writable clone."""
from roboco.config import settings from roboco.config import settings
from roboco.models.env_branches import effective_environments, prod_branch from roboco.models.env_branches import prod_branch, promotion_chain
from roboco.services.project import get_project_service from roboco.services.project import get_project_service
slug = (settings.self_heal_project_slug or "roboco-api").strip() slug = (settings.self_heal_project_slug or "roboco-api").strip()
@@ -541,10 +541,8 @@ async def get_release_executor(session: AsyncSession) -> ReleaseExecutor:
# of the dev retarget. default_branch stays as the legacy/shim source. # of the dev retarget. default_branch stays as the legacy/shim source.
default_branch = prod_branch(project) default_branch = prod_branch(project)
# Full-chain promotion: rung branches head→…→just-below-prod to merge into # Full-chain promotion: rung branches head→…→just-below-prod to merge into
# the prod checkout before bumping. Skips the prod rung itself and any rung # the prod checkout before bumping. Degenerate (head==prod) → [] → no-op.
# sharing the prod branch (degenerate head==prod → empty → no-op). env_chain = promotion_chain(project)
rungs = effective_environments(project)
env_chain = [r.branch for r in rungs[:-1] if r.branch != default_branch]
# PAT rides a per-call ``-c http.extraheader=Authorization: Basic …`` config # PAT rides a per-call ``-c http.extraheader=Authorization: Basic …`` config
# (mirrors workspace.py :642 / :1285) so it never lands in the clone/push # (mirrors workspace.py :642 / :1285) so it never lands in the clone/push
# argv — ``/proc/<pid>/cmdline`` would otherwise expose a URL-embedded token. # argv — ``/proc/<pid>/cmdline`` would otherwise expose a URL-embedded token.
+25 -11
View File
@@ -37,6 +37,7 @@ from roboco.services.notification import NotificationService
from roboco.services.project import get_project_service from roboco.services.project import get_project_service
from roboco.services.release_readiness import ( from roboco.services.release_readiness import (
ReleaseReadinessReport, ReleaseReadinessReport,
_run_git,
assess, assess,
gather_snapshot, gather_snapshot,
report_to_dict, report_to_dict,
@@ -213,7 +214,6 @@ class ReleaseManagerEngine(BaseService):
""" """
from roboco.models.env_branches import head_branch, prod_branch from roboco.models.env_branches import head_branch, prod_branch
from roboco.services.git import get_git_service from roboco.services.git import get_git_service
from roboco.services.release_readiness import _run_git
from roboco.services.workspace import get_workspace_service from roboco.services.workspace import get_workspace_service
slug = _roboco_slug() slug = _roboco_slug()
@@ -250,15 +250,9 @@ class ReleaseManagerEngine(BaseService):
# already present. Best-effort: a fetch failure degrades to last_tag..HEAD # already present. Best-effort: a fetch failure degrades to last_tag..HEAD
# (gather_snapshot falls back when prod_tip is None) — never aborts. # (gather_snapshot falls back when prod_tip is None) — never aborts.
prod_name = prod_branch(project) prod_name = prod_branch(project)
have_prod = prod_name != head_branch(project) prod_for_snapshot = await self._ensure_prod_fetched(
if have_prod: root, prod_name, prod_name != head_branch(project)
try: )
await asyncio.to_thread(
_run_git, Path(root), ["fetch", "origin", prod_name]
)
except Exception as exc:
self.log.warning("release-manager: prod fetch failed", error=str(exc))
have_prod = False
today = datetime.now(UTC).strftime("%Y-%m-%d") today = datetime.now(UTC).strftime("%Y-%m-%d")
# gather_snapshot runs multiple sync `subprocess.run` git calls + a # gather_snapshot runs multiple sync `subprocess.run` git calls + a
# filesystem walk; offload so the shared API event loop isn't blocked # filesystem walk; offload so the shared API event loop isn't blocked
@@ -267,10 +261,30 @@ class ReleaseManagerEngine(BaseService):
gather_snapshot, gather_snapshot,
Path(root), Path(root),
master_ci_conclusion=conclusion, master_ci_conclusion=conclusion,
prod_branch=prod_name if have_prod else None, prod_branch=prod_for_snapshot,
) )
return assess(snapshot, today=today) return assess(snapshot, today=today)
async def _ensure_prod_fetched(
self, root: Path, prod_name: str, needs_fetch: bool
) -> str | None:
"""Ensure ``origin/<prod>`` is fetched for the prod..head readiness diff.
Degenerate (prod==head) needs no fetch the read clone is pinned to
head==prod. Best-effort: a fetch failure returns None so gather_snapshot
falls back to last_tag..HEAD rather than aborting the assessment.
"""
if not needs_fetch:
return prod_name
try:
await asyncio.to_thread(
_run_git, Path(root), ["fetch", "origin", prod_name]
)
except Exception as exc:
self.log.warning("release-manager: prod fetch failed", error=str(exc))
return None
return prod_name
def get_release_manager_engine( def get_release_manager_engine(
session: AsyncSession, assessor: ReleaseAssessor | None = None session: AsyncSession, assessor: ReleaseAssessor | None = None
+1
View File
@@ -62,6 +62,7 @@ FEATURE_FLAGS: tuple[tuple[str, str], ...] = (
("gateway_health_enabled", "Gateway-health recovery"), ("gateway_health_enabled", "Gateway-health recovery"),
("ci_watch_enabled", "Multi-repo CI-watch"), ("ci_watch_enabled", "Multi-repo CI-watch"),
("dep_update_enabled", "Dependency-update bot"), ("dep_update_enabled", "Dependency-update bot"),
("env_sync_enabled", "Environment-branch sync (cascade prod→dev)"),
("docs_sync_enabled", "Docs-divergence sync (release -> docs-update task)"), ("docs_sync_enabled", "Docs-divergence sync (release -> docs-update task)"),
("release_manager_enabled", "Gated release manager"), ("release_manager_enabled", "Gated release manager"),
("org_memory_enabled", "Organizational memory loop"), ("org_memory_enabled", "Organizational memory loop"),
+26
View File
@@ -603,6 +603,12 @@ DEP_UPDATE_SOURCE = "dep_update"
# auto-merges; requires the roboco-website project to be registered. # auto-merges; requires the roboco-website project to be registered.
DOCS_SYNC_SOURCE = "docs_sync" DOCS_SYNC_SOURCE = "docs_sync"
# Source tag for an env-sync conflict task: opened by the EnvSyncEngine when the
# prod→…→head cascade hits a non-fast-forward on a rung and opens a sync PR. Rides
# the normal delivery lifecycle (+ PR-review gate) and is never auto-merged; the
# task tracks the PR so the dedup cap + panel visibility work.
ENV_SYNC_SOURCE = "env_sync"
# Source tag for a gated release proposal: opened by the release-manager engine # Source tag for a gated release proposal: opened by the release-manager engine
# when accumulated unreleased changes pass the threshold + the gate is green. # when accumulated unreleased changes pass the threshold + the gate is green.
# Unlike the sources above it is NEVER dispatched — it is HELD for the CEO # Unlike the sources above it is NEVER dispatched — it is HELD for the CEO
@@ -1575,6 +1581,26 @@ class TaskService(BaseService):
result = await self.session.execute(stmt) result = await self.session.execute(stmt)
return list(result.scalars().all()) return list(result.scalars().all())
async def list_open_env_sync_tasks(
self, git_url: str | None = None
) -> list[TaskTable]:
"""Non-terminal env_sync conflict tasks — the dedupe + open-cap basis.
Optionally scoped to one repo by ``git_url`` (matched on the normalized
repo key). A conflict stops the cascade at that rung, so at most one
open env_sync task exists per repo: while it is open the repo's cascade
is paused and the engine skips it until the PR resolves.
"""
stmt = select(TaskTable).where(
TaskTable.source == ENV_SYNC_SOURCE,
TaskTable.status.notin_([TaskStatus.COMPLETED, TaskStatus.CANCELLED]),
)
if git_url is not None:
stmt = stmt.join(ProjectTable, TaskTable.project_id == ProjectTable.id)
stmt = stmt.where(_repo_key_expr(ProjectTable.git_url) == repo_key(git_url))
result = await self.session.execute(stmt)
return list(result.scalars().all())
async def list_open_docs_sync_tasks( async def list_open_docs_sync_tasks(
self, version: str | None = None self, version: str | None = None
) -> list[TaskTable]: ) -> list[TaskTable]:
@@ -102,6 +102,7 @@ async def test_h24_wait_for_ci_polls_through_non_success(
git_url="", git_url="",
git_prefix=[], git_prefix=[],
ci_workflow=None, ci_workflow=None,
env_chain=[],
) )
ops = _GitReleaseOps(session=MagicMock(), ctx=ctx) ops = _GitReleaseOps(session=MagicMock(), ctx=ctx)
sha = "abc123" sha = "abc123"
@@ -0,0 +1,269 @@
"""EnvSyncEngine — cascade prod->head; conflict opens a PR + task, never prod.
Mirrors the ci-watch engine test: seeds projects + agents, mocks the GitService
(the merges API + PR open are GitHub calls) so the cascade is driven by the
fake's queued statuses, and asserts the engine's contract:
* clean cascade (merged / already_ancestor) opens nothing,
* a conflict opens ONE sync PR + tracked task and stops the cascade,
* a tokenless project is skipped,
* per-cycle + rolling caps + per-repo dedup are honoured,
* the cascade target is never prod (the lower rung of every pair is non-prod).
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from uuid import uuid4
import pytest
from roboco.config import settings
from roboco.db.tables import AgentTable, ProjectTable
from roboco.foundation import identity as _foundation
from roboco.models.base import AgentRole, AgentStatus, TaskStatus, Team
from roboco.services import env_sync_engine as env_sync_module
from roboco.services.env_sync_engine import get_env_sync_engine
from roboco.services.task import ENV_SYNC_SOURCE, get_task_service
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
SYSTEM_UUID = _foundation.AGENTS["system"].uuid
MAIN_PM_UUID = _foundation.AGENTS["main-pm"].uuid
# A two-rung ladder: head=dev (PR target), prod=master (release target).
_LADDER = [
{"name": "head", "branch": "dev"},
{"name": "prod", "branch": "master"},
]
class _FakeGit:
"""Stand-in for GitService: drives the cascade from queued statuses."""
def __init__(self, statuses: list[str], *, pr_number: int = 42) -> None:
self._statuses = list(statuses)
self.sync_calls: list[tuple[str, str, str]] = []
self.pr_calls: list[tuple[str, str, str, str]] = []
self._pr_number = pr_number
async def sync_env_branch(
self, slug: str, target_branch: str, source_branch: str
) -> dict[str, Any]:
self.sync_calls.append((slug, target_branch, source_branch))
status = self._statuses.pop(0) if self._statuses else "already_ancestor"
return {"status": status}
async def open_sync_pr(
self, slug: str, source_branch: str, target_branch: str, body: str
) -> dict[str, Any] | None:
self.pr_calls.append((slug, source_branch, target_branch, body))
return {
"number": self._pr_number,
"url": f"https://github.com/x/{slug}/pull/{self._pr_number}",
}
async def _get_or_create_agent(
db: AsyncSession, agent_id: object, role: AgentRole, slug: str
) -> None:
if await db.get(AgentTable, agent_id) is None:
db.add(
AgentTable(
id=agent_id,
name=slug,
slug=f"{slug}-{uuid4().hex[:8]}",
role=role,
team=None,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="x",
capabilities=[],
permissions={},
metrics={},
)
)
await db.flush()
async def _seed_project(
db: AsyncSession,
slug: str,
git_url: str,
*,
environments: list[dict[str, str]] | None = _LADDER,
token: str | None = "fake-encrypted-token",
) -> ProjectTable:
project = ProjectTable(
id=uuid4(),
name=slug,
slug=slug,
git_url=git_url,
assigned_cell=Team.BACKEND,
created_by=SYSTEM_UUID,
environments=environments,
git_token_encrypted=token,
)
db.add(project)
await db.flush()
return project
@pytest.fixture(autouse=True)
async def _enabled(db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "env_sync_enabled", True)
monkeypatch.setattr(settings, "env_sync_max_per_cycle", 5)
monkeypatch.setattr(settings, "env_sync_max_open_tasks", 5)
await _get_or_create_agent(db_session, SYSTEM_UUID, AgentRole.SYSTEM, "system")
await _get_or_create_agent(db_session, MAIN_PM_UUID, AgentRole.MAIN_PM, "main-pm")
def _patch_git(monkeypatch: pytest.MonkeyPatch, fake: _FakeGit) -> None:
monkeypatch.setattr(env_sync_module, "get_git_service", lambda _session: fake)
@pytest.mark.asyncio
async def test_clean_cascade_opens_nothing(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
proj = await _seed_project(db_session, "clean-a", "https://github.com/x/a.git")
fake = _FakeGit(["merged"])
_patch_git(monkeypatch, fake)
created = await get_env_sync_engine(db_session).run_cycle([proj])
assert created == []
# prod(master) merged into head(dev) — one cascade step for the 2-rung ladder.
assert fake.sync_calls == [("clean-a", "dev", "master")]
assert fake.pr_calls == []
@pytest.mark.asyncio
async def test_already_ancestor_is_clean(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
proj = await _seed_project(db_session, "anc-a", "https://github.com/x/anc.git")
fake = _FakeGit(["already_ancestor"])
_patch_git(monkeypatch, fake)
assert await get_env_sync_engine(db_session).run_cycle([proj]) == []
assert fake.sync_calls == [("anc-a", "dev", "master")]
@pytest.mark.asyncio
async def test_conflict_opens_pr_and_task_then_stops(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
# 4-rung ladder so a conflict on the FIRST (topmost) pair stops the cascade
# before reaching head — proving it does not cascade a dirty merge downward.
proj = await _seed_project(
db_session,
"conf-a",
"https://github.com/x/conf.git",
environments=[
{"name": "head", "branch": "dev"},
{"name": "qa", "branch": "qa"},
{"name": "stag", "branch": "stag"},
{"name": "prod", "branch": "master"},
],
)
fake = _FakeGit(["conflict", "merged"]) # only the first is consumed
_patch_git(monkeypatch, fake)
created = await get_env_sync_engine(db_session).run_cycle([proj])
assert len(created) == 1
task = created[0]
assert task.source == ENV_SYNC_SOURCE
assert task.status == TaskStatus.PENDING
assert task.project_id == proj.id
# The cascade stopped at the first conflict: only one sync_env_branch call.
assert len(fake.sync_calls) == 1
# The sync PR targets the lower (non-prod) rung of the conflicted pair.
assert len(fake.pr_calls) == 1
_slug, _source_branch, target_branch, _body = fake.pr_calls[0]
assert target_branch != "master" # never prod
@pytest.mark.asyncio
async def test_missing_ref_skips_without_pr(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
proj = await _seed_project(db_session, "miss-a", "https://github.com/x/miss.git")
fake = _FakeGit(["missing_ref"])
_patch_git(monkeypatch, fake)
created = await get_env_sync_engine(db_session).run_cycle([proj])
assert created == []
assert fake.pr_calls == []
@pytest.mark.asyncio
async def test_tokenless_project_skipped(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
proj = await _seed_project(
db_session, "notok", "https://github.com/x/notok.git", token=None
)
fake = _FakeGit(["merged"])
_patch_git(monkeypatch, fake)
created = await get_env_sync_engine(db_session).run_cycle([proj])
assert created == []
assert fake.sync_calls == [] # never attempted
@pytest.mark.asyncio
async def test_degenerate_ladder_skipped(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
# Single-rung ladder (head==prod) has no pairs to cascade.
proj = await _seed_project(
db_session,
"single",
"https://github.com/x/single.git",
environments=[{"name": "prod", "branch": "master"}],
)
fake = _FakeGit(["merged"])
_patch_git(monkeypatch, fake)
created = await get_env_sync_engine(db_session).run_cycle([proj])
assert created == []
assert fake.sync_calls == []
@pytest.mark.asyncio
async def test_per_cycle_cap(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(settings, "env_sync_max_per_cycle", 1)
p1 = await _seed_project(db_session, "cap-1", "https://github.com/x/c1.git")
p2 = await _seed_project(db_session, "cap-2", "https://github.com/x/c2.git")
fake = _FakeGit(["conflict", "conflict"])
_patch_git(monkeypatch, fake)
created = await get_env_sync_engine(db_session).run_cycle([p1, p2])
assert len(created) == 1 # capped at one per cycle
@pytest.mark.asyncio
async def test_deduped_per_repo(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A repo with an open env_sync task is skipped until the PR resolves."""
proj = await _seed_project(db_session, "dedup", "https://github.com/x/d.git")
fake = _FakeGit(["conflict"])
_patch_git(monkeypatch, fake)
first = await get_env_sync_engine(db_session).run_cycle([proj])
assert len(first) == 1
# Second cycle, same repo still has the open task -> deduped (no new PR).
fake2 = _FakeGit(["conflict"])
_patch_git(monkeypatch, fake2)
second = await get_env_sync_engine(db_session).run_cycle([proj])
assert second == []
assert fake2.sync_calls == [] # cascade paused at the conflicted rung
assert len(await get_task_service(db_session).list_open_env_sync_tasks()) == 1
@pytest.mark.asyncio
async def test_disabled_is_noop(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(settings, "env_sync_enabled", False)
proj = await _seed_project(db_session, "off", "https://github.com/x/off.git")
fake = _FakeGit(["merged"])
_patch_git(monkeypatch, fake)
assert await get_env_sync_engine(db_session).run_cycle([proj]) == []
assert fake.sync_calls == []
@@ -0,0 +1,74 @@
"""Per-project environment ladder column (migration 073).
Migration 073 adds ``projects.environments`` (JSONB null). The real
upgrade/downgrade chain is verified separately against a throwaway Postgres;
these assertions guard the resulting schema shape and a value round-trip,
mirroring ``test_migration_dep_update.py``.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from uuid import uuid4
import pytest
from roboco.db.tables import AgentTable, ProjectTable
from roboco.models import AgentRole, AgentStatus, Team
from sqlalchemy import select
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
async def _seed_project(db_session: AsyncSession) -> ProjectTable:
agent = AgentTable(
id=uuid4(),
name="Dev",
slug=f"be-dev-{uuid4().hex[:8]}",
role=AgentRole.DEVELOPER,
team=Team.BACKEND,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="dev",
capabilities=[],
permissions={},
metrics={},
)
db_session.add(agent)
await db_session.flush()
project = ProjectTable(
id=uuid4(),
name="L-Proj",
slug=f"l-proj-{uuid4().hex[:8]}",
git_url="https://example.com/r.git",
assigned_cell=Team.BACKEND,
created_by=agent.id,
)
db_session.add(project)
await db_session.flush()
return project
@pytest.mark.asyncio
async def test_environments_defaults_null(db_session: AsyncSession) -> None:
project = await _seed_project(db_session)
assert project.environments is None
@pytest.mark.asyncio
async def test_environments_round_trip(db_session: AsyncSession) -> None:
project = await _seed_project(db_session)
project.environments = [
{"name": "head", "branch": "dev"},
{"name": "prod", "branch": "master"},
]
await db_session.flush()
row = (
await db_session.execute(
select(ProjectTable).where(ProjectTable.id == project.id)
)
).scalar_one()
assert row.environments == [
{"name": "head", "branch": "dev"},
{"name": "prod", "branch": "master"},
]
+230
View File
@@ -0,0 +1,230 @@
"""env_branches — shim, ladder pairs, head/prod resolution, normalization.
Pure domain helpers (pydantic-only); no DB. The read-time shim synthesizes a
degenerate single-branch ladder from ``default_branch`` when ``environments``
is null, so every consumer behaves identically until a real ladder is declared.
"""
from __future__ import annotations
import pytest
from roboco.models.env_branches import (
EnvRung,
effective_environments,
head_branch,
ladder_pairs,
normalize_environments,
prod_branch,
promotion_chain,
)
class _Proj:
"""Duck-typed project row (matches Project + ProjectTable surface)."""
def __init__(
self,
*,
default_branch: str = "master",
environments: list[dict[str, str]] | None = None,
) -> None:
self.default_branch = default_branch
self.environments = environments
# --- effective_environments shim ------------------------------------------
def test_null_environments_synthesizes_degenerate_ladder() -> None:
proj = _Proj(default_branch="slave")
rungs = effective_environments(proj)
assert [(r.name, r.branch) for r in rungs] == [("head", "slave"), ("prod", "slave")]
def test_empty_environments_synthesizes_degenerate_ladder() -> None:
proj = _Proj(default_branch="master", environments=[])
rungs = effective_environments(proj)
assert [(r.name, r.branch) for r in rungs] == [
("head", "master"),
("prod", "master"),
]
def test_missing_default_branch_falls_back_to_master() -> None:
proj = _Proj(default_branch="") # falsy default_branch
assert head_branch(proj) == "master"
assert prod_branch(proj) == "master"
def test_set_environments_returned_as_is_preserving_order() -> None:
proj = _Proj(
environments=[
{"name": "head", "branch": "dev"},
{"name": "qa", "branch": "qa"},
{"name": "prod", "branch": "master"},
]
)
rungs = effective_environments(proj)
assert [r.branch for r in rungs] == ["dev", "qa", "master"]
assert all(isinstance(r, EnvRung) for r in rungs)
# --- head_branch / prod_branch --------------------------------------------
def test_head_and_prod_single_rung() -> None:
proj = _Proj(environments=[{"name": "prod", "branch": "master"}])
assert head_branch(proj) == "master"
assert prod_branch(proj) == "master"
def test_head_and_prod_two_rungs() -> None:
proj = _Proj(
environments=[
{"name": "head", "branch": "slave"},
{"name": "prod", "branch": "master"},
]
)
assert head_branch(proj) == "slave"
assert prod_branch(proj) == "master"
def test_head_and_prod_four_rungs() -> None:
proj = _Proj(
environments=[
{"name": "head", "branch": "dev"},
{"name": "qa", "branch": "qa"},
{"name": "stag", "branch": "stag"},
{"name": "prod", "branch": "master"},
]
)
assert head_branch(proj) == "dev"
assert prod_branch(proj) == "master"
# --- ladder_pairs (prod -> head cascade) -----------------------------------
def test_ladder_pairs_empty_for_single_rung() -> None:
assert (
ladder_pairs(_Proj(environments=[{"name": "prod", "branch": "master"}])) == []
)
def test_ladder_pairs_two_rungs() -> None:
proj = _Proj(
environments=[
{"name": "head", "branch": "dev"},
{"name": "prod", "branch": "master"},
]
)
pairs = ladder_pairs(proj)
assert [(u.branch, lower.branch) for u, lower in pairs] == [("master", "dev")]
def test_ladder_pairs_four_rungs_top_down() -> None:
proj = _Proj(
environments=[
{"name": "head", "branch": "dev"},
{"name": "qa", "branch": "qa"},
{"name": "stag", "branch": "stag"},
{"name": "prod", "branch": "master"},
]
)
# [(prod, stag), (stag, qa), (qa, head)] — merge upper into lower.
pairs = ladder_pairs(proj)
assert [(u.branch, lower.branch) for u, lower in pairs] == [
("master", "stag"),
("stag", "qa"),
("qa", "dev"),
]
def test_ladder_pairs_lower_rung_is_never_prod() -> None:
"""The cascade's target is never prod by construction — only CEO merges prod."""
proj = _Proj(
environments=[
{"name": "head", "branch": "dev"},
{"name": "qa", "branch": "qa"},
{"name": "prod", "branch": "master"},
]
)
prod_name = prod_branch(proj)
for _upper, lower in ladder_pairs(proj):
assert lower.branch != prod_name
# --- promotion_chain (full-chain release promotion) ----------------------
def test_promotion_chain_empty_for_degenerate_ladder() -> None:
"""head==prod => nothing to promote (no-op release promotion)."""
assert promotion_chain(_Proj(default_branch="master")) == []
assert (
promotion_chain(_Proj(environments=[{"name": "prod", "branch": "master"}]))
== []
)
def test_promotion_chain_two_rungs() -> None:
proj = _Proj(
environments=[
{"name": "head", "branch": "dev"},
{"name": "prod", "branch": "master"},
]
)
assert promotion_chain(proj) == ["dev"]
def test_promotion_chain_four_rungs_head_first_excluding_prod() -> None:
proj = _Proj(
environments=[
{"name": "head", "branch": "dev"},
{"name": "qa", "branch": "qa"},
{"name": "stag", "branch": "stag"},
{"name": "prod", "branch": "master"},
]
)
assert promotion_chain(proj) == ["dev", "qa", "stag"]
# --- normalize_environments ------------------------------------------------
def test_normalize_none_returns_none() -> None:
assert normalize_environments(None) is None
assert normalize_environments([]) is None
def test_normalize_strips_and_preserves_order() -> None:
out = normalize_environments(
[{"name": " head ", "branch": " dev "}, {"name": "prod", "branch": "master"}]
)
assert out == [
{"name": "head", "branch": "dev"},
{"name": "prod", "branch": "master"},
]
def test_normalize_rejects_empty_name() -> None:
with pytest.raises(ValueError, match="non-empty name"):
normalize_environments([{"name": "", "branch": "dev"}])
def test_normalize_rejects_empty_branch() -> None:
with pytest.raises(ValueError, match="non-empty name"):
normalize_environments(
[{"name": "head", "branch": " "}]
) # branch trimmed to empty
def test_normalize_rejects_duplicate_branch() -> None:
with pytest.raises(ValueError, match="duplicate environment branch"):
normalize_environments(
[{"name": "head", "branch": "dev"}, {"name": "prod", "branch": "dev"}]
)
def test_normalize_accepts_envrung_models() -> None:
out = normalize_environments([EnvRung(name="head", branch="dev")])
assert out == [{"name": "head", "branch": "dev"}]
@@ -50,6 +50,7 @@ def _make_orchestrator() -> AgentOrchestrator:
"_self_heal_task", "_self_heal_task",
"_ci_watch_task", "_ci_watch_task",
"_dep_update_task", "_dep_update_task",
"_env_sync_task",
"_release_manager_task", "_release_manager_task",
"_x_mentions_task", "_x_mentions_task",
"_roadmap_engine_task", "_roadmap_engine_task",
@@ -70,6 +70,9 @@ class _FakeOps:
# instance (not via __init__ — keeps the constructor under the arg-count # instance (not via __init__ — keeps the constructor under the arg-count
# gate) by tests that exercise the retry path. # gate) by tests that exercise the retry path.
self._existing_sha: str | None = None self._existing_sha: str | None = None
# env-chain promotion failure message; set on the instance (same arg-
# count-gate reason) by the promotion-failure test.
self._promote_raises: str | None = None
self.calls: list[str] = [] self.calls: list[str] = []
self.bumped_plan: list[str] | None = None self.bumped_plan: list[str] | None = None
self.bumped_version: str | None = None self.bumped_version: str | None = None
@@ -79,6 +82,11 @@ class _FakeOps:
self.calls.append("check") self.calls.append("check")
return self._already return self._already
async def promote_env_chain(self) -> None:
self.calls.append("promote")
if self._promote_raises is not None:
raise RuntimeError(self._promote_raises)
async def release_commit_sha(self, _version: str) -> str | None: async def release_commit_sha(self, _version: str) -> str | None:
# Half-landed detection: a prior `chore(release): {version}` commit # Half-landed detection: a prior `chore(release): {version}` commit
# already on the branch means a publish_failed retry must NOT re-run the # already on the branch means a publish_failed retry must NOT re-run the
@@ -127,6 +135,7 @@ async def test_green_path_publishes_once() -> None:
assert ops.calls.count("publish") == _ONE assert ops.calls.count("publish") == _ONE
assert ops.calls == [ assert ops.calls == [
"check", "check",
"promote",
"bump", "bump",
"changelog", "changelog",
"gate", "gate",
@@ -204,6 +213,22 @@ async def test_publish_failure_returns_structured_publish_failed() -> None:
assert ops.calls.count("publish") == _ONE assert ops.calls.count("publish") == _ONE
@pytest.mark.asyncio
async def test_promotion_failure_aborts_before_bump() -> None:
"""A RuntimeError from promote_env_chain (a merge conflict in the
head->...->prod chain) becomes a structured ``promotion_failed`` result
fail-closed: the bump/changelog/gate/commit/publish pipeline never runs."""
ops = _FakeOps()
ops._promote_raises = "env-chain promotion failed: non-fast-forward"
result = await ReleaseExecutor(ops).execute(_report())
assert result.status == "promotion_failed"
assert result.commit_sha is None
assert result.release_url is None
assert "bump" not in ops.calls
assert "commit" not in ops.calls
assert "publish" not in ops.calls
def test_release_result_carries_outcome_fields() -> None: def test_release_result_carries_outcome_fields() -> None:
result = ReleaseResult( result = ReleaseResult(
status="published", status="published",
@@ -291,6 +316,7 @@ async def test_wait_for_ci_scoped_to_release_commit_not_branch_latest(
git_url="x", git_url="x",
git_prefix=[], git_prefix=[],
ci_workflow="ci.yml", ci_workflow="ci.yml",
env_chain=[],
) )
ops = _GitReleaseOps(session=MagicMock(), ctx=ctx) ops = _GitReleaseOps(session=MagicMock(), ctx=ctx)
ok = await ops.wait_for_ci(commit_sha) ok = await ops.wait_for_ci(commit_sha)
@@ -341,6 +367,7 @@ async def test_wait_for_ci_polls_through_rerun(
git_url="x", git_url="x",
git_prefix=[], git_prefix=[],
ci_workflow="ci.yml", ci_workflow="ci.yml",
env_chain=[],
) )
ops = _GitReleaseOps(session=MagicMock(), ctx=ctx) ops = _GitReleaseOps(session=MagicMock(), ctx=ctx)
ok = await ops.wait_for_ci(commit_sha) ok = await ops.wait_for_ci(commit_sha)
@@ -387,6 +414,7 @@ async def test_wait_for_ci_exhausts_window_on_persistent_failure(
git_url="x", git_url="x",
git_prefix=[], git_prefix=[],
ci_workflow="ci.yml", ci_workflow="ci.yml",
env_chain=[],
) )
ops = _GitReleaseOps(session=MagicMock(), ctx=ctx) ops = _GitReleaseOps(session=MagicMock(), ctx=ctx)
ok = await ops.wait_for_ci(commit_sha) ok = await ops.wait_for_ci(commit_sha)
@@ -515,6 +543,7 @@ async def test_release_push_argv_uses_extraheader_not_url_token(
git_url=git_url, git_url=git_url,
git_prefix=git_prefix, git_prefix=git_prefix,
ci_workflow=None, ci_workflow=None,
env_chain=[],
) )
ops = _GitReleaseOps(session=MagicMock(), ctx=ctx) ops = _GitReleaseOps(session=MagicMock(), ctx=ctx)
sha = await ops.commit_and_push("0.13.0") sha = await ops.commit_and_push("0.13.0")
@@ -21,6 +21,7 @@ def _ctx() -> _ReleaseContext:
git_url="https://github.com/o/roboco", git_url="https://github.com/o/roboco",
git_prefix=[], git_prefix=[],
ci_workflow=None, ci_workflow=None,
env_chain=[],
) )