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
@@ -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;