feat(automation): connected repos (Epic 3)
feat(automation): connected repos (Epic 3)
This commit is contained in:
commit
0604d66008
23 changed files with 4680 additions and 5 deletions
312
apps/web/app/(app)/[workspaceSlug]/settings/repos/page.tsx
Normal file
312
apps/web/app/(app)/[workspaceSlug]/settings/repos/page.tsx
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
"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>
|
||||
);
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ import {
|
|||
CheckSquare,
|
||||
ChevronDown,
|
||||
FileText,
|
||||
GitBranch,
|
||||
LayoutGrid,
|
||||
LayoutTemplate,
|
||||
LogOut,
|
||||
|
|
@ -163,6 +164,15 @@ export function TopHeader({ onOpenSearch, onQuickAction }: TopHeaderProps) {
|
|||
<Sparkles className="size-4 text-muted-foreground" />
|
||||
Role routing
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="gap-2.5 px-4"
|
||||
onSelect={() =>
|
||||
workspace?.slug && router.push(`/${workspace.slug}/settings/repos`)
|
||||
}
|
||||
>
|
||||
<GitBranch className="size-4 text-muted-foreground" />
|
||||
Connected repos
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="gap-2.5 px-4"
|
||||
onSelect={() =>
|
||||
|
|
|
|||
145
apps/web/server/lib/forge/adapters.ts
Normal file
145
apps/web/server/lib/forge/adapters.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import type { ForgeType, RepoMetadata } from "@tasks/shared/connected-repos";
|
||||
|
||||
export type ForgeAdapterContext = {
|
||||
apiBaseUrl: string;
|
||||
owner: string;
|
||||
repo: string;
|
||||
token?: string;
|
||||
};
|
||||
|
||||
export interface ForgeAdapter {
|
||||
readonly forgeType: ForgeType;
|
||||
fetchRepoMetadata(ctx: ForgeAdapterContext): Promise<RepoMetadata>;
|
||||
}
|
||||
|
||||
async function readJsonResponse(response: Response): Promise<unknown> {
|
||||
const text = await response.text();
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(text) as unknown;
|
||||
} catch {
|
||||
throw new Error(`Forge API returned non-JSON response (${response.status}).`);
|
||||
}
|
||||
}
|
||||
|
||||
function authHeaders(token: string | undefined): HeadersInit {
|
||||
if (!token) {
|
||||
return {};
|
||||
}
|
||||
return { Authorization: `Bearer ${token}` };
|
||||
}
|
||||
|
||||
export const githubForgeAdapter: ForgeAdapter = {
|
||||
forgeType: "github",
|
||||
async fetchRepoMetadata(ctx) {
|
||||
const url = `${ctx.apiBaseUrl.replace(/\/$/, "")}/repos/${ctx.owner}/${ctx.repo}`;
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
...authHeaders(ctx.token),
|
||||
},
|
||||
});
|
||||
const body = await readJsonResponse(response);
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
typeof body === "object" &&
|
||||
body !== null &&
|
||||
"message" in body &&
|
||||
typeof body.message === "string"
|
||||
? body.message
|
||||
: `GitHub API error (${response.status})`;
|
||||
throw new Error(message);
|
||||
}
|
||||
if (
|
||||
typeof body !== "object" ||
|
||||
body === null ||
|
||||
!("default_branch" in body) ||
|
||||
typeof body.default_branch !== "string"
|
||||
) {
|
||||
throw new Error("GitHub API response missing default_branch.");
|
||||
}
|
||||
const cloneUrl =
|
||||
typeof body === "object" &&
|
||||
body !== null &&
|
||||
"clone_url" in body &&
|
||||
typeof body.clone_url === "string"
|
||||
? body.clone_url
|
||||
: `https://github.com/${ctx.owner}/${ctx.repo}.git`;
|
||||
const description =
|
||||
typeof body === "object" &&
|
||||
body !== null &&
|
||||
"description" in body &&
|
||||
typeof body.description === "string"
|
||||
? body.description
|
||||
: undefined;
|
||||
return {
|
||||
defaultBranch: body.default_branch,
|
||||
cloneUrl,
|
||||
description,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export const forgejoForgeAdapter: ForgeAdapter = {
|
||||
forgeType: "forgejo",
|
||||
async fetchRepoMetadata(ctx) {
|
||||
const url = `${ctx.apiBaseUrl.replace(/\/$/, "")}/repos/${ctx.owner}/${ctx.repo}`;
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
...authHeaders(ctx.token),
|
||||
},
|
||||
});
|
||||
const body = await readJsonResponse(response);
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
typeof body === "object" &&
|
||||
body !== null &&
|
||||
"message" in body &&
|
||||
typeof body.message === "string"
|
||||
? body.message
|
||||
: `Forge API error (${response.status})`;
|
||||
throw new Error(message);
|
||||
}
|
||||
if (
|
||||
typeof body !== "object" ||
|
||||
body === null ||
|
||||
!("default_branch" in body) ||
|
||||
typeof body.default_branch !== "string"
|
||||
) {
|
||||
throw new Error("Forge API response missing default_branch.");
|
||||
}
|
||||
const cloneUrl =
|
||||
typeof body === "object" &&
|
||||
body !== null &&
|
||||
"clone_url" in body &&
|
||||
typeof body.clone_url === "string"
|
||||
? body.clone_url
|
||||
: `${ctx.apiBaseUrl.replace(/\/api\/v1$/, "")}/${ctx.owner}/${ctx.repo}.git`;
|
||||
const description =
|
||||
typeof body === "object" &&
|
||||
body !== null &&
|
||||
"description" in body &&
|
||||
typeof body.description === "string"
|
||||
? body.description
|
||||
: undefined;
|
||||
return {
|
||||
defaultBranch: body.default_branch,
|
||||
cloneUrl,
|
||||
description,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const ADAPTERS: Record<ForgeType, ForgeAdapter> = {
|
||||
github: githubForgeAdapter,
|
||||
forgejo: forgejoForgeAdapter,
|
||||
gitlab: forgejoForgeAdapter,
|
||||
bitbucket: forgejoForgeAdapter,
|
||||
};
|
||||
|
||||
export function getForgeAdapter(forgeType: ForgeType): ForgeAdapter {
|
||||
return ADAPTERS[forgeType];
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ import { invitesRouter } from "@/server/routers/invites";
|
|||
import { auditRouter } from "@/server/routers/audit";
|
||||
import { runsRouter } from "@/server/routers/runs";
|
||||
import { backlogRouter } from "@/server/routers/backlog";
|
||||
import { connectedReposRouter } from "@/server/routers/connected-repos";
|
||||
|
||||
export const appRouter = router({
|
||||
health: healthRouter,
|
||||
|
|
@ -33,6 +34,7 @@ export const appRouter = router({
|
|||
audit: auditRouter,
|
||||
runs: runsRouter,
|
||||
backlog: backlogRouter,
|
||||
connectedRepos: connectedReposRouter,
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
|
|
|
|||
272
apps/web/server/routers/connected-repos.ts
Normal file
272
apps/web/server/routers/connected-repos.ts
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
import { TRPCError } from "@trpc/server";
|
||||
import { and, asc, eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
|
||||
import { connectedRepos } from "@tasks/database/schema";
|
||||
import {
|
||||
connectedRepoSchema,
|
||||
forgeTypeSchema,
|
||||
parseGitRemoteUrl,
|
||||
type ConnectedRepo,
|
||||
} from "@tasks/shared/connected-repos";
|
||||
|
||||
import { getForgeAdapter } from "@/server/lib/forge/adapters";
|
||||
import { router, workspaceProcedure } from "@/server/trpc";
|
||||
import type { WorkspaceProcedureContext } from "@/server/trpc";
|
||||
|
||||
type RepoRow = typeof connectedRepos.$inferSelect;
|
||||
|
||||
function assertWorkspaceAdmin(ctx: WorkspaceProcedureContext): void {
|
||||
if (ctx.workspace.role !== "owner" && ctx.workspace.role !== "admin") {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Only workspace owners and admins can manage connected repos.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function canManageWorkspace(ctx: WorkspaceProcedureContext): boolean {
|
||||
return ctx.workspace.role === "owner" || ctx.workspace.role === "admin";
|
||||
}
|
||||
|
||||
function readToken(envVarName: string | null | undefined): string | undefined {
|
||||
if (!envVarName?.trim()) {
|
||||
return undefined;
|
||||
}
|
||||
return process.env[envVarName]?.trim() || undefined;
|
||||
}
|
||||
|
||||
function mapRepoRow(row: RepoRow): ConnectedRepo {
|
||||
return connectedRepoSchema.parse({
|
||||
id: row.id,
|
||||
workspaceId: row.workspaceId,
|
||||
displayName: row.displayName,
|
||||
gitRemoteUrl: row.gitRemoteUrl,
|
||||
forgeType: row.forgeType,
|
||||
apiBaseUrl: row.apiBaseUrl,
|
||||
owner: row.owner,
|
||||
repoName: row.repoName,
|
||||
defaultBranch: row.defaultBranch,
|
||||
tokenEnvVar: row.tokenEnvVar,
|
||||
metadata: row.metadata,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
const connectInputSchema = z.object({
|
||||
workspace: z.string().min(1),
|
||||
remoteUrl: z.string().min(1),
|
||||
displayName: z.string().min(1).max(200).optional(),
|
||||
tokenEnvVar: z.string().min(1).max(120).nullable().optional(),
|
||||
forgeType: forgeTypeSchema.optional(),
|
||||
});
|
||||
|
||||
export const connectedReposRouter = router({
|
||||
list: workspaceProcedure.query(async ({ ctx }) => {
|
||||
const rows = await ctx.db
|
||||
.select()
|
||||
.from(connectedRepos)
|
||||
.where(eq(connectedRepos.workspaceId, ctx.workspace.id))
|
||||
.orderBy(asc(connectedRepos.displayName));
|
||||
|
||||
return {
|
||||
repos: rows.map(mapRepoRow),
|
||||
canManage: canManageWorkspace(ctx),
|
||||
};
|
||||
}),
|
||||
|
||||
parseRemote: workspaceProcedure
|
||||
.input(
|
||||
z.object({
|
||||
workspace: z.string().min(1),
|
||||
remoteUrl: z.string().min(1),
|
||||
}),
|
||||
)
|
||||
.mutation(({ ctx, input }) => {
|
||||
assertWorkspaceAdmin(ctx);
|
||||
try {
|
||||
return parseGitRemoteUrl(input.remoteUrl);
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: error instanceof Error ? error.message : "Invalid remote URL.",
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
connect: workspaceProcedure.input(connectInputSchema).mutation(async ({ ctx, input }) => {
|
||||
assertWorkspaceAdmin(ctx);
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = parseGitRemoteUrl(input.remoteUrl);
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: error instanceof Error ? error.message : "Invalid remote URL.",
|
||||
});
|
||||
}
|
||||
|
||||
const forgeType = input.forgeType ?? parsed.forgeType;
|
||||
const displayName =
|
||||
input.displayName?.trim() ||
|
||||
`${parsed.owner}/${parsed.repoName}`;
|
||||
|
||||
const [row] = await ctx.db
|
||||
.insert(connectedRepos)
|
||||
.values({
|
||||
workspaceId: ctx.workspace.id,
|
||||
displayName,
|
||||
gitRemoteUrl: parsed.gitRemoteUrl,
|
||||
forgeType,
|
||||
apiBaseUrl: parsed.apiBaseUrl,
|
||||
owner: parsed.owner,
|
||||
repoName: parsed.repoName,
|
||||
tokenEnvVar: input.tokenEnvVar ?? null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (!row) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to create connected repo.",
|
||||
});
|
||||
}
|
||||
|
||||
return mapRepoRow(row);
|
||||
}),
|
||||
|
||||
update: workspaceProcedure
|
||||
.input(
|
||||
z.object({
|
||||
workspace: z.string().min(1),
|
||||
repoId: z.string().uuid(),
|
||||
displayName: z.string().min(1).max(200).optional(),
|
||||
tokenEnvVar: z.string().min(1).max(120).nullable().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
assertWorkspaceAdmin(ctx);
|
||||
|
||||
const [row] = await ctx.db
|
||||
.update(connectedRepos)
|
||||
.set({
|
||||
...(input.displayName !== undefined ? { displayName: input.displayName } : {}),
|
||||
...(input.tokenEnvVar !== undefined ? { tokenEnvVar: input.tokenEnvVar } : {}),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(connectedRepos.id, input.repoId),
|
||||
eq(connectedRepos.workspaceId, ctx.workspace.id),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
|
||||
if (!row) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Connected repo not found." });
|
||||
}
|
||||
|
||||
return mapRepoRow(row);
|
||||
}),
|
||||
|
||||
disconnect: workspaceProcedure
|
||||
.input(
|
||||
z.object({
|
||||
workspace: z.string().min(1),
|
||||
repoId: z.string().uuid(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
assertWorkspaceAdmin(ctx);
|
||||
|
||||
const [row] = await ctx.db
|
||||
.delete(connectedRepos)
|
||||
.where(
|
||||
and(
|
||||
eq(connectedRepos.id, input.repoId),
|
||||
eq(connectedRepos.workspaceId, ctx.workspace.id),
|
||||
),
|
||||
)
|
||||
.returning({ id: connectedRepos.id });
|
||||
|
||||
if (!row) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Connected repo not found." });
|
||||
}
|
||||
|
||||
return { ok: true as const };
|
||||
}),
|
||||
|
||||
testConnection: workspaceProcedure
|
||||
.input(
|
||||
z.object({
|
||||
workspace: z.string().min(1),
|
||||
repoId: z.string().uuid(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
assertWorkspaceAdmin(ctx);
|
||||
|
||||
const [row] = await ctx.db
|
||||
.select()
|
||||
.from(connectedRepos)
|
||||
.where(
|
||||
and(
|
||||
eq(connectedRepos.id, input.repoId),
|
||||
eq(connectedRepos.workspaceId, ctx.workspace.id),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!row) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Connected repo not found." });
|
||||
}
|
||||
|
||||
const started = Date.now();
|
||||
const adapter = getForgeAdapter(forgeTypeSchema.parse(row.forgeType));
|
||||
const token = readToken(row.tokenEnvVar);
|
||||
|
||||
try {
|
||||
const metadata = await adapter.fetchRepoMetadata({
|
||||
apiBaseUrl: row.apiBaseUrl,
|
||||
owner: row.owner,
|
||||
repo: row.repoName,
|
||||
token,
|
||||
});
|
||||
|
||||
await ctx.db
|
||||
.update(connectedRepos)
|
||||
.set({
|
||||
defaultBranch: metadata.defaultBranch,
|
||||
metadata: {
|
||||
...(row.metadata ?? {}),
|
||||
cloneUrl: metadata.cloneUrl,
|
||||
description: metadata.description ?? null,
|
||||
lastTestedAt: new Date().toISOString(),
|
||||
},
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(connectedRepos.id, row.id),
|
||||
eq(connectedRepos.workspaceId, ctx.workspace.id),
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
ok: true as const,
|
||||
latencyMs: Date.now() - started,
|
||||
defaultBranch: metadata.defaultBranch,
|
||||
cloneUrl: metadata.cloneUrl,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false as const,
|
||||
latencyMs: Date.now() - started,
|
||||
error: error instanceof Error ? error.message : "Connection test failed.",
|
||||
};
|
||||
}
|
||||
}),
|
||||
});
|
||||
19
packages/database/migrations/0012_uneven_captain_stacy.sql
Normal file
19
packages/database/migrations/0012_uneven_captain_stacy.sql
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
CREATE TABLE "connected_repos" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"workspace_id" uuid NOT NULL,
|
||||
"display_name" varchar(200) NOT NULL,
|
||||
"git_remote_url" text NOT NULL,
|
||||
"forge_type" varchar(40) NOT NULL,
|
||||
"api_base_url" text NOT NULL,
|
||||
"owner" varchar(200) NOT NULL,
|
||||
"repo_name" varchar(200) NOT NULL,
|
||||
"default_branch" varchar(200),
|
||||
"token_env_var" varchar(120),
|
||||
"metadata" jsonb,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "connected_repos" ADD CONSTRAINT "connected_repos_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "connected_repos_workspace_id_idx" ON "connected_repos" USING btree ("workspace_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "connected_repos_workspace_repo_unique" ON "connected_repos" USING btree ("workspace_id","api_base_url","owner","repo_name");
|
||||
3532
packages/database/migrations/meta/0012_snapshot.json
Normal file
3532
packages/database/migrations/meta/0012_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -85,6 +85,13 @@
|
|||
"when": 1783451144455,
|
||||
"tag": "0011_cool_captain_america",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "7",
|
||||
"when": 1783451965716,
|
||||
"tag": "0012_uneven_captain_stacy",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
47
packages/database/src/schema/connected_repos.ts
Normal file
47
packages/database/src/schema/connected_repos.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import {
|
||||
index,
|
||||
jsonb,
|
||||
pgTable,
|
||||
text,
|
||||
timestamp,
|
||||
uniqueIndex,
|
||||
uuid,
|
||||
varchar,
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
import { workspaces } from "./workspaces";
|
||||
|
||||
/** Workspace-scoped git remote; API tokens live in env vars referenced by token_env_var. */
|
||||
export const connectedRepos = pgTable(
|
||||
"connected_repos",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
workspaceId: uuid("workspace_id")
|
||||
.notNull()
|
||||
.references(() => workspaces.id, { onDelete: "cascade" }),
|
||||
displayName: varchar("display_name", { length: 200 }).notNull(),
|
||||
gitRemoteUrl: text("git_remote_url").notNull(),
|
||||
forgeType: varchar("forge_type", { length: 40 }).notNull(),
|
||||
apiBaseUrl: text("api_base_url").notNull(),
|
||||
owner: varchar("owner", { length: 200 }).notNull(),
|
||||
repoName: varchar("repo_name", { length: 200 }).notNull(),
|
||||
defaultBranch: varchar("default_branch", { length: 200 }),
|
||||
tokenEnvVar: varchar("token_env_var", { length: 120 }),
|
||||
metadata: jsonb("metadata").$type<Record<string, unknown> | null>(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
workspaceIdIdx: index("connected_repos_workspace_id_idx").on(table.workspaceId),
|
||||
workspaceRepoUnique: uniqueIndex("connected_repos_workspace_repo_unique").on(
|
||||
table.workspaceId,
|
||||
table.apiBaseUrl,
|
||||
table.owner,
|
||||
table.repoName,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
|
@ -15,3 +15,4 @@ export * from "./audit";
|
|||
export * from "./agent_runs";
|
||||
export * from "./model_providers";
|
||||
export * from "./role_model_routes";
|
||||
export * from "./connected_repos";
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import { auditLog } from "./audit";
|
|||
import { agentRuns } from "./agent_runs";
|
||||
import { modelProviders } from "./model_providers";
|
||||
import { roleModelRoutes } from "./role_model_routes";
|
||||
import { connectedRepos } from "./connected_repos";
|
||||
|
||||
export const objectRelations = pgTable(
|
||||
"object_relations",
|
||||
|
|
@ -120,6 +121,7 @@ export const workspacesRelations = relations(workspaces, ({ one, many }) => ({
|
|||
markdownBacklogItems: many(markdownBacklogItems),
|
||||
modelProviders: many(modelProviders),
|
||||
roleModelRoutes: many(roleModelRoutes),
|
||||
connectedRepos: many(connectedRepos),
|
||||
}));
|
||||
|
||||
export const objectsRelations = relations(objects, ({ one, many }) => ({
|
||||
|
|
@ -277,6 +279,13 @@ export const modelProvidersRelations = relations(modelProviders, ({ one, many })
|
|||
roleRoutes: many(roleModelRoutes),
|
||||
}));
|
||||
|
||||
export const connectedReposRelations = relations(connectedRepos, ({ one }) => ({
|
||||
workspace: one(workspaces, {
|
||||
fields: [connectedRepos.workspaceId],
|
||||
references: [workspaces.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const roleModelRoutesRelations = relations(roleModelRoutes, ({ one }) => ({
|
||||
workspace: one(workspaces, {
|
||||
fields: [roleModelRoutes.workspaceId],
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./connected-repos": "./src/connected-repos/index.ts",
|
||||
"./model-routing": "./src/model-routing/index.ts",
|
||||
"./types": "./src/types/index.ts",
|
||||
"./utils": "./src/utils/index.ts"
|
||||
|
|
|
|||
11
packages/shared/src/connected-repos/index.ts
Normal file
11
packages/shared/src/connected-repos/index.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
export {
|
||||
connectedRepoSchema,
|
||||
forgeTypeSchema,
|
||||
parsedGitRemoteSchema,
|
||||
repoMetadataSchema,
|
||||
type ConnectedRepo,
|
||||
type ForgeType,
|
||||
type ParsedGitRemote,
|
||||
type RepoMetadata,
|
||||
} from "./schemas";
|
||||
export { parseGitRemoteUrl } from "./parse-git-remote";
|
||||
49
packages/shared/src/connected-repos/parse-git-remote.test.ts
Normal file
49
packages/shared/src/connected-repos/parse-git-remote.test.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { parseGitRemoteUrl } from "./parse-git-remote";
|
||||
|
||||
describe("parseGitRemoteUrl", () => {
|
||||
it("parses GitHub HTTPS remotes", () => {
|
||||
const parsed = parseGitRemoteUrl("https://github.com/stwl-labs/ubiquitous-invention.git");
|
||||
expect(parsed).toMatchObject({
|
||||
forgeType: "github",
|
||||
owner: "stwl-labs",
|
||||
repoName: "ubiquitous-invention",
|
||||
apiBaseUrl: "https://api.github.com",
|
||||
webUrl: "https://github.com/stwl-labs/ubiquitous-invention",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses GitHub SSH remotes", () => {
|
||||
const parsed = parseGitRemoteUrl("git@github.com:stwl-labs/ubiquitous-invention.git");
|
||||
expect(parsed.forgeType).toBe("github");
|
||||
expect(parsed.owner).toBe("stwl-labs");
|
||||
expect(parsed.repoName).toBe("ubiquitous-invention");
|
||||
});
|
||||
|
||||
it("parses self-hosted forge as forgejo", () => {
|
||||
const parsed = parseGitRemoteUrl("https://git.example.com/acme/widget.git");
|
||||
expect(parsed).toMatchObject({
|
||||
forgeType: "forgejo",
|
||||
owner: "acme",
|
||||
repoName: "widget",
|
||||
apiBaseUrl: "https://git.example.com/api/v1",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses GitLab.com remotes", () => {
|
||||
const parsed = parseGitRemoteUrl("https://gitlab.com/group/project");
|
||||
expect(parsed.forgeType).toBe("gitlab");
|
||||
expect(parsed.apiBaseUrl).toBe("https://gitlab.com/api/v4");
|
||||
});
|
||||
|
||||
it("rejects empty input", () => {
|
||||
expect(() => parseGitRemoteUrl(" ")).toThrow(/required/i);
|
||||
});
|
||||
|
||||
it("rejects owner-only paths", () => {
|
||||
expect(() => parseGitRemoteUrl("https://github.com/stwl-labs")).toThrow(
|
||||
/owner and repository/i,
|
||||
);
|
||||
});
|
||||
});
|
||||
115
packages/shared/src/connected-repos/parse-git-remote.ts
Normal file
115
packages/shared/src/connected-repos/parse-git-remote.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import { forgeTypeSchema, type ForgeType, type ParsedGitRemote } from "./schemas";
|
||||
|
||||
const HOST_FORGE_MAP: Record<string, ForgeType> = {
|
||||
"github.com": "github",
|
||||
"gitlab.com": "gitlab",
|
||||
"bitbucket.org": "bitbucket",
|
||||
};
|
||||
|
||||
function detectForgeType(host: string): ForgeType {
|
||||
const normalized = host.toLowerCase();
|
||||
return HOST_FORGE_MAP[normalized] ?? "forgejo";
|
||||
}
|
||||
|
||||
function stripGitSuffix(value: string): string {
|
||||
return value.replace(/\.git$/i, "");
|
||||
}
|
||||
|
||||
function buildParsedRemote(input: {
|
||||
forgeType: ForgeType;
|
||||
host: string;
|
||||
protocol: string;
|
||||
owner: string;
|
||||
repo: string;
|
||||
gitRemoteUrl: string;
|
||||
}): ParsedGitRemote {
|
||||
const owner = stripGitSuffix(input.owner);
|
||||
const repoName = stripGitSuffix(input.repo);
|
||||
const webUrl = `${input.protocol}//${input.host}/${owner}/${repoName}`;
|
||||
|
||||
let apiBaseUrl: string;
|
||||
switch (input.forgeType) {
|
||||
case "github":
|
||||
apiBaseUrl = "https://api.github.com";
|
||||
break;
|
||||
case "gitlab":
|
||||
apiBaseUrl = `${input.protocol}//${input.host}/api/v4`;
|
||||
break;
|
||||
case "bitbucket":
|
||||
apiBaseUrl = "https://api.bitbucket.org/2.0";
|
||||
break;
|
||||
case "forgejo":
|
||||
apiBaseUrl = `${input.protocol}//${input.host}/api/v1`;
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
forgeType: forgeTypeSchema.parse(input.forgeType),
|
||||
gitRemoteUrl: input.gitRemoteUrl,
|
||||
webUrl,
|
||||
apiBaseUrl,
|
||||
owner,
|
||||
repoName,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a pasted git remote (HTTPS or git@host:owner/repo) into forge metadata.
|
||||
*/
|
||||
export function parseGitRemoteUrl(input: string): ParsedGitRemote {
|
||||
const raw = input.trim();
|
||||
if (!raw) {
|
||||
throw new Error("Remote URL is required.");
|
||||
}
|
||||
|
||||
const scpMatch = /^git@([^:]+):([^/]+)\/(.+)$/i.exec(raw);
|
||||
if (scpMatch) {
|
||||
const [, host, owner, repo] = scpMatch;
|
||||
if (!host || !owner || !repo) {
|
||||
throw new Error("Invalid SSH git remote URL.");
|
||||
}
|
||||
const forgeType = detectForgeType(host);
|
||||
return buildParsedRemote({
|
||||
forgeType,
|
||||
host,
|
||||
protocol: "https:",
|
||||
owner,
|
||||
repo,
|
||||
gitRemoteUrl: raw,
|
||||
});
|
||||
}
|
||||
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(raw);
|
||||
} catch {
|
||||
try {
|
||||
url = new URL(`https://${raw}`);
|
||||
} catch {
|
||||
throw new Error("Invalid git remote URL.");
|
||||
}
|
||||
}
|
||||
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
||||
throw new Error("Git remote URL must use http(s) or git@host:owner/repo form.");
|
||||
}
|
||||
|
||||
const segments = url.pathname.split("/").filter(Boolean);
|
||||
if (segments.length < 2) {
|
||||
throw new Error("Remote URL must include owner and repository name.");
|
||||
}
|
||||
|
||||
const owner = segments[0]!;
|
||||
const repo = segments[1]!;
|
||||
const forgeType = detectForgeType(url.hostname);
|
||||
const gitRemoteUrl = url.toString();
|
||||
|
||||
return buildParsedRemote({
|
||||
forgeType,
|
||||
host: url.hostname,
|
||||
protocol: url.protocol,
|
||||
owner,
|
||||
repo,
|
||||
gitRemoteUrl,
|
||||
});
|
||||
}
|
||||
47
packages/shared/src/connected-repos/schemas.ts
Normal file
47
packages/shared/src/connected-repos/schemas.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { z } from "zod";
|
||||
|
||||
export const forgeTypeSchema = z.enum([
|
||||
"github",
|
||||
"forgejo",
|
||||
"gitlab",
|
||||
"bitbucket",
|
||||
]);
|
||||
|
||||
export type ForgeType = z.infer<typeof forgeTypeSchema>;
|
||||
|
||||
export const parsedGitRemoteSchema = z.object({
|
||||
forgeType: forgeTypeSchema,
|
||||
gitRemoteUrl: z.string().url(),
|
||||
webUrl: z.string().url(),
|
||||
apiBaseUrl: z.string().url(),
|
||||
owner: z.string().min(1).max(200),
|
||||
repoName: z.string().min(1).max(200),
|
||||
});
|
||||
|
||||
export type ParsedGitRemote = z.infer<typeof parsedGitRemoteSchema>;
|
||||
|
||||
export const connectedRepoSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
workspaceId: z.string().uuid(),
|
||||
displayName: z.string().min(1).max(200),
|
||||
gitRemoteUrl: z.string().url(),
|
||||
forgeType: forgeTypeSchema,
|
||||
apiBaseUrl: z.string().url(),
|
||||
owner: z.string().min(1).max(200),
|
||||
repoName: z.string().min(1).max(200),
|
||||
defaultBranch: z.string().min(1).max(200).nullable(),
|
||||
tokenEnvVar: z.string().min(1).max(120).nullable(),
|
||||
metadata: z.record(z.unknown()).nullable(),
|
||||
createdAt: z.coerce.date(),
|
||||
updatedAt: z.coerce.date(),
|
||||
});
|
||||
|
||||
export type ConnectedRepo = z.infer<typeof connectedRepoSchema>;
|
||||
|
||||
export const repoMetadataSchema = z.object({
|
||||
defaultBranch: z.string().min(1),
|
||||
cloneUrl: z.string().url(),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
|
||||
export type RepoMetadata = z.infer<typeof repoMetadataSchema>;
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
export * from "./connected-repos";
|
||||
export * from "./model-routing";
|
||||
export * from "./types";
|
||||
export * from "./utils";
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ kind: epic
|
|||
slug: connected-repos
|
||||
title: Connected repos — paste remote URL, forge adapters
|
||||
plan_slug: automation-foundation
|
||||
status: draft
|
||||
status: done
|
||||
priority: P1
|
||||
tenant_id: global
|
||||
cursor_epic_id: null
|
||||
|
|
@ -40,9 +40,12 @@ See automation foundation discussion: Vercel/Coolify-style “show me your repo.
|
|||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Admin pastes Forgejo URL; Echodo stores link and detects `forge_type`.
|
||||
- [ ] Worker/orchestrator can read `git_remote_url` + default branch from API.
|
||||
- [x] Admin pastes Forgejo URL; Echodo stores link and detects `forge_type`.
|
||||
- [x] Worker/orchestrator can read `git_remote_url` + default branch from API.
|
||||
|
||||
## Tasks
|
||||
|
||||
_To be decomposed when epic graduates to `ready`._
|
||||
- [x] [Task-shared-forge-schemas](./Task-shared-forge-schemas.md)
|
||||
- [x] [Task-db-connected-repos-migration](./Task-db-connected-repos-migration.md)
|
||||
- [x] [Task-forge-adapters-trpc](./Task-forge-adapters-trpc.md)
|
||||
- [x] [Task-settings-connected-repos-ui](./Task-settings-connected-repos-ui.md)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,23 @@
|
|||
---
|
||||
kind: task
|
||||
slug: db-connected-repos-migration
|
||||
title: Drizzle schema and migration for connected_repos
|
||||
plan_slug: automation-foundation
|
||||
epic_slug: connected-repos
|
||||
status: done
|
||||
priority: P1
|
||||
tenant_id: global
|
||||
owner: unassigned
|
||||
cursor_todo_id: null
|
||||
updated_at: "2026-07-07"
|
||||
---
|
||||
|
||||
# Task summary
|
||||
|
||||
Workspace-scoped `connected_repos` table with forge metadata and env-var token reference.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] Migration `0012` generated via `pnpm db:generate`
|
||||
- [x] Unique constraint per workspace + api base + owner + repo
|
||||
- [x] `workspace_id` index
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
---
|
||||
kind: task
|
||||
slug: forge-adapters-trpc
|
||||
title: Forge adapters and connectedRepos tRPC router
|
||||
plan_slug: automation-foundation
|
||||
epic_slug: connected-repos
|
||||
status: done
|
||||
priority: P1
|
||||
tenant_id: global
|
||||
owner: unassigned
|
||||
cursor_todo_id: null
|
||||
updated_at: "2026-07-07"
|
||||
---
|
||||
|
||||
# Task summary
|
||||
|
||||
GitHub and Forgejo/Gitea API adapters plus `api.connectedRepos.*` CRUD and test connection.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] `getForgeAdapter()` for github and forgejo
|
||||
- [x] `testConnection` fetches default branch and persists metadata
|
||||
- [x] Admin-only mutations; all queries filter by `workspace_id`
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
---
|
||||
kind: task
|
||||
slug: settings-connected-repos-ui
|
||||
title: Settings UI — connect / disconnect / test repo
|
||||
plan_slug: automation-foundation
|
||||
epic_slug: connected-repos
|
||||
status: done
|
||||
priority: P1
|
||||
tenant_id: global
|
||||
owner: unassigned
|
||||
cursor_todo_id: null
|
||||
updated_at: "2026-07-07"
|
||||
---
|
||||
|
||||
# Task summary
|
||||
|
||||
Paste-remote-URL UX at `/{workspaceSlug}/settings/repos` with parse preview and connection test.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] List connected repos with forge badge and default branch
|
||||
- [x] Connect dialog with parse + token env var field
|
||||
- [x] Top header link under workspace menu
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
---
|
||||
kind: task
|
||||
slug: shared-forge-schemas
|
||||
title: Shared forge types, connected repo schemas, and parseGitRemoteUrl
|
||||
plan_slug: automation-foundation
|
||||
epic_slug: connected-repos
|
||||
status: done
|
||||
priority: P1
|
||||
tenant_id: global
|
||||
owner: unassigned
|
||||
cursor_todo_id: null
|
||||
updated_at: "2026-07-07"
|
||||
---
|
||||
|
||||
# Task summary
|
||||
|
||||
Zod schemas and pure URL parser for pasted git remotes (GitHub, Forgejo, GitLab, Bitbucket).
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] `parseGitRemoteUrl` handles HTTPS and `git@host:owner/repo` forms
|
||||
- [x] Vitest coverage for GitHub, Forgejo self-hosted, GitLab.com
|
||||
- [x] Exported from `@tasks/shared/connected-repos`
|
||||
|
|
@ -164,7 +164,7 @@ flowchart LR
|
|||
|---|------|--------|----------|------------|
|
||||
| 1 | [Model provider registry](./Epic-model-provider-registry/Epic-model-provider-registry.md) | **ready** (6 tasks) | `resolveModel()`, Settings AI pages, run model logging | — |
|
||||
| 2 | [Pipeline convoy scaffolding](./Epic-pipeline-convoy-scaffolding/Epic-pipeline-convoy-scaffolding.md) | draft | `.convoys/`, `log-convoy-event.sh`, `convoy-planning.mdc` — dogfood metrics | — (parallel with 1) |
|
||||
| 3 | [Connected repos](./Epic-connected-repos/Epic-connected-repos.md) | draft | `repo_links` table, “paste remote URL”, forge adapter interface | — |
|
||||
| 3 | [Connected repos](./Epic-connected-repos/Epic-connected-repos.md) | done | `repo_links` table, “paste remote URL”, forge adapter interface | — |
|
||||
| 4 | [Convoy lifecycle MCP](./Epic-convoy-lifecycle-mcp/Epic-convoy-lifecycle-mcp.md) | draft | `create_convoy`, `transition_convoy_status`, state machine §7, `convoy_events` | 2, 3 |
|
||||
| 5 | [Mission control gates UI](./Epic-mission-control-gates-ui/Epic-mission-control-gates-ui.md) | draft | Convoy kanban, gate inbox, approve/deny/revise, gate co-pilot chat | 4 |
|
||||
| 6 | [Orchestrator](./Epic-apps-orchestrator/Epic-apps-orchestrator.md) | draft | `apps/orchestrator`, Redis queue, role dispatch, pause at gates | 1, 3, 4 |
|
||||
|
|
|
|||
Loading…
Reference in a new issue