mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
feat(git): auto-regenerate + commit codegen drift before push (#632)
A project that checks in generated artifacts (RoboCo's lifecycle renders, verb tables) drifts whenever their source changes. The agent pre-submit gate (make gate) omits foundation-check, so drift is invisible at the desk and only fails on CI's drift gate — a failure with no link back to the task, which made one live task thrash 8 revision rounds. New per-project codegen_command (migration 078): run in the task's worktree right before push, and any drift committed into the same push, so CI never sees stale artifacts. Fail-open — a broken/timeout codegen command logs and lets the push proceed (CI's drift gate is the safety net); a null command (every project without checked-in codegen) is a pure no-op. Hooked at both push_branch (open_pr's first push, the PR head CI grades) and push_task_branch (later re-pushes). RoboCo sets codegen_command='make codegen' (a new Makefile target — the write counterpart to foundation-check's read) via the panel. Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
@@ -577,3 +577,12 @@ foundation-check:
|
|||||||
# `foundation-check` is now the canonical drift gate; this alias just forwards.
|
# `foundation-check` is now the canonical drift gate; this alias just forwards.
|
||||||
.PHONY: ci-lifecycle-check
|
.PHONY: ci-lifecycle-check
|
||||||
ci-lifecycle-check: foundation-check
|
ci-lifecycle-check: foundation-check
|
||||||
|
|
||||||
|
# Write counterpart to foundation-check's read: regenerates the same checked-in
|
||||||
|
# artifacts IN PLACE (no diff/exit-code guard) so a project can point its
|
||||||
|
# `codegen_command` at this and have drift committed before a push, instead of
|
||||||
|
# only ever discovering it at CI's foundation-check hard-fail.
|
||||||
|
.PHONY: codegen
|
||||||
|
codegen:
|
||||||
|
@$(MAKE) lifecycle
|
||||||
|
@uv run python scripts/regenerate_verb_tables.py
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""Per-project codegen-drift regeneration command.
|
||||||
|
|
||||||
|
Some projects check in generated artifacts (rendered docs, generated verb/prompt
|
||||||
|
tables, ...) that drift whenever their source changes. The agent pre-submit
|
||||||
|
gate (``quality_command``, e.g. ``make gate``) never regenerates them, so drift
|
||||||
|
only ever surfaces later as CI's own foundation-check-style `git diff
|
||||||
|
--exit-code` hard-fail — a failure an agent has no way to trace back to its own
|
||||||
|
task. ``codegen_command`` (e.g. ``make codegen``) is run in the task's
|
||||||
|
workspace before every push; any resulting drift is committed so the pushed PR
|
||||||
|
head is never stale. Additive and nullable — null (the default) is a pure
|
||||||
|
no-op for projects with no generated artifacts, mirroring ``quality_command``.
|
||||||
|
|
||||||
|
Revision ID: 078_project_codegen_command
|
||||||
|
Revises: 077_github_app
|
||||||
|
Create Date: 2026-07-21
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "078_project_codegen_command"
|
||||||
|
down_revision = "077_github_app"
|
||||||
|
branch_labels: dict[str, str] | None = None
|
||||||
|
depends_on: dict[str, str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"projects",
|
||||||
|
sa.Column("codegen_command", sa.String(length=500), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("projects", "codegen_command")
|
||||||
@@ -148,6 +148,9 @@ function EditProjectForm({
|
|||||||
const [qualityCommand, setQualityCommand] = useState(
|
const [qualityCommand, setQualityCommand] = useState(
|
||||||
project.quality_command || "",
|
project.quality_command || "",
|
||||||
);
|
);
|
||||||
|
const [codegenCommand, setCodegenCommand] = useState(
|
||||||
|
project.codegen_command || "",
|
||||||
|
);
|
||||||
const [ciWatchEnabled, setCiWatchEnabled] = useState(
|
const [ciWatchEnabled, setCiWatchEnabled] = useState(
|
||||||
project.ci_watch_enabled,
|
project.ci_watch_enabled,
|
||||||
);
|
);
|
||||||
@@ -226,6 +229,7 @@ function EditProjectForm({
|
|||||||
typecheck_command: typecheckCommand || undefined,
|
typecheck_command: typecheckCommand || undefined,
|
||||||
build_command: buildCommand || undefined,
|
build_command: buildCommand || undefined,
|
||||||
quality_command: qualityCommand || undefined,
|
quality_command: qualityCommand || undefined,
|
||||||
|
codegen_command: codegenCommand || undefined,
|
||||||
ci_watch_enabled: ciWatchEnabled,
|
ci_watch_enabled: ciWatchEnabled,
|
||||||
ci_watch_workflow: ciWatchWorkflow || undefined,
|
ci_watch_workflow: ciWatchWorkflow || undefined,
|
||||||
video_engine_enabled: videoEngineEnabled,
|
video_engine_enabled: videoEngineEnabled,
|
||||||
@@ -552,6 +556,22 @@ function EditProjectForm({
|
|||||||
in the dev's workspace at hand-off to QA.
|
in the dev's workspace at hand-off to QA.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<HelpTip label="Command that regenerates checked-in generated files, e.g. `make codegen`; run and committed before push so codegen drift never fails CI. Leave blank if the project has no generated artifacts.">
|
||||||
|
<Label htmlFor="codegen_command">Codegen Command</Label>
|
||||||
|
</HelpTip>
|
||||||
|
<Input
|
||||||
|
id="codegen_command"
|
||||||
|
value={codegenCommand}
|
||||||
|
onChange={(e) => setCodegenCommand(e.target.value)}
|
||||||
|
placeholder="make codegen"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Regenerates checked-in generated artifacts; any drift is
|
||||||
|
committed in the task's workspace before each push.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -168,6 +168,7 @@ export const projectsApi = {
|
|||||||
typecheck_command: project.typecheck_command ?? null,
|
typecheck_command: project.typecheck_command ?? null,
|
||||||
build_command: project.build_command ?? null,
|
build_command: project.build_command ?? null,
|
||||||
quality_command: project.quality_command ?? null,
|
quality_command: project.quality_command ?? null,
|
||||||
|
codegen_command: project.codegen_command ?? null,
|
||||||
ci_watch_enabled: false,
|
ci_watch_enabled: false,
|
||||||
ci_watch_workflow: null,
|
ci_watch_workflow: null,
|
||||||
video_engine_enabled: false,
|
video_engine_enabled: false,
|
||||||
|
|||||||
@@ -1052,6 +1052,7 @@ export interface Project {
|
|||||||
typecheck_command: string | null;
|
typecheck_command: string | null;
|
||||||
build_command: string | null;
|
build_command: string | null;
|
||||||
quality_command: string | null;
|
quality_command: string | null;
|
||||||
|
codegen_command: string | null;
|
||||||
// Autonomous maintenance opt-in
|
// Autonomous maintenance opt-in
|
||||||
ci_watch_enabled: boolean;
|
ci_watch_enabled: boolean;
|
||||||
ci_watch_workflow: string | null;
|
ci_watch_workflow: string | null;
|
||||||
@@ -1091,6 +1092,7 @@ export interface ProjectCreate {
|
|||||||
typecheck_command?: string;
|
typecheck_command?: string;
|
||||||
build_command?: string;
|
build_command?: string;
|
||||||
quality_command?: string;
|
quality_command?: string;
|
||||||
|
codegen_command?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProjectUpdate {
|
export interface ProjectUpdate {
|
||||||
@@ -1114,6 +1116,7 @@ export interface ProjectUpdate {
|
|||||||
typecheck_command?: string;
|
typecheck_command?: string;
|
||||||
build_command?: string;
|
build_command?: string;
|
||||||
quality_command?: string;
|
quality_command?: string;
|
||||||
|
codegen_command?: string;
|
||||||
// Autonomous maintenance opt-in
|
// Autonomous maintenance opt-in
|
||||||
ci_watch_enabled?: boolean;
|
ci_watch_enabled?: boolean;
|
||||||
ci_watch_workflow?: string;
|
ci_watch_workflow?: string;
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ class ProjectResponse(BaseModel):
|
|||||||
typecheck_command: str | None = None
|
typecheck_command: str | None = None
|
||||||
build_command: str | None = None
|
build_command: str | None = None
|
||||||
quality_command: str | None = None
|
quality_command: str | None = None
|
||||||
|
codegen_command: str | None = None
|
||||||
|
|
||||||
# Autonomous maintenance opt-in
|
# Autonomous maintenance opt-in
|
||||||
ci_watch_enabled: bool = False
|
ci_watch_enabled: bool = False
|
||||||
@@ -162,6 +163,7 @@ class ProjectCreateRequest(BaseModel):
|
|||||||
typecheck_command: str | None = None
|
typecheck_command: str | None = None
|
||||||
build_command: str | None = None
|
build_command: str | None = None
|
||||||
quality_command: str | None = None
|
quality_command: str | None = None
|
||||||
|
codegen_command: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class ProjectUpdateRequest(BaseModel):
|
class ProjectUpdateRequest(BaseModel):
|
||||||
@@ -201,6 +203,7 @@ class ProjectUpdateRequest(BaseModel):
|
|||||||
typecheck_command: str | None = None
|
typecheck_command: str | None = None
|
||||||
build_command: str | None = None
|
build_command: str | None = None
|
||||||
quality_command: str | None = None
|
quality_command: str | None = None
|
||||||
|
codegen_command: str | None = None
|
||||||
|
|
||||||
# Autonomous maintenance opt-in
|
# Autonomous maintenance opt-in
|
||||||
ci_watch_enabled: bool | None = None
|
ci_watch_enabled: bool | None = None
|
||||||
@@ -294,6 +297,7 @@ def project_to_response(project: "ProjectTable") -> ProjectResponse:
|
|||||||
typecheck_command=project.typecheck_command,
|
typecheck_command=project.typecheck_command,
|
||||||
build_command=project.build_command,
|
build_command=project.build_command,
|
||||||
quality_command=project.quality_command,
|
quality_command=project.quality_command,
|
||||||
|
codegen_command=project.codegen_command,
|
||||||
ci_watch_enabled=bool(project.ci_watch_enabled),
|
ci_watch_enabled=bool(project.ci_watch_enabled),
|
||||||
ci_watch_workflow=project.ci_watch_workflow,
|
ci_watch_workflow=project.ci_watch_workflow,
|
||||||
video_engine_enabled=bool(project.video_engine_enabled),
|
video_engine_enabled=bool(project.video_engine_enabled),
|
||||||
|
|||||||
@@ -532,6 +532,10 @@ class ProjectTable(Base):
|
|||||||
# the agent i_am_done gate runs this in the dev's workspace instead of the
|
# the agent i_am_done gate runs this in the dev's workspace instead of the
|
||||||
# lint/typecheck pair — e.g. "make gate".
|
# lint/typecheck pair — e.g. "make gate".
|
||||||
quality_command: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
quality_command: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||||
|
# Regenerates checked-in generated artifacts (e.g. "make codegen"); run and
|
||||||
|
# any drift committed in the task's workspace before every push, so CI's
|
||||||
|
# codegen-drift gate never fails on inherited drift. Null = no codegen step.
|
||||||
|
codegen_command: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||||
|
|
||||||
# Autonomous maintenance opt-in (multi-repo CI-watch). Default-off: a
|
# Autonomous maintenance opt-in (multi-repo CI-watch). Default-off: a
|
||||||
# project is watched only when ci_watch_enabled is set; ci_watch_workflow
|
# project is watched only when ci_watch_enabled is set; ci_watch_workflow
|
||||||
|
|||||||
@@ -189,6 +189,15 @@ class Project(TimestampMixin):
|
|||||||
"When set it replaces the lint/typecheck pair in the gate."
|
"When set it replaces the lint/typecheck pair in the gate."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
codegen_command: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description=(
|
||||||
|
"Command that regenerates checked-in generated artifacts "
|
||||||
|
"(e.g. 'make codegen'); run and any drift committed before push "
|
||||||
|
"so CI's codegen-drift gate never fails on inherited drift. "
|
||||||
|
"Null = no codegen step."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
# Access Control
|
# Access Control
|
||||||
assigned_cell: Team = Field(..., description="Which cell owns this project")
|
assigned_cell: Team = Field(..., description="Which cell owns this project")
|
||||||
@@ -309,6 +318,7 @@ class ProjectCreate(RobocoBase):
|
|||||||
typecheck_command: str | None = None
|
typecheck_command: str | None = None
|
||||||
build_command: str | None = None
|
build_command: str | None = None
|
||||||
quality_command: str | None = None
|
quality_command: str | None = None
|
||||||
|
codegen_command: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class ProjectUpdate(RobocoBase):
|
class ProjectUpdate(RobocoBase):
|
||||||
@@ -339,6 +349,7 @@ class ProjectUpdate(RobocoBase):
|
|||||||
typecheck_command: str | None = None
|
typecheck_command: str | None = None
|
||||||
build_command: str | None = None
|
build_command: str | None = None
|
||||||
quality_command: str | None = None
|
quality_command: str | None = None
|
||||||
|
codegen_command: str | None = None
|
||||||
assigned_cell: Team | None = None
|
assigned_cell: Team | None = None
|
||||||
allowed_agents: list[UUID] | None = None
|
allowed_agents: list[UUID] | None = None
|
||||||
is_active: bool | None = None
|
is_active: bool | None = None
|
||||||
|
|||||||
@@ -1782,6 +1782,9 @@ class GitService(BaseService):
|
|||||||
if project is None:
|
if project is None:
|
||||||
return 0
|
return 0
|
||||||
workspace = await self.get_workspace(project.slug, agent_id)
|
workspace = await self.get_workspace(project.slug, agent_id)
|
||||||
|
# Regenerate + commit any codegen drift BEFORE the push carries it —
|
||||||
|
# a no-op unless the project sets codegen_command.
|
||||||
|
await self._run_codegen_and_commit(str(task.branch_name), workspace)
|
||||||
# Push the task's branch BY NAME, independent of the current checkout.
|
# Push the task's branch BY NAME, independent of the current checkout.
|
||||||
# The dev's clone is shared across tasks, so by the QA-submission /
|
# The dev's clone is shared across tasks, so by the QA-submission /
|
||||||
# open_pr boundary it is usually parked on a LATER task's branch; the
|
# open_pr boundary it is usually parked on a LATER task's branch; the
|
||||||
@@ -4242,6 +4245,77 @@ class GitService(BaseService):
|
|||||||
workspace = await self.get_workspace(project.slug, actor_agent_id)
|
workspace = await self.get_workspace(project.slug, actor_agent_id)
|
||||||
return await run_quality_commands(workspace, commands)
|
return await run_quality_commands(workspace, commands)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _codegen_command_for(project: Any) -> str | None:
|
||||||
|
"""The project's ``codegen_command``, or ``None`` if unset.
|
||||||
|
|
||||||
|
The ``isinstance`` check is defensive (the column is ``str | None``)
|
||||||
|
and also keeps a loosely-specced test double inert: a bare
|
||||||
|
``MagicMock`` auto-vivifies any attribute access, so without it every
|
||||||
|
unrelated GitService test would spuriously trip the codegen path.
|
||||||
|
"""
|
||||||
|
command = getattr(project, "codegen_command", None)
|
||||||
|
return command if isinstance(command, str) and command else None
|
||||||
|
|
||||||
|
async def _run_codegen_and_commit(self, branch_name: str, workspace: Path) -> None:
|
||||||
|
"""Regenerate + commit codegen drift in the branch's worktree before push.
|
||||||
|
|
||||||
|
Some projects check in generated artifacts (rendered docs, generated
|
||||||
|
verb tables, ...) that drift whenever their source changes. Left
|
||||||
|
unregenerated, drift only ever surfaces later as CI's own drift gate
|
||||||
|
(a `git diff --exit-code` hard-fail) — a failure with no obvious link
|
||||||
|
back to the task that caused it. Running the project's
|
||||||
|
``codegen_command`` here means any drift lands in the SAME push that's
|
||||||
|
about to open/update the PR, so CI never sees it stale.
|
||||||
|
|
||||||
|
Fail-open by design: a broken/timing-out codegen command, or any
|
||||||
|
resolution failure (missing task/project, worktree trouble), logs a
|
||||||
|
warning and is skipped — the push proceeds without a commit rather
|
||||||
|
than blocking delivery. A red CI drift-gate on the resulting PR is the
|
||||||
|
safety net, not a silent pass. A null/absent ``codegen_command`` (most
|
||||||
|
projects) is a pure no-op.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
task = await self._task_for_branch(branch_name)
|
||||||
|
if task is None:
|
||||||
|
return
|
||||||
|
project = await self._project_for_task(task)
|
||||||
|
if project is None:
|
||||||
|
return
|
||||||
|
command = self._codegen_command_for(project)
|
||||||
|
if command is None:
|
||||||
|
return
|
||||||
|
task_id = require_uuid(task.id)
|
||||||
|
worktree = self._worktree_for_task(workspace, task_id)
|
||||||
|
await self._ensure_worktree_for_commit(workspace, worktree, branch_name)
|
||||||
|
result = await run_quality_commands(worktree, [("codegen", command)])
|
||||||
|
if not result.passed:
|
||||||
|
self.log.warning(
|
||||||
|
"codegen_command_failed",
|
||||||
|
project=getattr(project, "slug", None),
|
||||||
|
task_id=str(task_id),
|
||||||
|
output=result.output_excerpt,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
status = await self._run_git(
|
||||||
|
worktree, ["status", "--porcelain"], check=False
|
||||||
|
)
|
||||||
|
if not status.stdout.strip():
|
||||||
|
return # codegen ran clean — nothing to commit
|
||||||
|
await self._run_git(worktree, ["add", "-A"])
|
||||||
|
await self._run_git(
|
||||||
|
worktree,
|
||||||
|
[
|
||||||
|
"commit",
|
||||||
|
"-m",
|
||||||
|
f"[{str(task_id)[:8]}] regenerate generated artifacts",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
self.log.warning(
|
||||||
|
"codegen_and_commit_failed", branch=branch_name, error=str(exc)
|
||||||
|
)
|
||||||
|
|
||||||
async def toolchain_status_for_task(
|
async def toolchain_status_for_task(
|
||||||
self, actor_agent_id: UUID, task: Any
|
self, actor_agent_id: UUID, task: Any
|
||||||
) -> str | None:
|
) -> str | None:
|
||||||
@@ -4377,6 +4451,9 @@ class GitService(BaseService):
|
|||||||
workspace = await self._workspace_for_branch(
|
workspace = await self._workspace_for_branch(
|
||||||
branch_name, actor_agent_id=actor_agent_id
|
branch_name, actor_agent_id=actor_agent_id
|
||||||
)
|
)
|
||||||
|
# Regenerate + commit any codegen drift BEFORE this first push opens
|
||||||
|
# the PR — a no-op unless the project sets codegen_command.
|
||||||
|
await self._run_codegen_and_commit(branch_name, workspace)
|
||||||
# Push the NAMED branch, not the workspace's current checkout. The
|
# Push the NAMED branch, not the workspace's current checkout. The
|
||||||
# clone root is shared across a dev's tasks and (F123) parked on the
|
# clone root is shared across a dev's tasks and (F123) parked on the
|
||||||
# default branch while the task branch lives in a per-task worktree;
|
# default branch while the task branch lives in a per-task worktree;
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ class ProjectService(BaseService):
|
|||||||
typecheck_command=data.typecheck_command,
|
typecheck_command=data.typecheck_command,
|
||||||
build_command=data.build_command,
|
build_command=data.build_command,
|
||||||
quality_command=data.quality_command,
|
quality_command=data.quality_command,
|
||||||
|
codegen_command=data.codegen_command,
|
||||||
created_by=created_by,
|
created_by=created_by,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"""Per-project codegen-drift regeneration command (migration 078).
|
||||||
|
|
||||||
|
Migration 078 adds ``projects.codegen_command`` (varchar 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.
|
||||||
|
"""
|
||||||
|
|
||||||
|
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="C-Proj",
|
||||||
|
slug=f"c-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_codegen_command_column_defaults_null(db_session: AsyncSession) -> None:
|
||||||
|
project = await _seed_project(db_session)
|
||||||
|
assert project.codegen_command is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_codegen_command_column_round_trip(db_session: AsyncSession) -> None:
|
||||||
|
project = await _seed_project(db_session)
|
||||||
|
project.codegen_command = "make codegen"
|
||||||
|
await db_session.flush()
|
||||||
|
row = (
|
||||||
|
await db_session.execute(
|
||||||
|
select(ProjectTable).where(ProjectTable.id == project.id)
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
assert row.codegen_command == "make codegen"
|
||||||
@@ -19,6 +19,7 @@ from roboco.config import settings
|
|||||||
from roboco.exceptions import GitCommandError, GitError, MergeConflictError
|
from roboco.exceptions import GitCommandError, GitError, MergeConflictError
|
||||||
from roboco.services.base import NotFoundError, UnauthorizedError, ValidationError
|
from roboco.services.base import NotFoundError, UnauthorizedError, ValidationError
|
||||||
from roboco.services.forge import RepoRef
|
from roboco.services.forge import RepoRef
|
||||||
|
from roboco.services.gateway.quality_gate import GateResult
|
||||||
from roboco.services.git import GitService
|
from roboco.services.git import GitService
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -163,6 +164,25 @@ async def test_push_task_branch_pushes_task_branch_by_name() -> None:
|
|||||||
assert_branch.assert_not_awaited()
|
assert_branch.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_push_task_branch_runs_codegen_before_push() -> None:
|
||||||
|
"""push_task_branch regenerates + commits codegen drift before pushing."""
|
||||||
|
task = MagicMock(branch_name="feature/backend/abc")
|
||||||
|
project = MagicMock(slug="roboco")
|
||||||
|
svc = _service()
|
||||||
|
_bind(svc, "_assert_task_owned_with_branch", AsyncMock(return_value=task))
|
||||||
|
_bind(svc, "_project_for_task", AsyncMock(return_value=project))
|
||||||
|
_bind(svc, "get_workspace", AsyncMock(return_value=Path("/tmp/ws")))
|
||||||
|
codegen_mock = AsyncMock()
|
||||||
|
_bind(svc, "_run_codegen_and_commit", codegen_mock)
|
||||||
|
push_mock = AsyncMock(return_value=("feature/backend/abc", _PUSHED_COMMIT_COUNT))
|
||||||
|
_bind(svc, "push", push_mock)
|
||||||
|
|
||||||
|
await svc.push_task_branch(uuid4(), uuid4())
|
||||||
|
|
||||||
|
codegen_mock.assert_awaited_once_with("feature/backend/abc", Path("/tmp/ws"))
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_push_branch_pushes_named_branch_not_current_checkout() -> None:
|
async def test_push_branch_pushes_named_branch_not_current_checkout() -> None:
|
||||||
"""push_branch (open_pr's push side effect) pushes the NAMED branch.
|
"""push_branch (open_pr's push side effect) pushes the NAMED branch.
|
||||||
@@ -192,6 +212,23 @@ async def test_push_branch_pushes_named_branch_not_current_checkout() -> None:
|
|||||||
push_mock.assert_awaited_once_with(Path("/tmp/ws"), branch=branch_name)
|
push_mock.assert_awaited_once_with(Path("/tmp/ws"), branch=branch_name)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_push_branch_runs_codegen_before_push() -> None:
|
||||||
|
"""push_branch (open_pr's FIRST push) regenerates + commits codegen drift
|
||||||
|
before pushing — the initial PR head must never carry stale artifacts."""
|
||||||
|
branch_name = "feature/backend/abc"
|
||||||
|
svc = _service()
|
||||||
|
_bind(svc, "_workspace_for_branch", AsyncMock(return_value=Path("/tmp/ws")))
|
||||||
|
codegen_mock = AsyncMock()
|
||||||
|
_bind(svc, "_run_codegen_and_commit", codegen_mock)
|
||||||
|
push_mock = AsyncMock(return_value=(branch_name, _PUSHED_COMMIT_COUNT))
|
||||||
|
_bind(svc, "push", push_mock)
|
||||||
|
|
||||||
|
await svc.push_branch(branch_name)
|
||||||
|
|
||||||
|
codegen_mock.assert_awaited_once_with(branch_name, Path("/tmp/ws"))
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_push_targets_explicit_branch_not_current_checkout() -> None:
|
async def test_push_targets_explicit_branch_not_current_checkout() -> None:
|
||||||
"""push(branch=X) pushes X by ref even when the workspace is on Y."""
|
"""push(branch=X) pushes X by ref even when the workspace is on Y."""
|
||||||
@@ -367,6 +404,150 @@ async def test_push_task_branch_noop_for_project_less_task() -> None:
|
|||||||
push_mock.assert_not_awaited()
|
push_mock.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _run_codegen_and_commit: regenerate + commit codegen drift before push
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_codegen_command_for_returns_none_when_unset() -> None:
|
||||||
|
project = MagicMock(codegen_command=None)
|
||||||
|
assert GitService._codegen_command_for(project) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_codegen_command_for_ignores_unspecced_mock_attribute() -> None:
|
||||||
|
"""A bare MagicMock auto-vivifies codegen_command as a truthy MagicMock —
|
||||||
|
the isinstance(str) guard must treat that the same as unset, or every
|
||||||
|
loosely-specced GitService test would spuriously trip the codegen path."""
|
||||||
|
assert GitService._codegen_command_for(MagicMock()) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_codegen_command_for_returns_configured_command() -> None:
|
||||||
|
project = MagicMock(codegen_command="make codegen")
|
||||||
|
assert GitService._codegen_command_for(project) == "make codegen"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_codegen_and_commit_noop_when_command_unset() -> None:
|
||||||
|
"""Null codegen_command (most projects) never runs a subprocess."""
|
||||||
|
task = MagicMock(id=uuid4(), branch_name="feature/backend/abc")
|
||||||
|
project = MagicMock(codegen_command=None)
|
||||||
|
svc = _service()
|
||||||
|
_bind(svc, "_task_for_branch", AsyncMock(return_value=task))
|
||||||
|
_bind(svc, "_project_for_task", AsyncMock(return_value=project))
|
||||||
|
run_mock = AsyncMock()
|
||||||
|
with patch("roboco.services.git.run_quality_commands", run_mock):
|
||||||
|
await svc._run_codegen_and_commit("feature/backend/abc", Path("/tmp/ws"))
|
||||||
|
run_mock.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_codegen_and_commit_noop_when_no_task() -> None:
|
||||||
|
svc = _service()
|
||||||
|
_bind(svc, "_task_for_branch", AsyncMock(return_value=None))
|
||||||
|
run_mock = AsyncMock()
|
||||||
|
with patch("roboco.services.git.run_quality_commands", run_mock):
|
||||||
|
await svc._run_codegen_and_commit("feature/backend/missing", Path("/tmp/ws"))
|
||||||
|
run_mock.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_codegen_and_commit_commits_drift() -> None:
|
||||||
|
"""Codegen runs clean but produces drift -> add -A + a task-prefixed commit."""
|
||||||
|
task_id = uuid4()
|
||||||
|
task = MagicMock(id=task_id, branch_name="feature/backend/abc")
|
||||||
|
project = MagicMock(codegen_command="make codegen", slug="roboco")
|
||||||
|
svc = _service()
|
||||||
|
_bind(svc, "_task_for_branch", AsyncMock(return_value=task))
|
||||||
|
_bind(svc, "_project_for_task", AsyncMock(return_value=project))
|
||||||
|
_bind(svc, "_ensure_worktree_for_commit", AsyncMock())
|
||||||
|
calls: list[list[str]] = []
|
||||||
|
|
||||||
|
async def _run_git(_ws: object, args: list[str], **_kw: object) -> MagicMock:
|
||||||
|
calls.append(args)
|
||||||
|
res = MagicMock()
|
||||||
|
res.returncode = 0
|
||||||
|
res.stdout = " M docs/rag/lifecycle/foo.md\n" if args[0] == "status" else ""
|
||||||
|
return res
|
||||||
|
|
||||||
|
_bind(svc, "_run_git", AsyncMock(side_effect=_run_git))
|
||||||
|
gate_result = GateResult(passed=True, output="ok")
|
||||||
|
with patch(
|
||||||
|
"roboco.services.git.run_quality_commands",
|
||||||
|
AsyncMock(return_value=gate_result),
|
||||||
|
):
|
||||||
|
await svc._run_codegen_and_commit("feature/backend/abc", Path("/tmp/ws"))
|
||||||
|
|
||||||
|
assert ["add", "-A"] in calls
|
||||||
|
commit_call = next(c for c in calls if c[0] == "commit")
|
||||||
|
assert commit_call == [
|
||||||
|
"commit",
|
||||||
|
"-m",
|
||||||
|
f"[{str(task_id)[:8]}] regenerate generated artifacts",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_codegen_and_commit_noop_when_codegen_clean() -> None:
|
||||||
|
"""Codegen runs clean with NO drift -> git status is clean, no commit."""
|
||||||
|
task = MagicMock(id=uuid4(), branch_name="feature/backend/abc")
|
||||||
|
project = MagicMock(codegen_command="make codegen", slug="roboco")
|
||||||
|
svc = _service()
|
||||||
|
_bind(svc, "_task_for_branch", AsyncMock(return_value=task))
|
||||||
|
_bind(svc, "_project_for_task", AsyncMock(return_value=project))
|
||||||
|
_bind(svc, "_ensure_worktree_for_commit", AsyncMock())
|
||||||
|
calls: list[list[str]] = []
|
||||||
|
|
||||||
|
async def _run_git(_ws: object, args: list[str], **_kw: object) -> MagicMock:
|
||||||
|
calls.append(args)
|
||||||
|
res = MagicMock()
|
||||||
|
res.returncode = 0
|
||||||
|
res.stdout = ""
|
||||||
|
return res
|
||||||
|
|
||||||
|
_bind(svc, "_run_git", AsyncMock(side_effect=_run_git))
|
||||||
|
gate_result = GateResult(passed=True, output="ok")
|
||||||
|
with patch(
|
||||||
|
"roboco.services.git.run_quality_commands",
|
||||||
|
AsyncMock(return_value=gate_result),
|
||||||
|
):
|
||||||
|
await svc._run_codegen_and_commit("feature/backend/abc", Path("/tmp/ws"))
|
||||||
|
|
||||||
|
assert not any(c[0] in ("add", "commit") for c in calls)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_codegen_and_commit_fail_open_on_command_failure() -> None:
|
||||||
|
"""A non-zero codegen exit logs a warning and skips the commit (fail-open) —
|
||||||
|
push must proceed rather than a broken codegen command blocking delivery."""
|
||||||
|
task = MagicMock(id=uuid4(), branch_name="feature/backend/abc")
|
||||||
|
project = MagicMock(codegen_command="make codegen", slug="roboco")
|
||||||
|
svc = _service()
|
||||||
|
_bind(svc, "_task_for_branch", AsyncMock(return_value=task))
|
||||||
|
_bind(svc, "_project_for_task", AsyncMock(return_value=project))
|
||||||
|
_bind(svc, "_ensure_worktree_for_commit", AsyncMock())
|
||||||
|
run_git_mock = AsyncMock()
|
||||||
|
_bind(svc, "_run_git", run_git_mock)
|
||||||
|
gate_result = GateResult(passed=False, failures=("codegen",), output="boom")
|
||||||
|
with patch(
|
||||||
|
"roboco.services.git.run_quality_commands",
|
||||||
|
AsyncMock(return_value=gate_result),
|
||||||
|
):
|
||||||
|
await svc._run_codegen_and_commit("feature/backend/abc", Path("/tmp/ws"))
|
||||||
|
|
||||||
|
# Never even checks git status — a failed codegen command skips straight
|
||||||
|
# through without touching git.
|
||||||
|
run_git_mock.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_codegen_and_commit_fail_open_on_exception() -> None:
|
||||||
|
"""Any unexpected exception (worktree resolution, git failure, ...) must
|
||||||
|
never raise out of this helper and break the caller's push."""
|
||||||
|
svc = _service()
|
||||||
|
_bind(svc, "_task_for_branch", AsyncMock(side_effect=RuntimeError("boom")))
|
||||||
|
await svc._run_codegen_and_commit("feature/backend/abc", Path("/tmp/ws"))
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# diff: derives parent + invokes git diff
|
# diff: derives parent + invokes git diff
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user