313 lines
11 KiB
TypeScript
313 lines
11 KiB
TypeScript
|
|
"use client";
|
||
|
|
|
||
|
|
import * as React from "react";
|
||
|
|
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||
|
|
import { GitBranch, Loader2, Plus, X } from "lucide-react";
|
||
|
|
import { useParams } from "next/navigation";
|
||
|
|
|
||
|
|
import { Button } from "@/components/ui/button";
|
||
|
|
import { Input } from "@/components/ui/input";
|
||
|
|
import { api } from "@/lib/trpc";
|
||
|
|
import type { ForgeType } from "@tasks/shared/connected-repos";
|
||
|
|
|
||
|
|
const FORGE_LABELS: Record<ForgeType, string> = {
|
||
|
|
github: "GitHub",
|
||
|
|
forgejo: "Forgejo / Gitea",
|
||
|
|
gitlab: "GitLab",
|
||
|
|
bitbucket: "Bitbucket",
|
||
|
|
};
|
||
|
|
|
||
|
|
type ConnectForm = {
|
||
|
|
remoteUrl: string;
|
||
|
|
displayName: string;
|
||
|
|
tokenEnvVar: string;
|
||
|
|
};
|
||
|
|
|
||
|
|
const EMPTY_FORM: ConnectForm = {
|
||
|
|
remoteUrl: "",
|
||
|
|
displayName: "",
|
||
|
|
tokenEnvVar: "",
|
||
|
|
};
|
||
|
|
|
||
|
|
export default function ConnectedReposSettingsPage() {
|
||
|
|
const params = useParams();
|
||
|
|
const workspaceSlug = params?.workspaceSlug as string | undefined;
|
||
|
|
const utils = api.useUtils();
|
||
|
|
|
||
|
|
const listQuery = api.connectedRepos.list.useQuery(
|
||
|
|
{ workspace: workspaceSlug ?? "" },
|
||
|
|
{ enabled: Boolean(workspaceSlug) },
|
||
|
|
);
|
||
|
|
|
||
|
|
const [dialogOpen, setDialogOpen] = React.useState(false);
|
||
|
|
const [form, setForm] = React.useState<ConnectForm>(EMPTY_FORM);
|
||
|
|
const [parsedPreview, setParsedPreview] = React.useState<{
|
||
|
|
forgeType: ForgeType;
|
||
|
|
owner: string;
|
||
|
|
repoName: string;
|
||
|
|
apiBaseUrl: string;
|
||
|
|
} | null>(null);
|
||
|
|
const [testResult, setTestResult] = React.useState<Record<string, string>>({});
|
||
|
|
|
||
|
|
const parseMut = api.connectedRepos.parseRemote.useMutation({
|
||
|
|
onSuccess: (parsed) => {
|
||
|
|
setParsedPreview({
|
||
|
|
forgeType: parsed.forgeType,
|
||
|
|
owner: parsed.owner,
|
||
|
|
repoName: parsed.repoName,
|
||
|
|
apiBaseUrl: parsed.apiBaseUrl,
|
||
|
|
});
|
||
|
|
setForm((prev) => ({
|
||
|
|
...prev,
|
||
|
|
displayName: prev.displayName || `${parsed.owner}/${parsed.repoName}`,
|
||
|
|
}));
|
||
|
|
},
|
||
|
|
onError: () => setParsedPreview(null),
|
||
|
|
});
|
||
|
|
|
||
|
|
const connectMut = api.connectedRepos.connect.useMutation({
|
||
|
|
onSuccess: () => {
|
||
|
|
void utils.connectedRepos.list.invalidate();
|
||
|
|
setDialogOpen(false);
|
||
|
|
setForm(EMPTY_FORM);
|
||
|
|
setParsedPreview(null);
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
const disconnectMut = api.connectedRepos.disconnect.useMutation({
|
||
|
|
onSuccess: () => void utils.connectedRepos.list.invalidate(),
|
||
|
|
});
|
||
|
|
|
||
|
|
const testMut = api.connectedRepos.testConnection.useMutation({
|
||
|
|
onSuccess: (result, vars) => {
|
||
|
|
setTestResult((prev) => ({
|
||
|
|
...prev,
|
||
|
|
[vars.repoId]: result.ok
|
||
|
|
? `OK — default branch ${result.defaultBranch} (${result.latencyMs}ms)`
|
||
|
|
: `Failed: ${result.error ?? "unknown"}`,
|
||
|
|
}));
|
||
|
|
if (result.ok) {
|
||
|
|
void utils.connectedRepos.list.invalidate();
|
||
|
|
}
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
const canManage = listQuery.data?.canManage ?? false;
|
||
|
|
const repos = listQuery.data?.repos ?? [];
|
||
|
|
|
||
|
|
const openCreate = () => {
|
||
|
|
setForm(EMPTY_FORM);
|
||
|
|
setParsedPreview(null);
|
||
|
|
setDialogOpen(true);
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleParse = () => {
|
||
|
|
if (!workspaceSlug || !form.remoteUrl.trim()) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
parseMut.mutate({ workspace: workspaceSlug, remoteUrl: form.remoteUrl.trim() });
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleConnect = () => {
|
||
|
|
if (!workspaceSlug || !form.remoteUrl.trim()) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
connectMut.mutate({
|
||
|
|
workspace: workspaceSlug,
|
||
|
|
remoteUrl: form.remoteUrl.trim(),
|
||
|
|
displayName: form.displayName.trim() || undefined,
|
||
|
|
tokenEnvVar: form.tokenEnvVar.trim() || null,
|
||
|
|
});
|
||
|
|
};
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="mx-auto max-w-4xl px-6 py-8">
|
||
|
|
<div className="mb-6 flex items-start justify-between gap-4">
|
||
|
|
<div>
|
||
|
|
<h1 className="text-2xl font-semibold tracking-tight">Connected repos</h1>
|
||
|
|
<p className="mt-1 text-sm text-muted-foreground">
|
||
|
|
Paste a git remote URL to link Forgejo, GitHub, or other forges. API tokens are
|
||
|
|
referenced by env var name — never stored in the database.
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
{canManage && (
|
||
|
|
<Button type="button" onClick={openCreate}>
|
||
|
|
<Plus className="mr-2 size-4" />
|
||
|
|
Connect repo
|
||
|
|
</Button>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{listQuery.isLoading ? (
|
||
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||
|
|
<Loader2 className="size-4 animate-spin" />
|
||
|
|
Loading repos…
|
||
|
|
</div>
|
||
|
|
) : repos.length === 0 ? (
|
||
|
|
<div className="rounded-lg border border-dashed border-border p-8 text-center text-sm text-muted-foreground">
|
||
|
|
No connected repos yet.
|
||
|
|
{canManage && " Connect one to prepare for orchestrator and worker automation."}
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<ul className="divide-y divide-border rounded-lg border border-border">
|
||
|
|
{repos.map((repo) => (
|
||
|
|
<li key={repo.id} className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center">
|
||
|
|
<div className="min-w-0 flex-1">
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
<GitBranch className="size-4 shrink-0 text-muted-foreground" />
|
||
|
|
<span className="font-medium">{repo.displayName}</span>
|
||
|
|
<span className="rounded bg-muted px-2 py-0.5 text-xs text-muted-foreground">
|
||
|
|
{FORGE_LABELS[repo.forgeType]}
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
<p className="mt-1 truncate text-sm text-muted-foreground">{repo.gitRemoteUrl}</p>
|
||
|
|
<p className="mt-0.5 text-xs text-muted-foreground">
|
||
|
|
{repo.owner}/{repo.repoName}
|
||
|
|
{repo.defaultBranch ? ` · ${repo.defaultBranch}` : ""}
|
||
|
|
{repo.tokenEnvVar ? ` · token: ${repo.tokenEnvVar}` : ""}
|
||
|
|
</p>
|
||
|
|
{testResult[repo.id] && (
|
||
|
|
<p className="mt-1 text-xs text-muted-foreground">{testResult[repo.id]}</p>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
{canManage && (
|
||
|
|
<div className="flex shrink-0 gap-2">
|
||
|
|
<Button
|
||
|
|
type="button"
|
||
|
|
variant="outline"
|
||
|
|
size="sm"
|
||
|
|
disabled={testMut.isPending}
|
||
|
|
onClick={() =>
|
||
|
|
workspaceSlug &&
|
||
|
|
testMut.mutate({ workspace: workspaceSlug, repoId: repo.id })
|
||
|
|
}
|
||
|
|
>
|
||
|
|
Test connection
|
||
|
|
</Button>
|
||
|
|
<Button
|
||
|
|
type="button"
|
||
|
|
variant="ghost"
|
||
|
|
size="sm"
|
||
|
|
className="text-destructive"
|
||
|
|
disabled={disconnectMut.isPending}
|
||
|
|
onClick={() =>
|
||
|
|
workspaceSlug &&
|
||
|
|
disconnectMut.mutate({ workspace: workspaceSlug, repoId: repo.id })
|
||
|
|
}
|
||
|
|
>
|
||
|
|
Disconnect
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</li>
|
||
|
|
))}
|
||
|
|
</ul>
|
||
|
|
)}
|
||
|
|
|
||
|
|
<DialogPrimitive.Root open={dialogOpen} onOpenChange={setDialogOpen}>
|
||
|
|
<DialogPrimitive.Portal>
|
||
|
|
<DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-black/50" />
|
||
|
|
<DialogPrimitive.Content className="fixed left-1/2 top-1/2 z-50 w-full max-w-lg -translate-x-1/2 -translate-y-1/2 rounded-lg border border-border bg-background p-6 shadow-lg">
|
||
|
|
<div className="mb-4 flex items-center justify-between">
|
||
|
|
<DialogPrimitive.Title className="text-lg font-semibold">
|
||
|
|
Connect repository
|
||
|
|
</DialogPrimitive.Title>
|
||
|
|
<DialogPrimitive.Close asChild>
|
||
|
|
<button type="button" className="rounded p-1 hover:bg-muted" aria-label="Close">
|
||
|
|
<X className="size-4" />
|
||
|
|
</button>
|
||
|
|
</DialogPrimitive.Close>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="space-y-4">
|
||
|
|
<div>
|
||
|
|
<label className="mb-1 block text-sm font-medium" htmlFor="remote-url">
|
||
|
|
Git remote URL
|
||
|
|
</label>
|
||
|
|
<div className="flex gap-2">
|
||
|
|
<Input
|
||
|
|
id="remote-url"
|
||
|
|
placeholder="https://github.com/org/repo or git@host:org/repo.git"
|
||
|
|
value={form.remoteUrl}
|
||
|
|
onChange={(e) => setForm((f) => ({ ...f, remoteUrl: e.target.value }))}
|
||
|
|
/>
|
||
|
|
<Button
|
||
|
|
type="button"
|
||
|
|
variant="outline"
|
||
|
|
disabled={parseMut.isPending || !form.remoteUrl.trim()}
|
||
|
|
onClick={handleParse}
|
||
|
|
>
|
||
|
|
Parse
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
{parseMut.error && (
|
||
|
|
<p className="mt-1 text-xs text-destructive">{parseMut.error.message}</p>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{parsedPreview && (
|
||
|
|
<div className="rounded-md bg-muted/50 p-3 text-sm">
|
||
|
|
<p>
|
||
|
|
Detected{" "}
|
||
|
|
<span className="font-medium">{FORGE_LABELS[parsedPreview.forgeType]}</span> —{" "}
|
||
|
|
{parsedPreview.owner}/{parsedPreview.repoName}
|
||
|
|
</p>
|
||
|
|
<p className="mt-1 text-xs text-muted-foreground">{parsedPreview.apiBaseUrl}</p>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
<div>
|
||
|
|
<label className="mb-1 block text-sm font-medium" htmlFor="display-name">
|
||
|
|
Display name
|
||
|
|
</label>
|
||
|
|
<Input
|
||
|
|
id="display-name"
|
||
|
|
value={form.displayName}
|
||
|
|
onChange={(e) => setForm((f) => ({ ...f, displayName: e.target.value }))}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div>
|
||
|
|
<label className="mb-1 block text-sm font-medium" htmlFor="token-env">
|
||
|
|
API token env var (optional)
|
||
|
|
</label>
|
||
|
|
<Input
|
||
|
|
id="token-env"
|
||
|
|
placeholder="GITHUB_TOKEN"
|
||
|
|
value={form.tokenEnvVar}
|
||
|
|
onChange={(e) => setForm((f) => ({ ...f, tokenEnvVar: e.target.value }))}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="flex justify-end gap-2 pt-2">
|
||
|
|
<DialogPrimitive.Close asChild>
|
||
|
|
<Button type="button" variant="outline">
|
||
|
|
Cancel
|
||
|
|
</Button>
|
||
|
|
</DialogPrimitive.Close>
|
||
|
|
<Button
|
||
|
|
type="button"
|
||
|
|
disabled={connectMut.isPending || !form.remoteUrl.trim()}
|
||
|
|
onClick={handleConnect}
|
||
|
|
>
|
||
|
|
{connectMut.isPending ? (
|
||
|
|
<>
|
||
|
|
<Loader2 className="mr-2 size-4 animate-spin" />
|
||
|
|
Connecting…
|
||
|
|
</>
|
||
|
|
) : (
|
||
|
|
"Connect"
|
||
|
|
)}
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
{connectMut.error && (
|
||
|
|
<p className="text-xs text-destructive">{connectMut.error.message}</p>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</DialogPrimitive.Content>
|
||
|
|
</DialogPrimitive.Portal>
|
||
|
|
</DialogPrimitive.Root>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|