Canvas: Configure deploy keys on repo owner

Expose Agent compact prop
Implement GitHub repo pagination

Change-Id: Ib444c53f2c7f83d7461e6f1a8e9d86698d320e92
diff --git a/apps/canvas/back/src/github.ts b/apps/canvas/back/src/github.ts
index df8eacf..e201c49 100644
--- a/apps/canvas/back/src/github.ts
+++ b/apps/canvas/back/src/github.ts
@@ -1,11 +1,5 @@
 import axios from "axios";
-import {
-	GithubRepository,
-	GithubRepositoriesSchema,
-	DeployKeysSchema,
-	ListWebhooksResponseSchema,
-	DeployKeys,
-} from "config";
+import { GithubRepository, GithubRepositoriesSchema, DeployKeysSchema, ListWebhooksResponseSchema } from "config";
 
 export class GithubClient {
 	private token: string;
@@ -23,94 +17,118 @@
 	}
 
 	async getRepositories(): Promise<GithubRepository[]> {
-		const response = await axios.get("https://api.github.com/user/repos", {
-			headers: this.getHeaders(),
-		});
-		return GithubRepositoriesSchema.parse(response.data);
+		const allRepos: GithubRepository[] = [];
+		let url: string | null = "https://api.github.com/user/repos?per_page=100";
+
+		while (url) {
+			const response = await axios.get(url, {
+				headers: this.getHeaders(),
+			});
+
+			const reposOnPage = GithubRepositoriesSchema.parse(response.data);
+			allRepos.push(...reposOnPage);
+
+			const linkHeader = response.headers.link as string | undefined;
+			if (linkHeader) {
+				const nextLink = linkHeader.split(",").find((s) => s.includes('rel="next"'));
+				if (nextLink) {
+					const match = nextLink.match(/<(.*)>/);
+					url = match ? match[1] : null;
+				} else {
+					url = null;
+				}
+			} else {
+				url = null;
+			}
+		}
+		return allRepos;
 	}
 
+	// TODO(giolekva): generate unique deploy key per repo and add it to the repo
 	async addDeployKey(repoPath: string, key: string) {
-		const sshUrl = repoPath;
-		const repoOwnerAndName = sshUrl.replace("git@github.com:", "").replace(".git", "");
-		let existingKeys: DeployKeys = [];
-		const response = await axios.get(`https://api.github.com/repos/${repoOwnerAndName}/keys`, {
-			headers: this.getHeaders(),
-		});
-		const parsedResult = DeployKeysSchema.safeParse(response.data);
-		if (parsedResult.success) {
-			existingKeys = parsedResult.data;
-		} else {
-			console.error("Failed to parse existing deploy keys:", parsedResult.error);
-		}
-		const keyToAddParts = key.trim().split(" ");
-		const mainKeyPartToAdd = keyToAddParts.length > 1 ? keyToAddParts.slice(0, 2).join(" ") : key.trim();
-		const keyAlreadyExists = existingKeys.some((existingKeyObj) => {
-			const existingKeyParts = existingKeyObj.key.trim().split(" ");
-			const mainExistingKeyPart =
-				existingKeyParts.length > 1 ? existingKeyParts.slice(0, 2).join(" ") : existingKeyObj.key.trim();
-			return mainExistingKeyPart === mainKeyPartToAdd;
-		});
-		if (keyAlreadyExists) {
-			return;
-		}
-		await axios.post(
-			`https://api.github.com/repos/${repoOwnerAndName}/keys`,
-			{
-				title: "dodo",
-				key: key,
-				read_only: true,
-			},
-			{
-				headers: this.getHeaders(),
-			},
-		);
+		await this.addSSHKey(key);
+		// 	const sshUrl = repoPath;
+		// 	const repoOwnerAndName = sshUrl.replace("git@github.com:", "").replace(".git", "");
+		// 	let existingKeys: DeployKeys = [];
+		// 	const response = await axios.get(`https://api.github.com/repos/${repoOwnerAndName}/keys`, {
+		// 		headers: this.getHeaders(),
+		// 	});
+		// 	const parsedResult = DeployKeysSchema.safeParse(response.data);
+		// 	if (parsedResult.success) {
+		// 		existingKeys = parsedResult.data;
+		// 	} else {
+		// 		console.error("Failed to parse existing deploy keys:", parsedResult.error);
+		// 	}
+		// 	const keyToAddParts = key.trim().split(" ");
+		// 	const mainKeyPartToAdd = keyToAddParts.length > 1 ? keyToAddParts.slice(0, 2).join(" ") : key.trim();
+		// 	const keyAlreadyExists = existingKeys.some((existingKeyObj) => {
+		// 		const existingKeyParts = existingKeyObj.key.trim().split(" ");
+		// 		const mainExistingKeyPart =
+		// 			existingKeyParts.length > 1 ? existingKeyParts.slice(0, 2).join(" ") : existingKeyObj.key.trim();
+		// 		return mainExistingKeyPart === mainKeyPartToAdd;
+		// 	});
+		// 	if (keyAlreadyExists) {
+		// 		return;
+		// 	}
+		// 	await axios.post(
+		// 		`https://api.github.com/repos/${repoOwnerAndName}/keys`,
+		// 		{
+		// 			title: "dodo",
+		// 			key: key,
+		// 			read_only: true,
+		// 		},
+		// 		{
+		// 			headers: this.getHeaders(),
+		// 		},
+		// 	);
 	}
 
 	async removeDeployKey(repoPath: string, key: string) {
-		const sshUrl = repoPath;
-		const repoOwnerAndName = sshUrl.replace("git@github.com:", "").replace(".git", "");
-		const response = await axios.get(`https://api.github.com/repos/${repoOwnerAndName}/keys`, {
-			headers: this.getHeaders(),
-		});
-		const result = DeployKeysSchema.safeParse(response.data);
-		if (!result.success) {
-			console.error("Failed to parse deploy keys response for removal:", result.error);
-			// Depending on desired robustness, you might throw an error or return early.
-			// For now, if parsing fails, we can't identify keys to remove.
-			throw new Error("Failed to parse deploy keys response during removal process");
-		}
+		this.removeSSHKey(key);
+		// const sshUrl = repoPath;
+		// const repoOwnerAndName = sshUrl.replace("git@github.com:", "").replace(".git", "");
+		// const response = await axios.get(`https://api.github.com/repos/${repoOwnerAndName}/keys`, {
+		// 	headers: this.getHeaders(),
+		// });
+		// const result = DeployKeysSchema.safeParse(response.data);
+		// if (!result.success) {
+		// 	console.error("Failed to parse deploy keys response for removal:", result.error);
+		// 	// Depending on desired robustness, you might throw an error or return early.
+		// 	// For now, if parsing fails, we can't identify keys to remove.
+		// 	throw new Error("Failed to parse deploy keys response during removal process");
+		// }
 
-		// Extract the main part of the key we intend to remove
-		const keyToRemoveParts = key.trim().split(" ");
-		const mainKeyPartToRemove = keyToRemoveParts.length > 1 ? keyToRemoveParts.slice(0, 2).join(" ") : key.trim();
+		// // Extract the main part of the key we intend to remove
+		// const keyToRemoveParts = key.trim().split(" ");
+		// const mainKeyPartToRemove = keyToRemoveParts.length > 1 ? keyToRemoveParts.slice(0, 2).join(" ") : key.trim();
 
-		const deployKeysToDelete = result.data.filter((existingKeyObj) => {
-			const existingKeyParts = existingKeyObj.key.trim().split(" ");
-			const mainExistingKeyPart =
-				existingKeyParts.length > 1 ? existingKeyParts.slice(0, 2).join(" ") : existingKeyObj.key.trim();
-			return mainExistingKeyPart === mainKeyPartToRemove;
-		});
+		// const deployKeysToDelete = result.data.filter((existingKeyObj) => {
+		// 	const existingKeyParts = existingKeyObj.key.trim().split(" ");
+		// 	const mainExistingKeyPart =
+		// 		existingKeyParts.length > 1 ? existingKeyParts.slice(0, 2).join(" ") : existingKeyObj.key.trim();
+		// 	return mainExistingKeyPart === mainKeyPartToRemove;
+		// });
 
-		if (deployKeysToDelete.length === 0) {
-			console.log(
-				`No deploy key matching '${mainKeyPartToRemove.substring(0, 50)}...' found in repo ${repoOwnerAndName} for removal.`,
-			);
-			return;
-		}
+		// if (deployKeysToDelete.length === 0) {
+		// 	console.log(
+		// 		`No deploy key matching '${mainKeyPartToRemove.substring(0, 50)}...' found in repo ${repoOwnerAndName} for removal.`,
+		// 	);
+		// 	return;
+		// }
 
-		await Promise.all(
-			deployKeysToDelete.map((deployKey) => {
-				console.log(
-					`Removing deploy key ID ${deployKey.id} ('${deployKey.key.substring(0, 50)}...') from repo ${repoOwnerAndName}`,
-				);
-				return axios.delete(`https://api.github.com/repos/${repoOwnerAndName}/keys/${deployKey.id}`, {
-					headers: this.getHeaders(),
-				});
-			}),
-		);
-		console.log(
-			`Successfully initiated removal of ${deployKeysToDelete.length} matching deploy key(s) from ${repoOwnerAndName}.`,
-		);
+		// await Promise.all(
+		// 	deployKeysToDelete.map((deployKey) => {
+		// 		console.log(
+		// 			`Removing deploy key ID ${deployKey.id} ('${deployKey.key.substring(0, 50)}...') from repo ${repoOwnerAndName}`,
+		// 		);
+		// 		return axios.delete(`https://api.github.com/repos/${repoOwnerAndName}/keys/${deployKey.id}`, {
+		// 			headers: this.getHeaders(),
+		// 		});
+		// 	}),
+		// );
+		// console.log(
+		// 	`Successfully initiated removal of ${deployKeysToDelete.length} matching deploy key(s) from ${repoOwnerAndName}.`,
+		// );
 	}
 
 	async addPushWebhook(repoPath: string, callbackAddress: string): Promise<boolean> {
@@ -163,4 +181,70 @@
 			throw error; // Re-throw to let the caller handle it
 		}
 	}
+
+	async addSSHKey(key: string) {
+		let existingKeys: { key: string }[] = [];
+		const response = await axios.get(`https://api.github.com/user/keys`, {
+			headers: this.getHeaders(),
+		});
+		if (Array.isArray(response.data)) {
+			existingKeys = response.data;
+		}
+		const keyToAddParts = key.trim().split(" ");
+		const mainKeyPartToAdd = keyToAddParts.length > 1 ? keyToAddParts.slice(0, 2).join(" ") : key.trim();
+		const keyAlreadyExists = existingKeys.some((existingKeyObj) => {
+			const existingKeyParts = existingKeyObj.key.trim().split(" ");
+			const mainExistingKeyPart =
+				existingKeyParts.length > 1 ? existingKeyParts.slice(0, 2).join(" ") : existingKeyObj.key.trim();
+			return mainExistingKeyPart === mainKeyPartToAdd;
+		});
+
+		if (keyAlreadyExists) {
+			return;
+		}
+		await axios.post(
+			`https://api.github.com/user/keys`,
+			{
+				title: "dodo",
+				key: key,
+			},
+			{
+				headers: this.getHeaders(),
+			},
+		);
+	}
+
+	async removeSSHKey(key: string) {
+		const response = await axios.get(`https://api.github.com/user/keys`, {
+			headers: this.getHeaders(),
+		});
+
+		const result = DeployKeysSchema.safeParse(response.data);
+		if (!result.success) {
+			throw new Error("Failed to parse deploy keys response during removal process");
+		}
+
+		// Extract the main part of the key we intend to remove
+		const keyToRemoveParts = key.trim().split(" ");
+		const mainKeyPartToRemove = keyToRemoveParts.length > 1 ? keyToRemoveParts.slice(0, 2).join(" ") : key.trim();
+
+		const deployKeysToDelete = result.data.filter((existingKeyObj) => {
+			const existingKeyParts = existingKeyObj.key.trim().split(" ");
+			const mainExistingKeyPart =
+				existingKeyParts.length > 1 ? existingKeyParts.slice(0, 2).join(" ") : existingKeyObj.key.trim();
+			return mainExistingKeyPart === mainKeyPartToRemove;
+		});
+
+		if (deployKeysToDelete.length === 0) {
+			return;
+		}
+
+		await Promise.all(
+			deployKeysToDelete.map((deployKey) => {
+				return axios.delete(`https://api.github.com/user/keys/${deployKey.id}`, {
+					headers: this.getHeaders(),
+				});
+			}),
+		);
+	}
 }
diff --git a/apps/canvas/back/src/index.ts b/apps/canvas/back/src/index.ts
index b6a7098..1fd9b37 100644
--- a/apps/canvas/back/src/index.ts
+++ b/apps/canvas/back/src/index.ts
@@ -809,6 +809,48 @@
 	}
 };
 
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
+const internalEnvSchema = z.object({
+	githubToken: z.string().optional(),
+	networks: z.array(
+		z.object({
+			name: z.string(),
+			domain: z.string(),
+			hasAuth: z.boolean(),
+		}),
+	),
+});
+
+type InternalEnv = z.infer<typeof internalEnvSchema>;
+
+const handleInternalEnv: express.Handler = async (req, resp) => {
+	try {
+		console.log("getting internal env");
+		const project = await db.project.findUnique({
+			where: {
+				id: Number(req.params["projectId"]),
+				userId: resp.locals.userId,
+			},
+			select: {
+				githubToken: true,
+			},
+		});
+		const networks = getNetworks(resp.locals.username);
+		const env: InternalEnv = {
+			networks,
+			githubToken: project?.githubToken ?? undefined,
+		};
+		resp.status(200);
+		resp.write(JSON.stringify(env));
+	} catch (error) {
+		console.error("Error getting env:", error);
+		resp.status(500);
+		resp.write(JSON.stringify({ error: "Internal server error" }));
+	} finally {
+		resp.end();
+	}
+};
+
 const handleServiceLogs: express.Handler = async (req, resp) => {
 	const projectId = Number(req.params["projectId"]);
 	const service = req.params["service"];
@@ -1263,6 +1305,7 @@
 	internalApi.get("/api/project/:projectId/config", handleConfigGet);
 	internalApi.post("/api/project/:projectId/saved", handleSave);
 	internalApi.post("/api/project/:projectId/deploy", handleDeploy);
+	internalApi.get("/api/project/:projectId/env", handleInternalEnv);
 	internalApi.post("/api/validate-config", handleValidateConfig);
 
 	app.listen(env.DODO_PORT_WEB, () => {
diff --git a/apps/canvas/front/src/Agent.tsx b/apps/canvas/front/src/Agent.tsx
index 3718805..974a3f3 100644
--- a/apps/canvas/front/src/Agent.tsx
+++ b/apps/canvas/front/src/Agent.tsx
@@ -2,11 +2,7 @@
 import { AgentAccess } from "config";
 import { useProjectId } from "./lib/state";
 
-export function Agent({ agent }: { agent: AgentAccess }): React.ReactNode {
-	return <AgentIframe agent={agent} />;
-}
-
-export function AgentIframe({ agent }: { agent: AgentAccess }): React.ReactNode {
+export function Agent({ agent, compact }: { agent: AgentAccess; compact?: boolean }): React.ReactNode {
 	const projectId = useProjectId();
 	const [ok, setOk] = useState<boolean>(false);
 	useEffect(() => {
@@ -32,5 +28,6 @@
 	if (!ok) {
 		return <div>Agent {agent.agentName} is loading...</div>;
 	}
-	return <iframe key={agent.name} src={`${agent.address}?m`} title={agent.agentName} className="w-full h-full" />;
+	const address = `${agent.address}${compact ? "/m" : ""}`;
+	return <iframe key={agent.name} src={address} title={agent.agentName} className="w-full h-full" />;
 }
diff --git a/apps/canvas/front/src/App.tsx b/apps/canvas/front/src/App.tsx
index 5f80c95..3f29313 100644
--- a/apps/canvas/front/src/App.tsx
+++ b/apps/canvas/front/src/App.tsx
@@ -11,7 +11,7 @@
 import { useAgents } from "./lib/state";
 import { Bot } from "lucide-react";
 import { Preview } from "./components/preview";
-import { AgentIframe } from "./Agent";
+import { Agent } from "./Agent";
 
 export default function App() {
 	return (
@@ -68,7 +68,7 @@
 			</TabsContent>
 			{agents.map((a) => (
 				<TabsContent value={`agent-${a.agentName}`} className="!mt-0 flex-1 min-h-0">
-					<AgentIframe agent={a} />
+					<Agent agent={a} compact={true} />
 				</TabsContent>
 			))}
 		</Tabs>
diff --git a/apps/canvas/front/src/Build.tsx b/apps/canvas/front/src/Build.tsx
index a3960ba..a3f2bc4 100644
--- a/apps/canvas/front/src/Build.tsx
+++ b/apps/canvas/front/src/Build.tsx
@@ -25,7 +25,7 @@
 			{leadAgent && (
 				<>
 					<ResizablePanel defaultSize={25}>
-						<Agent agent={leadAgent} />
+						<Agent agent={leadAgent} compact={true} />
 					</ResizablePanel>
 					<ResizableHandle withHandle />
 				</>
diff --git a/apps/canvas/front/src/components/preview.tsx b/apps/canvas/front/src/components/preview.tsx
index f5ae9cf..d7881f0 100644
--- a/apps/canvas/front/src/components/preview.tsx
+++ b/apps/canvas/front/src/components/preview.tsx
@@ -15,7 +15,7 @@
 		<ResizablePanelGroup direction="horizontal" className="h-full">
 			<ResizablePanel defaultSize={25}>
 				<div className="p-4 h-full">
-					{leadAgent ? <Agent agent={leadAgent} /> : <div>No agent available</div>}
+					{leadAgent ? <Agent agent={leadAgent} compact={true} /> : <div>No agent available</div>}
 				</div>
 			</ResizablePanel>
 			<ResizableHandle withHandle />