Canvas: Generate Github nodes out of the dodo-app config

Change-Id: Ifc5b09deb39352a3025f7ea66ce39b421daac94d
diff --git a/apps/canvas/config/src/config.test.ts b/apps/canvas/config/src/config.test.ts
new file mode 100644
index 0000000..5a927df
--- /dev/null
+++ b/apps/canvas/config/src/config.test.ts
@@ -0,0 +1,65 @@
+import { configToGraph } from "./config.js";
+import { Config } from "./types.js";
+import { Network } from "./graph.js";
+import { GithubRepository } from "./github.js";
+
+describe("configToGraph", () => {
+	it("should create a simple graph from a basic service configuration", () => {
+		const config: Config = {
+			service: [
+				{
+					nodeId: "service-1",
+					name: "my-app",
+					type: "nodejs:24.0.2",
+					source: {
+						repository: "git@github.com:user/repo.git",
+						branch: "main",
+						rootDir: "/",
+					},
+					ports: [{ name: "http", value: 3000, protocol: "TCP" }],
+				},
+			],
+		};
+
+		const networks: Network[] = [
+			{
+				name: "Public",
+				domain: "v1.dodo.cloud",
+				hasAuth: true,
+			},
+		];
+
+		const repos: GithubRepository[] = [
+			{
+				id: 123,
+				name: "repo",
+				full_name: "user/repo",
+				html_url: "https://github.com/user/repo",
+				ssh_url: "git@github.com:user/repo.git",
+			},
+		];
+
+		const graph = configToGraph(config, networks, repos);
+
+		// Expect one service node, one repo node, and one network node
+		expect(graph.nodes).toHaveLength(3);
+
+		const serviceNode = graph.nodes.find((n) => n.type === "app");
+		expect(serviceNode).toBeDefined();
+		expect(serviceNode?.data.label).toBe("my-app");
+
+		const repoNode = graph.nodes.find((n) => n.type === "github");
+		expect(repoNode).toBeDefined();
+		expect(repoNode?.data.repository?.sshURL).toBe("git@github.com:user/repo.git");
+
+		const networkNode = graph.nodes.find((n) => n.type === "network");
+		expect(networkNode).toBeDefined();
+		expect(networkNode?.data.domain).toBe("v1.dodo.cloud");
+
+		// Expect one edge from repo to service
+		expect(graph.edges).toHaveLength(1);
+		const repoEdge = graph.edges.find((e) => e.source === repoNode?.id && e.target === serviceNode?.id);
+		expect(repoEdge).toBeDefined();
+		console.log(JSON.stringify(graph, null, 2));
+	});
+});
diff --git a/apps/canvas/config/src/config.ts b/apps/canvas/config/src/config.ts
index 53fd64c..dc3b122 100644
--- a/apps/canvas/config/src/config.ts
+++ b/apps/canvas/config/src/config.ts
@@ -4,6 +4,7 @@
 	Env,
 	GatewayHttpsNode,
 	GatewayTCPNode,
+	GithubNode,
 	MongoDBNode,
 	Network,
 	NetworkNode,
@@ -15,6 +16,7 @@
 import { Edge } from "@xyflow/react";
 import { v4 as uuidv4 } from "uuid";
 import { ConfigWithInput, Ingress, Service, Volume, PostgreSQL, MongoDB, Config, PortDomain } from "./types.js";
+import { GithubRepository } from "./github.js";
 
 export function generateDodoConfig(appId: string | undefined, nodes: AppNode[], env: Env): ConfigWithInput | null {
 	try {
@@ -170,7 +172,7 @@
 	edges: Edge[];
 };
 
-export function configToGraph(config: Config, networks: Network[], current?: Graph): Graph {
+export function configToGraph(config: Config, networks: Network[], repos: GithubRepository[], current?: Graph): Graph {
 	if (current == null) {
 		current = { nodes: [], edges: [] };
 	}
@@ -181,6 +183,39 @@
 	if (networks.length === 0) {
 		return ret;
 	}
+	const repoNodes = (config.service || [])
+		.filter((s) => s.source.repository != null)
+		.map((s): GithubNode | null => {
+			const existing = current.nodes.find(
+				(n) => n.type === "github" && n.data.repository?.sshURL === s.source.repository,
+			);
+			const repo = repos.find((r) => r.ssh_url === s.source.repository);
+			if (repo == null) {
+				return null;
+			}
+			return {
+				id: existing != null ? existing.id : uuidv4(),
+				type: "github",
+				data: {
+					label: repo.full_name,
+					repository: {
+						id: repo.id,
+						sshURL: repo.ssh_url,
+						fullName: repo.full_name,
+					},
+					envVars: [],
+					ports: [],
+				},
+				position:
+					existing != null
+						? existing.position
+						: {
+								x: 0,
+								y: 0,
+							},
+			};
+		})
+		.filter((n) => n != null);
 	const networkNodes = networks.map((n): NetworkNode => {
 		let existing: NetworkNode | undefined = undefined;
 		existing = current.nodes
@@ -210,6 +245,12 @@
 				label: s.name,
 				type: s.type,
 				env: [],
+				repository: {
+					id: repoNodes.find((r) => r.data.repository?.sshURL === s.source.repository)!.data.repository!.id,
+					repoNodeId: repoNodes.find((r) => r.data.repository?.sshURL === s.source.repository)!.id,
+					branch: s.source.branch,
+					rootDir: s.source.rootDir,
+				},
 				ports: (s.ports || []).map(
 					(p): Port => ({
 						id: uuidv4(),
@@ -494,7 +535,7 @@
 		});
 	ret.nodes = [
 		...networkNodes,
-		...ret.nodes,
+		...repoNodes,
 		...(services || []),
 		...(serviceGateways || []),
 		...(volumes || []),
@@ -593,6 +634,15 @@
 			},
 		];
 	});
-	ret.edges = [...envVarEdges, ...exposureEdges, ...ingressEdges];
+	const repoEdges = (services || []).map((s): Edge => {
+		return {
+			id: uuidv4(),
+			source: s.data.repository!.repoNodeId!,
+			sourceHandle: "repository",
+			target: s.id,
+			targetHandle: "repository",
+		};
+	});
+	ret.edges = [...repoEdges, ...envVarEdges, ...exposureEdges, ...ingressEdges];
 	return ret;
 }
diff --git a/apps/canvas/config/src/github.ts b/apps/canvas/config/src/github.ts
new file mode 100644
index 0000000..4040487
--- /dev/null
+++ b/apps/canvas/config/src/github.ts
@@ -0,0 +1,35 @@
+import { z } from "zod";
+
+export const GithubRepositorySchema = z.object({
+	id: z.number(),
+	name: z.string(),
+	full_name: z.string(),
+	html_url: z.string(),
+	ssh_url: z.string(),
+});
+
+export const GithubRepositoriesSchema = z.array(GithubRepositorySchema);
+
+export const DeployKeysSchema = z.array(
+	z.object({
+		id: z.number(),
+		key: z.string(),
+	}),
+);
+
+export const WebhookSchema = z.object({
+	id: z.number(),
+	config: z.object({
+		url: z.string().optional(), // url might not always be present
+		content_type: z.string().optional(),
+	}),
+	events: z.array(z.string()),
+	active: z.boolean(),
+});
+
+export const ListWebhooksResponseSchema = z.array(WebhookSchema);
+export type DeployKeys = z.infer<typeof DeployKeysSchema>;
+export type Webhook = z.infer<typeof WebhookSchema>;
+export type ListWebhooksResponse = z.infer<typeof ListWebhooksResponseSchema>;
+export type GithubRepository = z.infer<typeof GithubRepositorySchema>;
+export type GithubRepositories = z.infer<typeof GithubRepositoriesSchema>;
diff --git a/apps/canvas/config/src/index.ts b/apps/canvas/config/src/index.ts
index d064f1c..f668b5b 100644
--- a/apps/canvas/config/src/index.ts
+++ b/apps/canvas/config/src/index.ts
@@ -49,3 +49,12 @@
 } from "./graph.js";
 
 export { generateDodoConfig, configToGraph } from "./config.js";
+
+export {
+	GithubRepository,
+	GithubRepositorySchema,
+	GithubRepositoriesSchema,
+	DeployKeysSchema,
+	ListWebhooksResponseSchema,
+	DeployKeys,
+} from "./github.js";