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 { validateLadder } from "@/components/projects/ladder-validation";
import { HelpTip } from "@/components/ui/help-tip";
import { Badge } from "@/components/ui/badge";
const cells: { value: Team; label: string }[] = [
{ value: Team.BACKEND, label: "Backend" },
@@ -188,6 +189,19 @@ export function CreateProjectDialog() {
</p>
</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 */}
<div className="grid gap-2">
<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 { validateLadder } from "@/components/projects/ladder-validation";
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 }[] = [
{ value: Team.BACKEND, label: "Backend" },
@@ -308,6 +317,19 @@ function EditProjectForm({
/>
</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 */}
<div className="grid gap-2 p-3 border rounded-lg bg-muted/30">
<div className="flex items-center justify-between">
+24
View File
@@ -8,6 +8,26 @@ import type {
} from "@/types";
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
const mockProjects: Project[] = [
{
@@ -15,6 +35,7 @@ const mockProjects: Project[] = [
name: "roboco",
slug: "roboco",
git_url: "https://github.com/rennf93/roboco.git",
git_provider: "github",
default_branch: "master",
protected_branches: ["master", "slave"],
assigned_cell: Team.BACKEND,
@@ -33,6 +54,7 @@ const mockProjects: Project[] = [
name: "roboco-website",
slug: "roboco-website",
git_url: "https://github.com/rennf93/roboco-website.git",
git_provider: "github",
default_branch: "master",
protected_branches: ["master"],
assigned_cell: Team.FRONTEND,
@@ -124,6 +146,8 @@ export const projectsApi = {
name: project.name,
slug: project.slug,
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",
environments: project.environments ?? null,
protected_branches: project.protected_branches ?? ["main", "master"],
+7
View File
@@ -1033,6 +1033,9 @@ export interface Project {
name: string;
slug: 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;
// Ordered environment ladder (first=head/PR-target, last=prod/release-target).
// Null/empty => degenerate 1-rung ladder synthesized from default_branch.
@@ -1071,6 +1074,8 @@ export interface ProjectCreate {
name: string;
slug: string;
git_url: string;
// null/omitted = auto-detect from git_url host (github.com -> github).
git_provider?: string | null;
default_branch?: string;
// Ordered environment ladder; null/empty inherits default_branch (shim).
environments?: EnvironmentRung[] | null;
@@ -1089,6 +1094,8 @@ export interface ProjectCreate {
export interface ProjectUpdate {
name?: string;
git_url?: string;
// null = revert to auto-detect; omitted = leave unchanged.
git_provider?: string | null;
default_branch?: string;
// Ordered environment ladder; null clears (reverts to default_branch shim).
environments?: EnvironmentRung[] | null;
+1
View File
@@ -157,6 +157,7 @@ async def create_project(
name=data.name,
slug=data.slug,
git_url=data.git_url,
git_provider=data.git_provider,
default_branch=data.default_branch,
protected_branches=protected_branches,
environments=data.environments,
+18
View File
@@ -29,6 +29,9 @@ class ProjectResponse(BaseModel):
name: str
slug: 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
protected_branches: list[str]
environments: list[dict[str, str]] | None = None
@@ -114,6 +117,13 @@ class ProjectCreateRequest(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
slug: str = Field(..., min_length=1, max_length=50, pattern=r"^[a-z0-9-]+$")
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"
protected_branches: list[str] | None = Field(
default=None,
@@ -149,6 +159,13 @@ class ProjectUpdateRequest(BaseModel):
name: 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
protected_branches: list[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),
slug=str(project.slug),
git_url=str(project.git_url),
git_provider=project.git_provider,
default_branch=str(default_branch) if default_branch else "master",
protected_branches=list(project.protected_branches or []),
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(
Text, nullable=True
) # 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)
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_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")
protected_branches: list[str] = Field(
default_factory=lambda: ["main", "master"],
@@ -258,6 +269,13 @@ class ProjectCreate(RobocoBase):
name: str = Field(..., min_length=1, max_length=100)
slug: str = Field(..., min_length=1, max_length=50, pattern=r"^[a-z0-9-]+$")
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"
protected_branches: list[str] = Field(default_factory=lambda: ["main", "master"])
environments: list[dict[str, str]] | None = None
@@ -283,6 +301,13 @@ class ProjectUpdate(RobocoBase):
name: 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
protected_branches: list[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.db.tables import ProjectTable, TaskTable
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.project import ProjectCreate, ProjectUpdate
from roboco.services.base import BaseService, ConflictError, NotFoundError
@@ -62,6 +63,18 @@ class ProjectService(BaseService):
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(
self,
data: ProjectCreate,
@@ -89,6 +102,13 @@ class ProjectService(BaseService):
)
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
encrypted_token = None
@@ -103,6 +123,7 @@ class ProjectService(BaseService):
name=data.name,
slug=data.slug,
git_url=data.git_url,
git_provider=git_provider,
default_branch=data.default_branch,
protected_branches=data.protected_branches,
environments=data.environments,
@@ -194,6 +215,27 @@ class ProjectService(BaseService):
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)
token_updated = False
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))
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():
if hasattr(project, key):
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"])
@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
async def test_create_project_rejects_protected_git_url(
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(
project_setup: dict,
) -> None:
svc = project_setup["svc"]
payload = _project_payload(uuid4().hex[:6])
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(
project_setup: dict,
) -> None:
svc = project_setup["svc"]
payload = _project_payload(uuid4().hex[:6])
pd = payload.model_dump()
@@ -691,6 +827,5 @@ async def test_check_agent_access_with_allowed_list_membership(
@pytest.mark.asyncio
async def test_get_project_service_factory(db_session: AsyncSession) -> None:
svc = get_project_service(db_session)
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")