feat(forge): Phase 0 — git_provider column + registration-time forge validation (#569)

* feat(forge): Phase 0 — git_provider column + registration-time forge validation

Pointing a project at a GitLab/Gitea git_url used to fail silently, several
steps deep, at first PR. New pure policy module (foundation/policy/forge.py)
detects the provider from the git_url host and validates at the
ProjectService create/update chokepoint: github auto-detects and
auto-stamps, explicit git_provider=github is the GitHub Enterprise escape
hatch, gitlab/gitea are recognized-but-not-yet-supported, unknown hosts get
a loud rejection with guidance. An update changing git_url does NOT inherit
a stored auto-stamped provider (restating the override is required), so a
host swap can't smuggle the escape hatch past validation. Migration 075
adds the nullable projects.git_provider column; the panel project dialogs
show the detected forge. Phase 0 of the forge-providers spec.

* fix(panel): mock-mode forge detection extracts the real host

CodeQL js/incomplete-url-substring-sanitization: the substring check
matched github.com anywhere in the URL. Extract the hostname (URL parse
or scp-form regex, mirroring forge.py) and require an exact match.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-18 19:10:43 +02:00
committed by GitHub
co-authored by Renn F
parent 5ed90429a8
commit 388bab2488
13 changed files with 565 additions and 8 deletions
@@ -0,0 +1,37 @@
"""Add projects.git_provider — Phase 0 of the forge-providers spec.
Nullable ``git_provider`` (plain string, not a pg enum — validated at the
service layer by ``roboco.foundation.policy.forge.validate_project_forge``
instead of a DB constraint, mirroring how ``assigned_cell``-adjacent free-text
columns like ``ci_watch_workflow`` are validated in Python, not SQL). Null
means "auto-detect from git_url host" (github.com -> github; anything else is
a registration-time rejection unless the operator sets this column explicitly
— the GitHub Enterprise escape hatch). Additive and inert: every existing
project keeps resolving to GitHub behavior until GitLab/Gitea providers land
in a later phase.
Revision ID: 075_project_git_provider
Revises: 074_telegram_credentials
Create Date: 2026-07-18
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "075_project_git_provider"
down_revision = "074_telegram_credentials"
branch_labels: dict[str, str] | None = None
depends_on: dict[str, str] | None = None
def upgrade() -> None:
op.add_column(
"projects",
sa.Column("git_provider", sa.String(16), nullable=True),
)
def downgrade() -> None:
op.drop_column("projects", "git_provider")
@@ -27,6 +27,7 @@ import { Team, type ProjectCreate } from "@/types";
import { EnvironmentLadderEditor } from "@/components/projects/environment-ladder-editor"; import { EnvironmentLadderEditor } from "@/components/projects/environment-ladder-editor";
import { validateLadder } from "@/components/projects/ladder-validation"; import { validateLadder } from "@/components/projects/ladder-validation";
import { HelpTip } from "@/components/ui/help-tip"; import { HelpTip } from "@/components/ui/help-tip";
import { Badge } from "@/components/ui/badge";
const cells: { value: Team; label: string }[] = [ const cells: { value: Team; label: string }[] = [
{ value: Team.BACKEND, label: "Backend" }, { value: Team.BACKEND, label: "Backend" },
@@ -188,6 +189,19 @@ export function CreateProjectDialog() {
</p> </p>
</div> </div>
{/* Forge (read-only — GitHub-only today) */}
<div className="grid gap-2">
<HelpTip label="Auto-detected from the Git URL's host at creation; RoboCo's PR/CI/review surface is GitHub-only today. GitLab & Gitea support planned.">
<Label>Forge</Label>
</HelpTip>
<div>
<Badge variant="secondary">GitHub</Badge>
</div>
<p className="text-xs text-muted-foreground">
GitLab & Gitea support planned.
</p>
</div>
{/* Git Token */} {/* Git Token */}
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="git_token" className="flex items-center gap-1"> <Label htmlFor="git_token" className="flex items-center gap-1">
@@ -30,6 +30,15 @@ import { Team, type ProjectUpdate, type Project } from "@/types";
import { EnvironmentLadderEditor } from "@/components/projects/environment-ladder-editor"; import { EnvironmentLadderEditor } from "@/components/projects/environment-ladder-editor";
import { validateLadder } from "@/components/projects/ladder-validation"; import { validateLadder } from "@/components/projects/ladder-validation";
import { HelpTip } from "@/components/ui/help-tip"; import { HelpTip } from "@/components/ui/help-tip";
import { Badge } from "@/components/ui/badge";
// A null git_provider means "not yet stamped" (pre-Phase-0 project or a
// non-github.com host awaiting an explicit choice) — RoboCo is GitHub-only
// today either way, so the badge falls back to "GitHub" rather than "Unknown".
function forgeLabel(gitProvider: string | null): string {
if (!gitProvider) return "GitHub";
return gitProvider.charAt(0).toUpperCase() + gitProvider.slice(1);
}
const cells: { value: Team; label: string }[] = [ const cells: { value: Team; label: string }[] = [
{ value: Team.BACKEND, label: "Backend" }, { value: Team.BACKEND, label: "Backend" },
@@ -308,6 +317,19 @@ function EditProjectForm({
/> />
</div> </div>
{/* Forge (read-only — GitHub-only today) */}
<div className="grid gap-2">
<HelpTip label="Auto-detected from the Git URL's host; RoboCo's PR/CI/review surface is GitHub-only today. GitLab & Gitea support planned.">
<Label>Forge</Label>
</HelpTip>
<div>
<Badge variant="secondary">{forgeLabel(project.git_provider)}</Badge>
</div>
<p className="text-xs text-muted-foreground">
GitLab & Gitea support planned.
</p>
</div>
{/* Git Token Section */} {/* Git Token Section */}
<div className="grid gap-2 p-3 border rounded-lg bg-muted/30"> <div className="grid gap-2 p-3 border rounded-lg bg-muted/30">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
+24
View File
@@ -8,6 +8,26 @@ import type {
} from "@/types"; } from "@/types";
import { isMockMode } from "@/lib/mock-data"; import { isMockMode } from "@/lib/mock-data";
// Mock-mode github.com detection: real host extraction (mirrors the backend's
// forge.py `_extract_host`) instead of a raw substring match, so a URL like
// "https://github.com.evil.tld/x/y.git" doesn't false-positive as github.
const detectGithubProvider = (gitUrl: string): "github" | null => {
const url = gitUrl.trim();
let host: string | null = null;
if (url.includes("://")) {
try {
host = new URL(url).hostname.toLowerCase() || null;
} catch {
host = null;
}
} else {
// scp-like SSH syntax: [user@]host:path (e.g. git@github.com:owner/repo.git)
const match = /^(?:[^@/]+@)?([^/:]+):/.exec(url);
host = match ? match[1].toLowerCase() : null;
}
return host === "github.com" ? "github" : null;
};
// Mock data for offline mode // Mock data for offline mode
const mockProjects: Project[] = [ const mockProjects: Project[] = [
{ {
@@ -15,6 +35,7 @@ const mockProjects: Project[] = [
name: "roboco", name: "roboco",
slug: "roboco", slug: "roboco",
git_url: "https://github.com/rennf93/roboco.git", git_url: "https://github.com/rennf93/roboco.git",
git_provider: "github",
default_branch: "master", default_branch: "master",
protected_branches: ["master", "slave"], protected_branches: ["master", "slave"],
assigned_cell: Team.BACKEND, assigned_cell: Team.BACKEND,
@@ -33,6 +54,7 @@ const mockProjects: Project[] = [
name: "roboco-website", name: "roboco-website",
slug: "roboco-website", slug: "roboco-website",
git_url: "https://github.com/rennf93/roboco-website.git", git_url: "https://github.com/rennf93/roboco-website.git",
git_provider: "github",
default_branch: "master", default_branch: "master",
protected_branches: ["master"], protected_branches: ["master"],
assigned_cell: Team.FRONTEND, assigned_cell: Team.FRONTEND,
@@ -124,6 +146,8 @@ export const projectsApi = {
name: project.name, name: project.name,
slug: project.slug, slug: project.slug,
git_url: project.git_url, git_url: project.git_url,
// Mock mode: mirror the backend's auto-detect (github.com -> github).
git_provider: project.git_provider ?? detectGithubProvider(project.git_url),
default_branch: project.default_branch ?? "main", default_branch: project.default_branch ?? "main",
environments: project.environments ?? null, environments: project.environments ?? null,
protected_branches: project.protected_branches ?? ["main", "master"], protected_branches: project.protected_branches ?? ["main", "master"],
+7
View File
@@ -1033,6 +1033,9 @@ export interface Project {
name: string; name: string;
slug: string; slug: string;
git_url: string; git_url: string;
// Forge provider ("github"|"gitlab"|"gitea"); null = auto-detect from
// git_url host (github.com -> github, stamped on create). GitHub-only today.
git_provider: string | null;
default_branch: string; default_branch: string;
// Ordered environment ladder (first=head/PR-target, last=prod/release-target). // Ordered environment ladder (first=head/PR-target, last=prod/release-target).
// Null/empty => degenerate 1-rung ladder synthesized from default_branch. // Null/empty => degenerate 1-rung ladder synthesized from default_branch.
@@ -1071,6 +1074,8 @@ export interface ProjectCreate {
name: string; name: string;
slug: string; slug: string;
git_url: string; git_url: string;
// null/omitted = auto-detect from git_url host (github.com -> github).
git_provider?: string | null;
default_branch?: string; default_branch?: string;
// Ordered environment ladder; null/empty inherits default_branch (shim). // Ordered environment ladder; null/empty inherits default_branch (shim).
environments?: EnvironmentRung[] | null; environments?: EnvironmentRung[] | null;
@@ -1089,6 +1094,8 @@ export interface ProjectCreate {
export interface ProjectUpdate { export interface ProjectUpdate {
name?: string; name?: string;
git_url?: string; git_url?: string;
// null = revert to auto-detect; omitted = leave unchanged.
git_provider?: string | null;
default_branch?: string; default_branch?: string;
// Ordered environment ladder; null clears (reverts to default_branch shim). // Ordered environment ladder; null clears (reverts to default_branch shim).
environments?: EnvironmentRung[] | null; environments?: EnvironmentRung[] | null;
+1
View File
@@ -157,6 +157,7 @@ async def create_project(
name=data.name, name=data.name,
slug=data.slug, slug=data.slug,
git_url=data.git_url, git_url=data.git_url,
git_provider=data.git_provider,
default_branch=data.default_branch, default_branch=data.default_branch,
protected_branches=protected_branches, protected_branches=protected_branches,
environments=data.environments, environments=data.environments,
+18
View File
@@ -29,6 +29,9 @@ class ProjectResponse(BaseModel):
name: str name: str
slug: str slug: str
git_url: str git_url: str
# Forge provider ("github"|"gitlab"|"gitea"); null = auto-detect from
# git_url host (github.com -> github, stamped on create).
git_provider: str | None = None
default_branch: str default_branch: str
protected_branches: list[str] protected_branches: list[str]
environments: list[dict[str, str]] | None = None environments: list[dict[str, str]] | None = None
@@ -114,6 +117,13 @@ class ProjectCreateRequest(BaseModel):
name: str = Field(..., min_length=1, max_length=100) name: str = Field(..., min_length=1, max_length=100)
slug: str = Field(..., min_length=1, max_length=50, pattern=r"^[a-z0-9-]+$") slug: str = Field(..., min_length=1, max_length=50, pattern=r"^[a-z0-9-]+$")
git_url: str git_url: str
git_provider: str | None = Field(
default=None,
description=(
"Forge provider ('github'|'gitlab'|'gitea'). null = auto-detect "
"from git_url host (github.com -> github)."
),
)
default_branch: str = "master" default_branch: str = "master"
protected_branches: list[str] | None = Field( protected_branches: list[str] | None = Field(
default=None, default=None,
@@ -149,6 +159,13 @@ class ProjectUpdateRequest(BaseModel):
name: str | None = None name: str | None = None
git_url: str | None = None git_url: str | None = None
git_provider: str | None = Field(
default=None,
description=(
"Forge provider ('github'|'gitlab'|'gitea'). Re-validated against "
"the (possibly also-updated) git_url whenever either is set."
),
)
default_branch: str | None = None default_branch: str | None = None
protected_branches: list[str] | None = None protected_branches: list[str] | None = None
environments: list[dict[str, str]] | None = None environments: list[dict[str, str]] | None = None
@@ -247,6 +264,7 @@ def project_to_response(project: "ProjectTable") -> ProjectResponse:
name=str(project.name), name=str(project.name),
slug=str(project.slug), slug=str(project.slug),
git_url=str(project.git_url), git_url=str(project.git_url),
git_provider=project.git_provider,
default_branch=str(default_branch) if default_branch else "master", default_branch=str(default_branch) if default_branch else "master",
protected_branches=list(project.protected_branches or []), protected_branches=list(project.protected_branches or []),
environments=list(project.environments) if project.environments else None, environments=list(project.environments) if project.environments else None,
+5
View File
@@ -509,6 +509,11 @@ class ProjectTable(Base):
git_token_encrypted: Mapped[str | None] = mapped_column( git_token_encrypted: Mapped[str | None] = mapped_column(
Text, nullable=True Text, nullable=True
) # Fernet-encrypted GitHub PAT ) # Fernet-encrypted GitHub PAT
# Forge provider ("github" | "gitlab" | "gitea"). Null = auto-detect from
# git_url host (github.com only today); a self-hosted/GHE host must set
# this explicitly. Validated at the service layer (foundation/policy/
# forge.py), not a DB enum — see migration 075.
git_provider: Mapped[str | None] = mapped_column(String(16), nullable=True)
# CI/CD Commands (optional) # CI/CD Commands (optional)
test_command: Mapped[str | None] = mapped_column(String(500), nullable=True) test_command: Mapped[str | None] = mapped_column(String(500), nullable=True)
+100
View File
@@ -0,0 +1,100 @@
"""Forge (git host) provider detection + registration-time validation.
Phase 0 of the forge-providers spec (``docs/internal/specs/2026-07-18-forge-
providers-spec.md``): RoboCo's PR/CI/review surface is GitHub-only today
(``GitService`` is inline ``httpx`` hardcoded to ``github.com``). Pointing a
project at a GitLab/Gitea ``git_url`` used to fail silently, several steps deep,
at first PR this module turns that into a loud, registration-time rejection
naming exactly what's unsupported and why, with an escape hatch for GitHub
Enterprise (a github.com-shaped API on a different host).
Pure + DB-free so it is unit-testable; ``ProjectService.create``/``update`` is
the sole enforcement chokepoint (see ``roboco/services/project.py``).
"""
from __future__ import annotations
import re
from urllib.parse import urlsplit
KNOWN_PROVIDERS: tuple[str, ...] = ("github", "gitlab", "gitea")
# scp-like SSH syntax: [user@]host:path (e.g. git@github.com:owner/repo.git).
# Only matches when the URL carries no "://" scheme (checked by the caller).
_SCP_HOST_RE = re.compile(r"^(?:[^@/]+@)?(?P<host>[^/:]+):")
def _extract_host(git_url: str) -> str | None:
"""Pull the host out of an https, ssh://, or scp-like git URL.
Returns None when no host can be found (unparseable input).
"""
url = git_url.strip()
if not url:
return None
if "://" in url:
host = urlsplit(url).hostname
return host.lower() if host else None
match = _SCP_HOST_RE.match(url)
if match:
return match.group("host").lower()
return None
def detect_provider(git_url: str) -> str | None:
"""Best-effort provider from the ``git_url`` host alone.
Only the two SaaS hosts are auto-detected (``github.com`` -> "github",
``gitlab.com`` -> "gitlab"); every other host including a self-hosted
GitLab/Gitea/GHE instance returns None, since the host alone can't tell
those apart. A project on a non-SaaS host must set ``git_provider``
explicitly (a one-click choice in the panel's project dialog).
"""
host = _extract_host(git_url)
if host == "github.com":
return "github"
if host == "gitlab.com":
return "gitlab"
return None
def validate_project_forge(git_url: str | None, git_provider: str | None) -> str | None:
"""Registration-time forge validation. Returns an error message, or None.
Rules, in order:
- empty/None ``git_url`` -> OK (no repo to validate yet).
- an unknown ``git_provider`` string -> error naming ``KNOWN_PROVIDERS``.
- explicit ``git_provider="github"`` -> OK regardless of host (the GitHub
Enterprise escape hatch current behavior preserved).
- explicit ``git_provider`` of "gitlab"/"gitea" -> error: recognized but
not yet supported.
- no explicit ``git_provider``, host detects to "github" -> OK.
- no explicit ``git_provider``, anything else (unknown host, or a detected
but unsupported host like gitlab.com) -> error steering the operator to
either a GitHub host or the explicit ``git_provider="github"`` escape
hatch for GHE.
"""
if not git_url:
return None
if git_provider is not None:
if git_provider not in KNOWN_PROVIDERS:
return (
f"Unknown git_provider {git_provider!r}; must be one of "
f"{', '.join(KNOWN_PROVIDERS)}."
)
if git_provider == "github":
return None
return (
f"git_provider={git_provider!r} is recognized but not yet "
"supported — RoboCo is GitHub-only today; GitLab/Gitea support "
"is planned."
)
if detect_provider(git_url) == "github":
return None
return (
"RoboCo currently supports GitHub-hosted repos only. If this is a "
'GitHub Enterprise host, set git_provider="github" explicitly.'
)
+25
View File
@@ -115,6 +115,17 @@ class Project(TimestampMixin):
# Git Configuration # Git Configuration
git_url: str = Field(..., description="Git repository URL") git_url: str = Field(..., description="Git repository URL")
# Forge provider ("github" | "gitlab" | "gitea"). Null = auto-detect from
# git_url host (github.com only today, stamped on create); a self-hosted
# host must set this explicitly. Validated by
# foundation.policy.forge.validate_project_forge at the service layer.
git_provider: str | None = Field(
default=None,
description=(
"Forge provider ('github'|'gitlab'|'gitea'). null = auto-detect "
"from git_url host; RoboCo is GitHub-only today."
),
)
default_branch: str = Field(default="master", description="Default branch name") default_branch: str = Field(default="master", description="Default branch name")
protected_branches: list[str] = Field( protected_branches: list[str] = Field(
default_factory=lambda: ["main", "master"], default_factory=lambda: ["main", "master"],
@@ -258,6 +269,13 @@ class ProjectCreate(RobocoBase):
name: str = Field(..., min_length=1, max_length=100) name: str = Field(..., min_length=1, max_length=100)
slug: str = Field(..., min_length=1, max_length=50, pattern=r"^[a-z0-9-]+$") slug: str = Field(..., min_length=1, max_length=50, pattern=r"^[a-z0-9-]+$")
git_url: str git_url: str
git_provider: str | None = Field(
default=None,
description=(
"Forge provider ('github'|'gitlab'|'gitea'). null = auto-detect "
"from git_url host (github.com -> github, stamped on create)."
),
)
default_branch: str = "master" default_branch: str = "master"
protected_branches: list[str] = Field(default_factory=lambda: ["main", "master"]) protected_branches: list[str] = Field(default_factory=lambda: ["main", "master"])
environments: list[dict[str, str]] | None = None environments: list[dict[str, str]] | None = None
@@ -283,6 +301,13 @@ class ProjectUpdate(RobocoBase):
name: str | None = None name: str | None = None
git_url: str | None = None git_url: str | None = None
git_provider: str | None = Field(
default=None,
description=(
"Forge provider ('github'|'gitlab'|'gitea'). Re-validated against "
"the (possibly also-updated) git_url whenever either field is set."
),
)
default_branch: str | None = None default_branch: str | None = None
protected_branches: list[str] | None = None protected_branches: list[str] | None = None
environments: list[dict[str, str]] | None = None environments: list[dict[str, str]] | None = None
+42 -5
View File
@@ -15,6 +15,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from roboco.config import settings from roboco.config import settings
from roboco.db.tables import ProjectTable, TaskTable from roboco.db.tables import ProjectTable, TaskTable
from roboco.exceptions import ValidationError from roboco.exceptions import ValidationError
from roboco.foundation.policy.forge import detect_provider, validate_project_forge
from roboco.models.base import TaskStatus, Team from roboco.models.base import TaskStatus, Team
from roboco.models.project import ProjectCreate, ProjectUpdate from roboco.models.project import ProjectCreate, ProjectUpdate
from roboco.services.base import BaseService, ConflictError, NotFoundError from roboco.services.base import BaseService, ConflictError, NotFoundError
@@ -62,6 +63,18 @@ class ProjectService(BaseService):
field="git_url", field="git_url",
) )
def _assert_forge_supported(
self, git_url: str | None, git_provider: str | None
) -> None:
"""Reject an unsupported/unrecognized forge (Phase 0 of the forge-
providers spec) turns today's silent multi-step-deep GitLab/Gitea
failure into a loud registration-time error."""
error = validate_project_forge(git_url, git_provider)
if error:
raise ValidationError(
error, field="git_provider" if git_provider is not None else "git_url"
)
async def create( async def create(
self, self,
data: ProjectCreate, data: ProjectCreate,
@@ -89,6 +102,13 @@ class ProjectService(BaseService):
) )
self._assert_git_url_allowed(data.git_url) self._assert_git_url_allowed(data.git_url)
self._assert_forge_supported(data.git_url, data.git_provider)
# Null + a github.com git_url auto-stamps "github" so the column
# reflects reality without forcing every caller to set it explicitly.
git_provider = data.git_provider
if git_provider is None and detect_provider(data.git_url) == "github":
git_provider = "github"
# Encrypt git token if provided # Encrypt git token if provided
encrypted_token = None encrypted_token = None
@@ -103,6 +123,7 @@ class ProjectService(BaseService):
name=data.name, name=data.name,
slug=data.slug, slug=data.slug,
git_url=data.git_url, git_url=data.git_url,
git_provider=git_provider,
default_branch=data.default_branch, default_branch=data.default_branch,
protected_branches=data.protected_branches, protected_branches=data.protected_branches,
environments=data.environments, environments=data.environments,
@@ -194,6 +215,27 @@ class ProjectService(BaseService):
self._assert_git_url_allowed(data.git_url) self._assert_git_url_allowed(data.git_url)
# Apply updates for explicitly-set fields (excluding git_token which we
# handle separately below). exclude_unset keeps UNSET fields out; we do
# NOT also exclude_none, so a field the caller explicitly set to None
# clears the stored value (distinct from unset = leave unchanged) — #197.
update_data = data.model_dump(exclude_unset=True, exclude={"git_token"})
# Re-validate the forge only when git_url or git_provider is actually
# changing. An unrelated rename (neither field touched) skips this
# entirely. When git_provider ISN'T explicitly part of THIS call, it
# is NOT carried forward from the stored row as if re-declared — a
# stored "github" may be a Phase-0 auto-stamp from the *old* git_url,
# and trusting it across a host swap would silently smuggle the GHE
# escape hatch through exactly the case Phase 0 exists to catch (a
# git_url edited onto gitlab.com/gitea while git_provider is left
# alone). Changing the host while keeping an explicit override
# requires restating git_provider in the same call.
if "git_url" in update_data or "git_provider" in update_data:
new_git_url = update_data.get("git_url", project.git_url)
new_git_provider = update_data.get("git_provider")
self._assert_forge_supported(new_git_url, new_git_provider)
# Handle git_token specially (empty string clears, None leaves unchanged) # Handle git_token specially (empty string clears, None leaves unchanged)
token_updated = False token_updated = False
if data.git_token is not None: if data.git_token is not None:
@@ -212,11 +254,6 @@ class ProjectService(BaseService):
self.log.error("Failed to encrypt git token", error=str(e)) self.log.error("Failed to encrypt git token", error=str(e))
raise raise
# Apply updates for explicitly-set fields (excluding git_token which we
# handled above). exclude_unset keeps UNSET fields out; we do NOT also
# exclude_none, so a field the caller explicitly set to None clears the
# stored value (distinct from unset = leave unchanged) — #197.
update_data = data.model_dump(exclude_unset=True, exclude={"git_token"})
for key, value in update_data.items(): for key, value in update_data.items():
if hasattr(project, key): if hasattr(project, key):
setattr(project, key, value) setattr(project, key, value)
+138 -3
View File
@@ -92,6 +92,144 @@ async def test_create_project_duplicate_slug_raises(project_setup: dict) -> None
await svc.create(payload, project_setup["creator_id"]) await svc.create(payload, project_setup["creator_id"])
@pytest.mark.asyncio
async def test_create_project_auto_stamps_github(project_setup: dict) -> None:
"""A github.com git_url with no explicit git_provider auto-stamps 'github'."""
svc = project_setup["svc"]
project = await svc.create(
_project_payload(uuid4().hex[:6]), project_setup["creator_id"]
)
assert project.git_provider == "github"
@pytest.mark.asyncio
async def test_create_project_explicit_github_provider_preserved(
project_setup: dict,
) -> None:
svc = project_setup["svc"]
payload_dict = _project_payload(uuid4().hex[:6]).model_dump()
payload_dict["git_provider"] = "github"
project = await svc.create(
ProjectCreate(**payload_dict), project_setup["creator_id"]
)
assert project.git_provider == "github"
@pytest.mark.asyncio
async def test_create_project_github_enterprise_escape_hatch(
project_setup: dict,
) -> None:
"""An explicit git_provider='github' is accepted even on a non-github.com
host (the GitHub Enterprise escape hatch)."""
svc = project_setup["svc"]
payload_dict = _project_payload(uuid4().hex[:6]).model_dump()
payload_dict["git_url"] = "https://ghe.example.com/owner/repo.git"
payload_dict["git_provider"] = "github"
project = await svc.create(
ProjectCreate(**payload_dict), project_setup["creator_id"]
)
assert project.git_provider == "github"
@pytest.mark.asyncio
async def test_create_project_rejects_gitlab_url(project_setup: dict) -> None:
"""A gitlab.com git_url with no explicit git_provider is rejected loud and
early Phase 0's whole point (was a silent multi-step-deep GitError)."""
svc = project_setup["svc"]
payload_dict = _project_payload(uuid4().hex[:6]).model_dump()
payload_dict["git_url"] = "https://gitlab.com/group/project.git"
with pytest.raises(ValidationError):
await svc.create(ProjectCreate(**payload_dict), project_setup["creator_id"])
@pytest.mark.asyncio
async def test_create_project_rejects_explicit_gitlab_provider(
project_setup: dict,
) -> None:
svc = project_setup["svc"]
payload_dict = _project_payload(uuid4().hex[:6]).model_dump()
payload_dict["git_url"] = "https://gitlab.com/group/project.git"
payload_dict["git_provider"] = "gitlab"
with pytest.raises(ValidationError):
await svc.create(ProjectCreate(**payload_dict), project_setup["creator_id"])
@pytest.mark.asyncio
async def test_create_project_rejects_unknown_host(project_setup: dict) -> None:
svc = project_setup["svc"]
payload_dict = _project_payload(uuid4().hex[:6]).model_dump()
payload_dict["git_url"] = "https://git.internal.example/owner/repo.git"
with pytest.raises(ValidationError):
await svc.create(ProjectCreate(**payload_dict), project_setup["creator_id"])
@pytest.mark.asyncio
async def test_create_project_rejects_unknown_provider_string(
project_setup: dict,
) -> None:
svc = project_setup["svc"]
payload_dict = _project_payload(uuid4().hex[:6]).model_dump()
payload_dict["git_provider"] = "bitbucket"
with pytest.raises(ValidationError):
await svc.create(ProjectCreate(**payload_dict), project_setup["creator_id"])
@pytest.mark.asyncio
async def test_update_rejects_git_url_changed_to_gitlab(project_setup: dict) -> None:
svc = project_setup["svc"]
project = await svc.create(
_project_payload(uuid4().hex[:6]), project_setup["creator_id"]
)
with pytest.raises(ValidationError):
await svc.update(
project.id,
ProjectUpdate(git_url="https://gitlab.com/group/project.git"),
)
@pytest.mark.asyncio
async def test_update_git_url_unrelated_field_does_not_reraise_forge(
project_setup: dict,
) -> None:
"""An update that touches neither git_url nor git_provider never
re-validates the forge, so a project's existing (grandfathered) combo
can't retroactively block an unrelated rename."""
svc = project_setup["svc"]
project = await svc.create(
_project_payload(uuid4().hex[:6]), project_setup["creator_id"]
)
updated = await svc.update(project.id, ProjectUpdate(name="renamed"))
assert updated is not None
assert updated.name == "renamed"
@pytest.mark.asyncio
async def test_update_git_provider_to_gitlab_rejected(project_setup: dict) -> None:
svc = project_setup["svc"]
project = await svc.create(
_project_payload(uuid4().hex[:6]), project_setup["creator_id"]
)
with pytest.raises(ValidationError):
await svc.update(project.id, ProjectUpdate(git_provider="gitlab"))
@pytest.mark.asyncio
async def test_update_git_provider_explicit_none_reverts_to_auto_detect(
project_setup: dict,
) -> None:
"""Explicit None clears the override (#197); the still-github.com git_url
keeps the update valid via auto-detect."""
svc = project_setup["svc"]
payload_dict = _project_payload(uuid4().hex[:6]).model_dump()
payload_dict["git_provider"] = "github"
project = await svc.create(
ProjectCreate(**payload_dict), project_setup["creator_id"]
)
updated = await svc.update(project.id, ProjectUpdate(git_provider=None))
assert updated is not None
assert updated.git_provider is None
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_project_rejects_protected_git_url( async def test_create_project_rejects_protected_git_url(
project_setup: dict, monkeypatch: pytest.MonkeyPatch project_setup: dict, monkeypatch: pytest.MonkeyPatch
@@ -521,7 +659,6 @@ async def test_update_sync_state_success(project_setup: dict) -> None:
async def test_get_decrypted_token_decryption_error( async def test_get_decrypted_token_decryption_error(
project_setup: dict, project_setup: dict,
) -> None: ) -> None:
svc = project_setup["svc"] svc = project_setup["svc"]
payload = _project_payload(uuid4().hex[:6]) payload = _project_payload(uuid4().hex[:6])
pd = payload.model_dump() pd = payload.model_dump()
@@ -549,7 +686,6 @@ async def test_get_decrypted_token_returns_none_when_project_missing(
async def test_get_decrypted_token_by_slug_decryption_error( async def test_get_decrypted_token_by_slug_decryption_error(
project_setup: dict, project_setup: dict,
) -> None: ) -> None:
svc = project_setup["svc"] svc = project_setup["svc"]
payload = _project_payload(uuid4().hex[:6]) payload = _project_payload(uuid4().hex[:6])
pd = payload.model_dump() pd = payload.model_dump()
@@ -691,6 +827,5 @@ async def test_check_agent_access_with_allowed_list_membership(
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_project_service_factory(db_session: AsyncSession) -> None: async def test_get_project_service_factory(db_session: AsyncSession) -> None:
svc = get_project_service(db_session) svc = get_project_service(db_session)
assert isinstance(svc, ProjectService) assert isinstance(svc, ProjectService)
+132
View File
@@ -0,0 +1,132 @@
"""Forge provider detection + registration-time validation — pure, no DB/IO."""
from __future__ import annotations
from roboco.foundation.policy.forge import (
KNOWN_PROVIDERS,
detect_provider,
validate_project_forge,
)
# ---------------------------------------------------------------------------
# detect_provider — host extraction across https/ssh/.git/subgroup shapes
# ---------------------------------------------------------------------------
def test_detect_https_github() -> None:
assert detect_provider("https://github.com/owner/repo.git") == "github"
def test_detect_https_github_no_dot_git_suffix() -> None:
assert detect_provider("https://github.com/owner/repo") == "github"
def test_detect_https_github_with_token_userinfo() -> None:
url = "https://x-access-token:ghp_abc123@github.com/owner/repo.git"
assert detect_provider(url) == "github"
def test_detect_ssh_scp_syntax_github() -> None:
assert detect_provider("git@github.com:owner/repo.git") == "github"
def test_detect_ssh_url_scheme_github() -> None:
assert detect_provider("ssh://git@github.com/owner/repo.git") == "github"
def test_detect_https_gitlab_com() -> None:
assert detect_provider("https://gitlab.com/group/project.git") == "gitlab"
def test_detect_ssh_scp_syntax_gitlab() -> None:
assert detect_provider("git@gitlab.com:group/project.git") == "gitlab"
def test_detect_gitlab_subgroup_path_still_detects_host() -> None:
# Subgroup paths (3+ segments) don't change host resolution — detect_provider
# only looks at the host, never the path shape.
url = "https://gitlab.com/group/subgroup/project.git"
assert detect_provider(url) == "gitlab"
def test_detect_self_hosted_gitlab_host_is_unresolvable() -> None:
# A self-hosted host can't be told apart from GHE/Gitea by host alone.
assert detect_provider("https://gitlab.example.com/group/project.git") is None
def test_detect_self_hosted_https_unknown_host() -> None:
assert detect_provider("https://git.internal.example/owner/repo.git") is None
def test_detect_bitbucket_host_unresolvable() -> None:
assert detect_provider("https://bitbucket.org/owner/repo.git") is None
def test_detect_empty_string() -> None:
assert detect_provider("") is None
def test_detect_unparseable_garbage() -> None:
assert detect_provider("not a url at all") is None
# ---------------------------------------------------------------------------
# validate_project_forge — the registration-time gate
# ---------------------------------------------------------------------------
def test_empty_git_url_is_ok() -> None:
assert validate_project_forge(None, None) is None
assert validate_project_forge("", None) is None
def test_detected_github_no_explicit_provider_is_ok() -> None:
assert validate_project_forge("https://github.com/owner/repo.git", None) is None
def test_detected_github_ssh_scp_no_explicit_provider_is_ok() -> None:
assert validate_project_forge("git@github.com:owner/repo.git", None) is None
def test_explicit_github_provider_is_ok_regardless_of_host() -> None:
# The GitHub Enterprise escape hatch — a non-github.com host is accepted
# once the operator explicitly names the provider.
url = "https://ghe.internal.example/owner/repo.git"
assert validate_project_forge(url, "github") is None
def test_explicit_gitlab_provider_rejected_as_not_yet_supported() -> None:
error = validate_project_forge("https://gitlab.com/group/project.git", "gitlab")
assert error is not None
assert "not yet" in error.lower()
assert "gitlab" in error.lower()
def test_explicit_gitea_provider_rejected_as_not_yet_supported() -> None:
error = validate_project_forge("https://gitea.example.com/owner/repo.git", "gitea")
assert error is not None
assert "not yet" in error.lower()
assert "gitea" in error.lower()
def test_unknown_host_no_explicit_provider_rejected() -> None:
error = validate_project_forge("https://git.internal.example/owner/repo.git", None)
assert error is not None
assert "github" in error.lower()
def test_detected_gitlab_no_explicit_provider_rejected() -> None:
error = validate_project_forge("https://gitlab.com/group/project.git", None)
assert error is not None
assert "github" in error.lower()
def test_unknown_provider_string_rejected_naming_known_providers() -> None:
error = validate_project_forge("https://github.com/owner/repo.git", "bitbucket")
assert error is not None
for provider in KNOWN_PROVIDERS:
assert provider in error
def test_known_providers_tuple_is_the_documented_set() -> None:
assert KNOWN_PROVIDERS == ("github", "gitlab", "gitea")