mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
feat(gateway): run the full fast gate (incl. complexity) at the dev's desk
Add a per-project `quality_command` (model + ORM + API + panel + migration 029) that the pre-submit gate prefers over the lint/typecheck pair. Pointed at the new `make gate` target (ruff format --check + ruff check + mypy + xenon, no tests), i_am_done now catches lint, type AND complexity failures in the developer's workspace before QA — closing the gap where over-complex code only failed in CI. Falls back to lint+typecheck when unset; a no-op when no commands are configured.
This commit is contained in:
@@ -277,6 +277,17 @@ quality-fast:
|
|||||||
@uv run mypy roboco/
|
@uv run mypy roboco/
|
||||||
@uv run pytest -q -x --no-cov
|
@uv run pytest -q -x --no-cov
|
||||||
|
|
||||||
|
# Fast pre-submit gate: format-check + lint + types + complexity, NO tests.
|
||||||
|
# This is the command a project points `quality_command` at, so the agent
|
||||||
|
# pre-submit gate (run at i_am_done) executes it in the dev's workspace and
|
||||||
|
# catches lint/type/complexity at the desk. The test suite stays on CI.
|
||||||
|
.PHONY: gate
|
||||||
|
gate:
|
||||||
|
@uv run ruff format --check .
|
||||||
|
@uv run ruff check .
|
||||||
|
@uv run mypy roboco/
|
||||||
|
@uv run xenon --max-absolute B --max-modules A --max-average A roboco/
|
||||||
|
|
||||||
# Run all analysis tools
|
# Run all analysis tools
|
||||||
.PHONY: analysis
|
.PHONY: analysis
|
||||||
analysis: deptry
|
analysis: deptry
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""Add projects.quality_command — the fast pre-submit gate command.
|
||||||
|
|
||||||
|
When set, the agent pre-submit gate (run at a developer's i_am_done) executes
|
||||||
|
this command in the developer's workspace — lint + types + complexity, no tests
|
||||||
|
(e.g. "make gate") — instead of the lint/typecheck pair, so a red gate is caught
|
||||||
|
at the dev's desk. Nullable; projects opt in via the panel.
|
||||||
|
|
||||||
|
Revision ID: 029_project_quality_command
|
||||||
|
Revises: 028_seed_self_hosted_provider
|
||||||
|
Create Date: 2026-06-14
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "029_project_quality_command"
|
||||||
|
down_revision = "028_seed_self_hosted_provider"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"projects",
|
||||||
|
sa.Column("quality_command", sa.String(length=500), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("projects", "quality_command")
|
||||||
@@ -82,6 +82,7 @@ export function CreateProjectDialog() {
|
|||||||
format_command: formData.format_command || undefined,
|
format_command: formData.format_command || undefined,
|
||||||
typecheck_command: formData.typecheck_command || undefined,
|
typecheck_command: formData.typecheck_command || undefined,
|
||||||
build_command: formData.build_command || undefined,
|
build_command: formData.build_command || undefined,
|
||||||
|
quality_command: formData.quality_command || undefined,
|
||||||
});
|
});
|
||||||
toast.success("Project created successfully");
|
toast.success("Project created successfully");
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
@@ -271,6 +272,22 @@ export function CreateProjectDialog() {
|
|||||||
placeholder="pnpm build"
|
placeholder="pnpm build"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="quality_command">Quality Gate Command</Label>
|
||||||
|
<Input
|
||||||
|
id="quality_command"
|
||||||
|
value={formData.quality_command || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, quality_command: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="make gate"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Fast pre-submit gate (lint + types + complexity, no tests) run
|
||||||
|
in the dev's workspace at hand-off to QA.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ function EditProjectForm({
|
|||||||
const [formatCommand, setFormatCommand] = useState(project.format_command || "");
|
const [formatCommand, setFormatCommand] = useState(project.format_command || "");
|
||||||
const [typecheckCommand, setTypecheckCommand] = useState(project.typecheck_command || "");
|
const [typecheckCommand, setTypecheckCommand] = useState(project.typecheck_command || "");
|
||||||
const [buildCommand, setBuildCommand] = useState(project.build_command || "");
|
const [buildCommand, setBuildCommand] = useState(project.build_command || "");
|
||||||
|
const [qualityCommand, setQualityCommand] = useState(project.quality_command || "");
|
||||||
|
|
||||||
// Token handling
|
// Token handling
|
||||||
const [newToken, setNewToken] = useState("");
|
const [newToken, setNewToken] = useState("");
|
||||||
@@ -88,6 +89,7 @@ function EditProjectForm({
|
|||||||
format_command: formatCommand || undefined,
|
format_command: formatCommand || undefined,
|
||||||
typecheck_command: typecheckCommand || undefined,
|
typecheck_command: typecheckCommand || undefined,
|
||||||
build_command: buildCommand || undefined,
|
build_command: buildCommand || undefined,
|
||||||
|
quality_command: qualityCommand || undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle token update
|
// Handle token update
|
||||||
@@ -296,6 +298,20 @@ function EditProjectForm({
|
|||||||
placeholder="pnpm build"
|
placeholder="pnpm build"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="quality_command">Quality Gate Command</Label>
|
||||||
|
<Input
|
||||||
|
id="quality_command"
|
||||||
|
value={qualityCommand}
|
||||||
|
onChange={(e) => setQualityCommand(e.target.value)}
|
||||||
|
placeholder="make gate"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Fast pre-submit gate (lint + types + complexity, no tests) run in
|
||||||
|
the dev's workspace at hand-off to QA.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ export const projectsApi = {
|
|||||||
format_command: project.format_command ?? null,
|
format_command: project.format_command ?? null,
|
||||||
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,
|
||||||
workspace_path: null,
|
workspace_path: null,
|
||||||
last_synced_at: null,
|
last_synced_at: null,
|
||||||
head_commit: null,
|
head_commit: null,
|
||||||
|
|||||||
@@ -1108,6 +1108,7 @@ export interface Project {
|
|||||||
format_command: string | null;
|
format_command: string | null;
|
||||||
typecheck_command: string | null;
|
typecheck_command: string | null;
|
||||||
build_command: string | null;
|
build_command: string | null;
|
||||||
|
quality_command: string | null;
|
||||||
// Runtime state
|
// Runtime state
|
||||||
workspace_path: string | null;
|
workspace_path: string | null;
|
||||||
last_synced_at: string | null;
|
last_synced_at: string | null;
|
||||||
@@ -1132,6 +1133,7 @@ export interface ProjectCreate {
|
|||||||
format_command?: string;
|
format_command?: string;
|
||||||
typecheck_command?: string;
|
typecheck_command?: string;
|
||||||
build_command?: string;
|
build_command?: string;
|
||||||
|
quality_command?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProjectUpdate {
|
export interface ProjectUpdate {
|
||||||
@@ -1148,6 +1150,7 @@ export interface ProjectUpdate {
|
|||||||
format_command?: string;
|
format_command?: string;
|
||||||
typecheck_command?: string;
|
typecheck_command?: string;
|
||||||
build_command?: string;
|
build_command?: string;
|
||||||
|
quality_command?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProjectSummary {
|
export interface ProjectSummary {
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ class ProjectResponse(BaseModel):
|
|||||||
format_command: str | None = None
|
format_command: str | None = None
|
||||||
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
|
||||||
|
|
||||||
# Runtime state
|
# Runtime state
|
||||||
workspace_path: str | None = None
|
workspace_path: str | None = None
|
||||||
@@ -103,6 +104,7 @@ class ProjectCreateRequest(BaseModel):
|
|||||||
format_command: str | None = None
|
format_command: str | None = None
|
||||||
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
|
||||||
|
|
||||||
|
|
||||||
class ProjectUpdateRequest(BaseModel):
|
class ProjectUpdateRequest(BaseModel):
|
||||||
@@ -126,6 +128,7 @@ class ProjectUpdateRequest(BaseModel):
|
|||||||
format_command: str | None = None
|
format_command: str | None = None
|
||||||
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
|
||||||
|
|
||||||
# State
|
# State
|
||||||
is_active: bool | None = None
|
is_active: bool | None = None
|
||||||
@@ -165,6 +168,7 @@ def project_to_response(project: "ProjectTable") -> ProjectResponse:
|
|||||||
format_command=project.format_command,
|
format_command=project.format_command,
|
||||||
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,
|
||||||
workspace_path=project.workspace_path,
|
workspace_path=project.workspace_path,
|
||||||
last_synced_at=project.last_synced_at,
|
last_synced_at=project.last_synced_at,
|
||||||
head_commit=project.head_commit,
|
head_commit=project.head_commit,
|
||||||
|
|||||||
@@ -448,6 +448,10 @@ class ProjectTable(Base):
|
|||||||
format_command: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
format_command: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||||
typecheck_command: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
typecheck_command: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||||
build_command: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
build_command: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||||
|
# Fast pre-submit gate command (lint+types+complexity, no tests). When set,
|
||||||
|
# the agent i_am_done gate runs this in the dev's workspace instead of the
|
||||||
|
# lint/typecheck pair — e.g. "make gate".
|
||||||
|
quality_command: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||||
|
|
||||||
# Access Control
|
# Access Control
|
||||||
assigned_cell: Mapped[Team] = mapped_column(_str_enum(Team), nullable=False)
|
assigned_cell: Mapped[Team] = mapped_column(_str_enum(Team), nullable=False)
|
||||||
|
|||||||
@@ -70,6 +70,14 @@ class Project(TimestampMixin):
|
|||||||
build_command: str | None = Field(
|
build_command: str | None = Field(
|
||||||
default=None, description="Command to build (e.g., 'pnpm build')"
|
default=None, description="Command to build (e.g., 'pnpm build')"
|
||||||
)
|
)
|
||||||
|
quality_command: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description=(
|
||||||
|
"Fast pre-submit gate command run in the dev's workspace at "
|
||||||
|
"i_am_done (lint+types+complexity, no tests; e.g. 'make gate'). "
|
||||||
|
"When set it replaces the lint/typecheck pair in the gate."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
# Access Control
|
# Access Control
|
||||||
assigned_cell: Team = Field(..., description="Which cell owns this project")
|
assigned_cell: Team = Field(..., description="Which cell owns this project")
|
||||||
@@ -120,6 +128,7 @@ class ProjectCreate(RobocoBase):
|
|||||||
format_command: str | None = None
|
format_command: str | None = None
|
||||||
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
|
||||||
|
|
||||||
|
|
||||||
class ProjectUpdate(RobocoBase):
|
class ProjectUpdate(RobocoBase):
|
||||||
@@ -141,6 +150,7 @@ class ProjectUpdate(RobocoBase):
|
|||||||
format_command: str | None = None
|
format_command: str | None = None
|
||||||
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
|
||||||
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
|
||||||
|
|||||||
@@ -1983,11 +1983,17 @@ class GitService(BaseService):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _fast_gate_commands(project: Any) -> list[tuple[str, str]]:
|
def _fast_gate_commands(project: Any) -> list[tuple[str, str]]:
|
||||||
"""The project's non-mutating fast-gate commands (lint + typecheck).
|
"""The project's non-mutating fast-gate commands.
|
||||||
|
|
||||||
Format and the test suite are intentionally excluded: format mutates
|
A configured ``quality_command`` is the project's complete fast gate
|
||||||
files, and the slow test run stays on CI.
|
(lint + types + complexity, no tests; e.g. ``make gate``) and takes
|
||||||
|
precedence — it runs alone. Otherwise fall back to the lint + typecheck
|
||||||
|
pair. Format and the test suite are intentionally excluded: format
|
||||||
|
mutates files, and the slow test run stays on CI.
|
||||||
"""
|
"""
|
||||||
|
quality = getattr(project, "quality_command", None)
|
||||||
|
if quality:
|
||||||
|
return [("quality", quality)]
|
||||||
candidates = (
|
candidates = (
|
||||||
("lint", getattr(project, "lint_command", None)),
|
("lint", getattr(project, "lint_command", None)),
|
||||||
("typecheck", getattr(project, "typecheck_command", None)),
|
("typecheck", getattr(project, "typecheck_command", None)),
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ class ProjectService(BaseService):
|
|||||||
format_command=data.format_command,
|
format_command=data.format_command,
|
||||||
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,
|
||||||
created_by=created_by,
|
created_by=created_by,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ def test_gate_result_summary_and_excerpt() -> None:
|
|||||||
|
|
||||||
def test_fast_gate_commands_picks_lint_and_typecheck() -> None:
|
def test_fast_gate_commands_picks_lint_and_typecheck() -> None:
|
||||||
project = SimpleNamespace(
|
project = SimpleNamespace(
|
||||||
|
quality_command=None, # not set → fall back to lint + typecheck
|
||||||
lint_command="uv run ruff check .",
|
lint_command="uv run ruff check .",
|
||||||
typecheck_command="uv run mypy roboco/",
|
typecheck_command="uv run mypy roboco/",
|
||||||
format_command="uv run ruff format .", # excluded (mutating)
|
format_command="uv run ruff format .", # excluded (mutating)
|
||||||
@@ -80,8 +81,21 @@ def test_fast_gate_commands_picks_lint_and_typecheck() -> None:
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_quality_command_takes_precedence_and_runs_alone() -> None:
|
||||||
|
"""A configured quality_command is the complete fast gate (e.g. it also runs
|
||||||
|
complexity/xenon) — it runs alone, not alongside lint/typecheck."""
|
||||||
|
project = SimpleNamespace(
|
||||||
|
quality_command="make gate",
|
||||||
|
lint_command="uv run ruff check .",
|
||||||
|
typecheck_command="uv run mypy roboco/",
|
||||||
|
)
|
||||||
|
assert GitService._fast_gate_commands(project) == [("quality", "make gate")]
|
||||||
|
|
||||||
|
|
||||||
def test_fast_gate_commands_empty_when_unconfigured() -> None:
|
def test_fast_gate_commands_empty_when_unconfigured() -> None:
|
||||||
project = SimpleNamespace(lint_command=None, typecheck_command=None)
|
project = SimpleNamespace(
|
||||||
|
quality_command=None, lint_command=None, typecheck_command=None
|
||||||
|
)
|
||||||
assert GitService._fast_gate_commands(project) == []
|
assert GitService._fast_gate_commands(project) == []
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user