Canvas: Github repository picker

Change-Id: Icb8f2ffbef2894b2fdea4e4c13c74c0f4970506b
diff --git a/apps/canvas/back/github.ts b/apps/canvas/back/github.ts
new file mode 100644
index 0000000..f234788
--- /dev/null
+++ b/apps/canvas/back/github.ts
@@ -0,0 +1,51 @@
+import axios from 'axios';
+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 type GithubRepository = z.infer<typeof GithubRepositorySchema>;
+
+export class GithubClient {
+    private token: string;
+
+    constructor(token: string) {
+        this.token = token;
+    }
+
+    private getHeaders() {
+        return {
+            "Authorization": `Bearer ${this.token}`,
+            "Accept": "application/vnd.github.v3+json",
+        };
+    }
+
+    async getRepositories(): Promise<GithubRepository[]> {
+        const response = await axios.get("https://api.github.com/user/repos", {
+            headers: this.getHeaders(),
+        });
+        return z.array(GithubRepositorySchema).parse(response.data);
+    }
+
+    async addDeployKey(repoPath: string, key: string) {
+        const sshUrl = repoPath;
+        const repoOwnerAndName = sshUrl.replace('git@github.com:', '').replace('.git', '');
+
+        await axios.post(
+            `https://api.github.com/repos/${repoOwnerAndName}/keys`,
+            {
+                title: "dodo",
+                key: key,
+                read_only: true
+            },
+            {
+                headers: this.getHeaders(),
+            }
+        );
+    }
+} 
\ No newline at end of file
diff --git a/apps/canvas/back/index.ts b/apps/canvas/back/index.ts
index 258bad9..cdcf9a0 100644
--- a/apps/canvas/back/index.ts
+++ b/apps/canvas/back/index.ts
@@ -2,6 +2,7 @@
 import express from "express";
 import { env } from "node:process";
 import axios from "axios";
+import { GithubClient } from "./github";
 
 const db = new PrismaClient();
 
@@ -134,7 +135,7 @@
                 where: {
                     id: projectId,
                 },
-            });    
+            });
         }
         resp.status(200);
     } catch (e) {
@@ -155,6 +156,8 @@
             },
             select: {
                 instanceId: true,
+                githubToken: true,
+                deployKey: true,
             },
         });
         if (p === null) {
@@ -178,6 +181,7 @@
                     config: req.body.config,
                 },
             });
+            console.log(r);
             if (r.status === 200) {
                 await db.project.update({
                     where: {
@@ -190,6 +194,20 @@
                         deployKey: r.data.deployKey,
                     },
                 });
+
+                if (p.githubToken && r.data.deployKey) {
+                    const stateObj = JSON.parse(JSON.parse(state.toString()));
+                    const githubNodes = stateObj.nodes.filter((n: any) => n.type === "github" && n.data?.repository?.id);
+
+                    const github = new GithubClient(p.githubToken);
+                    for (const node of githubNodes) {
+                        try {
+                            await github.addDeployKey(node.data.repository.sshURL, r.data.deployKey);
+                        } catch (error) {
+                            console.error(`Failed to add deploy key to repository ${node.data.repository.sshURL}:`, error);
+                        }
+                    }
+                }
             }
         } else {
             r = await axios.request({
@@ -255,6 +273,94 @@
     }
 };
 
+const handleGithubRepos: express.Handler = async (req, resp) => {
+    try {
+        const projectId = Number(req.params["projectId"]);
+        const project = await db.project.findUnique({
+            where: { id: projectId },
+            select: { githubToken: true }
+        });
+
+        if (!project?.githubToken) {
+            resp.status(400);
+            resp.write(JSON.stringify({ error: "GitHub token not configured" }));
+            return;
+        }
+
+        const github = new GithubClient(project.githubToken);
+        const repositories = await github.getRepositories();
+
+        resp.status(200);
+        resp.header("Content-Type", "application/json");
+        resp.write(JSON.stringify(repositories));
+    } catch (e) {
+        console.log(e);
+        resp.status(500);
+        resp.write(JSON.stringify({ error: "Failed to fetch repositories" }));
+    } finally {
+        resp.end();
+    }
+};
+
+const handleUpdateGithubToken: express.Handler = async (req, resp) => {
+    try {
+        const projectId = Number(req.params["projectId"]);
+        const { githubToken } = req.body;
+
+        await db.project.update({
+            where: { id: projectId },
+            data: { githubToken },
+        });
+
+        resp.status(200);
+    } catch (e) {
+        console.log(e);
+        resp.status(500);
+    } finally {
+        resp.end();
+    }
+};
+
+const handleEnv: express.Handler = async (req, resp) => {
+    const projectId = Number(req.params["projectId"]);
+    try {
+        const project = await db.project.findUnique({
+            where: { id: projectId },
+            select: {
+                deployKey: true,
+                githubToken: true
+            }
+        });
+
+        if (!project) {
+            resp.status(404);
+            resp.write(JSON.stringify({ error: "Project not found" }));
+            return;
+        }
+
+        resp.status(200);
+        resp.write(JSON.stringify({
+            deployKey: project.deployKey,
+            integrations: {
+                github: !!project.githubToken,
+            },
+            networks: [{
+                name: "Public",
+                domain: "v1.dodo.cloud",
+            }, {
+                name: "Private",
+                domain: "p.v1.dodo.cloud",
+            }]
+        }));
+    } catch (error) {
+        console.error("Error checking integrations:", error);
+        resp.status(500);
+        resp.write(JSON.stringify({ error: "Internal server error" }));
+    } finally {
+        resp.end();
+    }
+};
+
 async function start() {
     await db.$connect();
     const app = express();
@@ -266,6 +372,9 @@
     app.delete("/api/project/:projectId", handleDelete);
     app.get("/api/project", handleProjectAll);
     app.post("/api/project", handleProjectCreate);
+    app.get("/api/project/:projectId/repos/github", handleGithubRepos);
+    app.post("/api/project/:projectId/github-token", handleUpdateGithubToken);
+    app.get("/api/project/:projectId/env", handleEnv);
     app.use("/", express.static("../front/dist"));
     app.listen(env.DODO_PORT_WEB, () => {
         console.log("started");
diff --git a/apps/canvas/back/package-lock.json b/apps/canvas/back/package-lock.json
index cf48f57..89b0272 100644
--- a/apps/canvas/back/package-lock.json
+++ b/apps/canvas/back/package-lock.json
@@ -12,7 +12,8 @@
         "@prisma/client": "^6.6.0",
         "axios": "^1.8.4",
         "express": "^4.21.1",
-        "sqlite3": "^5.1.7"
+        "sqlite3": "^5.1.7",
+        "zod": "^3.24.4"
       },
       "devDependencies": {
         "@types/express": "^5.0.0",
@@ -2941,6 +2942,14 @@
       "version": "4.0.0",
       "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
       "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
+    },
+    "node_modules/zod": {
+      "version": "3.24.4",
+      "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.4.tgz",
+      "integrity": "sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg==",
+      "funding": {
+        "url": "https://github.com/sponsors/colinhacks"
+      }
     }
   }
 }
diff --git a/apps/canvas/back/package.json b/apps/canvas/back/package.json
index b05fb1e..360c453 100644
--- a/apps/canvas/back/package.json
+++ b/apps/canvas/back/package.json
@@ -13,7 +13,8 @@
     "@prisma/client": "^6.6.0",
     "axios": "^1.8.4",
     "express": "^4.21.1",
-    "sqlite3": "^5.1.7"
+    "sqlite3": "^5.1.7",
+    "zod": "^3.24.4"
   },
   "devDependencies": {
     "@types/express": "^5.0.0",
diff --git a/apps/canvas/back/prisma/migrations/20250507131125_add_github_token/migration.sql b/apps/canvas/back/prisma/migrations/20250507131125_add_github_token/migration.sql
new file mode 100644
index 0000000..e9eac71
--- /dev/null
+++ b/apps/canvas/back/prisma/migrations/20250507131125_add_github_token/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "Project" ADD COLUMN "githubToken" TEXT;
diff --git a/apps/canvas/back/prisma/schema.prisma b/apps/canvas/back/prisma/schema.prisma
index e19e84b..8dd7ceb 100644
--- a/apps/canvas/back/prisma/schema.prisma
+++ b/apps/canvas/back/prisma/schema.prisma
@@ -21,4 +21,5 @@
   draft Bytes?
   instanceId String?
   deployKey String?
+  githubToken String?
 }
\ No newline at end of file