feat: git test connection and pull

This commit is contained in:
null 2026-05-19 22:53:55 -05:00
parent 7e66742269
commit ee6cfe9531
4 changed files with 371 additions and 107 deletions

View File

@ -16,6 +16,8 @@ from app.schemas.common import OkResponse
from app.schemas.forgejo_connections import (
ForgejoConnectionCreate,
ForgejoConnectionRead,
ForgejoConnectionTestRequest,
ForgejoConnectionTestResponse,
ForgejoConnectionUpdate,
)
from app.schemas.forgejo_validation import ForgejoConnectionValidationResponse
@ -172,6 +174,62 @@ async def delete_connection(
return OkResponse()
@router.post(
"/test",
response_model=ForgejoConnectionTestResponse,
summary="Test Connection Before Saving",
description="Validate a base URL and token against a Forgejo instance without saving a connection record.",
)
async def test_connection(
payload: ForgejoConnectionTestRequest,
ctx: OrganizationContext = ORG_MEMBER_DEP,
) -> ForgejoConnectionTestResponse:
"""Test a Forgejo connection without saving it."""
import time
from app.services.forgejo_client import ForgejoAPIClient
base_url = payload.base_url.strip().rstrip("/")
if "/api/v1" in base_url:
import re
match = re.match(r"(https?://[^/]+)", base_url)
if match:
base_url = match.group(1).rstrip("/")
start = time.time()
try:
async with ForgejoAPIClient(base_url=base_url, token=payload.token.strip()) as client:
user_data = await client.get_user()
repos_raw = await client.list_user_repos(limit=50)
except Exception as exc:
return ForgejoConnectionTestResponse(
valid=False,
error=str(exc),
response_time_ms=(time.time() - start) * 1000,
)
repos = [
{
"full_name": r.get("full_name", ""),
"name": r.get("name", ""),
"owner": (r.get("owner") or {}).get("login", ""),
"default_branch": r.get("default_branch", "main") or "main",
"private": bool(r.get("private", False)),
"description": r.get("description") or None,
}
for r in repos_raw
if r.get("full_name") and r.get("name")
]
return ForgejoConnectionTestResponse(
valid=True,
user_login=str(user_data.get("login", "")),
user_full_name=str(user_data.get("full_name", "")) or None,
repo_count=len(repos),
repos=repos,
response_time_ms=(time.time() - start) * 1000,
)
@router.get(
"/{connection_id}/repos",
summary="List Available Repositories",

View File

@ -101,6 +101,36 @@ class ForgejoConnectionUpdate(SQLModel):
return value
class ForgejoConnectionTestRequest(SQLModel):
"""Payload for testing a connection before saving it."""
base_url: str
token: str
class ForgejoConnectionTestRepo(SQLModel):
"""Minimal repo info returned in a connection test."""
full_name: str
name: str
owner: str
default_branch: str
private: bool
description: str | None = None
class ForgejoConnectionTestResponse(SQLModel):
"""Result of a pre-save connection test."""
valid: bool
user_login: str | None = None
user_full_name: str | None = None
repo_count: int = 0
repos: list[ForgejoConnectionTestRepo] = []
error: str | None = None
response_time_ms: float = 0.0
class ForgejoConnectionRead(ForgejoConnectionBase):
"""Connection payload returned from read endpoints."""

View File

@ -2,11 +2,24 @@
import { useState } from "react";
import {
AlertCircle,
CheckCircle2,
FlaskConical,
GitBranch,
Loader2,
Lock,
Unlock,
User,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Loader2 } from "lucide-react";
import type { ForgejoConnectionCreate } from "@/lib/api-forgejo";
import {
testForgejoConnection,
type ForgejoConnectionCreate,
type ForgejoConnectionTestResult,
} from "@/lib/api-forgejo";
interface ForgejoConnectionFormProps {
defaultValues?: Partial<ForgejoConnectionFormValues>;
@ -37,140 +50,270 @@ export function ForgejoConnectionForm({
}: ForgejoConnectionFormProps) {
const [error, setError] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [isTesting, setIsTesting] = useState(false);
const [testResult, setTestResult] = useState<ForgejoConnectionTestResult | null>(null);
const [name, setName] = useState(defaultValues.name || "");
const [baseUrl, setBaseUrl] = useState(defaultValues.base_url || "");
const [token, setToken] = useState(defaultValues.token || "");
const isBusy = isSubmitting || isSaving;
const tokenHelpText = isTokenRequired
? "Paste your Git provider personal access token. Use read:issue; add write:issue if Pipeline should close issues. The token is stored server-side and never displayed."
? "Paste your Forgejo personal access token. Use read:issue scope; add write:issue if Pipeline should close issues."
: existingTokenLastEight
? `Leave blank to keep the saved token ending in ${existingTokenLastEight}. Paste a new token to replace it.`
: "Paste a new Git provider personal access token to replace the saved token.";
: "Paste a new personal access token to replace the saved one.";
const canTest = baseUrl.trim().length > 0 && token.trim().length > 0;
async function handleTest() {
setTestResult(null);
setError(null);
setIsTesting(true);
try {
const result = await testForgejoConnection({
base_url: baseUrl.trim(),
token: token.trim(),
});
setTestResult(result);
} catch (err) {
setError(err instanceof Error ? err.message : "Test request failed.");
} finally {
setIsTesting(false);
}
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
try {
setIsSaving(true);
await onSubmit({
name,
base_url: baseUrl,
token,
});
await onSubmit({ name, base_url: baseUrl, token });
} catch (err) {
setError(err instanceof Error ? err.message : "An error occurred");
const msg = err instanceof Error ? err.message : "An error occurred";
setError(msg);
} finally {
setIsSaving(false);
}
}
return (
<form
onSubmit={handleSubmit}
className="surface-panel w-full max-w-2xl space-y-6 rounded-2xl p-4 sm:p-6"
>
<div className="space-y-4">
<div>
<h3 className="text-lg font-semibold text-strong">{title}</h3>
{description && (
<p className="mt-1 text-sm text-muted">{description}</p>
)}
</div>
<div className="w-full max-w-2xl space-y-4">
<form
onSubmit={handleSubmit}
className="surface-panel space-y-6 rounded-2xl p-4 sm:p-6"
>
<div className="space-y-4">
<div>
<h3 className="text-lg font-semibold text-strong">{title}</h3>
{description && (
<p className="mt-1 text-sm text-muted">{description}</p>
)}
</div>
{error && (
<div className="rounded-xl border border-[color:rgba(248,113,113,0.35)] bg-[color:rgba(248,113,113,0.08)] p-4 text-[color:var(--danger)]">
<p className="font-medium">
{error === "Failed to fetch"
? "Could not reach Pipeline backend"
: "Error"}
</p>
<p className="text-sm">
{error === "Failed to fetch"
? "Pipeline could not connect to its own API. Check that NEXT_PUBLIC_API_URL points to your backend and that your frontend origin is listed in CORS_ORIGINS."
: error}
{error && (
<div className="rounded-xl border border-[color:rgba(248,113,113,0.35)] bg-[color:rgba(248,113,113,0.08)] p-4 text-[color:var(--danger)]">
<p className="font-medium">
{error === "Failed to fetch"
? "Could not reach Pipeline backend"
: "Error"}
</p>
<p className="text-sm">
{error === "Failed to fetch"
? "Pipeline could not connect to its own API. Check that NEXT_PUBLIC_API_URL points to your backend and that your frontend origin is listed in CORS_ORIGINS."
: error}
</p>
</div>
)}
<div className="space-y-2">
<label htmlFor="name" className="text-sm font-medium">
Name
</label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Team Git"
disabled={isBusy}
required
/>
<p className="text-xs text-muted">
A memorable name for this Git Projects connection.
</p>
</div>
)}
<div className="space-y-2">
<label htmlFor="name" className="text-sm font-medium">
Name
</label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Team Git"
disabled={isBusy}
required
/>
<p className="text-xs text-muted">
A memorable name for this Git Projects connection.
</p>
<div className="space-y-2">
<label htmlFor="base_url" className="text-sm font-medium">
Base URL
</label>
<Input
id="base_url"
value={baseUrl}
onChange={(e) => {
setBaseUrl(e.target.value);
setTestResult(null);
}}
placeholder="https://git.example.com"
disabled={isBusy}
required
/>
<p className="text-xs text-muted">
The base URL of your Forgejo instance, without a trailing slash.
</p>
</div>
<div className="space-y-2">
<label htmlFor="token" className="text-sm font-medium">
Token
</label>
<Input
id="token"
type="password"
value={token}
onChange={(e) => {
setToken(e.target.value);
setTestResult(null);
}}
placeholder={
isTokenRequired
? "Paste token"
: existingTokenLastEight
? `Current token ends in ${existingTokenLastEight}`
: "Paste token"
}
disabled={isBusy}
required={isTokenRequired}
autoComplete="new-password"
/>
<p className="text-xs text-muted">{tokenHelpText}</p>
</div>
</div>
<div className="space-y-2">
<label htmlFor="base_url" className="text-sm font-medium">
Base URL
</label>
<Input
id="base_url"
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
placeholder="https://git.example.com"
<div className="flex flex-col-reverse gap-3 border-t border-[color:var(--border)] pt-4 sm:flex-row sm:justify-end">
<Button
type="button"
variant="outline"
onClick={() => window.history.back()}
disabled={isBusy}
required
/>
<p className="text-xs text-muted">
The base URL of your Git provider instance, without a trailing
slash.
</p>
>
Cancel
</Button>
<Button
type="button"
variant="outline"
onClick={handleTest}
disabled={isBusy || isTesting || !canTest}
>
{isTesting ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Testing
</>
) : (
<>
<FlaskConical className="h-4 w-4" />
Test Connection
</>
)}
</Button>
<Button type="submit" disabled={isBusy}>
{isBusy ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving
</>
) : (
submitLabel
)}
</Button>
</div>
</form>
<div className="space-y-2">
<label htmlFor="token" className="text-sm font-medium">
Token
</label>
<Input
id="token"
type="password"
value={token}
onChange={(e) => setToken(e.target.value)}
placeholder={
isTokenRequired
? "Paste token"
: existingTokenLastEight
? `Current token ends in ${existingTokenLastEight}`
: "Paste token"
}
disabled={isBusy}
required={isTokenRequired}
/>
<p className="text-xs text-muted">{tokenHelpText}</p>
</div>
</div>
<div className="flex flex-col-reverse gap-3 border-t border-[color:var(--border)] pt-4 sm:flex-row sm:justify-end">
<Button
type="button"
variant="outline"
onClick={() => window.history.back()}
disabled={isBusy}
{/* Test results panel */}
{testResult && (
<div
className={`rounded-2xl border p-4 sm:p-6 ${
testResult.valid
? "border-[color:rgba(52,211,153,0.35)] bg-[color:rgba(52,211,153,0.06)]"
: "border-[color:rgba(248,113,113,0.35)] bg-[color:rgba(248,113,113,0.06)]"
}`}
>
Cancel
</Button>
<Button type="submit" disabled={isBusy}>
{isBusy ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving
</>
) : (
submitLabel
{/* Header */}
<div className="flex items-start gap-3 mb-4">
{testResult.valid ? (
<CheckCircle2 className="h-5 w-5 shrink-0 text-[color:var(--success)] mt-0.5" />
) : (
<AlertCircle className="h-5 w-5 shrink-0 text-[color:var(--danger)] mt-0.5" />
)}
<div className="min-w-0">
<p className={`font-semibold ${testResult.valid ? "text-[color:var(--success)]" : "text-[color:var(--danger)]"}`}>
{testResult.valid ? "Connection successful" : "Connection failed"}
</p>
<p className="text-xs text-muted mt-0.5">
{Math.round(testResult.response_time_ms)}ms
</p>
</div>
</div>
{testResult.error && (
<p className="text-sm text-[color:var(--danger)] mb-4">{testResult.error}</p>
)}
</Button>
</div>
</form>
{testResult.valid && (
<div className="space-y-4">
{/* Identity */}
<div className="flex items-center gap-2 text-sm text-strong">
<User className="h-4 w-4 shrink-0 text-muted" />
<span>
Connected as{" "}
<strong>@{testResult.user_login}</strong>
{testResult.user_full_name && testResult.user_full_name !== testResult.user_login
? ` (${testResult.user_full_name})`
: ""}
</span>
</div>
{/* Repo list */}
<div>
<div className="flex items-center gap-2 text-sm font-medium text-strong mb-2">
<GitBranch className="h-4 w-4 shrink-0 text-muted" />
<span>
{testResult.repo_count}{" "}
{testResult.repo_count === 1 ? "repository" : "repositories"} accessible
</span>
</div>
{testResult.repos.length > 0 && (
<div className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] divide-y divide-[color:var(--border)] overflow-hidden">
{testResult.repos.map((repo) => (
<div
key={repo.full_name}
className="flex items-start gap-3 px-4 py-2.5"
>
{repo.private ? (
<Lock className="h-3.5 w-3.5 shrink-0 text-muted mt-0.5" />
) : (
<Unlock className="h-3.5 w-3.5 shrink-0 text-muted mt-0.5" />
)}
<div className="min-w-0">
<p className="text-sm font-medium text-strong truncate">
{repo.full_name}
</p>
{repo.description && (
<p className="text-xs text-muted truncate">{repo.description}</p>
)}
</div>
<span className="ml-auto shrink-0 font-mono text-xs text-muted">
{repo.default_branch}
</span>
</div>
))}
</div>
)}
</div>
</div>
)}
</div>
)}
</div>
);
}

View File

@ -173,6 +173,39 @@ export async function deleteForgejoRepository(
);
}
// Pre-save connection test
export interface ForgejoConnectionTestRepo {
full_name: string;
name: string;
owner: string;
default_branch: string;
private: boolean;
description: string | null;
}
export interface ForgejoConnectionTestResult {
valid: boolean;
user_login: string | null;
user_full_name: string | null;
repo_count: number;
repos: ForgejoConnectionTestRepo[];
error: string | null;
response_time_ms: number;
}
export async function testForgejoConnection(data: {
base_url: string;
token: string;
}): Promise<ForgejoConnectionTestResult> {
return fetchJson<ForgejoConnectionTestResult>(
"/api/v1/forgejo/connections/test",
{
method: "POST",
body: JSON.stringify(data),
},
);
}
// Remote repo discovery
export interface ForgejoRemoteRepo {
full_name: string;