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