diff --git a/backend/app/api/forgejo_connections.py b/backend/app/api/forgejo_connections.py index 5dd131d..5a0bbd8 100644 --- a/backend/app/api/forgejo_connections.py +++ b/backend/app/api/forgejo_connections.py @@ -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", diff --git a/backend/app/schemas/forgejo_connections.py b/backend/app/schemas/forgejo_connections.py index 15245d4..40d51d5 100644 --- a/backend/app/schemas/forgejo_connections.py +++ b/backend/app/schemas/forgejo_connections.py @@ -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.""" diff --git a/frontend/src/components/git/ForgejoConnectionForm.tsx b/frontend/src/components/git/ForgejoConnectionForm.tsx index 531072c..e90bed8 100644 --- a/frontend/src/components/git/ForgejoConnectionForm.tsx +++ b/frontend/src/components/git/ForgejoConnectionForm.tsx @@ -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; @@ -37,140 +50,270 @@ export function ForgejoConnectionForm({ }: ForgejoConnectionFormProps) { const [error, setError] = useState(null); const [isSaving, setIsSaving] = useState(false); + const [isTesting, setIsTesting] = useState(false); + const [testResult, setTestResult] = useState(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 ( -
-
-
-

{title}

- {description && ( -

{description}

- )} -
+
+ +
+
+

{title}

+ {description && ( +

{description}

+ )} +
- {error && ( -
-

- {error === "Failed to fetch" - ? "Could not reach Pipeline backend" - : "Error"} -

-

- {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 && ( +

+

+ {error === "Failed to fetch" + ? "Could not reach Pipeline backend" + : "Error"} +

+

+ {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} +

+
+ )} + +
+ + setName(e.target.value)} + placeholder="Team Git" + disabled={isBusy} + required + /> +

+ A memorable name for this Git Projects connection.

- )} -
- - setName(e.target.value)} - placeholder="Team Git" - disabled={isBusy} - required - /> -

- A memorable name for this Git Projects connection. -

+
+ + { + setBaseUrl(e.target.value); + setTestResult(null); + }} + placeholder="https://git.example.com" + disabled={isBusy} + required + /> +

+ The base URL of your Forgejo instance, without a trailing slash. +

+
+ +
+ + { + 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" + /> +

{tokenHelpText}

+
-
- - setBaseUrl(e.target.value)} - placeholder="https://git.example.com" +
+ + +
+ -
- - setToken(e.target.value)} - placeholder={ - isTokenRequired - ? "Paste token" - : existingTokenLastEight - ? `Current token ends in ${existingTokenLastEight}` - : "Paste token" - } - disabled={isBusy} - required={isTokenRequired} - /> -

{tokenHelpText}

-
-
- -
- - -
- + + {testResult.valid && ( +
+ {/* Identity */} +
+ + + Connected as{" "} + @{testResult.user_login} + {testResult.user_full_name && testResult.user_full_name !== testResult.user_login + ? ` (${testResult.user_full_name})` + : ""} + +
+ + {/* Repo list */} +
+
+ + + {testResult.repo_count}{" "} + {testResult.repo_count === 1 ? "repository" : "repositories"} accessible + +
+ {testResult.repos.length > 0 && ( +
+ {testResult.repos.map((repo) => ( +
+ {repo.private ? ( + + ) : ( + + )} +
+

+ {repo.full_name} +

+ {repo.description && ( +

{repo.description}

+ )} +
+ + {repo.default_branch} + +
+ ))} +
+ )} +
+
+ )} +
+ )} +
); } diff --git a/frontend/src/lib/api-forgejo.ts b/frontend/src/lib/api-forgejo.ts index 04d3792..2fca81b 100644 --- a/frontend/src/lib/api-forgejo.ts +++ b/frontend/src/lib/api-forgejo.ts @@ -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 { + return fetchJson( + "/api/v1/forgejo/connections/test", + { + method: "POST", + body: JSON.stringify(data), + }, + ); +} + // Remote repo discovery export interface ForgejoRemoteRepo { full_name: string;