mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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:
@@ -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">
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.'
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user