feat(automation): connected repos (Epic 3)

feat(automation): connected repos (Epic 3)
This commit is contained in:
varutasu 2026-07-07 14:47:56 -05:00 committed by GitHub
commit 0604d66008
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 4680 additions and 5 deletions

View 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>
);
}

View file

@ -10,6 +10,7 @@ import {
CheckSquare, CheckSquare,
ChevronDown, ChevronDown,
FileText, FileText,
GitBranch,
LayoutGrid, LayoutGrid,
LayoutTemplate, LayoutTemplate,
LogOut, LogOut,
@ -163,6 +164,15 @@ export function TopHeader({ onOpenSearch, onQuickAction }: TopHeaderProps) {
<Sparkles className="size-4 text-muted-foreground" /> <Sparkles className="size-4 text-muted-foreground" />
Role routing Role routing
</DropdownMenuItem> </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 <DropdownMenuItem
className="gap-2.5 px-4" className="gap-2.5 px-4"
onSelect={() => onSelect={() =>

View 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];
}

View file

@ -15,6 +15,7 @@ import { invitesRouter } from "@/server/routers/invites";
import { auditRouter } from "@/server/routers/audit"; import { auditRouter } from "@/server/routers/audit";
import { runsRouter } from "@/server/routers/runs"; import { runsRouter } from "@/server/routers/runs";
import { backlogRouter } from "@/server/routers/backlog"; import { backlogRouter } from "@/server/routers/backlog";
import { connectedReposRouter } from "@/server/routers/connected-repos";
export const appRouter = router({ export const appRouter = router({
health: healthRouter, health: healthRouter,
@ -33,6 +34,7 @@ export const appRouter = router({
audit: auditRouter, audit: auditRouter,
runs: runsRouter, runs: runsRouter,
backlog: backlogRouter, backlog: backlogRouter,
connectedRepos: connectedReposRouter,
}); });
export type AppRouter = typeof appRouter; export type AppRouter = typeof appRouter;

View 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.",
};
}
}),
});

View 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");

File diff suppressed because it is too large Load diff

View file

@ -85,6 +85,13 @@
"when": 1783451144455, "when": 1783451144455,
"tag": "0011_cool_captain_america", "tag": "0011_cool_captain_america",
"breakpoints": true "breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1783451965716,
"tag": "0012_uneven_captain_stacy",
"breakpoints": true
} }
] ]
} }

View 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,
),
}),
);

View file

@ -15,3 +15,4 @@ export * from "./audit";
export * from "./agent_runs"; export * from "./agent_runs";
export * from "./model_providers"; export * from "./model_providers";
export * from "./role_model_routes"; export * from "./role_model_routes";
export * from "./connected_repos";

View file

@ -21,6 +21,7 @@ import { auditLog } from "./audit";
import { agentRuns } from "./agent_runs"; import { agentRuns } from "./agent_runs";
import { modelProviders } from "./model_providers"; import { modelProviders } from "./model_providers";
import { roleModelRoutes } from "./role_model_routes"; import { roleModelRoutes } from "./role_model_routes";
import { connectedRepos } from "./connected_repos";
export const objectRelations = pgTable( export const objectRelations = pgTable(
"object_relations", "object_relations",
@ -120,6 +121,7 @@ export const workspacesRelations = relations(workspaces, ({ one, many }) => ({
markdownBacklogItems: many(markdownBacklogItems), markdownBacklogItems: many(markdownBacklogItems),
modelProviders: many(modelProviders), modelProviders: many(modelProviders),
roleModelRoutes: many(roleModelRoutes), roleModelRoutes: many(roleModelRoutes),
connectedRepos: many(connectedRepos),
})); }));
export const objectsRelations = relations(objects, ({ one, many }) => ({ export const objectsRelations = relations(objects, ({ one, many }) => ({
@ -277,6 +279,13 @@ export const modelProvidersRelations = relations(modelProviders, ({ one, many })
roleRoutes: many(roleModelRoutes), roleRoutes: many(roleModelRoutes),
})); }));
export const connectedReposRelations = relations(connectedRepos, ({ one }) => ({
workspace: one(workspaces, {
fields: [connectedRepos.workspaceId],
references: [workspaces.id],
}),
}));
export const roleModelRoutesRelations = relations(roleModelRoutes, ({ one }) => ({ export const roleModelRoutesRelations = relations(roleModelRoutes, ({ one }) => ({
workspace: one(workspaces, { workspace: one(workspaces, {
fields: [roleModelRoutes.workspaceId], fields: [roleModelRoutes.workspaceId],

View file

@ -7,6 +7,7 @@
"types": "./src/index.ts", "types": "./src/index.ts",
"exports": { "exports": {
".": "./src/index.ts", ".": "./src/index.ts",
"./connected-repos": "./src/connected-repos/index.ts",
"./model-routing": "./src/model-routing/index.ts", "./model-routing": "./src/model-routing/index.ts",
"./types": "./src/types/index.ts", "./types": "./src/types/index.ts",
"./utils": "./src/utils/index.ts" "./utils": "./src/utils/index.ts"

View 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";

View 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,
);
});
});

View 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,
});
}

View 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>;

View file

@ -1,3 +1,4 @@
export * from "./connected-repos";
export * from "./model-routing"; export * from "./model-routing";
export * from "./types"; export * from "./types";
export * from "./utils"; export * from "./utils";

View file

@ -3,7 +3,7 @@ kind: epic
slug: connected-repos slug: connected-repos
title: Connected repos — paste remote URL, forge adapters title: Connected repos — paste remote URL, forge adapters
plan_slug: automation-foundation plan_slug: automation-foundation
status: draft status: done
priority: P1 priority: P1
tenant_id: global tenant_id: global
cursor_epic_id: null cursor_epic_id: null
@ -40,9 +40,12 @@ See automation foundation discussion: Vercel/Coolify-style “show me your repo.
## Acceptance criteria ## Acceptance criteria
- [ ] Admin pastes Forgejo URL; Echodo stores link and detects `forge_type`. - [x] Admin pastes Forgejo URL; Echodo stores link and detects `forge_type`.
- [ ] Worker/orchestrator can read `git_remote_url` + default branch from API. - [x] Worker/orchestrator can read `git_remote_url` + default branch from API.
## Tasks ## 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)

View file

@ -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

View file

@ -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`

View file

@ -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

View file

@ -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`

View file

@ -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 | — | | 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) | | 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 | | 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 | | 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 | | 6 | [Orchestrator](./Epic-apps-orchestrator/Epic-apps-orchestrator.md) | draft | `apps/orchestrator`, Redis queue, role dispatch, pause at gates | 1, 3, 4 |