feat: git test connection and pull
This commit is contained in:
parent
7e66742269
commit
ee6cfe9531
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue