Submatter/apps/web/app/(app)/[workspaceSlug]/settings/repos/page.tsx

347 lines
13 KiB
TypeScript
Raw Normal View History

"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 [connectError, setConnectError] = React.useState<string | null>(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);
setConnectError(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;
}
setConnectError(null);
connectMut.mutate(
{
workspace: workspaceSlug,
remoteUrl: form.remoteUrl.trim(),
displayName: form.displayName.trim() || undefined,
tokenEnvVar: form.tokenEnvVar.trim() || null,
},
{
onError: (err) => setConnectError(err.message),
},
);
};
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">
Link a git remote for worker automation (draft PRs, forge webhooks). Paste the clone
URL — OAuth repo pickers are planned; today you set the API token on the server and
reference its env var name here.
</p>
<details className="mt-3 text-sm text-muted-foreground">
<summary className="cursor-pointer font-medium text-foreground">Examples</summary>
<ul className="mt-2 list-disc space-y-2 pl-5">
<li>
<span className="font-medium text-foreground">Forgejo (self-hosted):</span>{" "}
<code className="text-xs">https://forgejo.stillwell.cloud/rstillwell/Submatter.git</code>
{" — "}token env var <code className="text-xs">FORGEJO_TOKEN</code> in{" "}
<code className="text-xs">/opt/submatter/.env</code>, then click Test connection.
</li>
<li>
<span className="font-medium text-foreground">GitHub.com:</span>{" "}
<code className="text-xs">https://github.com/org/repo.git</code>
{" — "}token env var <code className="text-xs">GITHUB_TOKEN</code> (PAT with repo
scope).
</li>
</ul>
</details>
</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
</label>
<Input
id="token-env"
placeholder="FORGEJO_TOKEN or GITHUB_TOKEN"
value={form.tokenEnvVar}
onChange={(e) => setForm((f) => ({ ...f, tokenEnvVar: e.target.value }))}
/>
<p className="mt-1 text-xs text-muted-foreground">
Name of the variable in the server <code className="text-xs">.env</code> — not the
secret itself. Required for Test connection to succeed.
</p>
</div>
{connectError && (
<p className="text-sm text-destructive">{connectError}</p>
)}
<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>
);
}