diff --git a/Makefile b/Makefile index c9c72c2d..c189fffc 100644 --- a/Makefile +++ b/Makefile @@ -277,6 +277,17 @@ quality-fast: @uv run mypy roboco/ @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 .PHONY: analysis analysis: deptry diff --git a/alembic/versions/029_project_quality_command.py b/alembic/versions/029_project_quality_command.py new file mode 100644 index 00000000..712d3ee0 --- /dev/null +++ b/alembic/versions/029_project_quality_command.py @@ -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") diff --git a/panel/src/components/projects/create-project-dialog.tsx b/panel/src/components/projects/create-project-dialog.tsx index 82b0ce9c..4d0a9db8 100644 --- a/panel/src/components/projects/create-project-dialog.tsx +++ b/panel/src/components/projects/create-project-dialog.tsx @@ -82,6 +82,7 @@ export function CreateProjectDialog() { format_command: formData.format_command || undefined, typecheck_command: formData.typecheck_command || undefined, build_command: formData.build_command || undefined, + quality_command: formData.quality_command || undefined, }); toast.success("Project created successfully"); setOpen(false); @@ -271,6 +272,22 @@ export function CreateProjectDialog() { placeholder="pnpm build" /> + +
+ + + setFormData({ ...formData, quality_command: e.target.value }) + } + placeholder="make gate" + /> +

+ Fast pre-submit gate (lint + types + complexity, no tests) run + in the dev's workspace at hand-off to QA. +

+
)} diff --git a/panel/src/components/projects/edit-project-dialog.tsx b/panel/src/components/projects/edit-project-dialog.tsx index c8744639..dd6e612a 100644 --- a/panel/src/components/projects/edit-project-dialog.tsx +++ b/panel/src/components/projects/edit-project-dialog.tsx @@ -61,6 +61,7 @@ function EditProjectForm({ const [formatCommand, setFormatCommand] = useState(project.format_command || ""); const [typecheckCommand, setTypecheckCommand] = useState(project.typecheck_command || ""); const [buildCommand, setBuildCommand] = useState(project.build_command || ""); + const [qualityCommand, setQualityCommand] = useState(project.quality_command || ""); // Token handling const [newToken, setNewToken] = useState(""); @@ -88,6 +89,7 @@ function EditProjectForm({ format_command: formatCommand || undefined, typecheck_command: typecheckCommand || undefined, build_command: buildCommand || undefined, + quality_command: qualityCommand || undefined, }; // Handle token update @@ -296,6 +298,20 @@ function EditProjectForm({ placeholder="pnpm build" /> + +
+ + setQualityCommand(e.target.value)} + placeholder="make gate" + /> +

+ Fast pre-submit gate (lint + types + complexity, no tests) run in + the dev's workspace at hand-off to QA. +

+
)} diff --git a/panel/src/lib/api/projects.ts b/panel/src/lib/api/projects.ts index aaf6dcb3..71d9ee18 100644 --- a/panel/src/lib/api/projects.ts +++ b/panel/src/lib/api/projects.ts @@ -83,6 +83,7 @@ export const projectsApi = { format_command: project.format_command ?? null, typecheck_command: project.typecheck_command ?? null, build_command: project.build_command ?? null, + quality_command: project.quality_command ?? null, workspace_path: null, last_synced_at: null, head_commit: null, diff --git a/panel/src/types/index.ts b/panel/src/types/index.ts index 0e420a7e..65c06861 100644 --- a/panel/src/types/index.ts +++ b/panel/src/types/index.ts @@ -1108,6 +1108,7 @@ export interface Project { format_command: string | null; typecheck_command: string | null; build_command: string | null; + quality_command: string | null; // Runtime state workspace_path: string | null; last_synced_at: string | null; @@ -1132,6 +1133,7 @@ export interface ProjectCreate { format_command?: string; typecheck_command?: string; build_command?: string; + quality_command?: string; } export interface ProjectUpdate { @@ -1148,6 +1150,7 @@ export interface ProjectUpdate { format_command?: string; typecheck_command?: string; build_command?: string; + quality_command?: string; } export interface ProjectSummary { diff --git a/roboco/api/schemas/project.py b/roboco/api/schemas/project.py index 940ae112..3165d8bf 100644 --- a/roboco/api/schemas/project.py +++ b/roboco/api/schemas/project.py @@ -42,6 +42,7 @@ class ProjectResponse(BaseModel): format_command: str | None = None typecheck_command: str | None = None build_command: str | None = None + quality_command: str | None = None # Runtime state workspace_path: str | None = None @@ -103,6 +104,7 @@ class ProjectCreateRequest(BaseModel): format_command: str | None = None typecheck_command: str | None = None build_command: str | None = None + quality_command: str | None = None class ProjectUpdateRequest(BaseModel): @@ -126,6 +128,7 @@ class ProjectUpdateRequest(BaseModel): format_command: str | None = None typecheck_command: str | None = None build_command: str | None = None + quality_command: str | None = None # State is_active: bool | None = None @@ -165,6 +168,7 @@ def project_to_response(project: "ProjectTable") -> ProjectResponse: format_command=project.format_command, typecheck_command=project.typecheck_command, build_command=project.build_command, + quality_command=project.quality_command, workspace_path=project.workspace_path, last_synced_at=project.last_synced_at, head_commit=project.head_commit, diff --git a/roboco/db/tables.py b/roboco/db/tables.py index 3a218534..25585a08 100644 --- a/roboco/db/tables.py +++ b/roboco/db/tables.py @@ -448,6 +448,10 @@ class ProjectTable(Base): format_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) + # 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 assigned_cell: Mapped[Team] = mapped_column(_str_enum(Team), nullable=False) diff --git a/roboco/models/project.py b/roboco/models/project.py index 83efb4fc..ce449e05 100644 --- a/roboco/models/project.py +++ b/roboco/models/project.py @@ -70,6 +70,14 @@ class Project(TimestampMixin): build_command: str | None = Field( 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 assigned_cell: Team = Field(..., description="Which cell owns this project") @@ -120,6 +128,7 @@ class ProjectCreate(RobocoBase): format_command: str | None = None typecheck_command: str | None = None build_command: str | None = None + quality_command: str | None = None class ProjectUpdate(RobocoBase): @@ -141,6 +150,7 @@ class ProjectUpdate(RobocoBase): format_command: str | None = None typecheck_command: str | None = None build_command: str | None = None + quality_command: str | None = None assigned_cell: Team | None = None allowed_agents: list[UUID] | None = None is_active: bool | None = None diff --git a/roboco/services/git.py b/roboco/services/git.py index 4f25b8ea..d9e38707 100644 --- a/roboco/services/git.py +++ b/roboco/services/git.py @@ -1983,11 +1983,17 @@ class GitService(BaseService): @staticmethod 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 - files, and the slow test run stays on CI. + A configured ``quality_command`` is the project's complete fast gate + (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 = ( ("lint", getattr(project, "lint_command", None)), ("typecheck", getattr(project, "typecheck_command", None)), diff --git a/roboco/services/project.py b/roboco/services/project.py index 98256e6f..2cd7d4a4 100644 --- a/roboco/services/project.py +++ b/roboco/services/project.py @@ -103,6 +103,7 @@ class ProjectService(BaseService): format_command=data.format_command, typecheck_command=data.typecheck_command, build_command=data.build_command, + quality_command=data.quality_command, created_by=created_by, ) diff --git a/tests/unit/gateway/test_quality_gate.py b/tests/unit/gateway/test_quality_gate.py index 2aa2e9b9..d3e4d035 100644 --- a/tests/unit/gateway/test_quality_gate.py +++ b/tests/unit/gateway/test_quality_gate.py @@ -68,6 +68,7 @@ def test_gate_result_summary_and_excerpt() -> None: def test_fast_gate_commands_picks_lint_and_typecheck() -> None: project = SimpleNamespace( + quality_command=None, # not set → fall back to lint + typecheck lint_command="uv run ruff check .", typecheck_command="uv run mypy roboco/", 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: - 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) == []