Canvas: Auto register github webhook upon deploy

Change-Id: I0321a032014d58016926189869b0fc24ad7ee2b1
diff --git a/apps/canvas/back/src/index.ts b/apps/canvas/back/src/index.ts
index d251998..76e250a 100644
--- a/apps/canvas/back/src/index.ts
+++ b/apps/canvas/back/src/index.ts
@@ -200,11 +200,22 @@
 	return { toAdd, toDelete };
 }
 
-async function manageGithubKeys(github: GithubClient, deployKey: string, diff: RepoDiff): Promise<void> {
+async function manageGithubRepos(
+	github: GithubClient,
+	diff: RepoDiff,
+	deployKey: string,
+	publicAddr?: string,
+): Promise<void> {
+	console.log(publicAddr);
 	for (const repoUrl of diff.toDelete ?? []) {
 		try {
 			await github.removeDeployKey(repoUrl, deployKey);
 			console.log(`Removed deploy key from repository ${repoUrl}`);
+			if (publicAddr) {
+				const webhookCallbackUrl = `${publicAddr}/api/webhook/github/push`;
+				await github.removePushWebhook(repoUrl, webhookCallbackUrl);
+				console.log(`Removed push webhook from repository ${repoUrl}`);
+			}
 		} catch (error) {
 			console.error(`Failed to remove deploy key from repository ${repoUrl}:`, error);
 		}
@@ -213,6 +224,11 @@
 		try {
 			await github.addDeployKey(repoUrl, deployKey);
 			console.log(`Added deploy key to repository ${repoUrl}`);
+			if (publicAddr) {
+				const webhookCallbackUrl = `${publicAddr}/api/webhook/github/push`;
+				await github.addPushWebhook(repoUrl, webhookCallbackUrl);
+				console.log(`Added push webhook to repository ${repoUrl}`);
+			}
 		} catch (error) {
 			console.error(`Failed to add deploy key from repository ${repoUrl}:`, error);
 		}
@@ -283,7 +299,7 @@
 			}
 			if (diff && p.githubToken && deployKey) {
 				const github = new GithubClient(p.githubToken);
-				await manageGithubKeys(github, deployKey, diff);
+				await manageGithubRepos(github, diff, deployKey, env.PUBLIC_ADDR);
 			}
 			resp.status(200);
 		} catch (error) {
@@ -372,7 +388,7 @@
 				const github = new GithubClient(p.githubToken);
 				const repos = extractGithubRepos(p.state);
 				const diff = { toDelete: repos, toAdd: [] };
-				await manageGithubKeys(github, p.deployKey, diff);
+				await manageGithubRepos(github, diff, p.deployKey, env.PUBLIC_ADDR);
 			} catch (error) {
 				console.error("Error removing GitHub deploy keys:", error);
 			}
@@ -589,45 +605,44 @@
 	}
 };
 
+async function reloadProject(projectId: number): Promise<boolean> {
+	const projectWorkers = workers.get(projectId) || [];
+	const workerCount = projectWorkers.length;
+	if (workerCount === 0) {
+		return true;
+	}
+	const results = await Promise.all(
+		projectWorkers.map(async (workerAddress) => {
+			const resp = await axios.post(`${workerAddress}/update`);
+			return resp.status === 200;
+		}),
+	);
+	return results.reduce((acc, curr) => acc && curr, true);
+}
+
 const handleReload: express.Handler = async (req, resp) => {
 	try {
 		const projectId = Number(req.params["projectId"]);
-		const projectWorkers = workers.get(projectId) || [];
-		const project = await db.project.findUnique({
+		const projectAuth = await db.project.findFirst({
 			where: {
 				id: projectId,
 				userId: resp.locals.userId,
 			},
+			select: { id: true },
 		});
-		if (project == null) {
+		if (!projectAuth) {
 			resp.status(404);
-			resp.write(JSON.stringify({ error: "Project not found" }));
 			return;
 		}
-		if (projectWorkers.length === 0) {
-			resp.status(404);
-			resp.write(JSON.stringify({ error: "No workers registered for this project" }));
-			return;
+		const success = await reloadProject(projectId);
+		if (success) {
+			resp.status(200);
+		} else {
+			resp.status(500);
 		}
-		await Promise.all(
-			projectWorkers.map(async (workerAddress) => {
-				try {
-					const updateEndpoint = `${workerAddress}/update`;
-					await axios.post(updateEndpoint);
-					// eslint-disable-next-line @typescript-eslint/no-explicit-any
-				} catch (error: any) {
-					console.log(`Failed to update worker ${workerAddress}: ${error.message || "Unknown error"}`);
-				}
-			}),
-		);
-		resp.status(200);
-		resp.write(JSON.stringify({ success: true }));
 	} catch (e) {
-		console.log(e);
+		console.error(e);
 		resp.status(500);
-		resp.write(JSON.stringify({ error: "Failed to reload workers" }));
-	} finally {
-		resp.end();
 	}
 };
 
@@ -645,36 +660,98 @@
 	next();
 };
 
+const handleGithubPushWebhook: express.Handler = async (req, resp) => {
+	try {
+		// TODO(gio): Implement GitHub signature verification for security
+		const webhookSchema = z.object({
+			repository: z.object({
+				ssh_url: z.string(),
+			}),
+		});
+
+		const result = webhookSchema.safeParse(req.body);
+		if (!result.success) {
+			console.warn("GitHub webhook: Invalid payload:", result.error.issues);
+			resp.status(400).json({ error: "Invalid webhook payload" });
+			return;
+		}
+		const { ssh_url: addr } = result.data.repository;
+		const allProjects = await db.project.findMany({
+			select: {
+				id: true,
+				state: true,
+			},
+			where: {
+				instanceId: {
+					not: null,
+				},
+			},
+		});
+		// TODO(gio): This should run in background
+		new Promise<boolean>((resolve, reject) => {
+			setTimeout(() => {
+				const projectsToReloadIds: number[] = [];
+				for (const project of allProjects) {
+					if (project.state && project.state.length > 0) {
+						const projectRepos = extractGithubRepos(project.state);
+						if (projectRepos.includes(addr)) {
+							projectsToReloadIds.push(project.id);
+						}
+					}
+				}
+				Promise.all(projectsToReloadIds.map((id) => reloadProject(id)))
+					.then((results) => {
+						resolve(results.reduce((acc, curr) => acc && curr, true));
+					})
+					// eslint-disable-next-line @typescript-eslint/no-explicit-any
+					.catch((reason: any) => reject(reason));
+			}, 10);
+		});
+		// eslint-disable-next-line @typescript-eslint/no-explicit-any
+	} catch (error: any) {
+		console.error(error);
+		resp.status(500);
+	}
+};
+
 async function start() {
 	await db.$connect();
 	const app = express();
-	app.use(express.json());
-	app.use(auth);
-	app.post("/api/project/:projectId/saved", handleSave);
-	app.get("/api/project/:projectId/saved/deploy", handleSavedGet("deploy"));
-	app.get("/api/project/:projectId/saved/draft", handleSavedGet("draft"));
-	app.post("/api/project/:projectId/deploy", handleDeploy);
-	app.get("/api/project/:projectId/status", handleStatus);
-	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.post("/api/project/:projectId/reload", handleReload);
-	app.get("/api/project/:projectId/logs/:service", handleServiceLogs);
-	app.post("/api/project/:projectId/remove-deployment", handleRemoveDeployment);
+	app.use(express.json()); // Global JSON parsing
+
+	// Public webhook route - no auth needed
+	app.post("/api/webhook/github/push", handleGithubPushWebhook);
+
+	// Authenticated project routes
+	const projectRouter = express.Router();
+	projectRouter.use(auth); // Apply auth middleware to this router
+	projectRouter.post("/:projectId/saved", handleSave);
+	projectRouter.get("/:projectId/saved/deploy", handleSavedGet("deploy"));
+	projectRouter.get("/:projectId/saved/draft", handleSavedGet("draft"));
+	projectRouter.post("/:projectId/deploy", handleDeploy);
+	projectRouter.get("/:projectId/status", handleStatus);
+	projectRouter.delete("/:projectId", handleDelete);
+	projectRouter.get("/:projectId/repos/github", handleGithubRepos);
+	projectRouter.post("/:projectId/github-token", handleUpdateGithubToken);
+	projectRouter.get("/:projectId/env", handleEnv);
+	projectRouter.post("/:projectId/reload", handleReload);
+	projectRouter.get("/:projectId/logs/:service", handleServiceLogs);
+	projectRouter.post("/:projectId/remove-deployment", handleRemoveDeployment);
+	projectRouter.get("/", handleProjectAll);
+	projectRouter.post("/", handleProjectCreate);
+	app.use("/api/project", projectRouter); // Mount the authenticated router
+
 	app.use("/", express.static("../front/dist"));
 
-	const api = express();
-	api.use(express.json());
-	api.post("/api/project/:projectId/workers", handleRegisterWorker);
+	const internalApi = express();
+	internalApi.use(express.json());
+	internalApi.post("/api/project/:projectId/workers", handleRegisterWorker);
 
 	app.listen(env.DODO_PORT_WEB, () => {
 		console.log("Web server started on port", env.DODO_PORT_WEB);
 	});
 
-	api.listen(env.DODO_PORT_API, () => {
+	internalApi.listen(env.DODO_PORT_API, () => {
 		console.log("Internal API server started on port", env.DODO_PORT_API);
 	});
 }